@monster-orange/den 0.4.4 → 0.4.5

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.
Files changed (43) hide show
  1. package/CHANGELOG.md +169 -0
  2. package/README.md +10 -9
  3. package/docs/distributions.md +223 -26
  4. package/docs/reference.md +8 -3
  5. package/package.json +1 -1
  6. package/src/shared/auth/sudo-vault.ts +115 -11
  7. package/src/shared/cli-command.ts +186 -0
  8. package/src/shared/confirm-code.ts +34 -0
  9. package/src/shared/den-config.ts +7 -0
  10. package/src/shared/dev-metrics/emit.ts +31 -1
  11. package/src/shared/edge/config.ts +39 -0
  12. package/src/shared/edge/render.ts +92 -18
  13. package/src/shared/edge/runtime.ts +57 -1
  14. package/src/shared/edge/types.ts +11 -1
  15. package/src/shared/github-app.ts +94 -0
  16. package/src/shared/github-device.ts +286 -0
  17. package/src/shared/secrets/onepassword.ts +27 -4
  18. package/src/shared/web-owner.ts +91 -0
  19. package/src/shared/web-session.ts +135 -1
  20. package/src/ui/cli/flag-parser.ts +1 -1
  21. package/src/ui/cli/web-command.ts +512 -33
  22. package/src/ui/server/device-flows.ts +274 -0
  23. package/src/ui/server/edge-exposure.ts +16 -4
  24. package/src/ui/server/github-routes.ts +321 -0
  25. package/src/ui/server/http-errors.ts +10 -0
  26. package/src/ui/server/index.ts +463 -27
  27. package/src/ui/server/login-waits.ts +203 -0
  28. package/src/ui/server/pair-requests.ts +237 -0
  29. package/src/ui/server/pair-routes.ts +196 -0
  30. package/src/ui/server/public/error-page.js +452 -40
  31. package/src/ui/server/public/error.html +59 -0
  32. package/src/ui/server/public/i18n/es.js +74 -8
  33. package/src/ui/server/public/i18n/ja.js +74 -8
  34. package/src/ui/server/public/local-dev.html +16 -4
  35. package/src/ui/server/public/qr.js +317 -0
  36. package/src/ui/server/public/settings-page.js +10 -3
  37. package/src/ui/server/public/settings-registry.js +392 -1
  38. package/src/ui/server/public/shell.js +123 -10
  39. package/src/ui/server/public/topbar.js +7 -3
  40. package/src/ui/server/public/workspace-picker.js +1 -1
  41. package/src/ui/server/request-guard.ts +102 -35
  42. package/src/ui/server/route-classes.ts +102 -2
  43. package/src/ui/server/ui-assets.ts +2 -0
@@ -27,7 +27,9 @@
27
27
  "org.title": "Org: {title}",
28
28
  "org.titleId": "Org: {title} ({id})",
29
29
  "ws.all": "Todos los activos",
30
- "ws.focus": "foco",
30
+ "ws.focus": "predeterminado de la CLI",
31
+ "ws.focusTip":
32
+ "Los comandos de la CLI se ejecutan aquí si no indicas un espacio de trabajo. Elegir un espacio en la barra superior solo cambia lo que muestran las páginas.",
31
33
  "ws.none": "No hay espacios de trabajo activos",
32
34
  "ws.manage": "Gestionar espacios de trabajo…",
33
35
  "ws.allN": "Todos los activos ({n})",
@@ -37,8 +39,8 @@
37
39
  "ws.viewTitle":
38
40
  "Vista: lo que muestra cada página ({ws}). Es solo un filtro: no inicia, detiene ni enruta nada.",
39
41
  "ws.viewTitleFocus":
40
- "Vista: lo que muestra cada página ({ws}). Es solo un filtro: no inicia, detiene ni enruta nada, y el foco ({focus}) sigue siendo el destino de los comandos.",
41
- "ws.setFocus": "Poner el foco en {ws}",
42
+ "Vista: lo que muestra cada página ({ws}). Es solo un filtro: no inicia, detiene ni enruta nada, y el predeterminado de la CLI ({focus}) sigue siendo el destino de los comandos.",
43
+ "ws.setFocus": "Hacer de {ws} el predeterminado de la CLI",
42
44
  "ws.setFocusTip":
43
45
  "Lo convierte en el destino por defecto de los comandos (escribe workspaces.yml)",
44
46
  "ws.viewFailed": "No se pudo cambiar la vista: {msg}",
@@ -242,19 +244,74 @@
242
244
  "Este servidor solo responde en sus propias direcciones, así que una página con otro nombre no puede alcanzarlo.",
243
245
  "err.421.open": "Abrir en localhost",
244
246
  "err.refused.title": "Solicitud rechazada",
245
- "err.signin.title": "Inicia sesión para usar {name}",
246
247
  "err.signin.terminal": "Inicia sesión para usar la terminal",
247
248
  "err.signin.agents": "Inicia sesión para iniciar agentes",
248
249
  "err.signin.sub": "Este navegador no ha iniciado sesión",
249
250
  "err.signin.lead": "Ejecuta {cmd} y abre el enlace.",
