@owear/cli 0.1.3 → 0.1.5
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/package.json +4 -4
- package/src/ow.js +83 -4
- package/template/README.md +34 -28
- package/template/app/main.ts +273 -4
- package/template/index.html +117 -28
- package/template/package.json +1 -1
- package/template/public/favicon.svg +4 -0
- package/template/src/assets/dancing-script.woff2 +0 -0
- package/template/src/assets/jetbrains-mono.woff2 +0 -0
- package/template/src/ow.d.ts +9 -1
- package/template/src/renderer.ts +489 -1
- package/template/src/style.css +280 -56
- package/template-installer/index.html +36 -26
- package/template-installer/src/assets/dancing-script.woff2 +0 -0
- package/template-installer/src/assets/jetbrains-mono.woff2 +0 -0
- package/template-installer/src/installer.ts +44 -25
- package/template-installer/src/style.css +429 -53
- package/template-installer/uninstaller/index.html +22 -16
- package/template-installer/uninstaller/src/uninstaller.ts +53 -28
- package/tools/owear-installer.mjs +5 -0
- package/tools/owear-sign.mjs +204 -0
- package/tools/owear-update.mjs +150 -0
|
@@ -5,14 +5,18 @@
|
|
|
5
5
|
<head>
|
|
6
6
|
<meta charset="UTF-8" />
|
|
7
7
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
8
|
-
<title>Desinstalar
|
|
8
|
+
<title>Desinstalar · __APP_NAME__</title>
|
|
9
9
|
</head>
|
|
10
10
|
<body>
|
|
11
11
|
<header class="titlebar">
|
|
12
12
|
<div class="titlebar__drag" data-ow-drag>
|
|
13
|
+
<span class="brand__mark" aria-hidden="true"></span>
|
|
13
14
|
<span class="brand__name">Desinstalador · __APP_NAME__</span>
|
|
14
15
|
</div>
|
|
15
16
|
<div class="titlebar__controls">
|
|
17
|
+
<button id="win-min" class="winbtn" title="Minimizar" aria-label="Minimizar">
|
|
18
|
+
<svg viewBox="0 0 12 12" width="12" height="12"><path d="M2 6h8" /></svg>
|
|
19
|
+
</button>
|
|
16
20
|
<button id="win-close" class="winbtn winbtn--close" title="Cerrar" aria-label="Cerrar">
|
|
17
21
|
<svg viewBox="0 0 12 12" width="12" height="12"><path d="M2.5 2.5l7 7M9.5 2.5l-7 7" /></svg>
|
|
18
22
|
</button>
|
|
@@ -21,28 +25,30 @@
|
|
|
21
25
|
|
|
22
26
|
<main class="page">
|
|
23
27
|
<section class="hero">
|
|
24
|
-
<span class="eyebrow">Owear Uninstaller</span>
|
|
25
28
|
<h1>Desinstalar __APP_NAME__</h1>
|
|
26
29
|
<p class="lead" id="summary">Localizando la instalación…</p>
|
|
27
30
|
</section>
|
|
28
31
|
|
|
29
|
-
<section class="
|
|
30
|
-
<
|
|
31
|
-
|
|
32
|
-
<
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
<
|
|
37
|
-
|
|
32
|
+
<section class="board">
|
|
33
|
+
<article class="card">
|
|
34
|
+
<header class="card__head"><h2>Instalación</h2></header>
|
|
35
|
+
<div class="row">
|
|
36
|
+
<input id="dir" class="field" type="text" spellcheck="false" aria-label="Directorio de instalación" />
|
|
37
|
+
<button id="pick" class="btn">Elegir…</button>
|
|
38
|
+
</div>
|
|
39
|
+
<div class="row">
|
|
40
|
+
<label class="opt"><input type="checkbox" id="keepData" checked /> Conservar mis datos</label>
|
|
41
|
+
</div>
|
|
42
|
+
</article>
|
|
38
43
|
</section>
|
|
39
|
-
|
|
40
|
-
<footer class="actions">
|
|
41
|
-
<span id="status" class="status"></span>
|
|
42
|
-
<button id="uninstall" class="btn btn--primary" disabled>Desinstalar</button>
|
|
43
|
-
</footer>
|
|
44
44
|
</main>
|
|
45
45
|
|
|
46
|
+
<footer class="foot">
|
|
47
|
+
<span id="status" class="status"></span>
|
|
48
|
+
<span class="foot__spacer"></span>
|
|
49
|
+
<button id="uninstall" class="btn btn--primary" disabled>Desinstalar</button>
|
|
50
|
+
</footer>
|
|
51
|
+
|
|
46
52
|
<script type="module" src="/src/uninstaller.ts"></script>
|
|
47
53
|
</body>
|
|
48
54
|
</html>
|
|
@@ -2,21 +2,35 @@
|
|
|
2
2
|
// SPDX-License-Identifier: Apache-2.0
|
|
3
3
|
//
|
|
4
4
|
// src/uninstaller.ts — UI del desinstalador (vanilla). Mismo bridge, pero
|
|
5
|
-
// invoca `installer.uninstall`.
|
|
5
|
+
// invoca `installer.uninstall`. Detecta la instalación por el REGISTRO de Owear
|
|
6
|
+
// (no hace falta escribir la ruta ni usar módulos .owm).
|
|
7
|
+
// Estilos: tokens del Starter (ver ../../src/style.css).
|
|
6
8
|
|
|
7
9
|
import '../../src/style.css'
|
|
8
10
|
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
__owWindowId: number
|
|
12
|
-
ow: { invoke<T = unknown>(module: string, fn: string, ...args: unknown[]): Promise<T> }
|
|
13
|
-
}
|
|
11
|
+
interface OwBridge {
|
|
12
|
+
invoke<T = unknown>(module: string, fn: string, ...args: unknown[]): Promise<T>
|
|
14
13
|
}
|
|
14
|
+
const ow = (window as unknown as { ow: OwBridge }).ow
|
|
15
|
+
const wid = (window as unknown as { __owWindowId: number }).__owWindowId
|
|
15
16
|
|
|
16
17
|
const $ = <T extends HTMLElement = HTMLElement>(sel: string): T =>
|
|
17
18
|
document.querySelector(sel) as T
|
|
18
19
|
const invoke = <T = unknown>(module: string, fn: string, ...args: unknown[]): Promise<T> =>
|
|
19
|
-
|
|
20
|
+
ow.invoke<T>(module, fn, ...args)
|
|
21
|
+
|
|
22
|
+
interface StateResult {
|
|
23
|
+
installed: boolean
|
|
24
|
+
version?: string
|
|
25
|
+
app?: string
|
|
26
|
+
dir?: string
|
|
27
|
+
}
|
|
28
|
+
interface Info {
|
|
29
|
+
appName?: string
|
|
30
|
+
version?: string
|
|
31
|
+
dir?: string
|
|
32
|
+
installed?: boolean
|
|
33
|
+
}
|
|
20
34
|
|
|
21
35
|
function setStatus(msg: string, kind: 'ok' | 'err' | '' = ''): void {
|
|
22
36
|
const el = $('#status')
|
|
@@ -25,25 +39,37 @@ function setStatus(msg: string, kind: 'ok' | 'err' | '' = ''): void {
|
|
|
25
39
|
}
|
|
26
40
|
|
|
27
41
|
async function boot(): Promise<void> {
|
|
28
|
-
$('#win-
|
|
42
|
+
$('#win-min').addEventListener('click', () => void invoke('ow-window', 'minimize', wid))
|
|
43
|
+
$('#win-close').addEventListener('click', () => void invoke('ow-window', 'close', wid))
|
|
29
44
|
|
|
30
|
-
const info = await invoke<
|
|
31
|
-
const st = await invoke<
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
45
|
+
const info = await invoke<Info>('installer', 'info').catch(() => ({}))
|
|
46
|
+
const st = await invoke<StateResult>('installer', 'state', {}).catch(() => ({
|
|
47
|
+
installed: false,
|
|
48
|
+
}))
|
|
49
|
+
const name = st.app ?? info.appName ?? 'La app'
|
|
50
|
+
const dir = st.dir ?? info.dir ?? ''
|
|
36
51
|
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
52
|
+
if (st.installed) {
|
|
53
|
+
const v = st.version ?? info.version ?? ''
|
|
54
|
+
$('#summary').textContent = `${name}${v ? ' ' + v : ''} está instalada${
|
|
55
|
+
dir ? ' en ' + dir : ''
|
|
56
|
+
}.`
|
|
57
|
+
} else {
|
|
58
|
+
$('#summary').textContent =
|
|
59
|
+
'No se detectó ninguna instalación registrada. Elige la carpeta donde se instaló.'
|
|
60
|
+
}
|
|
61
|
+
;($('#dir') as HTMLInputElement).value = dir
|
|
41
62
|
$('#uninstall').removeAttribute('disabled')
|
|
42
63
|
}
|
|
43
64
|
|
|
44
65
|
async function pickDir(): Promise<void> {
|
|
66
|
+
const current = ($('#dir') as HTMLInputElement).value.trim()
|
|
45
67
|
try {
|
|
46
|
-
|
|
68
|
+
// Selector NATIVO del builtin installer (no usa el módulo `dialog`).
|
|
69
|
+
const dir = await invoke<string | null>('installer', 'chooseDir', {
|
|
70
|
+
title: 'Elegir carpeta de instalación',
|
|
71
|
+
defaultPath: current,
|
|
72
|
+
})
|
|
47
73
|
if (dir) ($('#dir') as HTMLInputElement).value = dir
|
|
48
74
|
} catch {
|
|
49
75
|
/* cancelado */
|
|
@@ -52,19 +78,18 @@ async function pickDir(): Promise<void> {
|
|
|
52
78
|
|
|
53
79
|
async function doUninstall(): Promise<void> {
|
|
54
80
|
const dir = ($('#dir') as HTMLInputElement).value.trim()
|
|
55
|
-
|
|
56
|
-
setStatus('Elige la carpeta de instalación', 'err')
|
|
57
|
-
return
|
|
58
|
-
}
|
|
81
|
+
const keepData = ($('#keepData') as HTMLInputElement).checked
|
|
59
82
|
$('#uninstall').setAttribute('disabled', 'true')
|
|
60
83
|
setStatus('Desinstalando…')
|
|
61
84
|
try {
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
85
|
+
// Si no hay dir, el builtin usa el registrado.
|
|
86
|
+
const res = await invoke<{ removed: number }>(
|
|
87
|
+
'installer',
|
|
88
|
+
'uninstall',
|
|
89
|
+
dir ? { dir, keepData } : { keepData },
|
|
90
|
+
)
|
|
66
91
|
setStatus(`Eliminados ${res.removed} ficheros.`, 'ok')
|
|
67
|
-
setTimeout(() => void invoke('ow-window', 'close',
|
|
92
|
+
setTimeout(() => void invoke('ow-window', 'close', wid), 900)
|
|
68
93
|
} catch (e) {
|
|
69
94
|
setStatus(e instanceof Error ? e.message : String(e), 'err')
|
|
70
95
|
$('#uninstall').removeAttribute('disabled')
|
|
@@ -44,6 +44,7 @@ const payload = arg('--payload')
|
|
|
44
44
|
const bridge = arg('--bridge')
|
|
45
45
|
const meta = arg('--meta')
|
|
46
46
|
const modules = arg('--modules')
|
|
47
|
+
const icon = arg('--icon')
|
|
47
48
|
const name = arg('--name', 'app')
|
|
48
49
|
|
|
49
50
|
if (!kernel || !out || !ui) die('uso: --kernel <owear> --out <bin> --ui <distUI> [--payload <dir>]')
|
|
@@ -62,6 +63,10 @@ if (payload && fs.existsSync(payload)) copyInto(payload, 'payload')
|
|
|
62
63
|
if (bridge && fs.existsSync(bridge)) fs.copyFileSync(bridge, path.join(stage, 'bridge.json'))
|
|
63
64
|
if (meta && fs.existsSync(meta)) fs.copyFileSync(meta, path.join(stage, 'installer.json'))
|
|
64
65
|
if (modules && fs.existsSync(modules)) copyInto(modules, 'modules')
|
|
66
|
+
// icono de la app (para el .desktop / acceso directo del instalado)
|
|
67
|
+
if (icon && fs.existsSync(icon)) {
|
|
68
|
+
fs.copyFileSync(icon, path.join(stage, 'icon' + path.extname(icon)))
|
|
69
|
+
}
|
|
65
70
|
|
|
66
71
|
// manifiesto mínimo del payload del instalador (informativo)
|
|
67
72
|
fs.writeFileSync(
|
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Copyright 2026 Owear Contributors
|
|
3
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
4
|
+
//
|
|
5
|
+
// tools/owear-sign.mjs — firma de artefactos de Owear.
|
|
6
|
+
//
|
|
7
|
+
// Dos mecanismos, combinables:
|
|
8
|
+
//
|
|
9
|
+
// 1. Authenticode (Windows PE, .exe/.msi): firma del binario con un
|
|
10
|
+
// certificado (PFX/P12). Requiere `osslsigncode` (Linux/macOS) o
|
|
11
|
+
// `signtool` (Windows). Da confianza de **editor** ante el SO
|
|
12
|
+
// (SmartScreen, "Unknown publisher").
|
|
13
|
+
//
|
|
14
|
+
// 2. Ed25519 desprendida (cualquier plataforma): escribe `<file>.sig` con la
|
|
15
|
+
// firma del fichero completo. La clave pública (misma que el updater)
|
|
16
|
+
// permite verificar el artefacto de forma independiente del feed.
|
|
17
|
+
//
|
|
18
|
+
// Nunca rompe un build: si no hay material de firma o falta la herramienta,
|
|
19
|
+
// omite con un aviso (salvo `--require`, que lo convierte en error).
|
|
20
|
+
//
|
|
21
|
+
// Uso:
|
|
22
|
+
// node tools/owear-sign.mjs --file release/MiApp \
|
|
23
|
+
// [--ed25519-key owear-signing.pem] \
|
|
24
|
+
// [--pfx cert.p12 --pfx-password-env OW_PFX_PASS] \
|
|
25
|
+
// [--timestamp http://timestamp.digicert.com] \
|
|
26
|
+
// [--name "Mi App" --url https://miapp.dev] \
|
|
27
|
+
// [--require] [--dry-run]
|
|
28
|
+
//
|
|
29
|
+
import * as fs from 'node:fs'
|
|
30
|
+
import * as path from 'node:path'
|
|
31
|
+
import * as crypto from 'node:crypto'
|
|
32
|
+
import { spawnSync } from 'node:child_process'
|
|
33
|
+
import { fileURLToPath } from 'node:url'
|
|
34
|
+
|
|
35
|
+
const C = { reset: '\x1b[0m', dim: '\x1b[2m', green: '\x1b[32m', yellow: '\x1b[33m', red: '\x1b[31m' }
|
|
36
|
+
const info = (m) => console.log(`${C.dim}[owear-sign]${C.reset} ${m}`)
|
|
37
|
+
const warn = (m) => console.warn(`${C.yellow}[owear-sign] ⚠${C.reset} ${m}`)
|
|
38
|
+
const fail = (m) => console.error(`${C.red}[owear-sign] ✗${C.reset} ${m}`)
|
|
39
|
+
|
|
40
|
+
const isPE = (file) => {
|
|
41
|
+
try {
|
|
42
|
+
const fd = fs.openSync(file, 'r')
|
|
43
|
+
const b = Buffer.alloc(2)
|
|
44
|
+
fs.readSync(fd, b, 0, 2, 0)
|
|
45
|
+
fs.closeSync(fd)
|
|
46
|
+
return b[0] === 0x4d && b[1] === 0x5a // "MZ"
|
|
47
|
+
} catch {
|
|
48
|
+
return false
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const PE_EXT = new Set(['.exe', '.dll', '.msi', '.sys', '.ocx', '.scr'])
|
|
53
|
+
|
|
54
|
+
/** ¿Es un PE/MSI firmable con Authenticode? (por extensión o cabecera MZ). */
|
|
55
|
+
export function isAuthenticodeTarget(file) {
|
|
56
|
+
if (PE_EXT.has(path.extname(file).toLowerCase())) return true
|
|
57
|
+
return isPE(file)
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** Comando de firma Authenticode según la herramienta disponible. */
|
|
61
|
+
export function buildAuthenticodeInvocation({ tool, file, pfx, password, timestamp, name, url }) {
|
|
62
|
+
if (tool === 'osslsigncode') {
|
|
63
|
+
const args = ['sign', '-pkcs12', pfx, '-in', file, '-out', `${file}.signed`]
|
|
64
|
+
if (password) args.push('-pass', password)
|
|
65
|
+
if (name) args.push('-n', name)
|
|
66
|
+
if (url) args.push('-i', url)
|
|
67
|
+
if (timestamp) args.push('-t', timestamp)
|
|
68
|
+
return { cmd: 'osslsigncode', args, out: `${file}.signed`, inPlace: true }
|
|
69
|
+
}
|
|
70
|
+
if (tool === 'signtool') {
|
|
71
|
+
const args = ['sign', '/f', pfx, '/fd', 'SHA256', '/d', name || path.basename(file)]
|
|
72
|
+
if (password) args.push('/p', password)
|
|
73
|
+
if (timestamp) args.push('/tr', timestamp, '/td', 'SHA256')
|
|
74
|
+
args.push(file)
|
|
75
|
+
return { cmd: 'signtool', args, out: file, inPlace: false }
|
|
76
|
+
}
|
|
77
|
+
throw new Error(`herramienta de firma desconocida: ${tool}`)
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** ¿Está la herramienta en el PATH? */
|
|
81
|
+
export function hasTool(cmd) {
|
|
82
|
+
const probe = process.platform === 'win32' ? 'where' : 'which'
|
|
83
|
+
return spawnSync(probe, [cmd], { stdio: 'ignore' }).status === 0
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** Firma Ed25519 desprendida: escribe `<file>.sig` (base64) y devuelve la firma. */
|
|
87
|
+
export function signEd25519(file, keyPath) {
|
|
88
|
+
const data = fs.readFileSync(file)
|
|
89
|
+
const key = crypto.createPrivateKey(fs.readFileSync(keyPath))
|
|
90
|
+
const sig = crypto.sign(null, data, key)
|
|
91
|
+
fs.writeFileSync(`${file}.sig`, sig.toString('base64') + '\n')
|
|
92
|
+
return sig
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** Verifica una firma Ed25519 desprendida contra `pub` (PEM o SPKI base64). */
|
|
96
|
+
export function verifyEd25519(file, sigPath, pub) {
|
|
97
|
+
const key = pub.includes('BEGIN')
|
|
98
|
+
? crypto.createPublicKey(pub)
|
|
99
|
+
: crypto.createPublicKey({ key: Buffer.from(pub, 'base64'), format: 'der', type: 'spki' })
|
|
100
|
+
const data = fs.readFileSync(file)
|
|
101
|
+
const sig = Buffer.from(fs.readFileSync(sigPath, 'utf8').trim(), 'base64')
|
|
102
|
+
return crypto.verify(null, data, key, sig)
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** Firma Authenticode (in-place si es posible). Devuelve 'signed'|'skipped'|'failed'. */
|
|
106
|
+
export function signAuthenticode({ file, pfx, password, timestamp, name, url, require = false }) {
|
|
107
|
+
const tool = hasTool('osslsigncode') ? 'osslsigncode' : hasTool('signtool') ? 'signtool' : null
|
|
108
|
+
if (!tool) {
|
|
109
|
+
const msg = 'ni osslsigncode ni signtool están en el PATH'
|
|
110
|
+
if (require) throw new Error(msg)
|
|
111
|
+
warn(`${msg} — Authenticode omitido`)
|
|
112
|
+
return 'skipped'
|
|
113
|
+
}
|
|
114
|
+
if (!pfx || !fs.existsSync(pfx)) {
|
|
115
|
+
const msg = `certificado no encontrado: ${pfx ?? '(sin --pfx)'}`
|
|
116
|
+
if (require) throw new Error(msg)
|
|
117
|
+
warn(`${msg} — Authenticode omitido`)
|
|
118
|
+
return 'skipped'
|
|
119
|
+
}
|
|
120
|
+
const inv = buildAuthenticodeInvocation({ tool, file, pfx, password, timestamp, name, url })
|
|
121
|
+
const r = spawnSync(inv.cmd, inv.args, { stdio: 'inherit' })
|
|
122
|
+
if (r.status !== 0) {
|
|
123
|
+
if (require) throw new Error(`${inv.cmd} falló (${r.status})`)
|
|
124
|
+
warn(`${inv.cmd} falló (${r.status})`)
|
|
125
|
+
return 'failed'
|
|
126
|
+
}
|
|
127
|
+
if (inv.inPlace) {
|
|
128
|
+
fs.renameSync(inv.out, file)
|
|
129
|
+
info(`Authenticode OK (${tool})`)
|
|
130
|
+
} else {
|
|
131
|
+
info(`Authenticode OK (${tool})`)
|
|
132
|
+
}
|
|
133
|
+
return 'signed'
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function main() {
|
|
137
|
+
const argv = process.argv.slice(2)
|
|
138
|
+
const arg = (n, def = null) => (argv.includes(n) ? argv[argv.indexOf(n) + 1] : def)
|
|
139
|
+
const flag = (n) => argv.includes(n)
|
|
140
|
+
|
|
141
|
+
const file = arg('--file')
|
|
142
|
+
if (!file) {
|
|
143
|
+
fail('--file es obligatorio')
|
|
144
|
+
process.exit(1)
|
|
145
|
+
}
|
|
146
|
+
if (!fs.existsSync(file)) {
|
|
147
|
+
fail(`no existe: ${file}`)
|
|
148
|
+
process.exit(1)
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
const edKey = arg('--ed25519-key')
|
|
152
|
+
const pfx = arg('--pfx')
|
|
153
|
+
const pwEnv = arg('--pfx-password-env')
|
|
154
|
+
const password = arg('--password', pwEnv ? process.env[pwEnv] ?? '' : '')
|
|
155
|
+
const timestamp = arg('--timestamp')
|
|
156
|
+
const name = arg('--name')
|
|
157
|
+
const url = arg('--url')
|
|
158
|
+
const require = flag('--require')
|
|
159
|
+
const dryRun = flag('--dry-run')
|
|
160
|
+
const isWin = process.platform === 'win32'
|
|
161
|
+
|
|
162
|
+
let did = 0
|
|
163
|
+
|
|
164
|
+
// 1) Authenticode (solo PE/MSI y solo en Windows o con osslsigncode)
|
|
165
|
+
if (pfx) {
|
|
166
|
+
if (!isAuthenticodeTarget(file)) {
|
|
167
|
+
info(`${path.basename(file)} no es PE/MSI — Authenticode no aplica`)
|
|
168
|
+
} else if (dryRun) {
|
|
169
|
+
const tool = hasTool('osslsigncode') ? 'osslsigncode' : 'signtool'
|
|
170
|
+
const inv = buildAuthenticodeInvocation({ tool, file, pfx, password, timestamp, name, url })
|
|
171
|
+
console.log(JSON.stringify({ authenticode: { cmd: inv.cmd, args: inv.args } }))
|
|
172
|
+
did++
|
|
173
|
+
} else {
|
|
174
|
+
if (signAuthenticode({ file, pfx, password, timestamp, name, url, require }) === 'signed') did++
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
// 2) Ed25519 desprendida (siempre disponible)
|
|
179
|
+
if (edKey) {
|
|
180
|
+
if (!fs.existsSync(edKey)) {
|
|
181
|
+
fail(`clave no encontrada: ${edKey}`)
|
|
182
|
+
process.exit(1)
|
|
183
|
+
}
|
|
184
|
+
if (dryRun) {
|
|
185
|
+
console.log(JSON.stringify({ ed25519: { file, key: edKey, out: `${file}.sig` } }))
|
|
186
|
+
} else {
|
|
187
|
+
signEd25519(file, edKey)
|
|
188
|
+
info(`Ed25519 OK → ${path.basename(file)}.sig`)
|
|
189
|
+
did++
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
if (!did) {
|
|
194
|
+
if (require) {
|
|
195
|
+
fail('no se firmó nada (--require)')
|
|
196
|
+
process.exit(1)
|
|
197
|
+
}
|
|
198
|
+
warn('sin material de firma (--ed25519-key / --pfx) — nada que hacer')
|
|
199
|
+
}
|
|
200
|
+
void isWin
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
// Ejecuta main solo como script (permite importarlo en tests).
|
|
204
|
+
if (process.argv[1] && fileURLToPath(import.meta.url) === path.resolve(process.argv[1])) main()
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Copyright 2026 Owear Contributors
|
|
3
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
4
|
+
//
|
|
5
|
+
// tools/owear-update.mjs — publica un update de Owear: calcula el blockmap
|
|
6
|
+
// (delta por bloques), genera el manifiesto YAML del canal y lo firma con
|
|
7
|
+
// Ed25519.
|
|
8
|
+
//
|
|
9
|
+
// Salidas (en --out-dir, por defecto junto al artefacto):
|
|
10
|
+
// <file>.blockmap { blockSize, size, blocks:[sha256…] }
|
|
11
|
+
// <channel>.yml manifiesto YAML (version, sha256, sha512, blockSize,
|
|
12
|
+
// blockmap, path, releaseDate, notes, signature)
|
|
13
|
+
//
|
|
14
|
+
// El SDK (autoUpdater) verifica `signature` con la clave pública del bridge
|
|
15
|
+
// (Ed25519 sobre "<version>:<sha256>") y descarga solo los bloques cambiados.
|
|
16
|
+
//
|
|
17
|
+
// Uso:
|
|
18
|
+
// node tools/owear-update.mjs --file release/MiApp-1.4.0 --version 1.4.0 \
|
|
19
|
+
// --channel latest --url https://up.miapp.dev --key private.pem \
|
|
20
|
+
// [--notes "…"|--notes @notes.md] [--block-size 262144] [--mandatory] \
|
|
21
|
+
// [--out-dir release]
|
|
22
|
+
//
|
|
23
|
+
// # claves (una vez):
|
|
24
|
+
// node tools/owear-update.mjs --gen-key ./owear-signing
|
|
25
|
+
// → owear-signing.pem (privada, Ed25519 PKCS8)
|
|
26
|
+
// owear-signing.pub.b64 (pública SPKI base64 — va en el bridge)
|
|
27
|
+
//
|
|
28
|
+
import * as fs from 'node:fs'
|
|
29
|
+
import * as path from 'node:path'
|
|
30
|
+
import * as crypto from 'node:crypto'
|
|
31
|
+
|
|
32
|
+
function die(m) {
|
|
33
|
+
console.error(`[owear-update] ✗ ${m}`)
|
|
34
|
+
process.exit(1)
|
|
35
|
+
}
|
|
36
|
+
const argv = process.argv.slice(2)
|
|
37
|
+
const arg = (n, def = null) => (argv.includes(n) ? argv[argv.indexOf(n) + 1] : def)
|
|
38
|
+
const flag = (n) => argv.includes(n)
|
|
39
|
+
|
|
40
|
+
// ── generación de claves ────────────────────────────────────────────────────
|
|
41
|
+
const genKey = arg('--gen-key')
|
|
42
|
+
if (genKey) {
|
|
43
|
+
if (fs.existsSync(`${genKey}.pem`)) die(`ya existe ${genKey}.pem`)
|
|
44
|
+
const { publicKey, privateKey } = crypto.generateKeyPairSync('ed25519')
|
|
45
|
+
fs.writeFileSync(`${genKey}.pem`, privateKey.export({ format: 'pem', type: 'pkcs8' }), { mode: 0o600 })
|
|
46
|
+
fs.writeFileSync(`${genKey}.pub.b64`, publicKey.export({ format: 'der', type: 'spki' }).toString('base64'))
|
|
47
|
+
console.log(`[owear-update] ✓ clave privada: ${genKey}.pem`)
|
|
48
|
+
console.log(`[owear-update] ✓ clave pública: ${genKey}.pub.b64 (ponla en el bridge: updater.publicKey)`)
|
|
49
|
+
process.exit(0)
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// ── validación de argumentos ────────────────────────────────────────────────
|
|
53
|
+
const file = arg('--file')
|
|
54
|
+
const version = arg('--version')
|
|
55
|
+
if (!file || !version) die('--file y --version son obligatorios (o usa --gen-key)')
|
|
56
|
+
if (!fs.existsSync(file)) die(`no existe el artefacto: ${file}`)
|
|
57
|
+
if (!/^\d+\.\d+\.\d+/.test(version)) die(`versión inválida: ${version} (semver esperado)`)
|
|
58
|
+
|
|
59
|
+
const channel = arg('--channel', 'latest')
|
|
60
|
+
const blockSize = parseInt(arg('--block-size', '262144'), 10)
|
|
61
|
+
if (!Number.isFinite(blockSize) || blockSize <= 0) die('--block-size inválido')
|
|
62
|
+
const baseUrl = (arg('--url', '') || '').replace(/\/+$/, '')
|
|
63
|
+
const outDir = arg('--out-dir', path.dirname(file))
|
|
64
|
+
const keyPath = arg('--key')
|
|
65
|
+
const mandatory = flag('--mandatory')
|
|
66
|
+
|
|
67
|
+
let notes = arg('--notes', '')
|
|
68
|
+
if (notes.startsWith('@')) {
|
|
69
|
+
const nf = notes.slice(1)
|
|
70
|
+
if (!fs.existsSync(nf)) die(`no existe el fichero de notas: ${nf}`)
|
|
71
|
+
notes = fs.readFileSync(nf, 'utf8').trimEnd()
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// ── integridad + blockmap ───────────────────────────────────────────────────
|
|
75
|
+
const data = fs.readFileSync(file)
|
|
76
|
+
const sha256 = crypto.createHash('sha256').update(data).digest('hex')
|
|
77
|
+
const sha512 = crypto.createHash('sha512').update(data).digest('hex')
|
|
78
|
+
|
|
79
|
+
const blocks = []
|
|
80
|
+
for (let off = 0; off < data.length; off += blockSize) {
|
|
81
|
+
blocks.push(crypto.createHash('sha256').update(data.subarray(off, Math.min(off + blockSize, data.length))).digest('hex'))
|
|
82
|
+
}
|
|
83
|
+
if (data.length === 0) blocks.push(crypto.createHash('sha256').update(Buffer.alloc(0)).digest('hex'))
|
|
84
|
+
|
|
85
|
+
fs.mkdirSync(outDir, { recursive: true })
|
|
86
|
+
const artifactName = path.basename(file)
|
|
87
|
+
const blockmapPath = path.join(outDir, `${artifactName}.blockmap`)
|
|
88
|
+
fs.writeFileSync(blockmapPath, JSON.stringify({ blockSize, size: data.length, sha256, blocks }))
|
|
89
|
+
|
|
90
|
+
// ── firma Ed25519 (del manifiesto y del binario) ────────────────────────────
|
|
91
|
+
// signature: sobre "<version>:<sha256>" (autentica metadata)
|
|
92
|
+
// binarySig: sobre los bytes del artefacto (autentica el payload)
|
|
93
|
+
let signature = ''
|
|
94
|
+
let binarySig = ''
|
|
95
|
+
if (keyPath) {
|
|
96
|
+
if (!fs.existsSync(keyPath)) die(`no existe la clave: ${keyPath}`)
|
|
97
|
+
const privateKey = crypto.createPrivateKey(fs.readFileSync(keyPath))
|
|
98
|
+
signature = crypto
|
|
99
|
+
.sign(null, Buffer.from(`${version}:${sha256}`), privateKey)
|
|
100
|
+
.toString('base64')
|
|
101
|
+
binarySig = crypto.sign(null, data, privateKey).toString('base64')
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// ── manifiesto YAML ─────────────────────────────────────────────────────────
|
|
105
|
+
const blockmapUrl = baseUrl ? `${baseUrl}/${path.basename(blockmapPath)}` : path.basename(blockmapPath)
|
|
106
|
+
const releaseDate = new Date().toISOString()
|
|
107
|
+
const yamlEscape = (s) => (/[:#'"@|>{}\[\],&*!?%`-]|^\s|\s$|\n/.test(s) ? `'${s.replace(/'/g, "''")}'` : s)
|
|
108
|
+
// las notas van como bloque literal
|
|
109
|
+
const notesBlock = notes
|
|
110
|
+
? `notes: |\n${notes
|
|
111
|
+
.split('\n')
|
|
112
|
+
.map((l) => ` ${l}`)
|
|
113
|
+
.join('\n')}\n`
|
|
114
|
+
: ''
|
|
115
|
+
|
|
116
|
+
const manifest = [
|
|
117
|
+
`version: ${version}`,
|
|
118
|
+
`releaseDate: '${releaseDate}'`,
|
|
119
|
+
`path: ${yamlEscape(artifactName)}`,
|
|
120
|
+
// hashes y firmas se citan siempre: un valor "numérico" (p. ej. un hash solo
|
|
121
|
+
// de dígitos) no debe reinterpretarse como número al parsear el YAML.
|
|
122
|
+
`sha256: '${sha256}'`,
|
|
123
|
+
`sha512: '${sha512}'`,
|
|
124
|
+
`size: ${data.length}`,
|
|
125
|
+
`blockSize: ${blockSize}`,
|
|
126
|
+
`blockmap: ${yamlEscape(blockmapUrl)}`,
|
|
127
|
+
mandatory ? 'mandatory: true' : null,
|
|
128
|
+
signature ? `signature: '${signature}'` : null,
|
|
129
|
+
binarySig ? `binarySig: '${binarySig}'` : null,
|
|
130
|
+
notesBlock || null,
|
|
131
|
+
'',
|
|
132
|
+
]
|
|
133
|
+
.filter((l) => l !== null)
|
|
134
|
+
.join('\n')
|
|
135
|
+
|
|
136
|
+
const manifestPath = path.join(outDir, `${channel}.yml`)
|
|
137
|
+
fs.writeFileSync(manifestPath, manifest)
|
|
138
|
+
|
|
139
|
+
// ── resumen ─────────────────────────────────────────────────────────────────
|
|
140
|
+
const mb = (n) => (n / 1024 / 1024).toFixed(1)
|
|
141
|
+
console.log(`[owear-update] ✓ artefacto ${artifactName} ${mb(data.length)} MB`)
|
|
142
|
+
console.log(`[owear-update] ✓ blockmap ${path.basename(blockmapPath)} (${blocks.length} bloques × ${(blockSize / 1024).toFixed(0)} KiB)`)
|
|
143
|
+
console.log(`[owear-update] ✓ manifiesto ${manifestPath}`)
|
|
144
|
+
console.log(`[owear-update] ${signature ? '✓ firmado' : '⚠ sin firma (--key)'} sha256=${sha256.slice(0, 16)}…`)
|
|
145
|
+
if (baseUrl) {
|
|
146
|
+
console.log(`[owear-update] feed: ${baseUrl}`)
|
|
147
|
+
console.log(`[owear-update] ${baseUrl}/${channel}.yml`)
|
|
148
|
+
console.log(`[owear-update] ${baseUrl}/${artifactName}`)
|
|
149
|
+
console.log(`[owear-update] ${baseUrl}/${path.basename(blockmapPath)}`)
|
|
150
|
+
}
|