dsh-wsl-desktop 0.6.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 +5 -0
- package/README.md +4 -0
- package/lib/client.js +260 -46
- package/lib/index.js +8 -2
- package/lib/protocol.js +7 -1
- package/lib/release-check.js +69 -0
- package/lib/release-preflight.js +105 -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
|
@@ -133,6 +133,10 @@ These are real boundaries, not a disclaimer list. Each one tells you where to re
|
|
|
133
133
|
the whitelisted params, never the host message, stderr or resolved paths; after the user is
|
|
134
134
|
confirmed, the W dialog shows an advisory health check whose failed rows carry the fix. Codes,
|
|
135
135
|
checks and degradation are in [Diagnostics](docs/DIAGNOSTICS.md).
|
|
136
|
+
- **The W dialog is bilingual and keyboard-contained.** Copy follows `navigator.language` between
|
|
137
|
+
Chinese and English; the dialog traps Tab, closes on Escape and returns focus to the W button,
|
|
138
|
+
and copy feedback goes through a screen-reader live region. Details in
|
|
139
|
+
[Diagnostics](docs/DIAGNOSTICS.md#copy-language).
|
|
136
140
|
- **Who it is for.** For now it suits the plugin author or an operator of the same trust level, for
|
|
137
141
|
long-term use. Distribution to third-party users, or unattended high-value environments, are not
|
|
138
142
|
suitable yet — the reasoning is in [SECURITY.md](SECURITY.md).
|
|
@@ -151,6 +155,7 @@ These are real boundaries, not a disclaimer list. Each one tells you where to re
|
|
|
151
155
|
- [Diagnostics](docs/DIAGNOSTICS.md) — error codes, the health check, degradation and the privacy boundary
|
|
152
156
|
- [Engineering notes](docs/ENGINEERING-NOTES.md) — measured constraints, repository layout, release flow, install shape
|
|
153
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
|
|
154
159
|
- [Upstream API proposals](docs/UPSTREAM-PROPOSALS.md) — the two proposals to the harness upstream
|
|
155
160
|
- [Architecture and flow diagrams](docs/diagrams.md) — interactive architecture, sequence and lifecycle diagrams
|
|
156
161
|
|
package/README.md
CHANGED
|
@@ -108,6 +108,9 @@ plugin_manager install_bundle dsh-wsl-desktop@<version>
|
|
|
108
108
|
- **失败现在带 code,可以先自己诊断。** 浏览器侧的错误只回稳定 code 与允许的 params,
|
|
109
109
|
宿主 message、stderr 与解析后的路径不会过线;W 对话框在确认用户后会显示一条建议性的
|
|
110
110
|
「环境检查」,失败项带修复命令。错误码、检查项与降级行为见 [诊断](docs/DIAGNOSTICS.md)。
|
|
111
|
+
- **W 对话框是双语的,键盘也关得住。** 文案按 `navigator.language` 在中英文之间选择;对话框有
|
|
112
|
+
Tab 焦点陷阱、Escape 关闭并把焦点还给 W 按钮,复制反馈走读屏实时区。细节见
|
|
113
|
+
[诊断](docs/DIAGNOSTICS.md#文案语言)。
|
|
111
114
|
- **适合谁用。** 目前适合插件作者本人或同信任级别的操作者长期使用。分发给第三方用户、或放进
|
|
112
115
|
无人值守的高价值环境,都还不适合——理由写在 [SECURITY.md](SECURITY.md)。
|
|
113
116
|
|
|
@@ -125,6 +128,7 @@ plugin_manager install_bundle dsh-wsl-desktop@<version>
|
|
|
125
128
|
- [诊断](docs/DIAGNOSTICS.md) — 错误码、环境检查、宿主能力缺失时的降级与隐私边界
|
|
126
129
|
- [工程笔记](docs/ENGINEERING-NOTES.md) — 实测约束、仓库布局、发版流程、安装形态
|
|
127
130
|
- [发行版支持矩阵](docs/DISTRO-SUPPORT.md) — 哪些发行版验证过,每种缺什么
|
|
131
|
+
- [兼容性与升级](docs/COMPATIBILITY.md) — 插件与桌面的版本关系、升级顺序、回退步骤
|
|
128
132
|
- [上游 API 提案](docs/UPSTREAM-PROPOSALS.md) — 对 harness 上游的两项提案
|
|
129
133
|
- [架构与流程图](docs/diagrams.md) — 可交互的架构、时序与生命周期图
|
|
130
134
|
|
package/lib/client.js
CHANGED
|
@@ -68,15 +68,11 @@ window.__ModuleLoader__.load({
|
|
|
68
68
|
*/
|
|
69
69
|
const FOLDER_SLOT = /(^|_)folder$/
|
|
70
70
|
|
|
71
|
-
/** Accessible name and tooltip of the companion button. */
|
|
72
|
-
const BUTTON_LABEL = '添加 WSL 工作区'
|
|
73
|
-
|
|
74
71
|
/**
|
|
75
72
|
* The copy language of everything this release adds.
|
|
76
73
|
*
|
|
77
74
|
* The catalog is bilingual, so the dialog can render a host failure in the
|
|
78
|
-
* operator's language without a translation build step.
|
|
79
|
-
* stays Chinese; the protocol surface is localised from the first day.
|
|
75
|
+
* operator's language without a translation build step.
|
|
80
76
|
*/
|
|
81
77
|
const LOCALE = typeof navigator !== 'undefined' && /^zh/i.test(String(navigator.language ?? '')) ? 'zh' : 'en'
|
|
82
78
|
|
|
@@ -90,6 +86,86 @@ window.__ModuleLoader__.load({
|
|
|
90
86
|
return LOCALE === 'zh' ? zh : en
|
|
91
87
|
}
|
|
92
88
|
|
|
89
|
+
/**
|
|
90
|
+
* The older dialog copy, in one table so the whole W dialog is bilingual.
|
|
91
|
+
*
|
|
92
|
+
* The 0.6 surfaces above use `t(zh, en)` inline; these entries are older and
|
|
93
|
+
* carry `{name}` placeholders, which `copy()` fills. Keeping them here means a
|
|
94
|
+
* language sweep has one place to audit instead of 1,700 lines of literals.
|
|
95
|
+
*/
|
|
96
|
+
const COPY = {
|
|
97
|
+
trigger: { zh: '添加 WSL 工作区', en: 'Add a WSL workspace' },
|
|
98
|
+
createRefused: { zh: '宿主拒绝创建会话', en: 'The host refused to create the session' },
|
|
99
|
+
notADirectory: { zh: '{target} 不是一个存在的目录', en: '{target} is not an existing directory' },
|
|
100
|
+
sessionHint: { zh: '新会话的 bash 与文件工具都会在该发行版里工作;Windows 文件可在 /mnt/<盘符> 下访问。', en: 'The new session runs bash and the file tools inside this distribution; Windows files are under /mnt/<drive>.' },
|
|
101
|
+
distroLabel: { zh: '发行版', en: 'Distribution' },
|
|
102
|
+
reading: { zh: '读取中…', en: 'Reading…' },
|
|
103
|
+
noDistros: { zh: '没有可用的 WSL 发行版', en: 'No usable WSL distribution' },
|
|
104
|
+
userHint: { zh: '输入要进入该发行版的用户;确认后直接落在其主目录。', en: 'Enter the user to enter this distribution as; the browser lands in their home.' },
|
|
105
|
+
userPlaceholder: { zh: '留空 = 发行版默认用户', en: 'Leave blank for the distribution default user' },
|
|
106
|
+
resolving: { zh: '解析中…', en: 'Resolving…' },
|
|
107
|
+
confirmUser: { zh: '确认进入', en: 'Enter' },
|
|
108
|
+
cancel: { zh: '取消', en: 'Cancel' },
|
|
109
|
+
userHome: { zh: '用户 {user} · 主目录 {home}(可直接修改)', en: 'User {user} · home {home} (editable)' },
|
|
110
|
+
pathPlaceholder: { zh: '/home/用户名/项目', en: '/home/user/project' },
|
|
111
|
+
go: { zh: '前往', en: 'Go' },
|
|
112
|
+
readingDir: { zh: '读取目录…', en: 'Reading the directory…' },
|
|
113
|
+
noSubdirs: { zh: '没有子目录', en: 'No subdirectories' },
|
|
114
|
+
parentDir: { zh: '.. 上级目录', en: '.. Parent directory' },
|
|
115
|
+
working: { zh: '处理中…', en: 'Working…' },
|
|
116
|
+
createOpen: { zh: '创建并打开', en: 'Create and open' },
|
|
117
|
+
missingPrefix: { zh: '缺少:', en: 'Missing: ' },
|
|
118
|
+
healthSummary: { zh: '环境检查:{pass} 项通过{warn}{fail}', en: 'Health check: {pass} passed{warn}{fail}' },
|
|
119
|
+
healthWarn: { zh: ',{count} 项建议处理', en: ', {count} advisory' },
|
|
120
|
+
healthFail: { zh: ',{count} 项失败', en: ', {count} failed' },
|
|
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' },
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Look up one COPY entry and fill its `{name}` placeholders.
|
|
130
|
+
* @param {string} key - the COPY key.
|
|
131
|
+
* @param {Record<string, unknown>} [vars] - placeholder values.
|
|
132
|
+
* @returns {string} the copy.
|
|
133
|
+
*/
|
|
134
|
+
function copy(key, vars = {}) {
|
|
135
|
+
const entry = COPY[key]
|
|
136
|
+
const text = entry?.[LOCALE] ?? entry?.en ?? key
|
|
137
|
+
return text.replace(/\{(\w+)\}/g, (_, name) => String(vars[name] ?? ''))
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/** Accessible name and tooltip of the companion button. */
|
|
141
|
+
const BUTTON_LABEL = copy('trigger')
|
|
142
|
+
|
|
143
|
+
/** Inline style for text that screen readers get but the page does not show. */
|
|
144
|
+
const VISUALLY_HIDDEN = {
|
|
145
|
+
position: 'absolute',
|
|
146
|
+
width: '1px',
|
|
147
|
+
height: '1px',
|
|
148
|
+
padding: '0',
|
|
149
|
+
margin: '-1px',
|
|
150
|
+
overflow: 'hidden',
|
|
151
|
+
clip: 'rect(0,0,0,0)',
|
|
152
|
+
whiteSpace: 'nowrap',
|
|
153
|
+
border: '0',
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/** The open dialog's polite live region; null while no dialog is open. */
|
|
157
|
+
let liveStatus = null
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* Announce a short status through the dialog's live region.
|
|
161
|
+
* @param {string} text - the message.
|
|
162
|
+
*/
|
|
163
|
+
function announce(text) {
|
|
164
|
+
if (liveStatus === null) return
|
|
165
|
+
liveStatus.textContent = ''
|
|
166
|
+
liveStatus.textContent = text
|
|
167
|
+
}
|
|
168
|
+
|
|
93
169
|
/** Copy used only when the catalog itself could not be fetched. */
|
|
94
170
|
const FALLBACK_MESSAGES = {
|
|
95
171
|
transport: { zh: '连不上插件的本地路由。', en: 'The plugin route could not be reached.' },
|
|
@@ -135,7 +211,7 @@ window.__ModuleLoader__.load({
|
|
|
135
211
|
*/
|
|
136
212
|
function localizeDetail(detail) {
|
|
137
213
|
if (LOCALE !== 'zh' || typeof detail !== 'string') return detail
|
|
138
|
-
if (detail.startsWith('missing: ')) return
|
|
214
|
+
if (detail.startsWith('missing: ')) return `${copy('missingPrefix')}${detail.slice('missing: '.length)}`
|
|
139
215
|
// Version strings carry their own identity (Python x.y, dsh-wsl-confine v1.2).
|
|
140
216
|
if (detail.startsWith('Python ') || detail.startsWith('dsh-wsl-confine ')) return detail
|
|
141
217
|
return DETAIL_ZH[detail] ?? detail
|
|
@@ -299,7 +375,7 @@ window.__ModuleLoader__.load({
|
|
|
299
375
|
const create = ctx.remote?.session?.create
|
|
300
376
|
if (typeof create !== 'function') return ctx.sessions.create({ workspaceId })
|
|
301
377
|
const result = await create({ workspaceId, agentPreset })
|
|
302
|
-
if (result?.ok !== true) throw new Error(result?.error?.message ?? '
|
|
378
|
+
if (result?.ok !== true) throw new Error(result?.error?.message ?? copy('createRefused'))
|
|
303
379
|
return result.value.sessionId
|
|
304
380
|
}
|
|
305
381
|
|
|
@@ -428,6 +504,7 @@ window.__ModuleLoader__.load({
|
|
|
428
504
|
copy.addEventListener('click', () => {
|
|
429
505
|
void navigator.clipboard?.writeText?.(String(remedy.value ?? ''))
|
|
430
506
|
copy.textContent = t('已复制', 'Copied')
|
|
507
|
+
announce(t('已复制', 'Copied'))
|
|
431
508
|
})
|
|
432
509
|
row.append(copy)
|
|
433
510
|
return row
|
|
@@ -451,7 +528,7 @@ window.__ModuleLoader__.load({
|
|
|
451
528
|
* @param {object|null} bootstrap - the bootstrap result.
|
|
452
529
|
* @returns {HTMLElement|null} the node.
|
|
453
530
|
*/
|
|
454
|
-
function renderFailureNode(failure, bootstrap) {
|
|
531
|
+
function renderFailureNode(failure, bootstrap, state = null) {
|
|
455
532
|
if (failure === null) return null
|
|
456
533
|
const catalog = bootstrap?.ok === true ? bootstrap.value?.catalog : null
|
|
457
534
|
const entry = catalog?.codes?.[failure.code]
|
|
@@ -459,6 +536,7 @@ window.__ModuleLoader__.load({
|
|
|
459
536
|
?? t('插件内部错误。', 'The plugin hit an internal error.')
|
|
460
537
|
const box = el('div', { fontSize: '12px', color: T.error, display: 'flex', flexDirection: 'column', gap: '4px' })
|
|
461
538
|
box.setAttribute('role', 'alert')
|
|
539
|
+
box.setAttribute('aria-live', 'assertive')
|
|
462
540
|
box.setAttribute('data-wsl-error', failure.code)
|
|
463
541
|
box.append(el('div', {}, copy))
|
|
464
542
|
const remedy = entry === undefined ? null : renderRemedy(entry.remedy)
|
|
@@ -478,6 +556,7 @@ window.__ModuleLoader__.load({
|
|
|
478
556
|
copyRef.addEventListener('click', () => {
|
|
479
557
|
void navigator.clipboard?.writeText?.(ref)
|
|
480
558
|
copyRef.textContent = t('已复制', 'Copied')
|
|
559
|
+
announce(t('已复制', 'Copied'))
|
|
481
560
|
})
|
|
482
561
|
row.append(copyRef)
|
|
483
562
|
box.append(row)
|
|
@@ -495,9 +574,64 @@ window.__ModuleLoader__.load({
|
|
|
495
574
|
box.append(link)
|
|
496
575
|
}
|
|
497
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
|
+
}
|
|
498
592
|
return box
|
|
499
593
|
}
|
|
500
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
|
+
|
|
501
635
|
/**
|
|
502
636
|
* The degraded card: what is missing, what to do, and a copyable report.
|
|
503
637
|
* @param {object[]} body - the card's child list.
|
|
@@ -528,11 +662,7 @@ window.__ModuleLoader__.load({
|
|
|
528
662
|
const entry = catalog?.codes?.['host-capability-missing']
|
|
529
663
|
const remedy = entry === undefined ? null : renderRemedy(entry.remedy)
|
|
530
664
|
if (remedy !== null) panel.append(remedy)
|
|
531
|
-
const diagnostics =
|
|
532
|
-
pluginVersion: state.bootstrap?.ok === true ? state.bootstrap.value?.pluginVersion ?? 'unknown' : 'unknown',
|
|
533
|
-
features: state.features,
|
|
534
|
-
missing: state.missing,
|
|
535
|
-
}, null, 2)
|
|
665
|
+
const diagnostics = buildDiagnostics(state)
|
|
536
666
|
panel.append(el('pre', {
|
|
537
667
|
margin: '0',
|
|
538
668
|
padding: '8px',
|
|
@@ -548,6 +678,7 @@ window.__ModuleLoader__.load({
|
|
|
548
678
|
copy.addEventListener('click', () => {
|
|
549
679
|
void navigator.clipboard?.writeText?.(diagnostics)
|
|
550
680
|
copy.textContent = t('已复制', 'Copied')
|
|
681
|
+
announce(t('已复制', 'Copied'))
|
|
551
682
|
})
|
|
552
683
|
panel.append(copy)
|
|
553
684
|
if (typeof onRetry === 'function') {
|
|
@@ -644,6 +775,8 @@ window.__ModuleLoader__.load({
|
|
|
644
775
|
home: null,
|
|
645
776
|
path: '/',
|
|
646
777
|
draft: '/',
|
|
778
|
+
/** Case-insensitive substring filter for the current directory. */
|
|
779
|
+
filter: '',
|
|
647
780
|
listing: null,
|
|
648
781
|
/** `{code, params}` or null; the raw host message is never rendered. */
|
|
649
782
|
error: null,
|
|
@@ -656,7 +789,14 @@ window.__ModuleLoader__.load({
|
|
|
656
789
|
overlay.setAttribute('role', 'dialog')
|
|
657
790
|
overlay.setAttribute('aria-modal', 'true')
|
|
658
791
|
overlay.setAttribute('aria-label', BUTTON_LABEL)
|
|
659
|
-
|
|
792
|
+
const description = el('div', VISUALLY_HIDDEN, copy('dialogDescription'))
|
|
793
|
+
description.id = 'wsl-dialog-description'
|
|
794
|
+
overlay.setAttribute('aria-describedby', description.id)
|
|
795
|
+
const status = el('div', VISUALLY_HIDDEN, '')
|
|
796
|
+
status.setAttribute('role', 'status')
|
|
797
|
+
status.setAttribute('aria-live', 'polite')
|
|
798
|
+
liveStatus = status
|
|
799
|
+
overlay.append(description, card, status)
|
|
660
800
|
overlay.addEventListener('click', () => { close() })
|
|
661
801
|
card.addEventListener('click', (event) => { event.stopPropagation() })
|
|
662
802
|
|
|
@@ -676,6 +816,7 @@ window.__ModuleLoader__.load({
|
|
|
676
816
|
function forceClose() {
|
|
677
817
|
state.closed = true
|
|
678
818
|
state.token += 1
|
|
819
|
+
liveStatus = null
|
|
679
820
|
document.removeEventListener('keydown', onKey)
|
|
680
821
|
overlay.remove()
|
|
681
822
|
opener?.setAttribute?.('aria-expanded', 'false')
|
|
@@ -685,8 +826,34 @@ window.__ModuleLoader__.load({
|
|
|
685
826
|
teardown = null
|
|
686
827
|
}
|
|
687
828
|
|
|
829
|
+
/** The focusable controls inside the dialog, in document order. */
|
|
830
|
+
function focusables() {
|
|
831
|
+
return [...overlay.querySelectorAll('button:not([disabled]), [href], input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])')]
|
|
832
|
+
}
|
|
833
|
+
|
|
688
834
|
function onKey(event) {
|
|
689
|
-
if (event.key === 'Escape')
|
|
835
|
+
if (event.key === 'Escape') {
|
|
836
|
+
close()
|
|
837
|
+
return
|
|
838
|
+
}
|
|
839
|
+
if (event.key !== 'Tab') return
|
|
840
|
+
// A modal dialog owns the keyboard: Tab from the last control wraps to the
|
|
841
|
+
// first and Shift+Tab from the first wraps to the last, so focus cannot
|
|
842
|
+
// escape into the page behind the overlay.
|
|
843
|
+
const focusable = focusables()
|
|
844
|
+
if (focusable.length === 0) return
|
|
845
|
+
const first = focusable[0]
|
|
846
|
+
const last = focusable[focusable.length - 1]
|
|
847
|
+
if (event.shiftKey && document.activeElement === first) {
|
|
848
|
+
event.preventDefault()
|
|
849
|
+
last.focus()
|
|
850
|
+
} else if (!event.shiftKey && document.activeElement === last) {
|
|
851
|
+
event.preventDefault()
|
|
852
|
+
first.focus()
|
|
853
|
+
} else if (!overlay.contains(document.activeElement)) {
|
|
854
|
+
event.preventDefault()
|
|
855
|
+
first.focus()
|
|
856
|
+
}
|
|
690
857
|
}
|
|
691
858
|
|
|
692
859
|
/** Discover distributions only after the capability gate has cleared. */
|
|
@@ -816,13 +983,14 @@ window.__ModuleLoader__.load({
|
|
|
816
983
|
if (state.health.status === 'loading') {
|
|
817
984
|
strip.setAttribute('data-wsl-health', 'loading')
|
|
818
985
|
strip.setAttribute('role', 'status')
|
|
986
|
+
strip.setAttribute('aria-live', 'polite')
|
|
819
987
|
strip.append(el('div', { fontSize: '12px', color: T.dim }, t('环境检查:检查中…', 'Health check: running…')))
|
|
820
988
|
body.push(strip)
|
|
821
989
|
return
|
|
822
990
|
}
|
|
823
991
|
if (state.health.status === 'error') {
|
|
824
992
|
strip.setAttribute('data-wsl-health', 'error')
|
|
825
|
-
const failure = renderFailureNode(state.health.failure, state.bootstrap)
|
|
993
|
+
const failure = renderFailureNode(state.health.failure, state.bootstrap, state)
|
|
826
994
|
if (failure !== null) strip.append(failure)
|
|
827
995
|
const retry = el('button', {
|
|
828
996
|
...BUTTON_STYLE,
|
|
@@ -846,10 +1014,11 @@ window.__ModuleLoader__.load({
|
|
|
846
1014
|
const measured = checks.some((check) => check.status !== 'unknown')
|
|
847
1015
|
const summary = el('div', { display: 'flex', justifyContent: 'space-between', alignItems: 'center', gap: '8px' })
|
|
848
1016
|
summary.append(el('span', { fontSize: '12px', color: counts.fail > 0 ? T.error : T.dim },
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
1017
|
+
copy('healthSummary', {
|
|
1018
|
+
pass: counts.pass,
|
|
1019
|
+
warn: counts.warn > 0 ? copy('healthWarn', { count: counts.warn }) : '',
|
|
1020
|
+
fail: counts.fail > 0 ? copy('healthFail', { count: counts.fail }) : '',
|
|
1021
|
+
})))
|
|
853
1022
|
const toggle = el('button', { ...BUTTON_STYLE, padding: '2px 8px', fontSize: '11px' },
|
|
854
1023
|
state.healthOpen ? t('收起', 'Hide') : t('展开', 'Details'))
|
|
855
1024
|
toggle.type = 'button'
|
|
@@ -988,7 +1157,7 @@ window.__ModuleLoader__.load({
|
|
|
988
1157
|
try {
|
|
989
1158
|
const facts = await call('checkPath', { distro: state.distro, path: target })
|
|
990
1159
|
if (facts.isDirectory !== true) {
|
|
991
|
-
throw Object.assign(new Error(
|
|
1160
|
+
throw Object.assign(new Error(copy('notADirectory', { target })), {
|
|
992
1161
|
code: 'path-not-directory',
|
|
993
1162
|
params: { path: target },
|
|
994
1163
|
})
|
|
@@ -1054,8 +1223,7 @@ window.__ModuleLoader__.load({
|
|
|
1054
1223
|
const body = []
|
|
1055
1224
|
|
|
1056
1225
|
body.push(el('div', { fontSize: '15px', fontWeight: '600' }, BUTTON_LABEL))
|
|
1057
|
-
body.push(el('div', { fontSize: '12px', color: T.dim },
|
|
1058
|
-
'新会话的 bash 与文件工具都会在该发行版里工作;Windows 文件可在 /mnt/<盘符> 下访问。'))
|
|
1226
|
+
body.push(el('div', { fontSize: '12px', color: T.dim }, copy('sessionHint')))
|
|
1059
1227
|
|
|
1060
1228
|
if (state.phase === 'loading') {
|
|
1061
1229
|
const status = el('div', { fontSize: '12px', color: T.dim }, t('检查插件状态…', 'Checking the plugin…'))
|
|
@@ -1065,16 +1233,16 @@ window.__ModuleLoader__.load({
|
|
|
1065
1233
|
renderDegraded(body, state, close, retryDegraded)
|
|
1066
1234
|
} else {
|
|
1067
1235
|
if (state.bootstrapError !== null) {
|
|
1068
|
-
const failure = renderFailureNode(state.bootstrapError, state.bootstrap)
|
|
1236
|
+
const failure = renderFailureNode(state.bootstrapError, state.bootstrap, state)
|
|
1069
1237
|
if (failure !== null) body.push(failure)
|
|
1070
1238
|
}
|
|
1071
1239
|
|
|
1072
1240
|
const distros = el('div', { display: 'flex', flexWrap: 'wrap', gap: '6px', alignItems: 'center' })
|
|
1073
|
-
distros.append(el('span', { fontSize: '12px', color: T.dim }, '
|
|
1241
|
+
distros.append(el('span', { fontSize: '12px', color: T.dim }, copy('distroLabel')))
|
|
1074
1242
|
if (state.distros === null) {
|
|
1075
|
-
distros.append(el('span', { fontSize: '12px', color: T.dim }, '
|
|
1243
|
+
distros.append(el('span', { fontSize: '12px', color: T.dim }, copy('reading')))
|
|
1076
1244
|
} else if (state.distros.length === 0) {
|
|
1077
|
-
distros.append(el('span', { fontSize: '12px', color: T.error }, '
|
|
1245
|
+
distros.append(el('span', { fontSize: '12px', color: T.error }, copy('noDistros')))
|
|
1078
1246
|
} else {
|
|
1079
1247
|
for (const name of state.distros) distros.append(pill(name))
|
|
1080
1248
|
}
|
|
@@ -1084,11 +1252,10 @@ window.__ModuleLoader__.load({
|
|
|
1084
1252
|
// The user gate: no browsing until the distribution user is named.
|
|
1085
1253
|
// An empty field means the distribution's default user; an unknown
|
|
1086
1254
|
// user fails right here instead of opening the browser on a guess.
|
|
1087
|
-
body.push(el('div', { fontSize: '12px', color: T.dim },
|
|
1088
|
-
'输入要进入该发行版的用户;确认后直接落在其主目录。'))
|
|
1255
|
+
body.push(el('div', { fontSize: '12px', color: T.dim }, copy('userHint')))
|
|
1089
1256
|
const userField = el('input', FIELD_STYLE)
|
|
1090
1257
|
userField.value = state.userDraft
|
|
1091
|
-
userField.placeholder = '
|
|
1258
|
+
userField.placeholder = copy('userPlaceholder')
|
|
1092
1259
|
userField.addEventListener('input', () => { state.userDraft = userField.value })
|
|
1093
1260
|
userField.addEventListener('keydown', (event) => {
|
|
1094
1261
|
if (event.key === 'Enter' && state.distro !== null) void confirmUser()
|
|
@@ -1096,7 +1263,7 @@ window.__ModuleLoader__.load({
|
|
|
1096
1263
|
const enter = el('button', {
|
|
1097
1264
|
...PRIMARY_STYLE,
|
|
1098
1265
|
opacity: state.pending ? '0.6' : '1',
|
|
1099
|
-
}, state.pending ? '
|
|
1266
|
+
}, state.pending ? copy('resolving') : copy('confirmUser'))
|
|
1100
1267
|
enter.type = 'button'
|
|
1101
1268
|
enter.setAttribute('data-wsl-action', 'confirm-user')
|
|
1102
1269
|
enter.disabled = state.pending || state.distro === null
|
|
@@ -1104,30 +1271,30 @@ window.__ModuleLoader__.load({
|
|
|
1104
1271
|
body.push(el('div', { display: 'flex', gap: '8px' }, userField, enter))
|
|
1105
1272
|
|
|
1106
1273
|
if (state.error !== null) {
|
|
1107
|
-
const failure = renderFailureNode(state.error, state.bootstrap)
|
|
1274
|
+
const failure = renderFailureNode(state.error, state.bootstrap, state)
|
|
1108
1275
|
if (failure !== null) body.push(failure)
|
|
1109
1276
|
}
|
|
1110
1277
|
|
|
1111
|
-
const cancel = el('button', BUTTON_STYLE, '
|
|
1278
|
+
const cancel = el('button', BUTTON_STYLE, copy('cancel'))
|
|
1112
1279
|
cancel.type = 'button'
|
|
1113
1280
|
cancel.addEventListener('click', () => { close() })
|
|
1114
1281
|
body.push(el('div', { display: 'flex', justifyContent: 'flex-end' }, cancel))
|
|
1115
1282
|
} else {
|
|
1116
1283
|
// The confirmed user and their home: provenance of the prefill.
|
|
1117
1284
|
if (state.user !== null && state.home !== null) {
|
|
1118
|
-
body.push(el('div', { fontSize: '12px', color: T.dim },
|
|
1285
|
+
body.push(el('div', { fontSize: '12px', color: T.dim }, copy('userHome', { user: state.user, home: state.home })))
|
|
1119
1286
|
}
|
|
1120
1287
|
|
|
1121
1288
|
renderHealthStrip(body)
|
|
1122
1289
|
|
|
1123
1290
|
const input = el('input', FIELD_STYLE)
|
|
1124
1291
|
input.value = state.draft
|
|
1125
|
-
input.placeholder = '
|
|
1292
|
+
input.placeholder = copy('pathPlaceholder')
|
|
1126
1293
|
input.addEventListener('input', () => { state.draft = input.value })
|
|
1127
1294
|
input.addEventListener('keydown', (event) => {
|
|
1128
1295
|
if (event.key === 'Enter' && state.distro !== null) go(state.draft)
|
|
1129
1296
|
})
|
|
1130
|
-
const goButton = el('button', BUTTON_STYLE, '
|
|
1297
|
+
const goButton = el('button', BUTTON_STYLE, copy('go'))
|
|
1131
1298
|
goButton.type = 'button'
|
|
1132
1299
|
goButton.disabled = state.distro === null
|
|
1133
1300
|
goButton.addEventListener('click', () => { go(state.draft) })
|
|
@@ -1153,15 +1320,45 @@ window.__ModuleLoader__.load({
|
|
|
1153
1320
|
border: `1px solid ${T.border}`,
|
|
1154
1321
|
background: T.layer,
|
|
1155
1322
|
})
|
|
1156
|
-
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
|
+
}
|
|
1157
1354
|
if (state.listing === null) {
|
|
1158
|
-
list.append(el('div', { fontSize: '12px', color: T.dim, padding: '6px' }, '
|
|
1159
|
-
} else if (
|
|
1160
|
-
list.append(el('div', { fontSize: '12px', color: T.dim, padding: '6px' }, '
|
|
1355
|
+
list.append(el('div', { fontSize: '12px', color: T.dim, padding: '6px' }, copy('readingDir')))
|
|
1356
|
+
} else if (allDirectories.length === 0) {
|
|
1357
|
+
list.append(el('div', { fontSize: '12px', color: T.dim, padding: '6px' }, copy('noSubdirs')))
|
|
1161
1358
|
} else {
|
|
1162
|
-
for (const entry of
|
|
1359
|
+
for (const entry of allDirectories) {
|
|
1163
1360
|
const item = el('button', {
|
|
1164
|
-
display: 'block',
|
|
1361
|
+
display: matchesFilter(entry.name) ? 'block' : 'none',
|
|
1165
1362
|
width: '100%',
|
|
1166
1363
|
textAlign: 'left',
|
|
1167
1364
|
border: 'none',
|
|
@@ -1174,11 +1371,16 @@ window.__ModuleLoader__.load({
|
|
|
1174
1371
|
cursor: 'pointer',
|
|
1175
1372
|
}, `📁 ${entry.name}`)
|
|
1176
1373
|
item.type = 'button'
|
|
1374
|
+
item.setAttribute('data-wsl-entry', '')
|
|
1375
|
+
item.setAttribute('data-name', entry.name)
|
|
1177
1376
|
item.addEventListener('mouseenter', () => { item.style.background = T.hover })
|
|
1178
1377
|
item.addEventListener('mouseleave', () => { item.style.background = 'transparent' })
|
|
1179
1378
|
item.addEventListener('click', () => { go(joinLinux(state.path, entry.name)) })
|
|
1180
1379
|
list.append(item)
|
|
1181
1380
|
}
|
|
1381
|
+
list.append(empty)
|
|
1382
|
+
if (state.filter !== '') empty.setAttribute('data-wsl-filter-empty', '')
|
|
1383
|
+
applyFilter()
|
|
1182
1384
|
}
|
|
1183
1385
|
body.push(list)
|
|
1184
1386
|
if (typeof state.listing?.hidden === 'number' && state.listing.hidden > 0) {
|
|
@@ -1191,24 +1393,35 @@ window.__ModuleLoader__.load({
|
|
|
1191
1393
|
|
|
1192
1394
|
const parent = parentOf(state.path)
|
|
1193
1395
|
if (parent !== null) {
|
|
1194
|
-
const up = el('button', { ...BUTTON_STYLE, alignSelf: 'flex-start', padding: '4px 10px' }, '
|
|
1396
|
+
const up = el('button', { ...BUTTON_STYLE, alignSelf: 'flex-start', padding: '4px 10px' }, copy('parentDir'))
|
|
1195
1397
|
up.type = 'button'
|
|
1196
1398
|
up.addEventListener('click', () => { go(parent) })
|
|
1197
1399
|
body.push(up)
|
|
1198
1400
|
}
|
|
1199
1401
|
|
|
1200
1402
|
if (state.error !== null) {
|
|
1201
|
-
const failure = renderFailureNode(state.error, state.bootstrap)
|
|
1403
|
+
const failure = renderFailureNode(state.error, state.bootstrap, state)
|
|
1202
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
|
+
}
|
|
1203
1416
|
}
|
|
1204
1417
|
|
|
1205
|
-
const cancel = el('button', BUTTON_STYLE, '
|
|
1418
|
+
const cancel = el('button', BUTTON_STYLE, copy('cancel'))
|
|
1206
1419
|
cancel.type = 'button'
|
|
1207
1420
|
cancel.addEventListener('click', () => { close() })
|
|
1208
1421
|
const confirm = el('button', {
|
|
1209
1422
|
...PRIMARY_STYLE,
|
|
1210
1423
|
opacity: state.working ? '0.6' : '1',
|
|
1211
|
-
}, state.working ? '
|
|
1424
|
+
}, state.working ? copy('working') : copy('createOpen'))
|
|
1212
1425
|
confirm.type = 'button'
|
|
1213
1426
|
confirm.setAttribute('data-wsl-action', 'create')
|
|
1214
1427
|
confirm.disabled = state.working || state.distro === null
|
|
@@ -1247,6 +1460,7 @@ window.__ModuleLoader__.load({
|
|
|
1247
1460
|
document.addEventListener('keydown', onKey)
|
|
1248
1461
|
hostElement().append(overlay)
|
|
1249
1462
|
render()
|
|
1463
|
+
if (!overlay.contains(document.activeElement)) focusables()[0]?.focus()
|
|
1250
1464
|
|
|
1251
1465
|
// The rejection handler only ever sees a refusal of listDistros itself; a
|
|
1252
1466
|
// throw INSIDE the continuation (a body the route did not expect,
|
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
|
+
}
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure decisions behind the live-release preflight.
|
|
3
|
+
*
|
|
4
|
+
* The preflight exists because the 0.6.0 release exposed two failure modes that
|
|
5
|
+
* looked like code reds but were environment states: a production profile installs
|
|
6
|
+
* the npm package with `developerTools: false` (every dev-token call is a 401), and a
|
|
7
|
+
* staged generation can lag the tree while the host keeps serving the old bundle.
|
|
8
|
+
* Both are decided from small, testable inputs — a patch file and a bootstrap
|
|
9
|
+
* envelope — so this module carries no I/O and no `node:` imports.
|
|
10
|
+
* @module dsh-wsl-desktop/release-preflight
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
/** Markers the current browser half must carry in the served bundle. */
|
|
14
|
+
export const REQUIRED_CLIENT_MARKERS = Object.freeze([
|
|
15
|
+
'bootstrapOnce',
|
|
16
|
+
'data-wsl-degraded',
|
|
17
|
+
'data-wsl-health',
|
|
18
|
+
'data-wsl-degraded-retry',
|
|
19
|
+
])
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Read the developer-surface setting from an installed bundle patch.
|
|
23
|
+
*
|
|
24
|
+
* `sync.ps1` stages `config: { developerTools: true }`; the published package ships
|
|
25
|
+
* no config block at all, so absence means off. An unreadable/missing patch is also
|
|
26
|
+
* off: the preflight must not crash on the very state it reports.
|
|
27
|
+
* @param {unknown} patchText - `cordis.patch.yml` contents.
|
|
28
|
+
* @returns {{ found: boolean, developerTools: boolean }} the setting.
|
|
29
|
+
*/
|
|
30
|
+
export function parseDevSurface(patchText) {
|
|
31
|
+
if (typeof patchText !== 'string') return { found: false, developerTools: false }
|
|
32
|
+
const match = /^\s*developerTools:\s*(true|false)\s*$/mu.exec(patchText)
|
|
33
|
+
if (match === null) return { found: false, developerTools: false }
|
|
34
|
+
return { found: true, developerTools: match[1] === 'true' }
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Classify a bootstrap answer.
|
|
39
|
+
* @param {{ status: number, json: object|null, expectedVersion: string, expectedCodes?: string[] }} input -
|
|
40
|
+
* the HTTP status, parsed body, and the version/codes this checkout requires.
|
|
41
|
+
* @returns {{ kind: string, detail: string }} the verdict; `kind` is `ready` when the
|
|
42
|
+
* host is this tree with a working dev surface.
|
|
43
|
+
*/
|
|
44
|
+
export function classifyBootstrap({ status, json, expectedVersion, expectedCodes = [] }) {
|
|
45
|
+
if (status === 401) {
|
|
46
|
+
return { kind: 'dev-surface-disabled', detail: 'the route answered 401: the development token surface is off' }
|
|
47
|
+
}
|
|
48
|
+
if (status === 0 || status === undefined) {
|
|
49
|
+
return { kind: 'unreachable', detail: 'no HTTP response: the desktop is not running or the URL is wrong' }
|
|
50
|
+
}
|
|
51
|
+
if (json === null || typeof json !== 'object' || json.ok !== true) {
|
|
52
|
+
return { kind: 'bad-shape', detail: `HTTP ${status}: the route answered no bootstrap envelope` }
|
|
53
|
+
}
|
|
54
|
+
const value = json.value
|
|
55
|
+
if (value === null || typeof value !== 'object') {
|
|
56
|
+
return { kind: 'bad-shape', detail: `HTTP ${status}: bootstrap carried no value` }
|
|
57
|
+
}
|
|
58
|
+
if (value.pluginVersion !== expectedVersion) {
|
|
59
|
+
return {
|
|
60
|
+
kind: 'version-mismatch',
|
|
61
|
+
detail: `host reports ${String(value.pluginVersion)}, this checkout is ${expectedVersion}`,
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
const capability = value.capabilities?.status
|
|
65
|
+
if (capability !== 'ok' && capability !== 'degraded' && capability !== 'pending') {
|
|
66
|
+
return { kind: 'missing-capabilities', detail: `bootstrap carries no capability status (${String(capability)})` }
|
|
67
|
+
}
|
|
68
|
+
const codes = value.catalog?.codes
|
|
69
|
+
const missing = codes === null || typeof codes !== 'object'
|
|
70
|
+
? expectedCodes
|
|
71
|
+
: expectedCodes.filter((code) => !Object.hasOwn(codes, code))
|
|
72
|
+
if (missing.length > 0) {
|
|
73
|
+
return { kind: 'missing-codes', detail: `catalog is missing: ${missing.join(', ')}` }
|
|
74
|
+
}
|
|
75
|
+
return { kind: 'ready', detail: `host ${expectedVersion}, dev surface on` }
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Name the required markers a served bundle is missing.
|
|
80
|
+
* @param {unknown} bundleText - the served client module.
|
|
81
|
+
* @param {string[]} [markers] - the required markers.
|
|
82
|
+
* @returns {string[]} the missing markers, in requirement order.
|
|
83
|
+
*/
|
|
84
|
+
export function missingClientMarkers(bundleText, markers = REQUIRED_CLIENT_MARKERS) {
|
|
85
|
+
const text = String(bundleText ?? '')
|
|
86
|
+
return markers.filter((marker) => !text.includes(marker))
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* The operator-facing remedy for a host whose dev surface is off.
|
|
91
|
+
* @returns {string} a short, copy-pasteable explanation.
|
|
92
|
+
*/
|
|
93
|
+
export function devSurfaceRemedy() {
|
|
94
|
+
return [
|
|
95
|
+
'The running host is not exposing its development surface (401).',
|
|
96
|
+
'The published npm package ships developerTools: false by design; the staged dev',
|
|
97
|
+
'generation turns it on.',
|
|
98
|
+
'For a live verification run:',
|
|
99
|
+
' 1. .\\scripts\\sync.ps1 (stage a dev generation with developerTools: true)',
|
|
100
|
+
' 2. restart DSH Desktop (the host half reloads; a page refresh is not enough)',
|
|
101
|
+
' 3. node scripts/verify-all.mjs --live',
|
|
102
|
+
'To return to the release shape afterwards, reinstall the registry bundle with',
|
|
103
|
+
'plugin_manager remove_bundle + install_bundle dsh-wsl-desktop@<version>.',
|
|
104
|
+
].join('\n')
|
|
105
|
+
}
|
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
|
*
|