@dotrino/vaultd 0.156.0 → 0.158.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/src/tui/app.js CHANGED
@@ -30,9 +30,9 @@
30
30
  * que entrar antes), el candado es `k` (la `l` es el idioma) y la contraseña `c`.
31
31
  */
32
32
  import fs from 'node:fs'
33
- import { execFile, spawnSync } from 'node:child_process'
33
+ import { spawnSync } from 'node:child_process'
34
34
  import { parseEnvInput } from '../../lib/src/envtext.js'
35
- import { createTerm, widthOf } from './term.js'
35
+ import { createTerm, widthOf, makeTheme } from './term.js'
36
36
  import { qrToString } from '../qr.js'
37
37
  import { dict, otherLang, loadLang, saveLang } from './i18n.js'
38
38
  import * as vc from '../vaultControl.js'
@@ -40,6 +40,8 @@ import { readSettings, writeSettings } from '../settings.js'
40
40
  import * as hw from '../hwkeys.js'
41
41
  import { VERSION } from '../version.js'
42
42
  import * as up from '../update.js'
43
+ import * as service from '../service.js'
44
+ import { startDaemonService, restartDaemonService } from '../service.js'
43
45
  import { DEVICE_CAPS } from '@dotrino/identity/acta'
44
46
  import { machineLine } from '@dotrino/identity/machine'
45
47
 
@@ -189,14 +191,6 @@ function lockGlyph (p) {
189
191
  return p.locked ? '🔒' : '🔓'
190
192
  }
191
193
 
