dsh-wsl-desktop 0.6.0 → 0.7.0
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 +4 -0
- package/README.md +3 -0
- package/lib/client.js +143 -32
- package/lib/release-preflight.js +105 -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).
|
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
|
|
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,82 @@ 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
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Look up one COPY entry and fill its `{name}` placeholders.
|
|
126
|
+
* @param {string} key - the COPY key.
|
|
127
|
+
* @param {Record<string, unknown>} [vars] - placeholder values.
|
|
128
|
+
* @returns {string} the copy.
|
|
129
|
+
*/
|
|
130
|
+
function copy(key, vars = {}) {
|
|
131
|
+
const entry = COPY[key]
|
|
132
|
+
const text = entry?.[LOCALE] ?? entry?.en ?? key
|
|
133
|
+
return text.replace(/\{(\w+)\}/g, (_, name) => String(vars[name] ?? ''))
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** Accessible name and tooltip of the companion button. */
|
|
137
|
+
const BUTTON_LABEL = copy('trigger')
|
|
138
|
+
|
|
139
|
+
/** Inline style for text that screen readers get but the page does not show. */
|
|
140
|
+
const VISUALLY_HIDDEN = {
|
|
141
|
+
position: 'absolute',
|
|
142
|
+
width: '1px',
|
|
143
|
+
height: '1px',
|
|
144
|
+
padding: '0',
|
|
145
|
+
margin: '-1px',
|
|
146
|
+
overflow: 'hidden',
|
|
147
|
+
clip: 'rect(0,0,0,0)',
|
|
148
|
+
whiteSpace: 'nowrap',
|
|
149
|
+
border: '0',
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/** The open dialog's polite live region; null while no dialog is open. */
|
|
153
|
+
let liveStatus = null
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* Announce a short status through the dialog's live region.
|
|
157
|
+
* @param {string} text - the message.
|
|
158
|
+
*/
|
|
159
|
+
function announce(text) {
|
|
160
|
+
if (liveStatus === null) return
|
|
161
|
+
liveStatus.textContent = ''
|
|
162
|
+
liveStatus.textContent = text
|
|
163
|
+
}
|
|
164
|
+
|
|
93
165
|
/** Copy used only when the catalog itself could not be fetched. */
|
|
94
166
|
const FALLBACK_MESSAGES = {
|
|
95
167
|
transport: { zh: '连不上插件的本地路由。', en: 'The plugin route could not be reached.' },
|
|
@@ -135,7 +207,7 @@ window.__ModuleLoader__.load({
|
|
|
135
207
|
*/
|
|
136
208
|
function localizeDetail(detail) {
|
|
137
209
|
if (LOCALE !== 'zh' || typeof detail !== 'string') return detail
|
|
138
|
-
if (detail.startsWith('missing: ')) return
|
|
210
|
+
if (detail.startsWith('missing: ')) return `${copy('missingPrefix')}${detail.slice('missing: '.length)}`
|
|
139
211
|
// Version strings carry their own identity (Python x.y, dsh-wsl-confine v1.2).
|
|
140
212
|
if (detail.startsWith('Python ') || detail.startsWith('dsh-wsl-confine ')) return detail
|
|
141
213
|
return DETAIL_ZH[detail] ?? detail
|
|
@@ -299,7 +371,7 @@ window.__ModuleLoader__.load({
|
|
|
299
371
|
const create = ctx.remote?.session?.create
|
|
300
372
|
if (typeof create !== 'function') return ctx.sessions.create({ workspaceId })
|
|
301
373
|
const result = await create({ workspaceId, agentPreset })
|
|
302
|
-
if (result?.ok !== true) throw new Error(result?.error?.message ?? '
|
|
374
|
+
if (result?.ok !== true) throw new Error(result?.error?.message ?? copy('createRefused'))
|
|
303
375
|
return result.value.sessionId
|
|
304
376
|
}
|
|
305
377
|
|
|
@@ -428,6 +500,7 @@ window.__ModuleLoader__.load({
|
|
|
428
500
|
copy.addEventListener('click', () => {
|
|
429
501
|
void navigator.clipboard?.writeText?.(String(remedy.value ?? ''))
|
|
430
502
|
copy.textContent = t('已复制', 'Copied')
|
|
503
|
+
announce(t('已复制', 'Copied'))
|
|
431
504
|
})
|
|
432
505
|
row.append(copy)
|
|
433
506
|
return row
|
|
@@ -459,6 +532,7 @@ window.__ModuleLoader__.load({
|
|
|
459
532
|
?? t('插件内部错误。', 'The plugin hit an internal error.')
|
|
460
533
|
const box = el('div', { fontSize: '12px', color: T.error, display: 'flex', flexDirection: 'column', gap: '4px' })
|
|
461
534
|
box.setAttribute('role', 'alert')
|
|
535
|
+
box.setAttribute('aria-live', 'assertive')
|
|
462
536
|
box.setAttribute('data-wsl-error', failure.code)
|
|
463
537
|
box.append(el('div', {}, copy))
|
|
464
538
|
const remedy = entry === undefined ? null : renderRemedy(entry.remedy)
|
|
@@ -478,6 +552,7 @@ window.__ModuleLoader__.load({
|
|
|
478
552
|
copyRef.addEventListener('click', () => {
|
|
479
553
|
void navigator.clipboard?.writeText?.(ref)
|
|
480
554
|
copyRef.textContent = t('已复制', 'Copied')
|
|
555
|
+
announce(t('已复制', 'Copied'))
|
|
481
556
|
})
|
|
482
557
|
row.append(copyRef)
|
|
483
558
|
box.append(row)
|
|
@@ -548,6 +623,7 @@ window.__ModuleLoader__.load({
|
|
|
548
623
|
copy.addEventListener('click', () => {
|
|
549
624
|
void navigator.clipboard?.writeText?.(diagnostics)
|
|
550
625
|
copy.textContent = t('已复制', 'Copied')
|
|
626
|
+
announce(t('已复制', 'Copied'))
|
|
551
627
|
})
|
|
552
628
|
panel.append(copy)
|
|
553
629
|
if (typeof onRetry === 'function') {
|
|
@@ -656,7 +732,14 @@ window.__ModuleLoader__.load({
|
|
|
656
732
|
overlay.setAttribute('role', 'dialog')
|
|
657
733
|
overlay.setAttribute('aria-modal', 'true')
|
|
658
734
|
overlay.setAttribute('aria-label', BUTTON_LABEL)
|
|
659
|
-
|
|
735
|
+
const description = el('div', VISUALLY_HIDDEN, copy('dialogDescription'))
|
|
736
|
+
description.id = 'wsl-dialog-description'
|
|
737
|
+
overlay.setAttribute('aria-describedby', description.id)
|
|
738
|
+
const status = el('div', VISUALLY_HIDDEN, '')
|
|
739
|
+
status.setAttribute('role', 'status')
|
|
740
|
+
status.setAttribute('aria-live', 'polite')
|
|
741
|
+
liveStatus = status
|
|
742
|
+
overlay.append(description, card, status)
|
|
660
743
|
overlay.addEventListener('click', () => { close() })
|
|
661
744
|
card.addEventListener('click', (event) => { event.stopPropagation() })
|
|
662
745
|
|
|
@@ -676,6 +759,7 @@ window.__ModuleLoader__.load({
|
|
|
676
759
|
function forceClose() {
|
|
677
760
|
state.closed = true
|
|
678
761
|
state.token += 1
|
|
762
|
+
liveStatus = null
|
|
679
763
|
document.removeEventListener('keydown', onKey)
|
|
680
764
|
overlay.remove()
|
|
681
765
|
opener?.setAttribute?.('aria-expanded', 'false')
|
|
@@ -685,8 +769,34 @@ window.__ModuleLoader__.load({
|
|
|
685
769
|
teardown = null
|
|
686
770
|
}
|
|
687
771
|
|
|
772
|
+
/** The focusable controls inside the dialog, in document order. */
|
|
773
|
+
function focusables() {
|
|
774
|
+
return [...overlay.querySelectorAll('button:not([disabled]), [href], input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])')]
|
|
775
|
+
}
|
|
776
|
+
|
|
688
777
|
function onKey(event) {
|
|
689
|
-
if (event.key === 'Escape')
|
|
778
|
+
if (event.key === 'Escape') {
|
|
779
|
+
close()
|
|
780
|
+
return
|
|
781
|
+
}
|
|
782
|
+
if (event.key !== 'Tab') return
|
|
783
|
+
// A modal dialog owns the keyboard: Tab from the last control wraps to the
|
|
784
|
+
// first and Shift+Tab from the first wraps to the last, so focus cannot
|
|
785
|
+
// escape into the page behind the overlay.
|
|
786
|
+
const focusable = focusables()
|
|
787
|
+
if (focusable.length === 0) return
|
|
788
|
+
const first = focusable[0]
|
|
789
|
+
const last = focusable[focusable.length - 1]
|
|
790
|
+
if (event.shiftKey && document.activeElement === first) {
|
|
791
|
+
event.preventDefault()
|
|
792
|
+
last.focus()
|
|
793
|
+
} else if (!event.shiftKey && document.activeElement === last) {
|
|
794
|
+
event.preventDefault()
|
|
795
|
+
first.focus()
|
|
796
|
+
} else if (!overlay.contains(document.activeElement)) {
|
|
797
|
+
event.preventDefault()
|
|
798
|
+
first.focus()
|
|
799
|
+
}
|
|
690
800
|
}
|
|
691
801
|
|
|
692
802
|
/** Discover distributions only after the capability gate has cleared. */
|
|
@@ -816,6 +926,7 @@ window.__ModuleLoader__.load({
|
|
|
816
926
|
if (state.health.status === 'loading') {
|
|
817
927
|
strip.setAttribute('data-wsl-health', 'loading')
|
|
818
928
|
strip.setAttribute('role', 'status')
|
|
929
|
+
strip.setAttribute('aria-live', 'polite')
|
|
819
930
|
strip.append(el('div', { fontSize: '12px', color: T.dim }, t('环境检查:检查中…', 'Health check: running…')))
|
|
820
931
|
body.push(strip)
|
|
821
932
|
return
|
|
@@ -846,10 +957,11 @@ window.__ModuleLoader__.load({
|
|
|
846
957
|
const measured = checks.some((check) => check.status !== 'unknown')
|
|
847
958
|
const summary = el('div', { display: 'flex', justifyContent: 'space-between', alignItems: 'center', gap: '8px' })
|
|
848
959
|
summary.append(el('span', { fontSize: '12px', color: counts.fail > 0 ? T.error : T.dim },
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
960
|
+
copy('healthSummary', {
|
|
961
|
+
pass: counts.pass,
|
|
962
|
+
warn: counts.warn > 0 ? copy('healthWarn', { count: counts.warn }) : '',
|
|
963
|
+
fail: counts.fail > 0 ? copy('healthFail', { count: counts.fail }) : '',
|
|
964
|
+
})))
|
|
853
965
|
const toggle = el('button', { ...BUTTON_STYLE, padding: '2px 8px', fontSize: '11px' },
|
|
854
966
|
state.healthOpen ? t('收起', 'Hide') : t('展开', 'Details'))
|
|
855
967
|
toggle.type = 'button'
|
|
@@ -988,7 +1100,7 @@ window.__ModuleLoader__.load({
|
|
|
988
1100
|
try {
|
|
989
1101
|
const facts = await call('checkPath', { distro: state.distro, path: target })
|
|
990
1102
|
if (facts.isDirectory !== true) {
|
|
991
|
-
throw Object.assign(new Error(
|
|
1103
|
+
throw Object.assign(new Error(copy('notADirectory', { target })), {
|
|
992
1104
|
code: 'path-not-directory',
|
|
993
1105
|
params: { path: target },
|
|
994
1106
|
})
|
|
@@ -1054,8 +1166,7 @@ window.__ModuleLoader__.load({
|
|
|
1054
1166
|
const body = []
|
|
1055
1167
|
|
|
1056
1168
|
body.push(el('div', { fontSize: '15px', fontWeight: '600' }, BUTTON_LABEL))
|
|
1057
|
-
body.push(el('div', { fontSize: '12px', color: T.dim },
|
|
1058
|
-
'新会话的 bash 与文件工具都会在该发行版里工作;Windows 文件可在 /mnt/<盘符> 下访问。'))
|
|
1169
|
+
body.push(el('div', { fontSize: '12px', color: T.dim }, copy('sessionHint')))
|
|
1059
1170
|
|
|
1060
1171
|
if (state.phase === 'loading') {
|
|
1061
1172
|
const status = el('div', { fontSize: '12px', color: T.dim }, t('检查插件状态…', 'Checking the plugin…'))
|
|
@@ -1070,11 +1181,11 @@ window.__ModuleLoader__.load({
|
|
|
1070
1181
|
}
|
|
1071
1182
|
|
|
1072
1183
|
const distros = el('div', { display: 'flex', flexWrap: 'wrap', gap: '6px', alignItems: 'center' })
|
|
1073
|
-
distros.append(el('span', { fontSize: '12px', color: T.dim }, '
|
|
1184
|
+
distros.append(el('span', { fontSize: '12px', color: T.dim }, copy('distroLabel')))
|
|
1074
1185
|
if (state.distros === null) {
|
|
1075
|
-
distros.append(el('span', { fontSize: '12px', color: T.dim }, '
|
|
1186
|
+
distros.append(el('span', { fontSize: '12px', color: T.dim }, copy('reading')))
|
|
1076
1187
|
} else if (state.distros.length === 0) {
|
|
1077
|
-
distros.append(el('span', { fontSize: '12px', color: T.error }, '
|
|
1188
|
+
distros.append(el('span', { fontSize: '12px', color: T.error }, copy('noDistros')))
|
|
1078
1189
|
} else {
|
|
1079
1190
|
for (const name of state.distros) distros.append(pill(name))
|
|
1080
1191
|
}
|
|
@@ -1084,11 +1195,10 @@ window.__ModuleLoader__.load({
|
|
|
1084
1195
|
// The user gate: no browsing until the distribution user is named.
|
|
1085
1196
|
// An empty field means the distribution's default user; an unknown
|
|
1086
1197
|
// user fails right here instead of opening the browser on a guess.
|
|
1087
|
-
body.push(el('div', { fontSize: '12px', color: T.dim },
|
|
1088
|
-
'输入要进入该发行版的用户;确认后直接落在其主目录。'))
|
|
1198
|
+
body.push(el('div', { fontSize: '12px', color: T.dim }, copy('userHint')))
|
|
1089
1199
|
const userField = el('input', FIELD_STYLE)
|
|
1090
1200
|
userField.value = state.userDraft
|
|
1091
|
-
userField.placeholder = '
|
|
1201
|
+
userField.placeholder = copy('userPlaceholder')
|
|
1092
1202
|
userField.addEventListener('input', () => { state.userDraft = userField.value })
|
|
1093
1203
|
userField.addEventListener('keydown', (event) => {
|
|
1094
1204
|
if (event.key === 'Enter' && state.distro !== null) void confirmUser()
|
|
@@ -1096,7 +1206,7 @@ window.__ModuleLoader__.load({
|
|
|
1096
1206
|
const enter = el('button', {
|
|
1097
1207
|
...PRIMARY_STYLE,
|
|
1098
1208
|
opacity: state.pending ? '0.6' : '1',
|
|
1099
|
-
}, state.pending ? '
|
|
1209
|
+
}, state.pending ? copy('resolving') : copy('confirmUser'))
|
|
1100
1210
|
enter.type = 'button'
|
|
1101
1211
|
enter.setAttribute('data-wsl-action', 'confirm-user')
|
|
1102
1212
|
enter.disabled = state.pending || state.distro === null
|
|
@@ -1108,26 +1218,26 @@ window.__ModuleLoader__.load({
|
|
|
1108
1218
|
if (failure !== null) body.push(failure)
|
|
1109
1219
|
}
|
|
1110
1220
|
|
|
1111
|
-
const cancel = el('button', BUTTON_STYLE, '
|
|
1221
|
+
const cancel = el('button', BUTTON_STYLE, copy('cancel'))
|
|
1112
1222
|
cancel.type = 'button'
|
|
1113
1223
|
cancel.addEventListener('click', () => { close() })
|
|
1114
1224
|
body.push(el('div', { display: 'flex', justifyContent: 'flex-end' }, cancel))
|
|
1115
1225
|
} else {
|
|
1116
1226
|
// The confirmed user and their home: provenance of the prefill.
|
|
1117
1227
|
if (state.user !== null && state.home !== null) {
|
|
1118
|
-
body.push(el('div', { fontSize: '12px', color: T.dim },
|
|
1228
|
+
body.push(el('div', { fontSize: '12px', color: T.dim }, copy('userHome', { user: state.user, home: state.home })))
|
|
1119
1229
|
}
|
|
1120
1230
|
|
|
1121
1231
|
renderHealthStrip(body)
|
|
1122
1232
|
|
|
1123
1233
|
const input = el('input', FIELD_STYLE)
|
|
1124
1234
|
input.value = state.draft
|
|
1125
|
-
input.placeholder = '
|
|
1235
|
+
input.placeholder = copy('pathPlaceholder')
|
|
1126
1236
|
input.addEventListener('input', () => { state.draft = input.value })
|
|
1127
1237
|
input.addEventListener('keydown', (event) => {
|
|
1128
1238
|
if (event.key === 'Enter' && state.distro !== null) go(state.draft)
|
|
1129
1239
|
})
|
|
1130
|
-
const goButton = el('button', BUTTON_STYLE, '
|
|
1240
|
+
const goButton = el('button', BUTTON_STYLE, copy('go'))
|
|
1131
1241
|
goButton.type = 'button'
|
|
1132
1242
|
goButton.disabled = state.distro === null
|
|
1133
1243
|
goButton.addEventListener('click', () => { go(state.draft) })
|
|
@@ -1155,9 +1265,9 @@ window.__ModuleLoader__.load({
|
|
|
1155
1265
|
})
|
|
1156
1266
|
const directories = (state.listing?.entries ?? []).filter((entry) => entry.kind === 'directory')
|
|
1157
1267
|
if (state.listing === null) {
|
|
1158
|
-
list.append(el('div', { fontSize: '12px', color: T.dim, padding: '6px' }, '
|
|
1268
|
+
list.append(el('div', { fontSize: '12px', color: T.dim, padding: '6px' }, copy('readingDir')))
|
|
1159
1269
|
} else if (directories.length === 0) {
|
|
1160
|
-
list.append(el('div', { fontSize: '12px', color: T.dim, padding: '6px' }, '
|
|
1270
|
+
list.append(el('div', { fontSize: '12px', color: T.dim, padding: '6px' }, copy('noSubdirs')))
|
|
1161
1271
|
} else {
|
|
1162
1272
|
for (const entry of directories) {
|
|
1163
1273
|
const item = el('button', {
|
|
@@ -1191,7 +1301,7 @@ window.__ModuleLoader__.load({
|
|
|
1191
1301
|
|
|
1192
1302
|
const parent = parentOf(state.path)
|
|
1193
1303
|
if (parent !== null) {
|
|
1194
|
-
const up = el('button', { ...BUTTON_STYLE, alignSelf: 'flex-start', padding: '4px 10px' }, '
|
|
1304
|
+
const up = el('button', { ...BUTTON_STYLE, alignSelf: 'flex-start', padding: '4px 10px' }, copy('parentDir'))
|
|
1195
1305
|
up.type = 'button'
|
|
1196
1306
|
up.addEventListener('click', () => { go(parent) })
|
|
1197
1307
|
body.push(up)
|
|
@@ -1202,13 +1312,13 @@ window.__ModuleLoader__.load({
|
|
|
1202
1312
|
if (failure !== null) body.push(failure)
|
|
1203
1313
|
}
|
|
1204
1314
|
|
|
1205
|
-
const cancel = el('button', BUTTON_STYLE, '
|
|
1315
|
+
const cancel = el('button', BUTTON_STYLE, copy('cancel'))
|
|
1206
1316
|
cancel.type = 'button'
|
|
1207
1317
|
cancel.addEventListener('click', () => { close() })
|
|
1208
1318
|
const confirm = el('button', {
|
|
1209
1319
|
...PRIMARY_STYLE,
|
|
1210
1320
|
opacity: state.working ? '0.6' : '1',
|
|
1211
|
-
}, state.working ? '
|
|
1321
|
+
}, state.working ? copy('working') : copy('createOpen'))
|
|
1212
1322
|
confirm.type = 'button'
|
|
1213
1323
|
confirm.setAttribute('data-wsl-action', 'create')
|
|
1214
1324
|
confirm.disabled = state.working || state.distro === null
|
|
@@ -1247,6 +1357,7 @@ window.__ModuleLoader__.load({
|
|
|
1247
1357
|
document.addEventListener('keydown', onKey)
|
|
1248
1358
|
hostElement().append(overlay)
|
|
1249
1359
|
render()
|
|
1360
|
+
if (!overlay.contains(document.activeElement)) focusables()[0]?.focus()
|
|
1250
1361
|
|
|
1251
1362
|
// The rejection handler only ever sees a refusal of listDistros itself; a
|
|
1252
1363
|
// throw INSIDE the continuation (a body the route did not expect,
|
|
@@ -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
|
+
}
|