250
251
  "err.signin.expiredTitle": "El enlace de inicio de sesión caducó",
251
- "err.signin.expired":
252
- "Ese enlace de inicio de sesión caducó o ya se usó. Ejecuta {cmd} para obtener uno nuevo.",
253
252
  "err.signin.local":
254
253
  "Un inicio de sesión pertenece a una sola dirección. Esta página está en {host}: abre la misma página en localhost, donde este navegador tiene la sesión iniciada.",
255
- "err.signin.why":
256
- "No hay contraseña: el enlace sale de un archivo que solo tu cuenta de usuario puede leer.",
257
254
  "err.signin.copy": "Copiar comando",
255
+ "err.signin.title": "Inicia sesión en {name}",
256
+ "err.signin.where":
257
+ "Estás abriendo {name} desde otro ordenador. {name} corre en {host}, así que necesita saber que eres tú.",
258
+ "err.signin.githubNote":
259
+ "Usa tu cuenta de GitHub. El propietario de este {name} tiene que haber vinculado la suya.",
260
+ "err.signin.githubNoOwner":
261
+ "Iniciar sesión con GitHub necesita un propietario, y este {name} aún no tiene. En {host} (no en este ordenador), ejecuta:",
262
+ "err.signin.githubRefused":
263
+ "@{login} no es el propietario de este {name}. Pide al propietario que te añada, o usa «Aprobar este ordenador».",
264
+ "err.signin.approveOr": "O aprueba este ordenador",
265
+ "err.signin.approve": "Aprueba este ordenador",
266
+ "err.signin.step1": "En {host} (no en este ordenador), ejecuta:",
267
+ "err.signin.step1Or":
268
+ "O abre {name} en el propio {host} y ve a Ajustes › Acceso › Dispositivos en espera.",
269
+ "err.signin.step2":
270
+ "Esta página inicia sesión sola una vez aprobada. El código caduca en 10 minutos.",
271
+ "err.signin.waiting": "Esperando la aprobación…",
272
+ "err.signin.noTerminal":
273
+ "¿No tienes una terminal en {host}? Desde este ordenador, ejecuta:",
274
+ "err.signin.openTerminal": "Abrir Terminal",
275
+ "err.signin.sshNote":
276
+ "Abrir Terminal abre ssh://{target} en tu aplicación de terminal. Necesita acceso SSH a {target}.",
277
+ "err.signin.whyTitle": "¿Por qué?",
278
+ "err.signin.whyText":
279
+ "{name} puede iniciar terminales y agentes en {host}, así que solo deja entrar a quien demuestra ser el propietario. Solo lo haces una vez por ordenador: la sesión se mantiene.",
280
+ "err.signin.other": "Otras formas (el enlace de un solo uso con {cmd})",
281
+ "err.signin.otherStep":
282
+ "En {host} (no en este ordenador), ejecuta esto y abre en este navegador el enlace que imprime:",
283
+ "err.signin.codeExpired": "Este código caducó.",
284
+ "err.signin.codeDenied": "Este ordenador fue rechazado en {host}.",
285
+ "err.signin.codeFailed":
286
+ "{name} no dio un código a esta página. Inténtalo de nuevo.",
287
+ "err.signin.newCode": "Obtener un código nuevo",
288
+ "err.signin.youreIn": "Ya estás dentro.",
289
+ "err.signin.expiredLead":
290
+ "Ese enlace de inicio de sesión caducó o ya se usó. Inicia sesión de nuevo abajo.",
291
+ "err.signin.confirmLead":
292
+ "Introduce el código que web login imprimió en {host}:",
293
+ "err.signin.confirmGo": "Iniciar sesión",
294
+ "err.signin.confirmWrong":
295
+ "Ese código no es correcto. Revisa lo que imprimió web login e inténtalo de nuevo.",
296
+ "err.signin.confirmBusy":
297
+ "Demasiados intentos. Espera un minuto e inténtalo de nuevo.",
298
+ "err.signin.confirmGone":
299
+ "Este enlace ya no funciona. En {host}, ejecuta web login de nuevo para obtener uno nuevo.",
300
+ "err.signin.confirmWhy":
301
+ "El enlace solo no basta: también hace falta el código que se muestra en {host}, así que alguien que vea el enlace no puede iniciar sesión.",
302
+ "err.signin.github": "Iniciar sesión con GitHub",
303
+ "err.signin.githubEnter": "Introduce este código en {url}:",
304
+ "err.signin.githubOpen": "Abrir GitHub",
305
+ "err.signin.githubCopy": "Copiar código",
306
+ "err.signin.githubWaiting": "Esperando a que lo apruebes en GitHub…",
307
+ "err.signin.githubDone": "Sesión iniciada. Cargando…",
308
+ "err.signin.githubExpired": "El código caducó antes de aprobarse.",
309
+ "err.signin.githubDenied": "La solicitud se canceló en GitHub.",
310
+ "err.signin.githubBusy":
311
+ "Hay demasiados inicios de sesión en curso. Vuelve a intentarlo en unos minutos.",
312
+ "err.signin.githubFailed":
313
+ "No se pudo contactar con GitHub. Inténtalo de nuevo.",
314
+ "err.signin.githubAgain": "Empezar de nuevo",
258
315
  "err.422.title": "La configuración no es válida",
