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 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. Older copy in this file
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 `缺少:${detail.slice('missing: '.length)}`
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
- overlay.append(card)
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') close()
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
- t(
850
- `环境检查:${counts.pass} 项通过${counts.warn > 0 ? `,${counts.warn} 项建议处理` : ''}${counts.fail > 0 ? `,${counts.fail} 项失败` : ''}`,
851
- `Health: ${counts.pass} passed${counts.warn > 0 ? `, ${counts.warn} advisory` : ''}${counts.fail > 0 ? `, ${counts.fail} failed` : ''}`,
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(`${target} 不是一个存在的目录`), {
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 }, '没有可用的 WSL 发行版'))
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 }, `用户 ${state.user} · 主目录 ${state.home}(可直接修改)`))
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 = '/home/用户名/项目'
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
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-wsl-desktop",
3
- "version": "0.6.0",
3
+ "version": "0.7.0",
4
4
  "type": "module",
5
5
  "main": "lib/index.js",
6
6
  "icon": "./lib/icon.svg",