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