259
316
  "err.422.sub": "La configuración de la distribución no pasa la validación",
260
317
  "err.422.lead":
@@ -291,6 +348,15 @@
291
348
  "set.cli.web_port": "Tu puerto web",
292
349
  "set.cli.signin": "Inicio de sesión del navegador",
293
350
  "set.cli.sudo": "Contraseña de administrador",
351
+ "set.access.owner": "Propietario",
352
+ "set.access.devices": "Dispositivos vinculados",
353
+ "set.access.waiting": "Dispositivos en espera",
354
+ "set.access.machine_name": "Nombre de la máquina",
355
+ "ident.local": "Local",
356
+ "ident.device": "Sesión iniciada · este dispositivo",
357
+ "ident.toast":
358
+ "Este dispositivo tiene la sesión iniciada en {name} en {host}",
359
+ "set.access.pair": "Emparejar un dispositivo",
294
360
  "set.distribution.brand": "Nombre del producto",
295
361
  "set.distribution.github_org": "Org de GitHub",
296
362
  "set.distribution.orgs": "Etiquetas de org",
@@ -27,7 +27,9 @@
27
27
  "org.title": "組織: {title}",
28
28
  "org.titleId": "組織: {title} ({id})",
29
29
  "ws.all": "すべてのアクティブ",
30
- "ws.focus": "フォーカス",
30
+ "ws.focus": "CLI の既定",
31
+ "ws.focusTip":
32
+ "ワークスペースを指定しないとき、CLI のコマンドはここで実行されます。上部バーでワークスペースを選んでも、ページの表示が変わるだけです。",
31
33
  "ws.none": "アクティブなワークスペースはありません",
32
34
  "ws.manage": "ワークスペースを管理…",
33
35
  "ws.allN": "すべてのアクティブ ({n})",
@@ -37,8 +39,8 @@
37
39
  "ws.viewTitle":
38
40
  "表示: すべてのページに表示する範囲 ({ws})。表示の絞り込みだけで、何も起動・停止・ルーティングしません。",
39
41
  "ws.viewTitleFocus":
40
- "表示: すべてのページに表示する範囲 ({ws})。表示の絞り込みだけで、何も起動・停止・ルーティングしません。コマンドの対象はフォーカス ({focus}) のままです。",
41
- "ws.setFocus": "{ws} をフォーカスにする",
42
+ "表示: すべてのページに表示する範囲 ({ws})。表示の絞り込みだけで、何も起動・停止・ルーティングしません。コマンドの対象は CLI の既定 ({focus}) のままです。",
43
+ "ws.setFocus": "{ws} を CLI の既定にする",
42
44
  "ws.setFocusTip":
43
45
  "コマンドの既定の対象にします (workspaces.yml に書き込みます)",
44
46
  "ws.viewFailed": "表示を変更できませんでした: {msg}",
@@ -230,19 +232,75 @@
230
232
  "このサーバーは自身のアドレスでのみ応答するため、別の名前のページからは届きません。",
231
233
  "err.421.open": "localhost で開く",
232
234
  "err.refused.title": "リクエストが拒否されました",
233
- "err.signin.title": "{name} を使うにはサインインしてください",
234
235
  "err.signin.terminal": "ターミナルを使うにはサインインしてください",
235
236
  "err.signin.agents": "エージェントを起動するにはサインインしてください",
236
237
  "err.signin.sub": "このブラウザはサインインしていません",
237
238
  "err.signin.lead": "{cmd} を実行し、表示されたリンクを開いてください。",
238
239
  "err.signin.expiredTitle": "サインインリンクの期限切れ",
239
- "err.signin.expired":
240
- "このサインインリンクは期限切れか、使用済みです。{cmd} を実行して新しいリンクを取得してください。",
241
240
  "err.signin.local":
242
241
  "サインインはアドレスごとです。このページは {host} にあります。サインイン済みの localhost で同じページを開いてください。",
243
- "err.signin.why":
244
- "パスワードはありません。リンクは、あなたのユーザーアカウントだけが読めるファイルから作られます。",
245
242
  "err.signin.copy": "コマンドをコピー",
