@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/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 = {
|
|
@@ -33,11 +36,12 @@ const es = {
|
|
|
33
36
|
updateInstalling: (version) => `Bajando la ${version} y comprobando la firma…`,
|
|
34
37
|
updateFailed: (reason) => `No se instaló nada: ${reason}`,
|
|
35
38
|
updateNeedsCommand: 'Esta instalación no se puede cambiar desde aquí. Sal y corre: dotrino-vault update',
|
|
39
|
+
updateWindowsInstalling: (v) => `Instalando la ${v}: esta pantalla se cierra un momento y la bóveda vuelve sola.`,
|
|
36
40
|
updateNoteFailed: (reason) => `Instalada, pero no se pudo dejar el aviso para tus aprobadores: ${reason}`,
|
|
37
41
|
updateRestartOffer: (version) => `Instalada la ${version}. ¿Reiniciar ahora la bóveda y esta pantalla? Cierra las bóvedas abiertas`,
|
|
38
42
|
updateRestarting: 'Reiniciando la bóveda…',
|
|
39
43
|
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)`,
|
|
44
|
+
daemonStale: (running, installed) => `⚠ el servicio corre ${running} y el binario instalado es ${installed}: reinícialo${LINUX ? ' (systemctl --user restart dotrino-vault)' : ''}`,
|
|
41
45
|
activeVault: 'Bóveda activa: ',
|
|
42
46
|
noName: '(sin nombre)',
|
|
43
47
|
tooSmall: 'Terminal muy pequeño',
|
|
@@ -190,6 +194,14 @@ const es = {
|
|
|
190
194
|
settingUpdateNowAsks: 'Hecho: pide aprobación antes de actualizarse.',
|
|
191
195
|
settingUpdateNowAlone: 'Hecho: se actualiza sola.',
|
|
192
196
|
settingSaveFailed: (m) => `No se pudo guardar el ajuste: ${m}`,
|
|
197
|
+
settingAutostartOn: 'La bóveda arranca sola al iniciar sesión en esta computadora.',
|
|
198
|
+
settingAutostartOff: 'La bóveda no arranca sola: hay que abrirla cada vez.',
|
|
199
|
+
settingAutostartUnknown: 'No se sabe si la bóveda arranca sola al iniciar sesión.',
|
|
200
|
+
settingAutostartHint: 'Sin ella encendida, tus aparatos no pueden pedirle nada a esta bóveda.',
|
|
201
|
+
settingAutostartNoUnit: 'Para que arranque sola, instálala con el paquete de tu sistema (.deb o .rpm).',
|
|
202
|
+
settingAutostartSaving: 'Guardando…',
|
|
203
|
+
settingAutostartNowOn: 'Hecho: arranca sola al iniciar sesión.',
|
|
204
|
+
settingAutostartNowOff: 'Hecho: ya no arranca sola.',
|
|
193
205
|
doorsIntro: (name) => `Con qué se abre «${name}». Abre cualquiera de estas.`,
|
|
194
206
|
doorsNone: 'Sin candado: se abre sola en esta máquina.',
|
|
195
207
|
doorPassword: 'contraseña',
|
|
@@ -480,7 +492,7 @@ const es = {
|
|
|
480
492
|
|
|
481
493
|
// pantalla "daemon caído"
|
|
482
494
|
downTitle: 'El daemon del vault no está corriendo.',
|
|
483
|
-
downBody1: '
|
|
495
|
+
downBody1: 'Esta pantalla le da órdenes al servicio de la bóveda (el que guarda tu llave). Sin él no puede',
|
|
484
496
|
downBody2: 'crear bóvedas, listar dispositivos ni tocar secretos.',
|
|
485
497
|
downStart: ' intentar arrancarlo: ',
|
|
486
498
|
downRecheck: ' volver a comprobar',
|
|
@@ -496,7 +508,7 @@ const es = {
|
|
|
496
508
|
startFailed: (err) => `No se pudo arrancar: ${err}`,
|
|
497
509
|
|
|
498
510
|
// errores
|
|
499
|
-
errDaemonDown: 'El daemon no está corriendo. Arráncalo: systemctl --user start dotrino-vault (o reinicia la TUI).',
|
|
511
|
+
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
512
|
errNoReply: 'El daemon no respondió.',
|
|
501
513
|
errNotApplied: 'El daemon no aplicó el cambio (revisa los logs del servicio).',
|
|
502
514
|
errNotDeleted: 'El daemon no borró la variable (revisa los logs del servicio).',
|
|
@@ -530,11 +542,12 @@ const en = {
|
|
|
530
542
|
updateInstalling: (version) => `Downloading ${version} and checking its signature…`,
|
|
531
543
|
updateFailed: (reason) => `Nothing was installed: ${reason}`,
|
|
532
544
|
updateNeedsCommand: 'This install cannot be changed from here. Quit and run: dotrino-vault update',
|
|
545
|
+
updateWindowsInstalling: (v) => `Installing ${v}: this screen closes for a moment and the vault comes back on its own.`,
|
|
533
546
|
updateNoteFailed: (reason) => `Installed, but the note for your approvers could not be written: ${reason}`,
|
|
534
547
|
updateRestartOffer: (version) => `${version} is installed. Restart the vault and this screen now? It locks the open vaults`,
|
|
535
548
|
updateRestarting: 'Restarting the vault…',
|
|
536
549
|
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)`,
|
|
550
|
+
daemonStale: (running, installed) => `⚠ the service runs ${running} but the installed binary is ${installed}: restart it${LINUX ? ' (systemctl --user restart dotrino-vault)' : ''}`,
|
|
538
551
|
activeVault: 'Active vault: ',
|
|
539
552
|
tooSmall: 'Terminal too small',
|
|
540
553
|
tooSmallHint: (cols, rows) => `Resize to ≥ 24×9 (now ${cols}×${rows}).`,
|
|
@@ -667,6 +680,14 @@ const en = {
|
|
|
667
680
|
settingUpdateNowAsks: 'Done: it asks for approval before updating.',
|
|
668
681
|
settingUpdateNowAlone: 'Done: it updates on its own.',
|
|
669
682
|
settingSaveFailed: (m) => `The setting could not be saved: ${m}`,
|
|
683
|
+
settingAutostartOn: 'The vault starts on its own when you sign in to this computer.',
|
|
684
|
+
settingAutostartOff: 'The vault does not start on its own: you have to open it every time.',
|
|
685
|
+
settingAutostartUnknown: 'It is not known whether the vault starts on its own when you sign in.',
|
|
686
|
+
settingAutostartHint: 'While it is off, your devices cannot ask this vault for anything.',
|
|
687
|
+
settingAutostartNoUnit: 'To have it start on its own, install it with your system package (.deb or .rpm).',
|
|
688
|
+
settingAutostartSaving: 'Saving…',
|
|
689
|
+
settingAutostartNowOn: 'Done: it starts on its own when you sign in.',
|
|
690
|
+
settingAutostartNowOff: 'Done: it no longer starts on its own.',
|
|
670
691
|
doorsIntro: (name) => `What opens “${name}”. Any of these opens it.`,
|
|
671
692
|
doorsNone: 'No lock: it opens by itself on this machine.',
|
|
672
693
|
doorPassword: 'password',
|
|
@@ -926,7 +947,7 @@ const en = {
|
|
|
926
947
|
helpJoin: ['Esc vaults', 'l Español', 'q quit'],
|
|
927
948
|
|
|
928
949
|
downTitle: 'The vault daemon is not running.',
|
|
929
|
-
downBody1: '
|
|
950
|
+
downBody1: 'This screen gives orders to the vault service (the keeper of your key). Without it',
|
|
930
951
|
downBody2: 'it cannot create vaults, list devices or touch secrets.',
|
|
931
952
|
downStart: ' try to start it: ',
|
|
932
953
|
downRecheck: ' check again',
|
|
@@ -941,7 +962,7 @@ const en = {
|
|
|
941
962
|
startedNotReady: 'It started but does not answer yet; press R',
|
|
942
963
|
startFailed: (err) => `Could not start it: ${err}`,
|
|
943
964
|
|
|
944
|
-
errDaemonDown: 'The daemon is not running. Start it: systemctl --user start dotrino-vault (or restart the TUI).',
|
|
965
|
+
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
966
|
errNoReply: 'The daemon did not answer.',
|
|
946
967
|
errNotApplied: 'The daemon did not apply the change (check the service logs).',
|
|
947
968
|
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
|
-
|
|
91
|
-
|
|
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
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
import fs from 'node:fs'
|
|
13
13
|
import os from 'node:os'
|
|
14
14
|
import path from 'node:path'
|
|
15
|
-
import { execFileSync } from 'node:child_process'
|
|
15
|
+
import { execFileSync, spawn } from 'node:child_process'
|
|
16
16
|
import { latestVersion, isNewer, CHECK_EVERY_MS } from '@dotrino/update'
|
|
17
17
|
import { pickAsset, download, verifyArtifact as verify, fetchVerified } from '@dotrino/update/fetch'
|
|
18
18
|
import { VERSION } from './version.js'
|
|
@@ -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
|
|
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)
|
|
@@ -133,3 +137,143 @@ async function instalarDesde (work, pick, release, { binDir, log, fetchVerifiedI
|
|
|
133
137
|
}
|
|
134
138
|
return { ok: true, version: release.version }
|
|
135
139
|
}
|
|
140
|
+
|
|
141
|
+
// ------------------------- Windows y macOS ---------------------------------
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* CÓMO ESTÁ INSTALADO ESTE BINARIO, y por tanto si puede actualizarse solo (CONVENCIONES §15).
|
|
145
|
+
*
|
|
146
|
+
* 'user' Linux, instalación de usuario (`~/.local/share/dotrino/bin`): cambia sus binarios.
|
|
147
|
+
* 'inno' Windows, el instalador por usuario (`unins000.exe` al lado): corre el instalador
|
|
148
|
+
* nuevo en silencio, que cierra la bóveda, la reemplaza y la vuelve a arrancar.
|
|
149
|
+
* 'app' macOS, dentro de `Dotrino Vault.app` en una carpeta que se puede escribir: se
|
|
150
|
+
* cambia la app entera (nunca un binario suelto: la app es un conjunto firmado).
|
|
151
|
+
* 'system' un paquete de root (.deb/.rpm), o una app en una carpeta que no es tuya.
|
|
152
|
+
* 'other' npm, Docker, un checkout: se actualiza por su propia vía.
|
|
153
|
+
*/
|
|
154
|
+
export function installKind ({ platform = process.platform, execPath = process.execPath, home = os.homedir(), exists = fs.existsSync, writable = canWrite } = {}) {
|
|
155
|
+
const sea = /^dotrino-vaultd(\.exe)?$/.test(path.basename(execPath))
|
|
156
|
+
if (!sea) return 'other'
|
|
157
|
+
if (platform === 'linux') return isUserInstall(execPath, home) ? 'user' : execPath.startsWith('/usr/') ? 'system' : 'other'
|
|
158
|
+
if (platform === 'win32') return exists(path.join(path.dirname(execPath), 'unins000.exe')) ? 'inno' : 'other'
|
|
159
|
+
if (platform === 'darwin') {
|
|
160
|
+
const app = appBundleOf(execPath)
|
|
161
|
+
if (!app) return 'other'
|
|
162
|
+
return writable(path.dirname(app)) ? 'app' : 'system'
|
|
163
|
+
}
|
|
164
|
+
return 'other'
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/** ¿Puede este binario instalar él mismo la versión nueva? */
|
|
168
|
+
export const canSelfUpdate = (opts) => ['user', 'inno', 'app'].includes(installKind(opts))
|
|
169
|
+
|
|
170
|
+
/** `/Applications/Dotrino Vault.app` a partir de su `Contents/MacOS/dotrino-vaultd`. */
|
|
171
|
+
export function appBundleOf (execPath) {
|
|
172
|
+
const m = /^(.*\.app)\/Contents\/MacOS\/dotrino-vaultd$/.exec(execPath)
|
|
173
|
+
return m ? m[1] : null
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function canWrite (dir) { try { fs.accessSync(dir, fs.constants.W_OK); return true } catch { return false } }
|
|
177
|
+
|
|
178
|
+
/** Carpeta de trabajo de las descargas: la de los datos de Dotrino del usuario, no `/tmp`. */
|
|
179
|
+
function updateWorkDir (platform = process.platform) {
|
|
180
|
+
if (platform === 'win32') return path.join(process.env.LOCALAPPDATA || path.join(os.homedir(), 'AppData', 'Local'), 'Dotrino', 'update')
|
|
181
|
+
return path.join(os.homedir(), '.local', 'share', 'dotrino', 'update')
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* INSTALA la versión nueva por la vía de este sistema. Lo que se baja se verifica contra la
|
|
186
|
+
* atestación del release antes de tocar nada; si no cuadra, no se toca nada y se dice.
|
|
187
|
+
*
|
|
188
|
+
* `then` dice qué tiene que hacer quien llamó:
|
|
189
|
+
* 'watch' Linux: el binario cambió en su sitio; el vigilante (o systemd) lo reinicia.
|
|
190
|
+
* 'exit' el proceso que corre la bóveda tiene que irse YA. En Windows el instalador lo
|
|
191
|
+
* cierra y lo vuelve a arrancar; en macOS lo levanta launchd o el relanzador.
|
|
192
|
+
*
|
|
193
|
+
* @returns {Promise<{ok:true, version:string, then:'watch'|'exit'} | {ok:false, code?:string, reason:string, file?:string}>}
|
|
194
|
+
*/
|
|
195
|
+
export async function installRelease (release, { log = () => {}, kind = installKind(), fetchVerifiedImpl = fetchVerified, run = spawnDetached, execPath = process.execPath, pid = null } = {}) {
|
|
196
|
+
if (kind === 'user') {
|
|
197
|
+
const r = await installUserRelease(release, { log, fetchVerifiedImpl })
|
|
198
|
+
return r.ok ? { ...r, then: 'watch' } : r
|
|
199
|
+
}
|
|
200
|
+
if (kind === 'inno') return installWindows(release, { log, fetchVerifiedImpl, run })
|
|
201
|
+
if (kind === 'app') return installMac(release, { log, fetchVerifiedImpl, run, execPath, pid })
|
|
202
|
+
return { ok: false, code: 'CANNOT_SELF_UPDATE', reason: kind === 'system' ? 'installed as a system package (root): update it with the system installer' : 'not installed with an installer: update it the way you installed it (npm, Docker…)' }
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
function spawnDetached (cmd, args, { log } = {}) {
|
|
206
|
+
const child = spawn(cmd, args, { detached: true, windowsHide: true, stdio: 'ignore' })
|
|
207
|
+
child.on('error', (e) => log?.(`[vault] update: could not start ${cmd}: ${e.message}`))
|
|
208
|
+
child.unref()
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/** Baja y verifica un archivo del release a una carpeta propia (que se vacía antes). */
|
|
212
|
+
async function bajarVerificado (release, re, { log, fetchVerifiedImpl, work }) {
|
|
213
|
+
const pick = pickAsset(release.assets, [{ kind: 'asset', re }])
|
|
214
|
+
if (!pick.ok) return pick
|
|
215
|
+
fs.rmSync(work, { recursive: true, force: true })
|
|
216
|
+
fs.mkdirSync(work, { recursive: true, mode: 0o700 })
|
|
217
|
+
const got = await fetchVerifiedImpl(pick.asset, { repo: REPO, product: 'dotrino-vault', version: VERSION, dir: work })
|
|
218
|
+
if (got.ok) log(`[vault] update: ${pick.asset.name} verified against its attestation`)
|
|
219
|
+
return got
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/**
|
|
223
|
+
* WINDOWS: el instalador nuevo, en silencio. Es lo único que puede cambiar los archivos: un
|
|
224
|
+
* .exe en marcha no se sobrescribe. El instalador cierra lo que los usa (la bóveda, y la
|
|
225
|
+
* ventana si está abierta), los reemplaza y vuelve a arrancar la bóveda (`--start-daemon`).
|
|
226
|
+
* Las tareas elegidas al instalar (arrancar al iniciar sesión) se conservan.
|
|
227
|
+
*/
|
|
228
|
+
async function installWindows (release, { log, fetchVerifiedImpl, run }) {
|
|
229
|
+
const work = updateWorkDir('win32')
|
|
230
|
+
const got = await bajarVerificado(release, /-windows-x64-setup\.exe$/, { log, fetchVerifiedImpl, work })
|
|
231
|
+
if (!got.ok) return got
|
|
232
|
+
run(got.file, ['/VERYSILENT', '/SUPPRESSMSGBOXES', '/NORESTART', '/FORCECLOSEAPPLICATIONS', `/LOG=${path.join(work, 'setup.log')}`], { log })
|
|
233
|
+
log(`[vault] update: installer for ${release.version} started; it restarts the vault when it finishes`)
|
|
234
|
+
return { ok: true, version: release.version, then: 'exit' }
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/**
|
|
238
|
+
* macOS: la app entera. Se descomprime al lado de la actual (misma carpeta, así el cambio es
|
|
239
|
+
* un renombrado y no una copia a medias), se aparta la vieja, se pone la nueva y se borra la
|
|
240
|
+
* vieja. Lo que está corriendo sigue con su copia hasta que se reinicia.
|
|
241
|
+
*
|
|
242
|
+
* Quien la levanta: launchd si la bóveda corre como agente (KeepAlive); si corre suelta, un
|
|
243
|
+
* relanzador que espera a que este proceso se vaya y arranca el binario nuevo.
|
|
244
|
+
*/
|
|
245
|
+
async function installMac (release, { log, fetchVerifiedImpl, run, execPath, pid }) {
|
|
246
|
+
const app = appBundleOf(execPath)
|
|
247
|
+
if (!app) return { ok: false, code: 'CANNOT_SELF_UPDATE', reason: 'not running from Dotrino Vault.app' }
|
|
248
|
+
const work = updateWorkDir('darwin')
|
|
249
|
+
const got = await bajarVerificado(release, /-macos-universal\.zip$/, { log, fetchVerifiedImpl, work })
|
|
250
|
+
if (!got.ok) return got
|
|
251
|
+
const parent = path.dirname(app)
|
|
252
|
+
const stage = path.join(parent, `.dotrino-vault-update-${process.pid}`)
|
|
253
|
+
fs.rmSync(stage, { recursive: true, force: true })
|
|
254
|
+
try {
|
|
255
|
+
try { execFileSync('ditto', ['-x', '-k', got.file, stage], { stdio: ['ignore', 'ignore', 'pipe'] }) } catch (e) {
|
|
256
|
+
return { ok: false, code: 'UNPACK_FAILED', reason: `could not unpack the release: ${String(e.stderr || e.message).trim().split('\n').slice(-2).join(' · ')}` }
|
|
257
|
+
}
|
|
258
|
+
const nueva = path.join(stage, 'Dotrino Vault.app')
|
|
259
|
+
if (!fs.existsSync(path.join(nueva, 'Contents', 'MacOS', 'dotrino-vaultd'))) return { ok: false, code: 'BAD_ARCHIVE', reason: 'the archive has no Dotrino Vault.app inside' }
|
|
260
|
+
const vieja = `${app}.old-${process.pid}`
|
|
261
|
+
fs.renameSync(app, vieja)
|
|
262
|
+
try { fs.renameSync(nueva, app) } catch (e) {
|
|
263
|
+
fs.renameSync(vieja, app) // se deja como estaba
|
|
264
|
+
return { ok: false, code: 'SWAP_FAILED', reason: `could not put the new app in place: ${e.message}` }
|
|
265
|
+
}
|
|
266
|
+
fs.rmSync(vieja, { recursive: true, force: true })
|
|
267
|
+
} finally {
|
|
268
|
+
fs.rmSync(stage, { recursive: true, force: true })
|
|
269
|
+
fs.rmSync(work, { recursive: true, force: true })
|
|
270
|
+
}
|
|
271
|
+
// Suelta (sin launchd): el relanzador espera a que `pid` se vaya y arranca el nuevo.
|
|
272
|
+
if (pid && process.env.XPC_SERVICE_NAME !== 'com.dotrino.vault') {
|
|
273
|
+
const bin = path.join(app, 'Contents', 'MacOS', 'dotrino-vaultd')
|
|
274
|
+
const logFile = path.join(os.homedir(), '.local', 'share', 'dotrino', 'vault', 'daemon.log')
|
|
275
|
+
run('/bin/sh', ['-c', 'while kill -0 "$1" 2>/dev/null; do sleep 0.5; done; exec "$2" >>"$3" 2>&1', 'relaunch', String(pid), bin, logFile], { log })
|
|
276
|
+
}
|
|
277
|
+
log(`[vault] update: ${release.version} installed in ${app}`)
|
|
278
|
+
return { ok: true, version: release.version, then: 'exit' }
|
|
279
|
+
}
|