@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/README.md +56 -14
- package/bin/sea-entry.js +4 -0
- package/package.json +3 -2
- package/src/ctl.js +72 -2
- package/src/daemon.js +21 -6
- package/src/service.js +251 -0
- package/src/tui/app.js +326 -130
- package/src/tui/gui.js +214 -0
- package/src/tui/i18n.js +27 -6
- package/src/tui/term.js +7 -2
- package/src/update.js +146 -2
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 {
|
|
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.
|
|
219
|
+
async function installUpdate (term, st, release, { install = (rel) => up.installRelease(rel), userInstall = up.canSelfUpdate } = {}) {
|
|
226
220
|
const i = L(st)
|
|
227
|
-
// Solo
|
|
228
|
-
// de
|
|
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
|
|
2660
|
-
if (
|
|
2661
|
-
|
|
2662
|
-
|
|
2663
|
-
|
|
2664
|
-
|
|
2665
|
-
|
|
2666
|
-
|
|
2667
|
-
|
|
2668
|
-
|
|
2669
|
-
|
|
2670
|
-
|
|
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
|
-
|
|
2678
|
-
|
|
2679
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|