243
+ "err.signin.title": "{name} にサインイン",
244
+ "err.signin.where":
245
+ "別のコンピューターから {name} を開いています。{name} は {host} で動いているため、あなた本人であることを確認する必要があります。",
246
+ "err.signin.githubNote":
247
+ "あなたの GitHub アカウントを使います。この {name} のオーナーが自分のアカウントをリンクしている必要があります。",
248
+ "err.signin.githubNoOwner":
249
+ "GitHub でのサインインにはオーナーが必要ですが、この {name} にはまだいません。{host} で(このコンピューターではありません)次を実行してください:",
250
+ "err.signin.githubRefused":
251
+ "@{login} はこの {name} のオーナーではありません。オーナーに追加を依頼するか、「このコンピューターを承認」を使ってください。",
252
+ "err.signin.approveOr": "または、このコンピューターを承認",
253
+ "err.signin.approve": "このコンピューターを承認",
254
+ "err.signin.step1":
255
+ "{host} で(このコンピューターではありません)次を実行します:",
256
+ "err.signin.step1Or":
257
+ "または、{host} 上で {name} を開き、設定 › アクセス › 承認待ちのデバイス に進みます。",
258
+ "err.signin.step2":
259
+ "承認されると、このページは自動でサインインします。コードの有効期限は 10 分です。",
260
+ "err.signin.waiting": "承認を待っています…",
261
+ "err.signin.noTerminal":
262
+ "{host} のターミナルがありませんか?このコンピューターで次を実行します:",
263
+ "err.signin.openTerminal": "ターミナルを開く",
264
+ "err.signin.sshNote":
265
+ "「ターミナルを開く」はターミナルアプリで ssh://{target} を開きます。{target} への SSH アクセスが必要です。",
266
+ "err.signin.whyTitle": "なぜ?",
267
+ "err.signin.whyText":
268
+ "{name} は {host} でターミナルやエージェントを起動できるため、オーナーであることを証明した人だけを通します。これはコンピューターごとに一度だけで、サインインしたままになります。",
269
+ "err.signin.other": "その他の方法({cmd} による一回限りのリンク)",
270
+ "err.signin.otherStep":
271
+ "{host} で(このコンピューターではありません)次を実行し、表示されたリンクをこのブラウザで開きます:",
272
+ "err.signin.codeExpired": "このコードは期限切れです。",
273
+ "err.signin.codeDenied": "このコンピューターは {host} で拒否されました。",
274
+ "err.signin.codeFailed":
275
+ "{name} からコードを取得できませんでした。もう一度お試しください。",
276
+ "err.signin.newCode": "新しいコードを取得",
277
+ "err.signin.youreIn": "サインインしました。",
278
+ "err.signin.expiredLead":
279
+ "このサインインリンクは期限切れか、使用済みです。下から改めてサインインしてください。",
280
+ "err.signin.confirmLead":
281
+ "{host} で web login が表示したコードを入力してください:",
282
+ "err.signin.confirmGo": "サインイン",
283
+ "err.signin.confirmWrong":
284
+ "コードが正しくありません。web login の表示を確認して、もう一度お試しください。",
285
+ "err.signin.confirmBusy":
286
+ "試行回数が多すぎます。1 分待ってからもう一度お試しください。",
287
+ "err.signin.confirmGone":
288
+ "このリンクはもう使えません。{host} で web login をもう一度実行して新しいリンクを取得してください。",
289
+ "err.signin.confirmWhy":
290
+ "リンクだけでは足りません。{host} に表示されたコードも必要なので、他の人がリンクを見てもサインインできません。",
291
+ "err.signin.github": "GitHub でサインイン",
292
+ "err.signin.githubEnter": "{url} でこのコードを入力してください:",
293
+ "err.signin.githubOpen": "GitHub を開く",
294
+ "err.signin.githubCopy": "コードをコピー",
295
+ "err.signin.githubWaiting": "GitHub での承認を待っています…",
296
+ "err.signin.githubDone": "サインインしました。読み込み中…",
297
+ "err.signin.githubExpired": "承認される前にコードの有効期限が切れました。",
298
+ "err.signin.githubDenied": "GitHub でリクエストがキャンセルされました。",
299
+ "err.signin.githubBusy":
300
+ "進行中のサインインが多すぎます。数分後にもう一度お試しください。",
301
+ "err.signin.githubFailed":
302
+ "GitHub に接続できませんでした。もう一度お試しください。",
303
+ "err.signin.githubAgain": "やり直す",
246
304
  "err.422.title": "設定が無効です",
247
305
  "err.422.sub": "ディストリビューションの設定が検証に失敗しました",
248
306
  "err.422.lead":
@@ -279,6 +337,14 @@
279
337
  "set.cli.web_port": "あなたの web ポート",
280
338
  "set.cli.signin": "ブラウザのサインイン",
281
339
  "set.cli.sudo": "管理者パスワード",
340
+ "set.access.owner": "オーナー",
341
+ "set.access.devices": "リンク済みデバイス",
342
+ "set.access.waiting": "承認待ちのデバイス",
343
+ "set.access.machine_name": "マシン名",
344
+ "ident.local": "ローカル",
345
+ "ident.device": "サインイン済み · このデバイス",
346
+ "ident.toast": "このデバイスは {host} の {name} にサインインしました",
347
+ "set.access.pair": "デバイスをペアリング",
282
348
  "set.distribution.brand": "製品名",
283
349
  "set.distribution.github_org": "GitHub の組織",
284
350
  "set.distribution.orgs": "組織のラベル",
@@ -215,6 +215,13 @@
215
215
  font-weight: 600;
216
216
  background: color-mix(in srgb, var(--accent) 4%, var(--surface));
217
217
  }