192
- function restartDaemonService () {
193
- return new Promise((res, rej) => {
194
- execFile('systemctl', ['--user', 'restart', 'dotrino-vault'], { timeout: 20000 }, (err, so, se) => {
195
- if (err) rej(new Error(String(se || '').trim() || err.message)); else res()
196
- })
197
- })
198
- }
199
-
200
194
  /**
201
195
  * AL ENTRAR SE DICE SI HAY VERSIÓN NUEVA, Y SE OFRECE INSTALARLA (dueño, 2026-10-13: como
202
196
  * hace la app de Terminal). El servicio ya se actualiza solo; esto es para quien está
@@ -222,10 +216,11 @@ function offerUpdate (term, st) {
222
216
  setConfirm(st, { text: i.updateOffer(r.version, VERSION), onYes: () => installUpdate(term, st, r) })
223
217
  }
224
218
 
225
- async function installUpdate (term, st, release, { install = up.installUserRelease, userInstall = up.isUserInstall } = {}) {
219
+ async function installUpdate (term, st, release, { install = (rel) => up.installRelease(rel), userInstall = up.canSelfUpdate } = {}) {
226
220
  const i = L(st)
227
- // Solo la instalación de usuario se puede cambiar desde aquí. Un paquete del sistema es
228
- // de root: no se pide la contraseña por dentro, se dice el comando.
221
+ // Solo una instalación que puede cambiarse sola (la de usuario de Linux, el instalador de
222
+ // Windows, la app de macOS) se cambia desde aquí. Un paquete del sistema es de root: no se
223
+ // pide la contraseña por dentro, se dice el comando.
229
224
  if (!userInstall()) { flash(st, i.updateNeedsCommand, 'warn'); return }
230
225
  const r = await guard(term, st, i.updateInstalling(release.version), () => install(release))
231
226
  if (!r.ok) return
@@ -234,12 +229,15 @@ async function installUpdate (term, st, release, { install = up.installUserRelea
234
229
  flash(st, i.updateNoteFailed(e.message), 'warn')
235
230
  }
236
231
  st.update = { installed: r.v.version }
232
+ // WINDOWS: el instalador ya corre; cierra la bóveda y esta pantalla (son el mismo .exe), las
233
+ // cambia y vuelve a arrancar la bóveda. La ventana vuelve sola. No hay nada que preguntar.
234
+ if (r.v.then === 'exit' && process.platform === 'win32') { flash(st, i.updateWindowsInstalling(r.v.version), 'warn'); return }
237
235
  // Reiniciar se PREGUNTA: cierra las bóvedas abiertas y corta lo que estén haciendo los
238
236
  // aparatos. Si dices que no, el servicio se reinicia solo en un par de minutos.
239
237
  setConfirm(st, {
240
238
  text: i.updateRestartOffer(r.v.version),
241
239
  onYes: async () => {
242
- const rr = await guard(term, st, i.updateRestarting, () => restartDaemonService())
240
+ const rr = await guard(term, st, i.updateRestarting, () => restartDaemonService({ pid: st.state?.pid, alive: vc.daemonAlive }))
243
241
  if (!rr.ok) return
244
242
  st.relaunch = true
245
243
  st.quit = true
@@ -248,14 +246,6 @@ async function installUpdate (term, st, release, { install = up.installUserRelea
248
246
  })
249
247
  }
250
248
 
251
- function startDaemonService () {
252
- return new Promise((res) => {
253
- execFile('systemctl', ['--user', 'start', 'dotrino-vault'], { timeout: 8000 }, (err, so, se) => {
254
- res({ ok: !err, err: err ? (String(se || '').trim() || err.message) : '' })
255
- })
256
- })
257
- }
258
-
259
249
  // -------------------- render: modelo de filas + listas ---------------------
260
250
 
261
251
  /**
@@ -428,12 +418,45 @@ function settingsRows (st, t) {
428
418
  rows.push({ text: ` ${t.bold(frase)} ${t.accent(i.settingChange)}`, sel: true, meta: { setting: 'updateApproval' } })
429
419
  rows.push({ text: t.muted(' ' + i.settingUpdateHint1), sel: false })
430
420
  rows.push({ text: t.muted(' ' + i.settingUpdateHint2), sel: false })
421
+ // ARRANCAR AL INICIAR SESIÓN. En Linux lo deja la unidad de systemd; en Windows y macOS no
422
+ // había forma de pedirlo sin la línea de comandos, y una bóveda que hay que arrancar a mano
423
+ // cada día es una bóveda que un día no está.
424
+ const as = st.autostart
425
+ if (as) {
426
+ rows.push({ text: '', sel: false })
427
+ if (as.how === 'none') {
428
+ rows.push({ text: ' ' + t.muted(i.settingAutostartNoUnit), sel: false })
429
+ } else {
430
+ const f = as.error ? i.settingAutostartUnknown : as.on ? i.settingAutostartOn : i.settingAutostartOff
431
+ rows.push({ text: ` ${t.bold(f)} ${t.accent(i.settingChange)}`, sel: true, meta: { setting: 'autostart' } })
432
+ rows.push({ text: t.muted(' ' + i.settingAutostartHint), sel: false })
433
+ }
434
+ }
431
435
  return rows
432
436
  }
437
+
438
+ /** Mira si la bóveda arranca sola al iniciar sesión (`service.js`). `st.autostartApi`: pruebas. */
439
+ async function loadAutostart (st) {
440
+ const api = st.autostartApi || service
441
+ try { st.autostart = await api.autostartStatus() } catch (e) { st.autostart = { error: e?.message || String(e) } }
442
+ }
433
443
  async function onKeySettings (term, st, key) {
434
444
  const i = L(st)
435
445
  const ch = key.name === 'char' ? key.ch.toLowerCase() : null
436
446
  if (key.name === 'escape' || ch === 'b') { st.screen = 'profiles'; return true }
447
+ const selectable = settingsRows(st, makeTheme()).filter((r) => r.sel)
448
+ if (['up', 'down', 'pageup', 'pagedown', 'home', 'end'].includes(key.name)) { moveSel(st, key, 'settings', selectable.length); return true }
449
+ const cur = selectable[Math.min(st.sel.settings || 0, selectable.length - 1)]?.meta
450
+ if (cur?.setting === 'autostart' && (key.name === 'enter' || (key.name === 'char' && key.ch === ' '))) {
451
+ const api = st.autostartApi || service
452
+ const on = !st.autostart?.on
453
+ const r = await guard(term, st, i.settingAutostartSaving, () => (on ? api.enableAutostart() : api.disableAutostart()))
454
+ if (!r.ok) return true
455
+ if (!r.v?.ok) { flash(st, i.settingSaveFailed(r.v?.err || '?'), 'danger'); return true }
456
+ await loadAutostart(st)
457
+ flash(st, st.autostart?.on ? i.settingAutostartNowOn : i.settingAutostartNowOff)
458
+ return true
459
+ }
437
460
  if (key.name === 'enter' || (key.name === 'char' && key.ch === ' ')) {
438
461
  // Con el ajuste ilegible, Enter lo ESCRIBE de nuevo (encendido: es lo prudente cuando
439
462
  // no se sabe qué había); así la pantalla también es la salida del atasco.
@@ -1311,6 +1334,7 @@ async function onKeyProfiles (term, st, key) {
1311
1334
  }))
