@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/gui.js ADDED
@@ -0,0 +1,214 @@
1
+ /**
2
+ * gui.js — el PUENTE entre la ventana de escritorio y la pantalla de control.
3
+ *
4
+ * La ventana (`desktop/`, Rust + iced) no sabe nada de la bóveda: lanza
5
+ * `dotrino-vault gui-bridge` (el mismo binario) y le habla por stdin/stdout, una línea JSON
6
+ * por mensaje. Este proceso corre LA MISMA sesión que la TUI —`newSession`, `handleKey`,
7
+ * `viewModel` de `app.js`— y le manda a la ventana lo que tiene que pintar. Así la ventana
8
+ * tiene exactamente las pantallas, los textos y las acciones de la TUI, y una acción nueva
9
+ * aparece en las dos sin tocar la otra.
10
+ *
11
+ * No abre ningún puerto: el canal son las tuberías del proceso hijo, que solo ve quien lo
12
+ * lanzó. Y como la TUI, no toca la identidad ni la red: le habla al daemon por su carpeta.
13
+ *
14
+ * Mensajes de la ventana (uno por línea):
15
+ * {"key":{"name":"char","ch":"p"}} una tecla, con los mismos nombres que `term.js`
16
+ * {"select":3} señalar la fila 3 (entre las seleccionables)
17
+ * {"activate":3} señalarla y pulsar Enter
18
+ * {"tab":"secrets"} ir a una pestaña de la bóveda entrada
19
+ * {"submit":"texto"} responder la pregunta abierta con ese texto
20
+ * {"confirm":true|false} responder la confirmación abierta
21
+ * {"quit":true} salir (cerrando lo que se abrió aquí)
22
+ *
23
+ * Mensajes a la ventana:
24
+ * {"t":"view","v":{…}} qué pintar (ver `viewModel`/`downModel`)
25
+ * {"t":"bye","relaunch":bool} se terminó; con `relaunch`, volver a lanzar el puente
26
+ * (se instaló una versión nueva)
27
+ *
28
+ * Los textos llevan sus estilos como tramos: `[{"t":"…","c":"muted","b":true}]`.
29
+ */
30
+ import * as vc from '../vaultControl.js'
31
+ import { makeTheme } from './term.js'
32
+ import {
33
+ newSession, startSession, beforeRender, tickFor, handleKey, endSession,
34
+ viewModel, downModel, selectRow, openTab, onKeyDown
35
+ } from './app.js'
36
+
37
+ // stdout es el canal: cualquier `console.log` suelto lo rompería. Va a stderr.
38
+ console.log = (...a) => console.error(...a)
39
+
40
+ // ----------------------------- estilos → tramos ------------------------------
41
+
42
+ /** Color del tema de la TUI (`makeTheme`) → nombre que entiende la ventana. */
43
+ const COLORS = { 44: 'accent', 114: 'ok', 214: 'warn', 203: 'danger', 244: 'muted', 81: 'title' }
44
+
45
+ /** Un texto con escapes SGR → tramos `{t, c?, b?}`. */
46
+ export function spans (str) {
47
+ const out = []
48
+ let bold = false
49
+ let color = null
50
+ let buf = ''
51
+ const flush = () => {
52
+ if (!buf) return
53
+ const sp = { t: buf }
54
+ if (color) sp.c = color
55
+ if (bold) sp.b = true
56
+ out.push(sp)
57
+ buf = ''
58
+ }
59
+ const re = /\x1b\[([0-9;]*)m/g
60
+ const s = String(str ?? '')
61
+ let last = 0
62
+ let m
63
+ while ((m = re.exec(s))) {
64
+ buf += s.slice(last, m.index)
65
+ last = re.lastIndex
66
+ flush()
67
+ const codes = m[1] === '' ? [0] : m[1].split(';').map(Number)
68
+ for (let k = 0; k < codes.length; k++) {
69
+ const c = codes[k]
70
+ if (c === 0) { bold = false; color = null } else if (c === 1) bold = true
71
+ else if (c === 38 && codes[k + 1] === 5) { color = COLORS[codes[k + 2]] || null; k += 2 }
72
+ }
73
+ }
74
+ buf += s.slice(last)
75
+ flush()
76
+ return out
77
+ }
78
+
79
+ /** Pasa a tramos todos los textos que el modelo trae con estilos. */
80
+ function toWire (vm) {
81
+ const v = { ...vm }
82
+ if (v.rows) v.rows = v.rows.map((r) => ({ ...r, text: spans(r.text) }))
83
+ if (v.body) v.body = v.body.map(spans)
84
+ for (const k of ['title', 'notice', 'status']) {
85
+ if (v[k] && typeof v[k] === 'object' && 'text' in v[k]) v[k] = { ...v[k], text: spans(v[k].text) }
86
+ }
87
+ if (v.confirm) v.confirm = { ...v.confirm, text: spans(v.confirm.text) }
88
+ // «p emparejar» → { key: 'p', label: 'emparejar' }. La tecla es el mnemónico de la TUI, y
89
+ // la ventana la usa como atajo de teclado y la manda al pulsar el botón.
90
+ v.help = (v.help || []).map((seg) => {
91
+ const sp = String(seg).indexOf(' ')
92
+ return sp < 0 ? { key: seg, label: '' } : { key: seg.slice(0, sp), label: seg.slice(sp + 1) }
93
+ })
94
+ return v
95
+ }
96
+
97
+ // --------------------------------- el bucle ---------------------------------
98
+
99
+ export async function runGuiBridge ({ input = process.stdin, output = process.stdout } = {}) {
100
+ const t = makeTheme({ color: true })
101
+ const st = newSession()
102
+ const queue = []
103
+ let waiter = null
104
+
105
+ const push = (ev) => { if (waiter) { const w = waiter; waiter = null; w(ev) } else queue.push(ev) }
106
+
107
+ let pending = ''
108
+ input.setEncoding('utf8')
109
+ input.on('data', (chunk) => {
110
+ pending += chunk
111
+ let nl
112
+ while ((nl = pending.indexOf('\n')) >= 0) {
113
+ const line = pending.slice(0, nl).trim()
114
+ pending = pending.slice(nl + 1)
115
+ if (!line) continue
116
+ try { push(JSON.parse(line)) } catch (e) { console.error('[gui-bridge] bad message:', e.message) }
117
+ }
118
+ })
119
+ // La ventana se cerró (o murió): se sale igual que con `q`, cerrando lo abierto aquí.
120
+ input.on('end', () => push({ quit: true }))
121
+ input.on('error', () => push({ quit: true }))
122
+
123
+ const next = (ms) => {
124
+ if (queue.length) return Promise.resolve(queue.shift())
125
+ return new Promise((resolve) => {
126
+ let timer = null
127
+ waiter = (ev) => { if (timer) clearTimeout(timer); resolve(ev) }
128
+ if (ms) timer = setTimeout(() => { waiter = null; resolve({ key: { name: 'tick' } }) }, ms)
129
+ })
130
+ }
131
+
132
+ // Si la ventana ya no está, escribir da EPIPE: no es un fallo de la sesión, y no puede
133
+ // tumbar el cierre ordenado (que es el que vuelve a cerrar las bóvedas).
134
+ let gone = false
135
+ output.on?.('error', () => { gone = true })
136
+ let lastSent = ''
137
+ const send = (msg) => {
138
+ if (gone) return
139
+ const line = JSON.stringify(msg)
140
+ if (msg.t === 'view' && line === lastSent) return
141
+ if (msg.t === 'view') lastSent = line
142
+ output.write(line + '\n')
143
+ }
144
+ const sendView = () => send({ t: 'view', v: toWire(st.down ? downModel(st) : viewModel(st, t)) })
145
+
146
+ // La sesión pinta también a mitad de una operación (`guard` pone «Guardando…»): el
147
+ // «terminal» de la ventana solo avisa de que hay algo nuevo.
148
+ const term = {
149
+ t,
150
+ gui: () => sendView(),
151
+ size: () => ({ cols: 200, rows: 60 }),
152
+ render: () => sendView(),
153
+ readKey: async () => ({ name: 'tick' }),
154
+ close: () => {}
155
+ }
156
+
157
+ /** Un mensaje de la ventana → la tecla (o las teclas) que equivale. */
158
+ const toKeys = (ev) => {
159
+ if (ev.key) return [ev.key]
160
+ if (typeof ev.select === 'number') { selectRow(st, ev.select); return [] }
161
+ if (typeof ev.activate === 'number') { selectRow(st, ev.activate); return [{ name: 'enter' }] }
162
+ if (typeof ev.submit === 'string') {
163
+ if (st.input) st.input.value = ev.submit
164
+ return [{ name: 'enter' }]
165
+ }
166
+ if (typeof ev.confirm === 'boolean') return [{ name: 'char', ch: ev.confirm ? 'y' : 'n' }]
167
+ return []
168
+ }
169
+
170
+ let relaunch = false
171
+ let inSession = false
172
+ try {
173
+ while (true) {
174
+ if (!vc.daemonAlive()) {
175
+ // SIN SERVICIO: la pantalla de arrancarlo. Si se cae a mitad de sesión, se vuelve
176
+ // aquí en vez de dejar una ventana que contesta errores a todo.
177
+ st.down = true
178
+ sendView()
179
+ const ev = await next(3000)
180
+ if (ev.quit) break
181
+ if (ev.key?.name === 'tick') continue
182
+ const keys = toKeys(ev)
183
+ let r = null
184
+ for (const k of keys) r = await onKeyDown(st, k, sendView)
185
+ if (r === false) break
186
+ continue
187
+ }
188
+ if (st.down || !inSession) {
189
+ st.down = false
190
+ await startSession(term, st)
191
+ inSession = true
192
+ }
193
+ await beforeRender(term, st)
194
+ sendView()
195
+ const ev = await next(tickFor(st) || 2000)
196
+ if (ev.quit) break
197
+ if (ev.tab) { await openTab(term, st, ev.tab); continue }
198
+ let running = true
199
+ for (const k of toKeys(ev)) {
200
+ // Un tick sin pantalla que lo espere no hace nada; solo sirve para repintar.
201
+ running = await handleKey(term, st, k)
202
+ if (!running) break
203
+ }
204
+ if (!running) { relaunch = !!st.relaunch; break }
205
+ }
206
+ } finally {
207
+ await endSession(st)
208
+ send({ t: 'bye', relaunch })
209
+ // La entrada sigue abierta si quien salió fue la propia sesión (`q`, o tras actualizar):
210
+ // sin soltarla, el proceso se quedaba vivo esperando líneas que ya nadie iba a leer.
211
+ input.pause()
212
+ input.destroy?.()
213
+ }
214
+ }
package/src/tui/i18n.js CHANGED
@@ -15,6 +15,9 @@
15
15
  import path from 'node:path'
16
16
  import { dataDir, readJson, writeJson } from '../paths.js'
17
17
 
18
+ // Solo Linux tiene la unidad de systemd: en Windows y macOS nombrarla manda a un comando que no existe.
19
+ const LINUX = process.platform === 'linux'
20
+
18
21
  // --------------------------------- español ---------------------------------
19
22
 
20
23
  const es = {
@@ -37,7 +40,7 @@ const es = {
37
40
  updateRestartOffer: (version) => `Instalada la ${version}. ¿Reiniciar ahora la bóveda y esta pantalla? Cierra las bóvedas abiertas`,
38
41
  updateRestarting: 'Reiniciando la bóveda…',
39
42
  updateRestartLater: (version) => `La ${version} queda instalada: la bóveda se reinicia sola en un par de minutos`,
40
- daemonStale: (running, installed) => `⚠ el servicio corre ${running} y el binario instalado es ${installed}: reinícialo (systemctl --user restart dotrino-vault)`,
43
+ daemonStale: (running, installed) => `⚠ el servicio corre ${running} y el binario instalado es ${installed}: reinícialo${LINUX ? ' (systemctl --user restart dotrino-vault)' : ''}`,
41
44
  activeVault: 'Bóveda activa: ',
42
45
  noName: '(sin nombre)',
43
46
  tooSmall: 'Terminal muy pequeño',
@@ -190,6 +193,14 @@ const es = {
190
193
  settingUpdateNowAsks: 'Hecho: pide aprobación antes de actualizarse.',
191
194
  settingUpdateNowAlone: 'Hecho: se actualiza sola.',
192
195
  settingSaveFailed: (m) => `No se pudo guardar el ajuste: ${m}`,
196
+ settingAutostartOn: 'La bóveda arranca sola al iniciar sesión en esta computadora.',
197
+ settingAutostartOff: 'La bóveda no arranca sola: hay que abrirla cada vez.',
198
+ settingAutostartUnknown: 'No se sabe si la bóveda arranca sola al iniciar sesión.',
199
+ settingAutostartHint: 'Sin ella encendida, tus aparatos no pueden pedirle nada a esta bóveda.',
200
+ settingAutostartNoUnit: 'Para que arranque sola, instálala con el paquete de tu sistema (.deb o .rpm).',
201
+ settingAutostartSaving: 'Guardando…',
202
+ settingAutostartNowOn: 'Hecho: arranca sola al iniciar sesión.',
203
+ settingAutostartNowOff: 'Hecho: ya no arranca sola.',
193
204
  doorsIntro: (name) => `Con qué se abre «${name}». Abre cualquiera de estas.`,
194
205
  doorsNone: 'Sin candado: se abre sola en esta máquina.',
195
206
  doorPassword: 'contraseña',
@@ -480,7 +491,7 @@ const es = {
480
491
 
481
492
  // pantalla "daemon caído"
482
493
  downTitle: 'El daemon del vault no está corriendo.',
483
- downBody1: 'La TUI le da órdenes al daemon (custodio de tu clave). Sin él no puede',
494
+ downBody1: 'Esta pantalla le da órdenes al servicio de la bóveda (el que guarda tu llave). Sin él no puede',
484
495
  downBody2: 'crear bóvedas, listar dispositivos ni tocar secretos.',
485
496
  downStart: ' intentar arrancarlo: ',
486
497
  downRecheck: ' volver a comprobar',
@@ -496,7 +507,7 @@ const es = {
496
507
  startFailed: (err) => `No se pudo arrancar: ${err}`,
497
508
 
498
509
  // errores
499
- errDaemonDown: 'El daemon no está corriendo. Arráncalo: systemctl --user start dotrino-vault (o reinicia la TUI).',
510
+ errDaemonDown: LINUX ? 'El daemon no está corriendo. Arráncalo: systemctl --user start dotrino-vault (o reinicia la TUI).' : 'El servicio de la bóveda no está corriendo. Cierra esta pantalla y vuelve a abrirla para arrancarlo.',
500
511
  errNoReply: 'El daemon no respondió.',
501
512
  errNotApplied: 'El daemon no aplicó el cambio (revisa los logs del servicio).',
502
513
  errNotDeleted: 'El daemon no borró la variable (revisa los logs del servicio).',
@@ -534,7 +545,7 @@ const en = {
534
545
  updateRestartOffer: (version) => `${version} is installed. Restart the vault and this screen now? It locks the open vaults`,
535
546
  updateRestarting: 'Restarting the vault…',
536
547
  updateRestartLater: (version) => `${version} is installed: the vault restarts by itself in a couple of minutes`,
537
- daemonStale: (running, installed) => `⚠ the service runs ${running} but the installed binary is ${installed}: restart it (systemctl --user restart dotrino-vault)`,
548
+ daemonStale: (running, installed) => `⚠ the service runs ${running} but the installed binary is ${installed}: restart it${LINUX ? ' (systemctl --user restart dotrino-vault)' : ''}`,
538
549
  activeVault: 'Active vault: ',
539
550
  tooSmall: 'Terminal too small',
540
551
  tooSmallHint: (cols, rows) => `Resize to ≥ 24×9 (now ${cols}×${rows}).`,
@@ -667,6 +678,14 @@ const en = {
667
678
  settingUpdateNowAsks: 'Done: it asks for approval before updating.',
668
679
  settingUpdateNowAlone: 'Done: it updates on its own.',
669
680
  settingSaveFailed: (m) => `The setting could not be saved: ${m}`,
681
+ settingAutostartOn: 'The vault starts on its own when you sign in to this computer.',
682
+ settingAutostartOff: 'The vault does not start on its own: you have to open it every time.',
683
+ settingAutostartUnknown: 'It is not known whether the vault starts on its own when you sign in.',
684
+ settingAutostartHint: 'While it is off, your devices cannot ask this vault for anything.',
685
+ settingAutostartNoUnit: 'To have it start on its own, install it with your system package (.deb or .rpm).',
686
+ settingAutostartSaving: 'Saving…',
687
+ settingAutostartNowOn: 'Done: it starts on its own when you sign in.',
688
+ settingAutostartNowOff: 'Done: it no longer starts on its own.',
670
689
  doorsIntro: (name) => `What opens “${name}”. Any of these opens it.`,
671
690
  doorsNone: 'No lock: it opens by itself on this machine.',
672
691
  doorPassword: 'password',
@@ -926,7 +945,7 @@ const en = {
926
945
  helpJoin: ['Esc vaults', 'l Español', 'q quit'],
927
946
 
928
947
  downTitle: 'The vault daemon is not running.',
929
- downBody1: 'The TUI gives orders to the daemon (the keeper of your key). Without it',
948
+ downBody1: 'This screen gives orders to the vault service (the keeper of your key). Without it',
930
949
  downBody2: 'it cannot create vaults, list devices or touch secrets.',
931
950
  downStart: ' try to start it: ',
932
951
  downRecheck: ' check again',
@@ -941,7 +960,7 @@ const en = {
941
960
  startedNotReady: 'It started but does not answer yet; press R',
942
961
  startFailed: (err) => `Could not start it: ${err}`,
943
962
 
944
- errDaemonDown: 'The daemon is not running. Start it: systemctl --user start dotrino-vault (or restart the TUI).',
963
+ errDaemonDown: LINUX ? 'The daemon is not running. Start it: systemctl --user start dotrino-vault (or restart the TUI).' : 'The vault service is not running. Close this screen and open it again to start it.',
945
964
  errNoReply: 'The daemon did not answer.',
946
965
  errNotApplied: 'The daemon did not apply the change (check the service logs).',
947
966
  errNotDeleted: 'The daemon did not delete the variable (check the service logs).',
package/src/tui/term.js CHANGED
@@ -87,8 +87,13 @@ export function padEnd (s, width) {
87
87
 
88
88
  // ------------------------------- tema/estilos -------------------------------
89
89
 
90
- export function makeTheme () {
91
- const on = supportsColor()
90
+ /**
91
+ * `color: true` fuerza los estilos aunque no haya terminal: lo usa el puente de la ventana
92
+ * (`gui.js`), que convierte esos mismos escapes en tramos con estilo. Así las filas que
93
+ * pinta la TUI son, carácter a carácter, las que pinta la ventana.
94
+ */
95
+ export function makeTheme ({ color } = {}) {
96
+ const on = color ?? supportsColor()
92
97
  const sgr = (...c) => (on ? CSI + c.join(';') + 'm' : '')
93
98
  const R = on ? CSI + '0m' : ''
94
99
  const wrap = (open) => (s) => on ? open + s + R : s
package/src/update.js CHANGED
@@ -124,8 +124,12 @@ async function instalarDesde (work, pick, release, { binDir, log, fetchVerifiedI
124
124
  const src = fs.readdirSync(work).map((d) => path.join(work, d)).find((d) => fs.existsSync(path.join(d, 'dotrino-vaultd')))
125
125
  if (!src) return { ok: false, code: 'BAD_TARBALL', reason: 'the tarball has no dotrino-vaultd inside' }
126
126
  fs.mkdirSync(binDir, { recursive: true })
127
+ // LA VENTANA se pone al día con la bóveda, pero solo donde ya estaba: una bóveda de
128
+ // servidor no tiene por qué recibir un programa de escritorio que nadie instaló.
129
+ const names = ['dotrino-vault', 'dotrino-vaultd']
130
+ if (fs.existsSync(path.join(binDir, 'dotrino-vault-desktop')) && fs.existsSync(path.join(src, 'dotrino-vault-desktop'))) names.unshift('dotrino-vault-desktop')
127
131
  // El CLI primero y el daemon al final: cambiar el daemon es lo que dispara el reinicio.
128
- for (const name of ['dotrino-vault', 'dotrino-vaultd']) {
132
+ for (const name of names) {
129
133
  const tmp = path.join(binDir, `.${name}.new`)
130
134
  fs.copyFileSync(path.join(src, name), tmp)
131
135
  fs.chmodSync(tmp, 0o755)