218
+ /* The CLI default (focus): a word, never a bare glyph. */
219
+ .ws-cli-default {
220
+ font-size: 10px;
221
+ color: var(--accent-text);
222
+ margin-left: 4px;
223
+ cursor: help;
224
+ }
218
225
  .ws-item-right {
219
226
  display: flex;
220
227
  align-items: center;
@@ -1544,6 +1551,11 @@
1544
1551
  .replace(/</g, "&lt;")
1545
1552
  .replace(/>/g, "&gt;")
1546
1553
  .replace(/"/g, "&quot;");
1554
+ /** A shell string, with the English when the shell isn't up yet. */
1555
+ const wsT = (key, fallback) => {
1556
+ const v = window.denShell?.t?.(key);
1557
+ return v && v !== key ? v : fallback;
1558
+ };
1547
1559
 
1548
1560
  // ── The admin password (R8, LK-0) ─────────────────────────────────────────
1549
1561
  // It is for sudo, and nothing else: no action here asks for it up
@@ -1601,7 +1613,7 @@
1601
1613
  ? `<button class="danger" data-action="delete">Delete</button>`
1602
1614
  : "";
1603
1615
  return `<div class="ws-item ${n === activeWs ? "active" : ""}" data-name="${escHtml(n)}" data-action="select">
1604
- <span>${escHtml(n)}${isWsActive(n) ? ' <span style="font-size:10px;color:var(--muted)">(active)</span>' : ""}${n === focusName() ? ' <span style="font-size:10px;color:var(--accent-text)">●</span>' : ""}</span>
1616
+ <span>${escHtml(n)}${isWsActive(n) ? ' <span style="font-size:10px;color:var(--muted)">(active)</span>' : ""}${n === focusName() ? ` <span class="ws-cli-default" title="${escHtml(wsT("ws.focusTip", "CLI commands run here when you don't name a workspace. Picking a workspace in the top bar only changes what pages show."))}">${escHtml(wsT("ws.focus", "CLI default"))}</span>` : ""}</span>
1605
1617
  <span class="ws-item-right">
1606
1618
  <button class="ws-term-btn" title="Open in terminal" data-open-term="${escHtml(dirPath)}">&#62;_</button>
1607
1619
  <button class="ws-open-btn" title="Open in editor" data-open-path="${escHtml(wsData.codeWorkspacePaths?.[n] || dirPath)}">↗</button>
@@ -2021,11 +2033,11 @@
2021
2033
  <h2>${escHtml(wsName)}</h2>
2022
2034
  ${
2023
2035
  isActive
2024
- ? `<span class="badge active-ws">active${isFocus ? " · focus" : ""}</span>
2025
- ${!isFocus ? `<button class="switch-btn" data-action="focus-ws">set focus</button>` : ""}
2036
+ ? `<span class="badge active-ws"${isFocus ? ` title="${escHtml(wsT("ws.focusTip", ""))}"` : ""}>active${isFocus ? ` · ${escHtml(wsT("ws.focus", "CLI default"))}` : ""}</span>
2037
+ ${!isFocus ? `<button class="switch-btn" data-action="focus-ws" title="${escHtml(wsT("ws.setFocusTip", ""))}">Make CLI default</button>` : ""}
2026
2038
  <button class="switch-btn" data-action="deactivate-ws">deactivate</button>`
2027
2039
  : `<button class="switch-btn" data-action="activate-ws">activate</button>
2028
- <button class="switch-btn" data-action="switch-ws">activate + focus</button>`
2040
+ <button class="switch-btn" data-action="switch-ws">activate + make CLI default</button>`
2029
2041
  }
2030
2042
  ${stackNsChip}
2031
2043
  <span class="badge ws-agents" id="ws-agents-count" hidden></span>
@@ -0,0 +1,317 @@
1
+ /**
2
+ * A tiny QR code encoder (SEC-2a, "Pair a device"): byte mode, error
3
+ * correction level M, versions 1 to 10 (up to 213 bytes, enough for a
4
+ * sign-in link). Written for Den from ISO/IEC 18004; no dependency, no CDN.
5
+ * Its matrices are checked against an independent encoder's in
6
+ * tests/unit/server/qr.test.ts.
7
+ *
8
+ * qrMatrix(text, { mask }?) rows of booleans (true: dark), no quiet zone
9
+ * qrSvg(text, { scale, margin }?) an SVG string, dark on light
10
+ */
11
+
12
+ // Level M, versions 1..10: ECC codewords per block, and blocks.
13
+ const ECC_PER_BLOCK = [0, 10, 16, 26, 18, 24, 16, 18, 22, 22, 26];
14
+ const ECC_BLOCKS = [0, 1, 1, 1, 2, 2, 4, 4, 4, 5, 5];
15
+ const MAX_VERSION = 10;
16
+ const FORMAT_BITS_M = 0;
17
+
18
+ function gfMul(x, y) {
19
+ let z = 0;
20
+ for (let i = 7; i >= 0; i--) {
21
+ z = (z << 1) ^ ((z >>> 7) * 0x11d);
22
+ z ^= ((y >>> i) & 1) * x;
23
+ }
24
+ return z & 0xff;
25
+ }
26
+
27
+ function rsDivisor(degree) {
28
+ const out = new Array(degree).fill(0);
29
+ out[degree - 1] = 1;
30
+ let root = 1;
31
+ for (let i = 0; i < degree; i++) {
32
+ for (let j = 0; j < out.length; j++) {
33
+ out[j] = gfMul(out[j], root);
34
+ if (j + 1 < out.length) out[j] ^= out[j + 1];
35
+ }
36
+ root = gfMul(root, 0x02);
37
+ }
38
+ return out;
39
+ }
40
+
41
+ function rsRemainder(data, divisor) {
42
+ const out = divisor.map(() => 0);
43
+ for (const b of data) {
44
+ const factor = b ^ out.shift();
45
+ out.push(0);
46
+ divisor.forEach((c, i) => (out[i] ^= gfMul(c, factor)));
47
+ }
48
+ return out;
49
+ }
50
+
51
+ function alignPositions(ver) {
52
+ if (ver === 1) return [];
53
+ const size = ver * 4 + 17;
54
+ const n = Math.floor(ver / 7) + 2;
55
+ const step = Math.floor((ver * 8 + n * 3 + 5) / (n * 4 - 4)) * 2;
56
+ const out = [6];
57
+ for (let pos = size - 7; out.length < n; pos -= step) out.splice(1, 0, pos);
58
+ return out;
59
+ }
60
+
61
+ function rawModules(ver) {
62
+ let r = (16 * ver + 128) * ver + 64;
63
+ if (ver >= 2) {
64
+ const n = Math.floor(ver / 7) + 2;
65
+ r -= (25 * n - 10) * n - 55;
66
+ if (ver >= 7) r -= 36;
67
+ }
68
+ return r;
69
+ }
70
+
71
+ function dataCodewords(ver) {
72
+ return Math.floor(rawModules(ver) / 8) - ECC_PER_BLOCK[ver] * ECC_BLOCKS[ver];
73
+ }
74
+
75
+ function utf8(text) {
76
+ return Array.from(new TextEncoder().encode(String(text)));
77
+ }
78
+
79
+ /** The data codewords for `bytes` at version `ver` (mode, count, pad). */
80
+ function encodeData(bytes, ver) {
81
+ const bits = [];
82
+ const put = (v, n) => {
83
+ for (let i = n - 1; i >= 0; i--) bits.push((v >>> i) & 1);
84
+ };
85
+ put(0b0100, 4);
86
+ put(bytes.length, ver <= 9 ? 8 : 16);
87
+ for (const b of bytes) put(b, 8);
88
+ const cap = dataCodewords(ver) * 8;
89
+ put(0, Math.min(4, cap - bits.length));
90
+ put(0, (8 - (bits.length % 8)) % 8);
91
+ for (let pad = 0xec; bits.length < cap; pad ^= 0xec ^ 0x11) put(pad, 8);
92
+ const out = [];
93
+ for (let i = 0; i < bits.length; i += 8)
94
+ out.push(bits.slice(i, i + 8).reduce((a, b) => (a << 1) | b, 0));
95
+ return out;
96
+ }
97
+
98
+ function withEcc(data, ver) {
99
+ const nBlocks = ECC_BLOCKS[ver];
100
+ const eccLen = ECC_PER_BLOCK[ver];
101
+ const raw = Math.floor(rawModules(ver) / 8);
102
+ const nShort = nBlocks - (raw % nBlocks);
103
+ const shortLen = Math.floor(raw / nBlocks);
104
+ const div = rsDivisor(eccLen);
105
+ const blocks = [];
106
+ for (let i = 0, k = 0; i < nBlocks; i++) {
107
+ const dat = data.slice(k, k + shortLen - eccLen + (i < nShort ? 0 : 1));
108
+ k += dat.length;
109
+ const ecc = rsRemainder(dat, div);
110
+ if (i < nShort) dat.push(0);
111
+ blocks.push(dat.concat(ecc));
112
+ }
113
+ const out = [];
114
+ for (let i = 0; i < blocks[0].length; i++)
115
+ blocks.forEach((b, j) => {
116
+ if (i !== shortLen - eccLen || j >= nShort) out.push(b[i]);
117
+ });
118
+ return out;
119
+ }
120
+
121
+ function makeGrid(ver) {
122
+ const size = ver * 4 + 17;
123
+ const mods = Array.from({ length: size }, () => new Array(size).fill(false));
124
+ const fn = Array.from({ length: size }, () => new Array(size).fill(false));
125
+ const set = (x, y, dark) => {
126
+ mods[y][x] = dark;
127
+ fn[y][x] = true;
128
+ };
129
+ for (let i = 0; i < size; i++) {
130
+ set(6, i, i % 2 === 0);
131
+ set(i, 6, i % 2 === 0);
132
+ }
133
+ for (const [cx, cy] of [
134
+ [3, 3],
135
+ [size - 4, 3],
136
+ [3, size - 4],
137
+ ])
138
+ for (let dy = -4; dy <= 4; dy++)
139
+ for (let dx = -4; dx <= 4; dx++) {
140
+ const d = Math.max(Math.abs(dx), Math.abs(dy));
141
+ const x = cx + dx;
142
+ const y = cy + dy;
143
+ if (x >= 0 && x < size && y >= 0 && y < size)
144
+ set(x, y, d !== 2 && d !== 4);
145
+ }
146
+ const al = alignPositions(ver);
147
+ const last = al.length - 1;
148
+ al.forEach((ax, i) =>
149
+ al.forEach((ay, j) => {
150
+ if ((i === 0 && j === 0) || (i === 0 && j === last)) return;
151
+ if (i === last && j === 0) return;
152
+ for (let dy = -2; dy <= 2; dy++)
153
+ for (let dx = -2; dx <= 2; dx++)
154
+ set(ax + dx, ay + dy, Math.max(Math.abs(dx), Math.abs(dy)) !== 1);
155
+ }),
156
+ );
157
+ drawFormat(set, size, 0);
158
+ if (ver >= 7) {
159
+ let rem = ver;
160
+ for (let i = 0; i < 12; i++) rem = (rem << 1) ^ ((rem >>> 11) * 0x1f25);
161
+ const bits = (ver << 12) | rem;
162
+ for (let i = 0; i < 18; i++) {
163
+ const dark = ((bits >>> i) & 1) === 1;
164
+ const a = size - 11 + (i % 3);
165
+ const b = Math.floor(i / 3);
166
+ set(a, b, dark);
167
+ set(b, a, dark);
168
+ }
169
+ }
170
+ return { size, mods, fn, set };
171
+ }
172
+
173
+ function drawFormat(set, size, mask) {
174
+ const data = (FORMAT_BITS_M << 3) | mask;
175
+ let rem = data;
176
+ for (let i = 0; i < 10; i++) rem = (rem << 1) ^ ((rem >>> 9) * 0x537);
177
+ const bits = ((data << 10) | rem) ^ 0x5412;
178
+ const bit = (i) => ((bits >>> i) & 1) === 1;
179
+ for (let i = 0; i <= 5; i++) set(8, i, bit(i));
180
+ set(8, 7, bit(6));
181
+ set(8, 8, bit(7));
182
+ set(7, 8, bit(8));
183
+ for (let i = 9; i < 15; i++) set(14 - i, 8, bit(i));
184
+ for (let i = 0; i < 8; i++) set(size - 1 - i, 8, bit(i));
185
+ for (let i = 8; i < 15; i++) set(8, size - 15 + i, bit(i));
186
+ set(8, size - 8, true);
187
+ }
188
+
189
+ const MASKS = [
190
+ (x, y) => (x + y) % 2 === 0,
191
+ (x, y) => y % 2 === 0,
192
+ (x) => x % 3 === 0,
193
+ (x, y) => (x + y) % 3 === 0,
194
+ (x, y) => (Math.floor(x / 3) + Math.floor(y / 2)) % 2 === 0,
195
+ (x, y) => ((x * y) % 2) + ((x * y) % 3) === 0,
196
+ (x, y) => (((x * y) % 2) + ((x * y) % 3)) % 2 === 0,
197
+ (x, y) => (((x + y) % 2) + ((x * y) % 3)) % 2 === 0,
198
+ ];
199
+
200
+ function penalty(m) {
201
+ const n = m.length;
202
+ let score = 0;
203
+ const lines = [];
204
+ for (let i = 0; i < n; i++) {
205
+ lines.push(m[i]);
206
+ lines.push(m.map((row) => row[i]));
207
+ }
208
+ const finder = [true, false, true, true, true, false, true];
209
+ for (const line of lines) {
210
+ for (let i = 0; i < n; ) {
211
+ let j = i;
212
+ while (j < n && line[j] === line[i]) j++;
213
+ if (j - i >= 5) score += 3 + (j - i - 5);
214
+ i = j;
215
+ }
216
+ for (let i = 0; i + 7 <= n; i++) {
217
+ if (!finder.every((v, k) => line[i + k] === v)) continue;
218
+ const light = (a, b) => {
219
+ for (let k = a; k < b; k++)
220
+ if (k >= 0 && k < n && line[k]) return false;
221
+ return true;
222
+ };
223
+ if (light(i - 4, i) || light(i + 7, i + 11)) score += 40;
224
+ }
225
+ }
226
+ let dark = 0;
227
+ for (let y = 0; y < n; y++)
228
+ for (let x = 0; x < n; x++) {
229
+ if (m[y][x]) dark++;
230
+ if (
231
+ x + 1 < n &&
232
+ y + 1 < n &&
233
+ m[y][x] === m[y][x + 1] &&
234
+ m[y][x] === m[y + 1][x] &&
235
+ m[y][x] === m[y + 1][x + 1]
236
+ )
237
+ score += 3;
238
+ }
239
+ const total = n * n;
240
+ score += 10 * Math.floor(Math.abs(dark * 20 - total * 10) / total);
241
+ return score;
242
+ }
243
+
244
+ /**
245
+ * The QR code for `text` (UTF-8, byte mode, level M), as rows of booleans
246
+ * (true: dark), no quiet zone. `mask` 0..7 fixes the mask; by default the
247
+ * one with the lowest penalty. Throws when `text` is longer than version
248
+ * 10 holds (213 bytes).
249
+ */
250
+ export function qrMatrix(text, opts = {}) {
251
+ const bytes = utf8(text);
252
+ let ver = 1;
253
+ const need = (v) => 4 + (v <= 9 ? 8 : 16) + bytes.length * 8;
254
+ while (ver <= MAX_VERSION && need(ver) > dataCodewords(ver) * 8) ver++;
255
+ if (ver > MAX_VERSION)
256
+ throw new Error(`QR: ${bytes.length} bytes is too long (213 at most)`);
257
+ const code = withEcc(encodeData(bytes, ver), ver);
258
+ const { size, mods, fn } = makeGrid(ver);
259
+ let i = 0;
260
+ for (let right = size - 1; right >= 1; right -= 2) {
261
+ if (right === 6) right = 5;
262
+ for (let vert = 0; vert < size; vert++)
263
+ for (let j = 0; j < 2; j++) {
264
+ const x = right - j;
265
+ const up = ((right + 1) & 2) === 0;
266
+ const y = up ? size - 1 - vert : vert;
267
+ if (!fn[y][x] && i < code.length * 8) {
268
+ mods[y][x] = ((code[i >>> 3] >>> (7 - (i & 7))) & 1) === 1;
269
+ i++;
270
+ }
271
+ }
272
+ }
273
+ const masked = (mask) => {
274
+ const m = mods.map((row) => row.slice());
275
+ const put = (x, y, dark) => (m[y][x] = dark);
276
+ for (let y = 0; y < size; y++)
277
+ for (let x = 0; x < size; x++)
278
+ if (!fn[y][x] && MASKS[mask](x, y)) m[y][x] = !m[y][x];
279
+ drawFormat(put, size, mask);
280
+ return m;
281
+ };
282
+ if (Number.isInteger(opts.mask) && opts.mask >= 0 && opts.mask <= 7)
283
+ return masked(opts.mask);
284
+ let best = null;
285
+ let bestScore = Infinity;
286
+ for (let k = 0; k < 8; k++) {
287
+ const m = masked(k);
288
+ const s = penalty(m);
289
+ if (s < bestScore) {
290
+ best = m;
291
+ bestScore = s;
292
+ }
293
+ }
294
+ return best;
295
+ }
296
+
297
+ /** `text`'s QR code as an SVG (one path), with a quiet zone of `margin` modules. */
298
+ export function qrSvg(text, opts = {}) {
299
+ const m = qrMatrix(text);
300
+ const scale = opts.scale ?? 4;
301
+ const margin = opts.margin ?? 4;
302
+ const n = m.length + margin * 2;
303
+ let d = "";
304
+ m.forEach((row, y) =>
305
+ row.forEach((dark, x) => {
306
+ if (dark) d += `M${x + margin} ${y + margin}h1v1h-1z`;
307
+ }),
308
+ );
309
+ return (
310
+ `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${n} ${n}" ` +
311
+ `width="${n * scale}" height="${n * scale}" shape-rendering="crispEdges" role="img">` +
312
+ // Black on white whatever the theme: a scanner needs that contrast
313
+ // (an inverted code fails on many phones), so no theme token here.
314
+ `<rect width="${n}" height="${n}" fill="white"/>` +
315
+ `<path d="${d}" fill="black"/></svg>`
316
+ );
317
+ }
@@ -293,6 +293,14 @@ export async function mountSettings(deps) {
293
293
  const v = typeof o === "function" ? o(ctx) : o;
294
294
  return Array.isArray(v) ? v : [];
295
295
  }
296
+ // A custom control draws itself (it may act through its own API rather
297
+ // than save a value: Settings › Access), so it renders whether or not the
298
+ // row has a write.
299
+ function drawsItself(r) {
300
+ return (
301
+ r.control?.type === "custom" && typeof r.control.render === "function"
302
+ );
303
+ }
296
304
  function drawRow(r) {
297
305
  const id = slug(r.id);
298
306
  const lid = `l-${id}`;
@@ -300,8 +308,7 @@ export async function mountSettings(deps) {
300
308
  const did = `d-${id}`;
301
309
  const v = value(r);
302
310
  const t = r.control?.type || "readonly";
303
- // A custom control draws (and saves) itself: no write to read it by.
304
- const ro = !r.write && t !== "custom";
311
+ const ro = !r.write && !drawsItself(r);
305
312
  const single = ["number", "select", "toggle", "text"].includes(t);
306
313
  const label = single
307
314
  ? `<label class="l" id="${lid}" for="${cid}">${esc(r.label)}</label>`
@@ -500,7 +507,7 @@ export async function mountSettings(deps) {
500
507
  }, 2000);
501
508
  }
502
509
  function wireRow(r) {
503
- if (!r.write && r.control?.type !== "custom") return;
510
+ if (!r.write && !drawsItself(r)) return;
504
511
  const row = $(`setting-${slug(r.id)}`);
505
512
  if (!row) return;
506
513
  const t = r.control?.type;