1312
1335
  } else if (ch === 's') { // ajustes de la bóveda (no de un perfil: no hace falta elegir uno)
1313
1336
  loadSettings(st)
1337
+ await loadAutostart(st)
1314
1338
  st.sel.settings = 0
1315
1339
  st.scroll.settings = { value: 0 }
1316
1340
  st.screen = 'settings'
@@ -2291,6 +2315,8 @@ function promptLoadVars (term, st, { ns = null, pub = null, where }) {
2291
2315
  setInput(st, {
2292
2316
  label: i.loadLabel(where),
2293
2317
  hint: i.loadHint,
2318
+ // La ventana ofrece además elegir el `.env` con el diálogo del sistema.
2319
+ file: true,
2294
2320
  onSubmit: async (raw) => {
2295
2321
  const text = raw.trim()
2296
2322
  st.input = null
@@ -2476,7 +2502,7 @@ function renderTabs (st, t) {
2476
2502
  }).join(' ') + t.muted(i.tabsHint)
2477
2503
  }
2478
2504
 
2479
- function pairingBody (st, t, cols, height) {
2505
+ function pairingBody (st, t, cols, height, { qr: withQr = true } = {}) {
2480
2506
  const i = L(st)
2481
2507
  const info = st.pairing
2482
2508
  const lines = []
@@ -2492,8 +2518,9 @@ function pairingBody (st, t, cols, height) {
2492
2518
  if (info.service) lines.push(t.warn(i.pairService(info.service)))
2493
2519
  // QR: se dibuja siempre que quepa de ancho; si es más alto que la pantalla se
2494
2520
  // puede hacer scroll hacia arriba/abajo para verlo completo.
2521
+ // La ventana dibuja el QR como imagen: aquí solo le hacen falta los textos.
2495
2522
  let qr = ''
2496
- try { qr = qrToString(info.url) } catch (_) {}
2523
+ if (withQr) { try { qr = qrToString(info.url) } catch (_) {} }
2497
2524
  const qrLines = qr ? qr.replace(/\n$/, '').split('\n') : []
2498
2525
  const qrWidth = qrLines.length ? Math.max(...qrLines.map((l) => l.replace(/\x1b\[[0-9;]*m/g, '').length)) : 0
2499
2526
  if (qrLines.length && qrWidth <= cols) {
@@ -2539,6 +2566,9 @@ function contentHeight (st, cols, rows, help = helpLines(st, L(st), cols, rows))
2539
2566
  }
2540
2567
 
2541
2568
  function render (term, st) {
2569
+ // LA VENTANA dibuja el mismo estado con sus propios widgets (`gui.js`): aquí solo se le
2570
+ // avisa de que hay algo nuevo que pintar.
2571
+ if (term.gui) { term.gui(st); return }
2542
2572
  const t = term.t
2543
2573
  const i = L(st)
2544
2574
  const { cols, rows } = term.size()
@@ -2625,6 +2655,120 @@ function render (term, st) {
2625
2655
  term.render(lines)
2626
2656
  }
2627
2657
 
2658
+ // ------------------------- modelo para la ventana --------------------------
2659
+
2660
+ /** Las filas de cada pantalla de lista, y qué índice de selección usa. */
2661
+ const LIST_SCREENS = {
2662
+ profiles: [profileRows, 'profiles'],
2663
+ devices: [deviceRows, 'devices'],
2664
+ secrets: [secretRows, 'secrets'],
2665
+ logins: [loginRows, 'logins'],
2666
+ me: [meRows, null],
2667
+ caps: [capsRows, 'caps'],
2668
+ devvars: [devVarRows, 'devvars'],
2669
+ pairmode: [pairModeRows, 'pairmode'],
2670
+ doors: [doorRows, 'doors'],
2671
+ settings: [settingsRows, 'settings'],
2672
+ paircaps: [pairCapsRows, 'paircaps']
2673
+ }
2674
+
2675
+ /**
2676
+ * LO MISMO QUE PINTA `render`, pero como datos: la ventana de escritorio (`gui.js` y la app
2677
+ * de `desktop/`) lo dibuja con sus propios controles. Las filas, los títulos, los avisos y
2678
+ * las acciones salen de las MISMAS funciones que usa la TUI, con sus estilos como escapes
2679
+ * que el puente convierte en tramos. Así no hay una segunda copia de ninguna pantalla.
2680
+ *
2681
+ * `t` es el tema con color forzado (`makeTheme({ color: true })`).
2682
+ */
2683
+ export function viewModel (st, t) {
2684
+ const i = L(st)
2685
+ const s = st.state
2686
+ const up = st.daemonUp
2687
+ const ap = activeProfile(st)
2688
+ const vm = {
2689
+ lang: st.lang,
2690
+ version: s?.version || VERSION,
2691
+ uiVersion: VERSION,
2692
+ daemonUp: !!up,
2693
+ daemonText: up ? i.daemonRunning : i.daemonStopped,
2694
+ dir: vc.vaultDir(),
2695
+ screen: st.screen,
2696
+ active: ap ? { id: ap.id, name: ap.name || i.noName, protected: !!ap.protected, locked: !!ap.locked, fingerprint: ap.fingerprint || '' } : null,
2697
+ activeLabel: i.activeVault,
2698
+ notice: null,
2699
+ tabs: null,
2700
+ title: '',
2701
+ rows: null,
2702
+ selected: -1,
2703
+ body: null,
2704
+ qr: null,
2705
+ status: null,
2706
+ help: [],
2707
+ input: null,
2708
+ confirm: null
2709
+ }
2710
+ // El aviso de la segunda línea de la TUI, con el mismo orden de prioridad.
2711
+ if (up && s?.version && VERSION !== 'dev' && s.version !== VERSION) vm.notice = { text: i.daemonStale(s.version, VERSION), kind: 'warn' }
2712
+ else if (up && s?.updateAsk?.version) {
2713
+ const u = s.updateAsk
2714
+ const dia = (ms) => new Date(ms).toISOString().slice(0, 16).replace('T', ' ')
2715
+ vm.notice = u.result === 'pending'
2716
+ ? { text: i.updateWaiting(u.version, dia(u.askedAt + 24 * 60 * 60 * 1000)), kind: 'muted' }
2717
+ : { text: i.updateNotApproved(u.version, dia(u.askedAt)), kind: 'warn' }
2718
+ } else if (st.update?.release) vm.notice = { text: i.updateAvailable(st.update.release.version), kind: 'warn' }
2719
+
2720
+ if (INNER_TABS.includes(st.screen)) vm.tabs = INNER_TABS.map((k) => ({ key: k, label: tabLabel(i, k), active: st.screen === k }))
2721
+ else vm.title = title(i, st.screen)
2722
+
2723
+ const list = LIST_SCREENS[st.screen]
2724
+ if (list) {
2725
+ const [rowsOf, selKey] = list
2726
+ const rows = rowsOf(st, t)
2727
+ let n = 0
2728
+ vm.rows = rows.map((r) => ({ text: r.text, sel: !!r.sel, idx: r.sel ? n++ : -1 }))
2729
+ vm.selected = selKey ? Math.max(0, Math.min(st.sel[selKey] || 0, n - 1)) : -1
2730
+ if (!n) vm.selected = -1
2731
+ } else if (st.screen === 'pairing' && st.pairing) {
2732
+ vm.body = pairingBody(st, t, 999, 999, { qr: false })
2733
+ vm.qr = st.pairing.url
2734
+ vm.code = st.pairing.b64 || st.pairing.payload || ''
2735
+ } else if (st.screen === 'join') vm.body = joinBody(st, t, 999)
2736
+
2737
+ if (st.busy) vm.status = { text: st.busy, kind: 'busy' }
2738
+ else if (st.flash) vm.status = { text: st.flash.text, kind: st.flash.kind || 'ok' }
2739
+ if (st.input) {
2740
+ const inp = st.input
2741
+ vm.input = { label: inp.label || '', hint: inp.hint || '', mask: !!inp.mask, value: inp.value || '', file: !!inp.file }
2742
+ } else if (st.confirm) vm.confirm = { text: st.confirm.text, defaultYes: !!st.confirm.defaultYes }
2743
+ else vm.help = helpSegs(i, st.screen, st)
2744
+ return vm
2745
+ }
2746
+
2747
+ /** La pantalla de «el servicio no está», como datos. */
2748
+ export function downModel (st) {
2749
+ const i = L(st)
2750
+ return {
2751
+ lang: st.lang,
2752
+ down: true,
2753
+ uiVersion: VERSION,
2754
+ dir: vc.vaultDir(),
2755
+ title: i.downTitle,
2756
+ body: [i.downBody1 + ' ' + i.downBody2],
2757
+ help: i.downHelp,
2758
+ status: st.busy ? { text: st.busy, kind: 'busy' } : st.flash ? { text: st.flash.text, kind: st.flash.kind || 'warn' } : null
2759
+ }
2760
+ }
2761
+
2762
+ /** Seleccionar la fila `idx` (entre las seleccionables) de la pantalla actual. */
2763
+ export function selectRow (st, idx) {
2764
+ const list = LIST_SCREENS[st.screen]
2765
+ if (!list || !list[1]) return false
2766
+ st.sel[list[1]] = Math.max(0, idx | 0)
2767
+ return true
2768
+ }
2769
+
2770
+ export { onKeyDown }
2771
+
2628
2772
  // --------------------------- pantalla daemon caído -------------------------
2629
2773
 
2630
2774
  async function daemonDownScreen (term, st) {
@@ -2641,7 +2785,7 @@ async function daemonDownScreen (term, st) {
2641
2785
  i.downBody1,
2642
2786
  i.downBody2,
2643
2787
  '',
2644
- t.bold('S') + i.downStart + t.muted('systemctl --user start dotrino-vault'),
2788
+ t.bold('S') + i.downStart + t.muted(process.platform === 'linux' ? 'systemctl --user start dotrino-vault' : ''),
2645
2789
  t.bold('R') + i.downRecheck,
2646
2790
  t.bold('l') + i.downLang,
2647
2791
  t.bold('Q') + i.downQuit,
@@ -2656,27 +2800,39 @@ async function daemonDownScreen (term, st) {
2656
2800
  term.render(lines)
2657
2801
 
2658
2802
  const key = await term.readKey()
2659
- const ch = key.name === 'char' ? key.ch.toLowerCase() : null
2660
- if (ch === 'q' || key.name === 'ctrl-c') return false
2661
- if (ch === 'l') { toggleLang(st); continue }
2662
- if (ch === 'r') { if (vc.daemonAlive()) return true; flash(st, L(st).stillDown, 'warn') }
2663
- if (ch === 's') {
2664
- st.busy = L(st).starting // (no re-render aquí; mensaje simple)
2665
- flash(st, L(st).startingShort, 'warn'); term.render(lines)
2666
- const r = await startDaemonService()
2667
- await sleep(1500)
2668
- if (vc.daemonAlive()) return true
2669
- flash(st, r.ok ? L(st).startedNotReady : L(st).startFailed(r.err), 'danger')
2670
- st.busy = null
2671
- }
2803
+ const r = await onKeyDown(st, key, () => term.render(lines))
2804
+ if (r !== null) return r
2805
+ }
2806
+ }
2807
+
2808
+ /**
2809
+ * Una tecla en la pantalla de «el servicio no está». Devuelve `false` (salir), `true` (ya
2810
+ * está: seguir a la TUI) o `null` (seguir aquí). La comparten la TUI y la ventana.
2811
+ */
2812
+ async function onKeyDown (st, key, redraw = () => {}) {
2813
+ const ch = key.name === 'char' ? key.ch.toLowerCase() : null
2814
+ if (ch === 'q' || key.name === 'ctrl-c') return false
2815
+ if (ch === 'l') { toggleLang(st); return null }
2816
+ if (ch === 'r') { if (vc.daemonAlive()) return true; flash(st, L(st).stillDown, 'warn') }
2817
+ if (ch === 's') {
2818
+ st.busy = L(st).starting
2819
+ flash(st, L(st).startingShort, 'warn'); redraw()
2820
+ const r = await startDaemonService()
2821
+ // Un daemon recién lanzado tarda en escribir su `state.json`: se le espera un poco, no
2822
+ // un tiempo fijo, para no dar por fallido uno que solo iba lento.
2823
+ for (let n = 0; n < 20 && !vc.daemonAlive(); n++) await sleep(250)
2824
+ st.busy = null
2825
+ if (vc.daemonAlive()) return true
2826
+ flash(st, r.ok ? L(st).startedNotReady : L(st).startFailed(r.err), 'danger')
2672
2827
  }
2828
+ return null
2673
2829
  }
2674
2830
 
2675
2831
  // ---------------------------------- loop -----------------------------------
2676
2832
 
2677
- export async function runTui () {
2678
- const term = createTerm()
2679
- const st = {
2833
+ /** El estado de una sesión de la pantalla de control (TUI o ventana). */
2834
+ export function newSession () {
2835
+ return {
2680
2836
  screen: 'profiles', // se arranca en la lista de bóvedas: hay que ENTRAR a una
2681
2837
  lang: loadLang(), // es/en — se conmuta con `l` y se recuerda en prefs.json
2682
2838
  sel: { profiles: 0, devices: 0, secrets: 0, pairmode: 0, paircaps: 0, devvars: 0, logins: 0, doors: 0, settings: 0 },
@@ -2684,7 +2840,7 @@ export async function runTui () {
2684
2840
  capsDrafts: {},
2685
2841
  // Las bóvedas que ha abierto ESTA sesión, para volver a cerrarlas al salir.
2686
2842
  unlockedHere: new Set(),
2687
- // Su contraseña, SOLO en memoria y SOLO mientras la TUI esté abierta: sirve para
2843
+ // Su contraseña, SOLO en memoria y SOLO mientras la pantalla esté abierta: sirve para
2688
2844
  // reabrir sin volver a preguntar si el daemon pierde el estado (ver
2689
2845
  // `reunlockSilently`). Se olvida con `k`, al quitar la contraseña y al salir.
2690
2846
  sessionPwd: new Map(),
@@ -2704,107 +2860,147 @@ export async function runTui () {
2704
2860
  input: null,
2705
2861
  confirm: null
2706
2862
  }
2863
+ }
2864
+
2865
+ /** Con el daemon ya vivo: la primera foto y la búsqueda de versión nueva. */
2866
+ export async function startSession (term, st) {
2867
+ st.flash = null
2868
+ st.state = vc.readState()
2869
+ st.daemonUp = true
2870
+ await refreshAll(term, st)
2871
+ checkForUpdate(st)
2872
+ }
2873
+
2874
+ /** Lo que se mira antes de cada pintado: estado del daemon, aviso de versión, candado, flash. */
2875
+ export async function beforeRender (term, st) {
2876
+ st.state = vc.readState()
2877
+ st.daemonUp = vc.daemonAlive()
2878
+ // El aviso de versión nueva sale cuando no hay otra cosa preguntando.
2879
+ if (st.update?.offer && !st.input && !st.confirm) offerUpdate(term, st)
2880
+ // ¿Se cerró sola mientras nadie miraba? Olvida su contraseña y sal de su contenido.
2881
+ await forgetAutoLocked(term, st)
2882
+ // caducar el flash a los ~4 s
2883
+ if (st.flash && Date.now() - st.flash.at > 4000) st.flash = null
2884
+ }
2885
+
2886
+ /** Cada cuánto despertar sin tecla (0 = solo con tecla). */
2887
+ export function tickFor (st) {
2888
+ const base = (st.screen === 'pairing' || st.screen === 'join' || (st.screen === 'devices' && !st.input && !st.confirm)) ? 800 : 0
2889
+ // Despertar a tiempo del bloqueo automático, además del sondeo de la pantalla.
2890
+ const wake = autoLockWakeIn(st)
2891
+ let tick = base && wake ? Math.min(base, wake) : (base || wake)
2892
+ // Mientras se mira si hay versión nueva hay que despertar sin tecla, o el aviso no
2893
+ // saldría hasta que tocaras algo.
2894
+ if (st.update?.checking) tick = tick ? Math.min(tick, 500) : 500
2895
+ return tick
2896
+ }
2897
+
2898
+ /**
2899
+ * UNA TECLA. Devuelve `false` si hay que salir. Es el corazón de la pantalla de control y
2900
+ * lo comparten la TUI y la ventana: una acción nueva aparece en las dos sin tocar la otra.
2901
+ */
2902
+ export async function handleKey (term, st, key) {
2903
+ if (key.name === 'resize') return true
2904
+ // Un despertar del bloqueo automático no es una tecla: ya se atendió arriba, y las
2905
+ // pantallas que NO esperan `tick` (todas menos aparatos y emparejamiento) no tienen
2906
+ // por qué verlo.
2907
+ if (key.name === 'tick' && !['pairing', 'join', 'devices'].includes(st.screen)) return true
2908
+ // input/confirm se AWAITan: serializa las ops contra el daemon (ver onInputKey).
2909
+ // Ctrl-C dentro de un modal lo CANCELA (no sale); fuera de un modal, sale.
2910
+ if (st.input) { await onInputKey(st, key); return true }
2911
+ if (st.confirm) { await onConfirmKey(st, key); return !st.quit }
2912
+ if (key.name === 'ctrl-c') return false
2913
+
2914
+ // TECLEAR ES USO. El candado se cierra a los 5 min de no usarse, y hasta ahora
2915
+ // «usar» solo contaba cuando algo pedía al daemon: podías estar media hora
2916
+ // navegando esta pantalla y se cerraba igual, que es lo contrario de lo que se
2917
+ // quería. Se avisa como mucho una vez por minuto —el plazo son cinco, así que
2918
+ // sobra— para no escribir un archivo por tecla.
2919
+ seguirAqui(st)
2920
+
2921
+ const ch = key.name === 'char' ? key.ch.toLowerCase() : null
2922
+ // 'q' global sale — preguntando antes si hay permisos sin guardar.
2923
+ if (ch === 'q') {
2924
+ if (draftChanges(st).length) { unlessDrafts(st, () => { st.quit = true }); return true }
2925
+ return false
2926
+ }
2927
+ // 'l' global: idioma es⇄en en cualquier pantalla (por eso el candado es 'c').
2928
+ if (ch === 'l') { toggleLang(st); return true }
2929
+ // ←→ cambia entre las pestañas de la bóveda entrada (Dispositivos/Scopes).
2930
+ if ((key.name === 'left' || key.name === 'right') && INNER_TABS.includes(st.screen)) {
2931
+ const n = INNER_TABS.indexOf(st.screen)
2932
+ await openTab(term, st, INNER_TABS[(n + (key.name === 'right' ? 1 : -1) + INNER_TABS.length) % INNER_TABS.length])
2933
+ return true
2934
+ }
2935
+ // Esc/'b' desde una pestaña vuelve a la lista de bóvedas (salir de la bóveda
2936
+ // entrada). La pantalla de emparejamiento maneja su propio Esc (va a Dispositivos).
2937
+ if ((key.name === 'escape' || ch === 'b') && INNER_TABS.includes(st.screen)) {
2938
+ // El borrador es de ESTA bóveda: al salir de ella se guarda o se tira.
2939
+ unlessDrafts(st, () => { st.capsDrafts = {}; st.screen = 'profiles' })
2940
+ return true
2941
+ }
2942
+
2943
+ if (st.screen === 'profiles') return onKeyProfiles(term, st, key)
2944
+ if (st.screen === 'devices') return onKeyDevices(term, st, key)
2945
+ if (st.screen === 'secrets') return onKeySecrets(term, st, key)
2946
+ if (st.screen === 'logins') return onKeyLogins(term, st, key)
2947
+ if (st.screen === 'me') return onKeyMe(term, st, key)
2948
+ if (st.screen === 'caps') return onKeyCaps(term, st, key)
2949
+ if (st.screen === 'devvars') return onKeyDevVars(term, st, key)
2950
+ if (st.screen === 'pairmode') return onKeyPairMode(term, st, key)
2951
+ if (st.screen === 'doors') return onKeyDoors(term, st, key)
2952
+ if (st.screen === 'settings') return onKeySettings(term, st, key)
2953
+ if (st.screen === 'paircaps') return onKeyPairCaps(term, st, key)
2954
+ if (st.screen === 'pairing') return onKeyPairing(term, st, key)
2955
+ if (st.screen === 'join') return onKeyJoin(term, st, key)
2956
+ return true
2957
+ }
2958
+
2959
+ /**
2960
+ * Ir a una pestaña de la bóveda entrada. El perfil y los inicios de sesión se piden al
2961
+ * ENTRAR en su pestaña, no al arrancar: es contenido del usuario y no hay por qué sacarlo
2962
+ * del cifrado si nadie lo está mirando.
2963
+ */
2964
+ export async function openTab (term, st, tab) {
2965
+ if (!INNER_TABS.includes(tab) || !INNER_TABS.includes(st.screen)) return
2966
+ st.screen = tab
2967
+ if (st.screen === 'me' && st.me === undefined) await refreshMe(term, st)
2968
+ if (st.screen === 'logins' && st.logins === null) await refreshLogins(term, st)
2969
+ }
2970
+
2971
+ /**
2972
+ * AL SALIR SE VUELVE A CERRAR lo que se abrió aquí. Sin esto, teclear la contraseña una
2973
+ * vez dejaba la bóveda abierta para todo el que pasara por esta máquina hasta el
2974
+ * siguiente reinicio del servicio — un candado que solo se cierra reiniciando no es un
2975
+ * candado. (Si la pantalla muere de un tirón —kill, ventana cerrada— no hay quien lo haga:
2976
+ * ahí el cierre lo pone el reinicio, como antes.)
2977
+ */
2978
+ export async function endSession (st) {
2979
+ st.sessionPwd.clear()
2980
+ for (const id of st.unlockedHere) { try { await vc.lockProfile(id) } catch (_) {} }
2981
+ }
2982
+
2983
+ export async function runTui () {
2984
+ const term = createTerm()
2985
+ const st = newSession()
2707
2986
 
2708
2987
  try {
2709
2988
  // Arranque: exige daemon vivo.
2710
2989
  if (!vc.daemonAlive()) {
2711
2990
  const cont = await daemonDownScreen(term, st)
2712
2991
  if (!cont) { term.close(); return }
2713
- st.flash = null
2714
2992
  }
2715
- st.state = vc.readState()
2716
- st.daemonUp = true
2717
- await refreshAll(term, st)
2718
- checkForUpdate(st)
2993
+ await startSession(term, st)
2719
2994
 
2720
2995
  let running = true
2721
2996
  while (running) {
2722
- st.state = vc.readState()
2723
- st.daemonUp = vc.daemonAlive()
2724
- // El aviso de versión nueva sale cuando no hay otra cosa preguntando.
2725
- if (st.update?.offer && !st.input && !st.confirm) offerUpdate(term, st)
2726
- // ¿Se cerró sola mientras nadie miraba? Olvida su contraseña y sal de su contenido.
2727
- await forgetAutoLocked(term, st)
2728
- // caducar el flash a los ~4 s
2729
- if (st.flash && Date.now() - st.flash.at > 4000) st.flash = null
2997
+ await beforeRender(term, st)
2730
2998
  render(term, st)
2731
-
2732
- const base = (st.screen === 'pairing' || st.screen === 'join' || (st.screen === 'devices' && !st.input && !st.confirm)) ? 800 : 0
2733
- // Despertar a tiempo del bloqueo automático, además del sondeo de la pantalla.
2734
- const wake = autoLockWakeIn(st)
2735
- let tick = base && wake ? Math.min(base, wake) : (base || wake)
2736
- // Mientras se mira si hay versión nueva hay que despertar sin tecla, o el aviso no
2737
- // saldría hasta que tocaras algo.
2738
- if (st.update?.checking) tick = tick ? Math.min(tick, 500) : 500
2739
- const key = await term.readKey(tick)
2740
-
2741
- if (key.name === 'resize') continue
2742
- // Un despertar del bloqueo automático no es una tecla: ya se atendió arriba, y las
2743
- // pantallas que NO esperan `tick` (todas menos aparatos y emparejamiento) no tienen
2744
- // por qué verlo.
2745
- if (key.name === 'tick' && !['pairing', 'join', 'devices'].includes(st.screen)) continue
2746
- // input/confirm se AWAITan: serializa las ops contra el daemon (ver onInputKey).
2747
- // Ctrl-C dentro de un modal lo CANCELA (no sale); fuera de un modal, sale.
2748
- if (st.input) { await onInputKey(st, key); continue }
2749
- if (st.confirm) { await onConfirmKey(st, key); if (st.quit) running = false; continue }
2750
- if (key.name === 'ctrl-c') { running = false; continue }
2751
-
2752
- // TECLEAR ES USO. El candado se cierra a los 5 min de no usarse, y hasta ahora
2753
- // «usar» solo contaba cuando algo pedía al daemon: podías estar media hora
2754
- // navegando esta pantalla y se cerraba igual, que es lo contrario de lo que se
2755
- // quería. Se avisa como mucho una vez por minuto —el plazo son cinco, así que
2756
- // sobra— para no escribir un archivo por tecla.
2757
- seguirAqui(st)
2758
-
2759
- const ch = key.name === 'char' ? key.ch.toLowerCase() : null
2760
- // 'q' global sale — preguntando antes si hay permisos sin guardar.
2761
- if (ch === 'q') {
2762
- if (draftChanges(st).length) unlessDrafts(st, () => { st.quit = true })
2763
- else running = false
2764
- continue
2765
- }
2766
- // 'l' global: idioma es⇄en en cualquier pantalla (por eso el candado es 'c').
2767
- if (ch === 'l') { toggleLang(st); continue }
2768
- // ←→ cambia entre las pestañas de la bóveda entrada (Dispositivos/Scopes).
2769
- if ((key.name === 'left' || key.name === 'right') && INNER_TABS.includes(st.screen)) {
2770
- const n = INNER_TABS.indexOf(st.screen)
2771
- st.screen = INNER_TABS[(n + (key.name === 'right' ? 1 : -1) + INNER_TABS.length) % INNER_TABS.length]
2772
- // El perfil se pide al ENTRAR en su pestaña, no al arrancar: es contenido del
2773
- // usuario y no hay por qué sacarlo del cifrado si nadie lo está mirando.
2774
- if (st.screen === 'me' && st.me === undefined) await refreshMe(term, st)
2775
- if (st.screen === 'logins' && st.logins === null) await refreshLogins(term, st)
2776
- continue
2777
- }
2778
- // Esc/'b' desde una pestaña vuelve a la lista de bóvedas (salir de la bóveda
2779
- // entrada). La pantalla de emparejamiento maneja su propio Esc (va a Dispositivos).
2780
- if ((key.name === 'escape' || ch === 'b') && INNER_TABS.includes(st.screen)) {
2781
- // El borrador es de ESTA bóveda: al salir de ella se guarda o se tira.
2782
- unlessDrafts(st, () => { st.capsDrafts = {}; st.screen = 'profiles' })
2783
- continue
2784
- }
2785
-
2786
- if (st.screen === 'profiles') running = await onKeyProfiles(term, st, key)
2787
- else if (st.screen === 'devices') running = await onKeyDevices(term, st, key)
2788
- else if (st.screen === 'secrets') running = await onKeySecrets(term, st, key)
2789
- else if (st.screen === 'logins') running = await onKeyLogins(term, st, key)
2790
- else if (st.screen === 'me') running = await onKeyMe(term, st, key)
2791
- else if (st.screen === 'caps') running = await onKeyCaps(term, st, key)
2792
- else if (st.screen === 'devvars') running = await onKeyDevVars(term, st, key)
2793
- else if (st.screen === 'pairmode') running = await onKeyPairMode(term, st, key)
2794
- else if (st.screen === 'doors') running = await onKeyDoors(term, st, key)
2795
- else if (st.screen === 'settings') running = await onKeySettings(term, st, key)
2796
- else if (st.screen === 'paircaps') running = await onKeyPairCaps(term, st, key)
2797
- else if (st.screen === 'pairing') running = await onKeyPairing(term, st, key)
2798
- else if (st.screen === 'join') running = await onKeyJoin(term, st, key)
2999
+ const key = await term.readKey(tickFor(st))
3000
+ running = await handleKey(term, st, key)
2799
3001
  }
2800
3002
  } finally {
2801
- // AL SALIR SE VUELVE A CERRAR lo que se abrió aquí. Sin esto, teclear la contraseña una
2802
- // vez dejaba la bóveda abierta para todo el que pasara por esta máquina hasta el
2803
- // siguiente reinicio del servicio — un candado que solo se cierra reiniciando no es un
2804
- // candado. (Si la TUI muere de un tirón —kill, ventana cerrada— no hay quien lo haga:
2805
- // ahí el cierre lo pone el reinicio, como antes.)
2806
- st.sessionPwd.clear()
2807
- for (const id of st.unlockedHere) { try { await vc.lockProfile(id) } catch (_) {} }
3003
+ await endSession(st)
2808
3004
  term.close()
2809
3005
  }
2810
3006
  // TRAS ACTUALIZAR Y REINICIAR, esta pantalla también se vuelve a abrir: es el binario