@owear/core 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/dist/app.d.ts +6 -0
- package/dist/app.js +9 -0
- package/dist/appicon.d.ts +4 -0
- package/dist/appicon.js +16 -0
- package/dist/browserwindow.d.ts +2 -0
- package/dist/browserwindow.js +14 -3
- package/dist/index.d.ts +7 -0
- package/dist/index.js +2 -0
- package/dist/installer.d.ts +17 -3
- package/dist/installer.js +14 -2
- package/dist/updater/boot.d.ts +20 -0
- package/dist/updater/boot.js +49 -0
- package/dist/updater/delta.d.ts +30 -0
- package/dist/updater/delta.js +96 -0
- package/dist/updater/yaml.d.ts +6 -0
- package/dist/updater/yaml.js +160 -0
- package/dist/updater.d.ts +119 -0
- package/dist/updater.js +429 -0
- package/package.json +2 -2
package/dist/app.d.ts
CHANGED
|
@@ -8,6 +8,12 @@ export declare const app: {
|
|
|
8
8
|
/** Conecta con el kernel. Resuelve cuando el canal de control está listo. */
|
|
9
9
|
whenReady(): Promise<void>;
|
|
10
10
|
quit(exitCode?: number): Promise<void>;
|
|
11
|
+
/**
|
|
12
|
+
* Define el icono por defecto de la app (ruta a PNG/JPEG). Se aplica a las
|
|
13
|
+
* ventanas creadas a partir de entonces y a `new BrowserWindow()` sin `icon`.
|
|
14
|
+
* (Equivale a `BrowserWindow({ icon })` global.)
|
|
15
|
+
*/
|
|
16
|
+
setIcon(path: string): void;
|
|
11
17
|
info(): Promise<{
|
|
12
18
|
pid: number;
|
|
13
19
|
version: string;
|
package/dist/app.js
CHANGED
|
@@ -9,6 +9,7 @@ import * as fs from 'node:fs';
|
|
|
9
9
|
import { channel, socketPathFromEnv } from './channel.js';
|
|
10
10
|
import { forkWorker } from './node/worker.js';
|
|
11
11
|
import { nodeHandlers, nodeContextHandlers } from './ipc/node.js';
|
|
12
|
+
import { setWindowIcon } from './appicon.js';
|
|
12
13
|
import { portRecords, makeMainPort, allocPortId } from './port.js';
|
|
13
14
|
import { protocolHandlers } from './protocol.js';
|
|
14
15
|
/** Resuelto por app.whenReady(). Compartido con BrowserWindow. */
|
|
@@ -106,6 +107,14 @@ export const app = {
|
|
|
106
107
|
quit(exitCode = 0) {
|
|
107
108
|
return channel.call('app.quit', { exitCode }).catch(() => undefined);
|
|
108
109
|
},
|
|
110
|
+
/**
|
|
111
|
+
* Define el icono por defecto de la app (ruta a PNG/JPEG). Se aplica a las
|
|
112
|
+
* ventanas creadas a partir de entonces y a `new BrowserWindow()` sin `icon`.
|
|
113
|
+
* (Equivale a `BrowserWindow({ icon })` global.)
|
|
114
|
+
*/
|
|
115
|
+
setIcon(path) {
|
|
116
|
+
setWindowIcon(path);
|
|
117
|
+
},
|
|
109
118
|
info() {
|
|
110
119
|
return channel.call('app.info');
|
|
111
120
|
},
|
package/dist/appicon.js
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
// Copyright 2026 Owear Contributors
|
|
2
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
//
|
|
4
|
+
// @owear/core/src/appicon.ts — icono por defecto de la app (compartido).
|
|
5
|
+
//
|
|
6
|
+
// Módulo sin dependencias para evitar ciclos: `app.setIcon(path)` lo fija y
|
|
7
|
+
// `BrowserWindow` lo aplica a cada ventana creada a partir de entonces.
|
|
8
|
+
let iconPath;
|
|
9
|
+
/** Fija el icono por defecto (ruta a PNG/JPEG). */
|
|
10
|
+
export function setWindowIcon(path) {
|
|
11
|
+
iconPath = path;
|
|
12
|
+
}
|
|
13
|
+
/** Icono por defecto actual (o undefined). */
|
|
14
|
+
export function windowIcon() {
|
|
15
|
+
return iconPath;
|
|
16
|
+
}
|
package/dist/browserwindow.d.ts
CHANGED
|
@@ -11,6 +11,8 @@ export declare class BrowserWindow extends EventEmitter {
|
|
|
11
11
|
private _onDisconnected;
|
|
12
12
|
constructor(options?: WindowOptions);
|
|
13
13
|
private _create;
|
|
14
|
+
/** Fija el icono de ESTA ventana (ruta a PNG/JPEG). */
|
|
15
|
+
setIcon(path: string): Promise<void>;
|
|
14
16
|
private _wireEvents;
|
|
15
17
|
private _unwireEvents;
|
|
16
18
|
get id(): number | null;
|
package/dist/browserwindow.js
CHANGED
|
@@ -3,7 +3,8 @@
|
|
|
3
3
|
//
|
|
4
4
|
// @owear/core/src/browserwindow.ts — BrowserWindow (C9).
|
|
5
5
|
import { EventEmitter } from 'node:events';
|
|
6
|
-
import { channel } from './channel.js';
|
|
6
|
+
import { channel, invokeNative } from './channel.js';
|
|
7
|
+
import { windowIcon } from './appicon.js';
|
|
7
8
|
import { readyPromise } from './app.js';
|
|
8
9
|
import { WebContents, WIN_EVENT_NAMES, WC_EVENT_NAMES } from './webcontents.js';
|
|
9
10
|
// ── BrowserWindow ───────────────────────────────────────────────────────────
|
|
@@ -45,14 +46,24 @@ export class BrowserWindow extends EventEmitter {
|
|
|
45
46
|
readyPromise.then(() => this._create()).catch((e) => this.emit('error', e));
|
|
46
47
|
}
|
|
47
48
|
async _create() {
|
|
48
|
-
const
|
|
49
|
-
const res = await channel.call('window.create',
|
|
49
|
+
const { icon, ...rest } = this._options;
|
|
50
|
+
const res = await channel.call('window.create', { ...rest });
|
|
50
51
|
this._id = res.windowId;
|
|
51
52
|
this._wc._setWindowId(res.windowId);
|
|
52
53
|
windowsById.set(res.windowId, this);
|
|
53
54
|
this._wireEvents();
|
|
55
|
+
// Icono: el de la opción o el global (app.setIcon). PNG/JPEG.
|
|
56
|
+
const ic = icon ?? windowIcon();
|
|
57
|
+
if (ic)
|
|
58
|
+
await this.setIcon(ic).catch(() => undefined);
|
|
54
59
|
this.emit('ready-to-show', this._id);
|
|
55
60
|
}
|
|
61
|
+
/** Fija el icono de ESTA ventana (ruta a PNG/JPEG). */
|
|
62
|
+
async setIcon(path) {
|
|
63
|
+
const { readFileSync } = await import('node:fs');
|
|
64
|
+
const pngB64 = readFileSync(path).toString('base64');
|
|
65
|
+
await invokeNative('window', 'setIcon', this.requireId(), pngB64);
|
|
66
|
+
}
|
|
56
67
|
_wireEvents() {
|
|
57
68
|
channel.on('window.event', this._onWindowEvent);
|
|
58
69
|
channel.on('disconnected', this._onDisconnected);
|
package/dist/index.d.ts
CHANGED
|
@@ -42,6 +42,11 @@ export interface WindowOptions {
|
|
|
42
42
|
* Crea la partición con `session.fromPartition(name)`.
|
|
43
43
|
*/
|
|
44
44
|
session?: string;
|
|
45
|
+
/**
|
|
46
|
+
* Icono de la ventana (ruta a un PNG/JPEG). Para un icono por defecto de toda
|
|
47
|
+
* la app usa `app.setIcon(path)`.
|
|
48
|
+
*/
|
|
49
|
+
icon?: string;
|
|
45
50
|
}
|
|
46
51
|
export interface Bounds {
|
|
47
52
|
x: number;
|
|
@@ -105,6 +110,8 @@ export { app } from './app.js';
|
|
|
105
110
|
export { readyPromise } from './app.js';
|
|
106
111
|
export { installer } from './installer.js';
|
|
107
112
|
export type { InstallerMode, PayloadEntry, InstallerInfo, PlanOptions, InstallOptions, InstallResult, UninstallResult, VerifyResult, StateResult, ShortcutOptions, LaunchOptions, } from './installer.js';
|
|
113
|
+
export { autoUpdater } from './updater.js';
|
|
114
|
+
export type { FeedOptions, UpdateInfo, UpdateFile, ProgressInfo, UpdateCheckResult, } from './updater.js';
|
|
108
115
|
export { defineBridge } from './bridge.js';
|
|
109
116
|
export type { OwearBridge, BridgeApp, BridgeTarget, BridgePlatform, BridgeFormat, BridgeLayout, BridgePreset, BridgeGroup, BridgeProtect, BridgeNode, BridgeHooks, } from './bridge.js';
|
|
110
117
|
export { forkWorker, resolveWorkerEntry } from './node/worker.js';
|
package/dist/index.js
CHANGED
|
@@ -35,6 +35,8 @@ export { app } from './app.js';
|
|
|
35
35
|
export { readyPromise } from './app.js';
|
|
36
36
|
// ── instalador (D1): bridge + API del modo instalador/desinstalador ──────────
|
|
37
37
|
export { installer } from './installer.js';
|
|
38
|
+
// ── auto-update ─────────────────────────────────────────────────────────────
|
|
39
|
+
export { autoUpdater } from './updater.js';
|
|
38
40
|
export { defineBridge } from './bridge.js';
|
|
39
41
|
// ── workers Node (utilityProcess.fork) ──────────────────────────────────────
|
|
40
42
|
export { forkWorker, resolveWorkerEntry } from './node/worker.js';
|
package/dist/installer.d.ts
CHANGED
|
@@ -53,10 +53,15 @@ export interface StateResult {
|
|
|
53
53
|
version?: string;
|
|
54
54
|
mode?: string;
|
|
55
55
|
app?: string;
|
|
56
|
+
/** Directorio de instalación (si se conoce / está registrado). */
|
|
57
|
+
dir?: string;
|
|
56
58
|
}
|
|
57
59
|
export interface ShortcutOptions {
|
|
58
60
|
execPath: string;
|
|
59
61
|
iconPath?: string;
|
|
62
|
+
/** Anulan los del manifiesto (por defecto: appId/appName del installer.json). */
|
|
63
|
+
appId?: string;
|
|
64
|
+
appName?: string;
|
|
60
65
|
desktop?: boolean;
|
|
61
66
|
menu?: boolean;
|
|
62
67
|
startup?: boolean;
|
|
@@ -76,6 +81,13 @@ export declare const installer: {
|
|
|
76
81
|
info(): Promise<InstallerInfo>;
|
|
77
82
|
/** Bridge embebido (contrato app ↔ installer). */
|
|
78
83
|
bridge<T = unknown>(): Promise<T | null>;
|
|
84
|
+
/** Directorio de instalación por defecto (absoluto, por usuario). */
|
|
85
|
+
defaultDir(): Promise<string>;
|
|
86
|
+
/** Selector NATIVO de carpeta (no depende de módulos .owm). null si cancela. */
|
|
87
|
+
chooseDir(opts?: {
|
|
88
|
+
title?: string;
|
|
89
|
+
defaultPath?: string;
|
|
90
|
+
}): Promise<string | null>;
|
|
79
91
|
/** Lista los ficheros del payload embebido. */
|
|
80
92
|
payloadList(): Promise<PayloadEntry[]>;
|
|
81
93
|
/** Lee un fichero del payload (base64). */
|
|
@@ -97,10 +109,12 @@ export declare const installer: {
|
|
|
97
109
|
verify(opts: {
|
|
98
110
|
dir: string;
|
|
99
111
|
}): Promise<VerifyResult>;
|
|
100
|
-
/** Estado de instalación en `dir
|
|
101
|
-
state(opts
|
|
102
|
-
dir
|
|
112
|
+
/** Estado de instalación en `dir` (o el registrado si se omite). */
|
|
113
|
+
state(opts?: {
|
|
114
|
+
dir?: string;
|
|
103
115
|
}): Promise<StateResult>;
|
|
116
|
+
/** Lista las apps instaladas (registro de Owear). */
|
|
117
|
+
list(): Promise<Array<Record<string, unknown>>>;
|
|
104
118
|
/** Crea accesos directos / integración de escritorio. */
|
|
105
119
|
shortcuts(opts: ShortcutOptions): Promise<boolean>;
|
|
106
120
|
/** Lanza un ejecutable en segundo plano (p. ej. la app instalada). */
|
package/dist/installer.js
CHANGED
|
@@ -29,6 +29,14 @@ export const installer = {
|
|
|
29
29
|
bridge() {
|
|
30
30
|
return invokeNative('installer', 'bridge');
|
|
31
31
|
},
|
|
32
|
+
/** Directorio de instalación por defecto (absoluto, por usuario). */
|
|
33
|
+
defaultDir() {
|
|
34
|
+
return invokeNative('installer', 'defaultDir');
|
|
35
|
+
},
|
|
36
|
+
/** Selector NATIVO de carpeta (no depende de módulos .owm). null si cancela. */
|
|
37
|
+
chooseDir(opts = {}) {
|
|
38
|
+
return invokeNative('installer', 'chooseDir', opts);
|
|
39
|
+
},
|
|
32
40
|
/** Lista los ficheros del payload embebido. */
|
|
33
41
|
payloadList() {
|
|
34
42
|
return invokeNative('installer', 'payloadList');
|
|
@@ -53,10 +61,14 @@ export const installer = {
|
|
|
53
61
|
verify(opts) {
|
|
54
62
|
return invokeNative('installer', 'verify', opts);
|
|
55
63
|
},
|
|
56
|
-
/** Estado de instalación en `dir
|
|
57
|
-
state(opts) {
|
|
64
|
+
/** Estado de instalación en `dir` (o el registrado si se omite). */
|
|
65
|
+
state(opts = {}) {
|
|
58
66
|
return invokeNative('installer', 'state', opts);
|
|
59
67
|
},
|
|
68
|
+
/** Lista las apps instaladas (registro de Owear). */
|
|
69
|
+
list() {
|
|
70
|
+
return invokeNative('installer', 'list');
|
|
71
|
+
},
|
|
60
72
|
/** Crea accesos directos / integración de escritorio. */
|
|
61
73
|
shortcuts(opts) {
|
|
62
74
|
return invokeNative('installer', 'shortcuts', opts);
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
export interface BootRecord {
|
|
2
|
+
version: string;
|
|
3
|
+
attempts: number;
|
|
4
|
+
updatedAt: string;
|
|
5
|
+
}
|
|
6
|
+
/** Ruta del registro de arranque dentro de `<userData>`. */
|
|
7
|
+
export declare function bootRecordPath(userDataDir: string): string;
|
|
8
|
+
/** Lee el registro; null si no existe o está corrupto. */
|
|
9
|
+
export declare function readBoot(file: string): BootRecord | null;
|
|
10
|
+
/** Escribe el registro (crea el directorio si hace falta). */
|
|
11
|
+
export declare function writeBoot(file: string, rec: BootRecord): void;
|
|
12
|
+
/**
|
|
13
|
+
* Calcula el estado del siguiente arranque:
|
|
14
|
+
* - misma versión que la anterior → `attempts + 1`; si llega al umbral, rollback.
|
|
15
|
+
* - versión distinta (o primer arranque) → `attempts = 1`, sin rollback.
|
|
16
|
+
*/
|
|
17
|
+
export declare function nextBoot(prev: BootRecord | null, version: string, threshold: number, now?: () => Date): {
|
|
18
|
+
record: BootRecord;
|
|
19
|
+
shouldRollback: boolean;
|
|
20
|
+
};
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
// Copyright 2026 Owear Contributors
|
|
2
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
//
|
|
4
|
+
// @owear/core/src/updater/boot.ts — registro de arranque para el **rollback**.
|
|
5
|
+
//
|
|
6
|
+
// Tras aplicar un update el binario nuevo arranca con una copia de seguridad
|
|
7
|
+
// (`<exe>.owprev`). Si el arranque se repite sin que la app marque "sano"
|
|
8
|
+
// (crash loop), el updater revierte al binario anterior.
|
|
9
|
+
//
|
|
10
|
+
// El contador vive en `<userData>/update-boot.json`:
|
|
11
|
+
// { "version": "1.4.0", "attempts": 2, "updatedAt": "…" }
|
|
12
|
+
// Cada arranque incrementa `attempts`; cuando la app confirma salud
|
|
13
|
+
// (`commit`), se pone a 0. Si `attempts` alcanza el umbral → rollback.
|
|
14
|
+
import * as fs from 'node:fs';
|
|
15
|
+
import * as path from 'node:path';
|
|
16
|
+
/** Ruta del registro de arranque dentro de `<userData>`. */
|
|
17
|
+
export function bootRecordPath(userDataDir) {
|
|
18
|
+
return path.join(userDataDir, 'update-boot.json');
|
|
19
|
+
}
|
|
20
|
+
/** Lee el registro; null si no existe o está corrupto. */
|
|
21
|
+
export function readBoot(file) {
|
|
22
|
+
try {
|
|
23
|
+
const raw = JSON.parse(fs.readFileSync(file, 'utf8'));
|
|
24
|
+
if (typeof raw?.version !== 'string' || typeof raw?.attempts !== 'number')
|
|
25
|
+
return null;
|
|
26
|
+
return { version: raw.version, attempts: raw.attempts, updatedAt: String(raw.updatedAt ?? '') };
|
|
27
|
+
}
|
|
28
|
+
catch {
|
|
29
|
+
return null;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
/** Escribe el registro (crea el directorio si hace falta). */
|
|
33
|
+
export function writeBoot(file, rec) {
|
|
34
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
35
|
+
fs.writeFileSync(file, JSON.stringify(rec) + '\n');
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* Calcula el estado del siguiente arranque:
|
|
39
|
+
* - misma versión que la anterior → `attempts + 1`; si llega al umbral, rollback.
|
|
40
|
+
* - versión distinta (o primer arranque) → `attempts = 1`, sin rollback.
|
|
41
|
+
*/
|
|
42
|
+
export function nextBoot(prev, version, threshold, now = () => new Date()) {
|
|
43
|
+
const sameVersion = !!prev && prev.version === version;
|
|
44
|
+
const attempts = sameVersion ? prev.attempts + 1 : 1;
|
|
45
|
+
return {
|
|
46
|
+
record: { version, attempts, updatedAt: now().toISOString() },
|
|
47
|
+
shouldRollback: sameVersion && attempts >= threshold,
|
|
48
|
+
};
|
|
49
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/** sha256 (hex) de cada bloque de `data` (tamaño `blockSize`). */
|
|
2
|
+
export declare function blockHashes(data: Buffer, blockSize: number): string[];
|
|
3
|
+
/** Bloque de bytes [start, end] (end INCLUSIVE), como un HTTP Range. */
|
|
4
|
+
export interface ByteRange {
|
|
5
|
+
start: number;
|
|
6
|
+
end: number;
|
|
7
|
+
}
|
|
8
|
+
export interface DeltaPlan {
|
|
9
|
+
blockSize: number;
|
|
10
|
+
total: number;
|
|
11
|
+
reused: number;
|
|
12
|
+
changed: number;
|
|
13
|
+
ranges: ByteRange[];
|
|
14
|
+
/** Bytes a descargar si se aplica el delta. */
|
|
15
|
+
downloadBytes: number;
|
|
16
|
+
/** Bytes del artefacto completo. */
|
|
17
|
+
totalBytes: number;
|
|
18
|
+
/** Ratio de cambio (0..1). */
|
|
19
|
+
ratio: number;
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* Plan de delta: compara los hashes locales con los remotos y agrupa los
|
|
23
|
+
* bloques cambiados en rangos contiguos. `remoteSize` acota el último bloque.
|
|
24
|
+
*/
|
|
25
|
+
export declare function planDelta(localHashes: string[], remoteHashes: string[], blockSize: number, remoteSize: number): DeltaPlan;
|
|
26
|
+
/**
|
|
27
|
+
* Ensambla el artefacto nuevo reutilizando los bloques locales y pidiendo los
|
|
28
|
+
* cambiados con `fetchRange` (que debe devolver exactamente ese rango).
|
|
29
|
+
*/
|
|
30
|
+
export declare function assemble(local: Buffer, remoteHashes: string[], plan: DeltaPlan, remoteSize: number, fetchRange: (r: ByteRange) => Promise<Buffer>): Promise<Buffer>;
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
// Copyright 2026 Owear Contributors
|
|
2
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
//
|
|
4
|
+
// @owear/core/src/updater/delta.ts — delta por bloques (estilo blockmap).
|
|
5
|
+
//
|
|
6
|
+
// El artefacto (p. ej. el binario único) se parte en bloques de tamaño fijo y
|
|
7
|
+
// se publica un blockmap con el sha256 de cada bloque. En el cliente se
|
|
8
|
+
// comparan los bloques locales con los remotos y **solo se descargan los que
|
|
9
|
+
// cambian** (HTTP Range), reutilizando el resto del fichero actual.
|
|
10
|
+
import { createHash } from 'node:crypto';
|
|
11
|
+
/** sha256 (hex) de cada bloque de `data` (tamaño `blockSize`). */
|
|
12
|
+
export function blockHashes(data, blockSize) {
|
|
13
|
+
const out = [];
|
|
14
|
+
if (data.length === 0) {
|
|
15
|
+
out.push(createHash('sha256').update(Buffer.alloc(0)).digest('hex'));
|
|
16
|
+
return out;
|
|
17
|
+
}
|
|
18
|
+
for (let off = 0; off < data.length; off += blockSize) {
|
|
19
|
+
out.push(createHash('sha256')
|
|
20
|
+
.update(data.subarray(off, Math.min(off + blockSize, data.length)))
|
|
21
|
+
.digest('hex'));
|
|
22
|
+
}
|
|
23
|
+
return out;
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* Plan de delta: compara los hashes locales con los remotos y agrupa los
|
|
27
|
+
* bloques cambiados en rangos contiguos. `remoteSize` acota el último bloque.
|
|
28
|
+
*/
|
|
29
|
+
export function planDelta(localHashes, remoteHashes, blockSize, remoteSize) {
|
|
30
|
+
const total = remoteHashes.length;
|
|
31
|
+
const ranges = [];
|
|
32
|
+
let cur = null;
|
|
33
|
+
let changed = 0;
|
|
34
|
+
const blockEnd = (i) => Math.min((i + 1) * blockSize, remoteSize) - 1;
|
|
35
|
+
for (let i = 0; i < total; i++) {
|
|
36
|
+
const same = i < localHashes.length && localHashes[i] === remoteHashes[i];
|
|
37
|
+
if (same) {
|
|
38
|
+
if (cur) {
|
|
39
|
+
ranges.push(cur);
|
|
40
|
+
cur = null;
|
|
41
|
+
}
|
|
42
|
+
continue;
|
|
43
|
+
}
|
|
44
|
+
changed++;
|
|
45
|
+
const start = i * blockSize;
|
|
46
|
+
if (cur && start === cur.end + 1)
|
|
47
|
+
cur.end = blockEnd(i);
|
|
48
|
+
else {
|
|
49
|
+
if (cur)
|
|
50
|
+
ranges.push(cur);
|
|
51
|
+
cur = { start, end: blockEnd(i) };
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
if (cur)
|
|
55
|
+
ranges.push(cur);
|
|
56
|
+
const downloadBytes = ranges.reduce((n, r) => n + (r.end - r.start + 1), 0);
|
|
57
|
+
return {
|
|
58
|
+
blockSize,
|
|
59
|
+
total,
|
|
60
|
+
reused: total - changed,
|
|
61
|
+
changed,
|
|
62
|
+
ranges,
|
|
63
|
+
downloadBytes,
|
|
64
|
+
totalBytes: remoteSize,
|
|
65
|
+
ratio: total ? changed / total : 1,
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
/**
|
|
69
|
+
* Ensambla el artefacto nuevo reutilizando los bloques locales y pidiendo los
|
|
70
|
+
* cambiados con `fetchRange` (que debe devolver exactamente ese rango).
|
|
71
|
+
*/
|
|
72
|
+
export async function assemble(local, remoteHashes, plan, remoteSize, fetchRange) {
|
|
73
|
+
const out = Buffer.alloc(remoteSize);
|
|
74
|
+
// bloque = reutilizado salvo que caiga dentro de un rango de descarga
|
|
75
|
+
let ri = 0;
|
|
76
|
+
for (let i = 0; i < remoteHashes.length; i++) {
|
|
77
|
+
const start = i * plan.blockSize;
|
|
78
|
+
const end = Math.min(start + plan.blockSize, remoteSize);
|
|
79
|
+
while (ri < plan.ranges.length && plan.ranges[ri].end < start)
|
|
80
|
+
ri++;
|
|
81
|
+
const inRange = ri < plan.ranges.length && plan.ranges[ri].start <= start;
|
|
82
|
+
if (!inRange) {
|
|
83
|
+
// reutiliza del local (si existe y coincide); si no, se pedirá abajo
|
|
84
|
+
if (start < local.length)
|
|
85
|
+
local.copy(out, start, start, Math.min(end, local.length));
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
// descarga de los rangos cambiados
|
|
89
|
+
for (const r of plan.ranges) {
|
|
90
|
+
const buf = await fetchRange(r);
|
|
91
|
+
if (buf.length !== r.end - r.start + 1)
|
|
92
|
+
throw new Error(`rango ${r.start}-${r.end}: recibidos ${buf.length} bytes`);
|
|
93
|
+
buf.copy(out, r.start);
|
|
94
|
+
}
|
|
95
|
+
return out;
|
|
96
|
+
}
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
// Copyright 2026 Owear Contributors
|
|
2
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
//
|
|
4
|
+
// @owear/core/src/updater/yaml.ts — parser YAML mínimo (subconjunto) para los
|
|
5
|
+
// manifiestos de auto-update. Soporta:
|
|
6
|
+
// - mapas (`key: value`), anidados por indentación
|
|
7
|
+
// - listas (`- item`) de escalares o mapas
|
|
8
|
+
// - escalares (texto, comillas simples/dobles, números, true/false/null)
|
|
9
|
+
// - bloque literal (`key: |` y `key: >`) con líneas indentadas
|
|
10
|
+
// No soporta: anchors/aliases, flow style, multi-documento, tags. Suficiente
|
|
11
|
+
// para `latest.yml` / `<channel>.yml` (estilo electron-builder).
|
|
12
|
+
function stripComment(s) {
|
|
13
|
+
// Solo elimina comentarios fuera de comillas.
|
|
14
|
+
let inS = false;
|
|
15
|
+
let inD = false;
|
|
16
|
+
for (let i = 0; i < s.length; i++) {
|
|
17
|
+
const c = s[i];
|
|
18
|
+
if (c === "'" && !inD)
|
|
19
|
+
inS = !inS;
|
|
20
|
+
else if (c === '"' && !inS)
|
|
21
|
+
inD = !inD;
|
|
22
|
+
else if (c === '#' && !inS && !inD && (i === 0 || s[i - 1] === ' '))
|
|
23
|
+
return s.slice(0, i);
|
|
24
|
+
}
|
|
25
|
+
return s;
|
|
26
|
+
}
|
|
27
|
+
function tokenize(text) {
|
|
28
|
+
const out = [];
|
|
29
|
+
for (const raw of text.replace(/\r\n?/g, '\n').split('\n')) {
|
|
30
|
+
const noComment = stripComment(raw);
|
|
31
|
+
if (noComment.trim() === '' || /^---\s*$/.test(noComment.trim()) || /^\.\.\.\s*$/.test(noComment.trim()))
|
|
32
|
+
continue;
|
|
33
|
+
const indent = noComment.match(/^ */)[0].length;
|
|
34
|
+
out.push({ indent, text: noComment.slice(indent) });
|
|
35
|
+
}
|
|
36
|
+
return out;
|
|
37
|
+
}
|
|
38
|
+
function parseScalar(s) {
|
|
39
|
+
const t = s.trim();
|
|
40
|
+
if (t === '' || t === '~' || t === 'null')
|
|
41
|
+
return null;
|
|
42
|
+
if (t === 'true')
|
|
43
|
+
return true;
|
|
44
|
+
if (t === 'false')
|
|
45
|
+
return false;
|
|
46
|
+
if (/^'.*'$/.test(t))
|
|
47
|
+
return t.slice(1, -1).replace(/''/g, "'");
|
|
48
|
+
if (/^".*"$/.test(t)) {
|
|
49
|
+
try {
|
|
50
|
+
return JSON.parse(t);
|
|
51
|
+
}
|
|
52
|
+
catch {
|
|
53
|
+
return t.slice(1, -1);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
if (/^-?\d+$/.test(t))
|
|
57
|
+
return parseInt(t, 10);
|
|
58
|
+
if (/^-?\d*\.\d+$/.test(t))
|
|
59
|
+
return parseFloat(t);
|
|
60
|
+
return t;
|
|
61
|
+
}
|
|
62
|
+
/** Divide "key: value" por el primer ':' seguido de espacio o fin. */
|
|
63
|
+
function splitKey(text) {
|
|
64
|
+
let inS = false;
|
|
65
|
+
let inD = false;
|
|
66
|
+
for (let i = 0; i < text.length; i++) {
|
|
67
|
+
const c = text[i];
|
|
68
|
+
if (c === "'" && !inD)
|
|
69
|
+
inS = !inS;
|
|
70
|
+
else if (c === '"' && !inS)
|
|
71
|
+
inD = !inD;
|
|
72
|
+
else if (c === ':' && !inS && !inD && (i + 1 === text.length || text[i + 1] === ' '))
|
|
73
|
+
return { key: text.slice(0, i).trim(), rest: text.slice(i + 1).trim() };
|
|
74
|
+
}
|
|
75
|
+
return null;
|
|
76
|
+
}
|
|
77
|
+
function parseBlock(lines, start, indent) {
|
|
78
|
+
if (start >= lines.length)
|
|
79
|
+
return { value: null, next: start };
|
|
80
|
+
const first = lines[start];
|
|
81
|
+
// Lista
|
|
82
|
+
if (first.text.startsWith('- ') || first.text === '-') {
|
|
83
|
+
const arr = [];
|
|
84
|
+
let i = start;
|
|
85
|
+
while (i < lines.length && lines[i].indent === indent && (lines[i].text.startsWith('- ') || lines[i].text === '-')) {
|
|
86
|
+
const item = lines[i].text === '-' ? '' : lines[i].text.slice(2);
|
|
87
|
+
if (item === '') {
|
|
88
|
+
const sub = parseBlock(lines, i + 1, lines[i + 1]?.indent ?? indent + 2);
|
|
89
|
+
arr.push(sub.value);
|
|
90
|
+
i = sub.next;
|
|
91
|
+
}
|
|
92
|
+
else {
|
|
93
|
+
const kv = splitKey(item);
|
|
94
|
+
if (kv) {
|
|
95
|
+
// mapa en línea (primer campo) + campos siguientes más indentados
|
|
96
|
+
const obj = {};
|
|
97
|
+
obj[kv.key] = kv.rest === '' ? null : parseScalar(kv.rest);
|
|
98
|
+
const sub = parseBlock(lines, i + 1, lines[i + 1]?.indent ?? indent + 2);
|
|
99
|
+
if (sub.value && typeof sub.value === 'object' && !Array.isArray(sub.value)) {
|
|
100
|
+
Object.assign(obj, sub.value);
|
|
101
|
+
i = sub.next;
|
|
102
|
+
}
|
|
103
|
+
else {
|
|
104
|
+
i++;
|
|
105
|
+
}
|
|
106
|
+
arr.push(obj);
|
|
107
|
+
}
|
|
108
|
+
else {
|
|
109
|
+
arr.push(parseScalar(item));
|
|
110
|
+
i++;
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
return { value: arr, next: i };
|
|
115
|
+
}
|
|
116
|
+
// Mapa
|
|
117
|
+
const obj = {};
|
|
118
|
+
let i = start;
|
|
119
|
+
while (i < lines.length && lines[i].indent === indent) {
|
|
120
|
+
const kv = splitKey(lines[i].text);
|
|
121
|
+
if (!kv)
|
|
122
|
+
break;
|
|
123
|
+
if (kv.rest === '|' || kv.rest === '>') {
|
|
124
|
+
const blockIndent = lines[i + 1]?.indent ?? indent + 2;
|
|
125
|
+
const parts = [];
|
|
126
|
+
i++;
|
|
127
|
+
while (i < lines.length && lines[i].indent >= blockIndent) {
|
|
128
|
+
parts.push(lines[i].text);
|
|
129
|
+
i++;
|
|
130
|
+
}
|
|
131
|
+
obj[kv.key] = kv.rest === '>' ? parts.join(' ') : parts.join('\n');
|
|
132
|
+
continue;
|
|
133
|
+
}
|
|
134
|
+
if (kv.rest === '') {
|
|
135
|
+
// valor anidado (mapa/lista) o null
|
|
136
|
+
const nextLine = lines[i + 1];
|
|
137
|
+
if (nextLine && nextLine.indent > indent) {
|
|
138
|
+
const sub = parseBlock(lines, i + 1, nextLine.indent);
|
|
139
|
+
obj[kv.key] = sub.value;
|
|
140
|
+
i = sub.next;
|
|
141
|
+
}
|
|
142
|
+
else {
|
|
143
|
+
obj[kv.key] = null;
|
|
144
|
+
i++;
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
else {
|
|
148
|
+
obj[kv.key] = parseScalar(kv.rest);
|
|
149
|
+
i++;
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
return { value: obj, next: i };
|
|
153
|
+
}
|
|
154
|
+
/** Parsea un manifiesto YAML a un objeto JS. */
|
|
155
|
+
export function parseYaml(text) {
|
|
156
|
+
const lines = tokenize(text);
|
|
157
|
+
if (!lines.length)
|
|
158
|
+
return null;
|
|
159
|
+
return parseBlock(lines, 0, lines[0].indent).value;
|
|
160
|
+
}
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
import { EventEmitter } from 'node:events';
|
|
2
|
+
import { type BootRecord } from './updater/boot.js';
|
|
3
|
+
export interface FeedOptions {
|
|
4
|
+
provider?: 'generic' | 'github';
|
|
5
|
+
/** Base del feed (generic) o `owner/repo` (github). */
|
|
6
|
+
url: string;
|
|
7
|
+
channel?: string;
|
|
8
|
+
/** Clave pública Ed25519 (PEM o base64 SPKI). */
|
|
9
|
+
publicKey?: string;
|
|
10
|
+
}
|
|
11
|
+
export interface UpdateFile {
|
|
12
|
+
url: string;
|
|
13
|
+
sha256?: string;
|
|
14
|
+
sha512?: string;
|
|
15
|
+
size?: number;
|
|
16
|
+
}
|
|
17
|
+
export interface UpdateInfo {
|
|
18
|
+
version: string;
|
|
19
|
+
notes?: string;
|
|
20
|
+
releaseDate?: string;
|
|
21
|
+
mandatory?: boolean;
|
|
22
|
+
sha256?: string;
|
|
23
|
+
sha512?: string;
|
|
24
|
+
size?: number;
|
|
25
|
+
blockSize?: number;
|
|
26
|
+
blockmap?: string;
|
|
27
|
+
/** Firma Ed25519 de la metadata: `"<version>:<sha256>"`. */
|
|
28
|
+
signature?: string;
|
|
29
|
+
/** Firma Ed25519 del artefacto completo (defensa en profundidad). */
|
|
30
|
+
binarySig?: string;
|
|
31
|
+
file: UpdateFile;
|
|
32
|
+
}
|
|
33
|
+
export interface ProgressInfo {
|
|
34
|
+
total: number;
|
|
35
|
+
transferred: number;
|
|
36
|
+
percent: number;
|
|
37
|
+
bytesPerSecond: number;
|
|
38
|
+
}
|
|
39
|
+
export interface UpdateCheckResult {
|
|
40
|
+
updateInfo: UpdateInfo;
|
|
41
|
+
}
|
|
42
|
+
export interface InstallOptions {
|
|
43
|
+
/** Guarda una copia del binario actual para poder revertir (rollback). */
|
|
44
|
+
backup?: boolean;
|
|
45
|
+
}
|
|
46
|
+
export interface BootGuardOptions {
|
|
47
|
+
/** Arranques sin confirmar antes de revertir. */
|
|
48
|
+
threshold?: number;
|
|
49
|
+
/** ms de vida "sana" tras los que se confirma el update. */
|
|
50
|
+
healthDelayMs?: number;
|
|
51
|
+
}
|
|
52
|
+
declare function semverGt(a: string, b: string): boolean;
|
|
53
|
+
declare function isPrerelease(v: string): boolean;
|
|
54
|
+
declare class AutoUpdater extends EventEmitter {
|
|
55
|
+
currentVersion: string;
|
|
56
|
+
channel: string;
|
|
57
|
+
allowPrerelease: boolean;
|
|
58
|
+
autoDownload: boolean;
|
|
59
|
+
autoInstallOnAppQuit: boolean;
|
|
60
|
+
/** Nº de reintentos por petición (con backoff exponencial). */
|
|
61
|
+
maxRetries: number;
|
|
62
|
+
/** Espera base entre reintentos (ms); se duplica cada intento. */
|
|
63
|
+
retryDelay: number;
|
|
64
|
+
/** Timeout por petición (ms). */
|
|
65
|
+
requestTimeout: number;
|
|
66
|
+
private feed;
|
|
67
|
+
private info;
|
|
68
|
+
private newFile;
|
|
69
|
+
private healthTimer;
|
|
70
|
+
setFeedURL(opts: FeedOptions): void;
|
|
71
|
+
private manifestUrl;
|
|
72
|
+
private publicKey;
|
|
73
|
+
/** Verifica la firma Ed25519 del manifiesto (sobre `"<version>:<sha256>"`). */
|
|
74
|
+
private verifyManifest;
|
|
75
|
+
private backoff;
|
|
76
|
+
/**
|
|
77
|
+
* `fetch` con timeout y reintentos (backoff exponencial) para errores de red
|
|
78
|
+
* y estados transitorios. Devuelve la respuesta (incluidos 4xx no
|
|
79
|
+
* reintentables, para que el llamante dé un error concreto).
|
|
80
|
+
*/
|
|
81
|
+
private request;
|
|
82
|
+
checkForUpdates(): Promise<UpdateCheckResult | null>;
|
|
83
|
+
private fetchRange;
|
|
84
|
+
/** Delta por bloques: blockmap + reutilización local + rangos. */
|
|
85
|
+
private downloadDelta;
|
|
86
|
+
/**
|
|
87
|
+
* Descarga completa con **reanudación**: si la conexión cae a mitad, reintenta
|
|
88
|
+
* con `Range: bytes=<recibido>-` y continúa en vez de empezar de cero.
|
|
89
|
+
*/
|
|
90
|
+
private downloadFull;
|
|
91
|
+
/** Descarga el update (delta si hay blockmap; si no, completa con resume). */
|
|
92
|
+
downloadUpdate(): Promise<string[]>;
|
|
93
|
+
/**
|
|
94
|
+
* Aplica el update (reemplazo atómico + relaunch) vía el módulo nativo.
|
|
95
|
+
*
|
|
96
|
+
* El kernel se reemplaza a sí mismo y se re-ejecuta (`execv`) dentro de esta
|
|
97
|
+
* llamada, así que **no habrá respuesta**: la conexión cae. Lo tratamos como
|
|
98
|
+
* éxito (el proceso nuevo arranca con el binario actualizado).
|
|
99
|
+
*/
|
|
100
|
+
quitAndInstall(opts?: InstallOptions): Promise<void>;
|
|
101
|
+
/**
|
|
102
|
+
* Revierte al binario anterior (`<exe>.owprev`) y relanza. Solo tiene efecto
|
|
103
|
+
* si hay una copia de seguridad pendiente (p. ej. tras un update con backup).
|
|
104
|
+
*/
|
|
105
|
+
rollback(): Promise<void>;
|
|
106
|
+
/** Confirma el update (borra la copia de seguridad) → desarma el rollback. */
|
|
107
|
+
commitUpdate(): Promise<void>;
|
|
108
|
+
/**
|
|
109
|
+
* Arma el **guard de arranque** tras un update: si el binario nuevo se
|
|
110
|
+
* reinicia sin llegar a "sano" (crash loop) más de `threshold` veces,
|
|
111
|
+
* revierte. Si arranca bien `healthDelayMs`, confirma (borra el backup).
|
|
112
|
+
*
|
|
113
|
+
* app.whenReady().then(() => autoUpdater.armBootGuard())
|
|
114
|
+
*/
|
|
115
|
+
armBootGuard(opts?: BootGuardOptions): Promise<'idle' | 'armed' | 'rolled-back'>;
|
|
116
|
+
}
|
|
117
|
+
export declare const autoUpdater: AutoUpdater;
|
|
118
|
+
export { semverGt, isPrerelease };
|
|
119
|
+
export type { BootRecord };
|
package/dist/updater.js
ADDED
|
@@ -0,0 +1,429 @@
|
|
|
1
|
+
// Copyright 2026 Owear Contributors
|
|
2
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
//
|
|
4
|
+
// @owear/core/src/updater.ts — autoUpdater (proceso principal).
|
|
5
|
+
//
|
|
6
|
+
// Estilo electron-updater pero para Owear:
|
|
7
|
+
// autoUpdater.checkForUpdates() · downloadUpdate() · quitAndInstall()
|
|
8
|
+
// autoUpdater.rollback() · commitUpdate() · armBootGuard()
|
|
9
|
+
// eventos: checking-for-update · update-available · update-not-available ·
|
|
10
|
+
// error · download-progress · update-downloaded
|
|
11
|
+
//
|
|
12
|
+
// Manifiesto YAML por canal (`<channel>.yml`, p. ej. latest.yml):
|
|
13
|
+
// version: 1.4.0
|
|
14
|
+
// releaseDate: '2026-09-29T…'
|
|
15
|
+
// notes: |
|
|
16
|
+
// …
|
|
17
|
+
// path: MiApp-1.4.0.bin
|
|
18
|
+
// sha256: <hex> # integridad del artefacto
|
|
19
|
+
// sha512: <hex>
|
|
20
|
+
// size: 123456
|
|
21
|
+
// blockSize: 262144 # delta por bloques
|
|
22
|
+
// blockmap: https://…/MiApp-1.4.0.bin.blockmap
|
|
23
|
+
// signature: <base64> # Ed25519 sobre "<version>:<sha256>" (metadata)
|
|
24
|
+
// binarySig: <base64> # Ed25519 sobre los bytes del artefacto (payload)
|
|
25
|
+
//
|
|
26
|
+
// La firma se verifica con la clave pública del bridge (`updater.publicKey`).
|
|
27
|
+
import { EventEmitter } from 'node:events';
|
|
28
|
+
import * as crypto from 'node:crypto';
|
|
29
|
+
import * as fs from 'node:fs';
|
|
30
|
+
import * as os from 'node:os';
|
|
31
|
+
import * as path from 'node:path';
|
|
32
|
+
import { app } from './app.js';
|
|
33
|
+
import { invokeNative } from './channel.js';
|
|
34
|
+
import { parseYaml } from './updater/yaml.js';
|
|
35
|
+
import { assemble, blockHashes, planDelta } from './updater/delta.js';
|
|
36
|
+
import { bootRecordPath, nextBoot, readBoot, writeBoot } from './updater/boot.js';
|
|
37
|
+
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
38
|
+
function semverGt(a, b) {
|
|
39
|
+
const p = (s) => {
|
|
40
|
+
const core = s.replace(/^v/, '').split('-')[0];
|
|
41
|
+
const parts = core.split('.').map((n) => parseInt(n, 10) || 0);
|
|
42
|
+
while (parts.length < 3)
|
|
43
|
+
parts.push(0);
|
|
44
|
+
return parts;
|
|
45
|
+
};
|
|
46
|
+
const [x, y] = [p(a), p(b)];
|
|
47
|
+
for (let i = 0; i < 3; i++)
|
|
48
|
+
if (x[i] !== y[i])
|
|
49
|
+
return x[i] > y[i];
|
|
50
|
+
return false;
|
|
51
|
+
}
|
|
52
|
+
function isPrerelease(v) {
|
|
53
|
+
return v.replace(/^v/, '').includes('-');
|
|
54
|
+
}
|
|
55
|
+
function canonical(version, sha256) {
|
|
56
|
+
return Buffer.from(`${version}:${sha256}`);
|
|
57
|
+
}
|
|
58
|
+
const RETRYABLE = new Set([408, 425, 429, 500, 502, 503, 504]);
|
|
59
|
+
class AutoUpdater extends EventEmitter {
|
|
60
|
+
currentVersion = app.getVersion();
|
|
61
|
+
channel = 'latest';
|
|
62
|
+
allowPrerelease = false;
|
|
63
|
+
autoDownload = true;
|
|
64
|
+
autoInstallOnAppQuit = true;
|
|
65
|
+
/** Nº de reintentos por petición (con backoff exponencial). */
|
|
66
|
+
maxRetries = 3;
|
|
67
|
+
/** Espera base entre reintentos (ms); se duplica cada intento. */
|
|
68
|
+
retryDelay = 800;
|
|
69
|
+
/** Timeout por petición (ms). */
|
|
70
|
+
requestTimeout = 60_000;
|
|
71
|
+
feed = null;
|
|
72
|
+
info = null;
|
|
73
|
+
newFile = '';
|
|
74
|
+
healthTimer = null;
|
|
75
|
+
setFeedURL(opts) {
|
|
76
|
+
this.feed = opts;
|
|
77
|
+
if (opts.channel)
|
|
78
|
+
this.channel = opts.channel;
|
|
79
|
+
}
|
|
80
|
+
manifestUrl() {
|
|
81
|
+
if (!this.feed)
|
|
82
|
+
throw new Error('autoUpdater.setFeedURL() primero');
|
|
83
|
+
const { provider = 'generic', url } = this.feed;
|
|
84
|
+
if (provider === 'github')
|
|
85
|
+
return `https://github.com/${url}/releases/latest/download/${this.channel}.yml`;
|
|
86
|
+
return `${url.replace(/\/+$/, '')}/${this.channel}.yml`;
|
|
87
|
+
}
|
|
88
|
+
publicKey() {
|
|
89
|
+
const pub = this.feed?.publicKey;
|
|
90
|
+
if (!pub)
|
|
91
|
+
return null;
|
|
92
|
+
try {
|
|
93
|
+
if (pub.includes('BEGIN'))
|
|
94
|
+
return crypto.createPublicKey(pub);
|
|
95
|
+
return crypto.createPublicKey({
|
|
96
|
+
key: Buffer.from(pub, 'base64'),
|
|
97
|
+
format: 'der',
|
|
98
|
+
type: 'spki',
|
|
99
|
+
});
|
|
100
|
+
}
|
|
101
|
+
catch {
|
|
102
|
+
return null;
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
/** Verifica la firma Ed25519 del manifiesto (sobre `"<version>:<sha256>"`). */
|
|
106
|
+
verifyManifest(info) {
|
|
107
|
+
const key = this.publicKey();
|
|
108
|
+
if (!key)
|
|
109
|
+
return true; // sin clave pública: no se exige (pero sha256 sí)
|
|
110
|
+
if (!info.signature || !info.sha256)
|
|
111
|
+
return false;
|
|
112
|
+
try {
|
|
113
|
+
return crypto.verify(null, canonical(info.version, info.sha256), key, Buffer.from(info.signature, 'base64'));
|
|
114
|
+
}
|
|
115
|
+
catch {
|
|
116
|
+
return false;
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
backoff(attempt) {
|
|
120
|
+
const ms = Math.min(this.retryDelay * 2 ** (attempt - 1), 15_000) + Math.floor(Math.random() * 250);
|
|
121
|
+
return sleep(ms);
|
|
122
|
+
}
|
|
123
|
+
/**
|
|
124
|
+
* `fetch` con timeout y reintentos (backoff exponencial) para errores de red
|
|
125
|
+
* y estados transitorios. Devuelve la respuesta (incluidos 4xx no
|
|
126
|
+
* reintentables, para que el llamante dé un error concreto).
|
|
127
|
+
*/
|
|
128
|
+
async request(url, init = {}, what = 'petición') {
|
|
129
|
+
let lastErr;
|
|
130
|
+
for (let attempt = 0;; attempt++) {
|
|
131
|
+
const ctrl = new AbortController();
|
|
132
|
+
const timer = setTimeout(() => ctrl.abort(), this.requestTimeout);
|
|
133
|
+
try {
|
|
134
|
+
const res = await fetch(url, { ...init, signal: ctrl.signal });
|
|
135
|
+
if (RETRYABLE.has(res.status) && attempt < this.maxRetries) {
|
|
136
|
+
// libera la conexión antes de reintentar (si no, se filtra el socket)
|
|
137
|
+
await res.body?.cancel().catch(() => undefined);
|
|
138
|
+
await this.backoff(attempt + 1);
|
|
139
|
+
continue;
|
|
140
|
+
}
|
|
141
|
+
return res;
|
|
142
|
+
}
|
|
143
|
+
catch (e) {
|
|
144
|
+
lastErr = e;
|
|
145
|
+
if (attempt >= this.maxRetries)
|
|
146
|
+
throw e;
|
|
147
|
+
await this.backoff(attempt + 1);
|
|
148
|
+
}
|
|
149
|
+
finally {
|
|
150
|
+
clearTimeout(timer);
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
throw lastErr instanceof Error ? lastErr : new Error(`${what} falló`);
|
|
154
|
+
}
|
|
155
|
+
async checkForUpdates() {
|
|
156
|
+
this.emit('checking-for-update');
|
|
157
|
+
try {
|
|
158
|
+
const res = await this.request(this.manifestUrl(), {}, 'manifiesto');
|
|
159
|
+
if (!res.ok)
|
|
160
|
+
throw new Error(`feed ${res.status} ${res.statusText}`);
|
|
161
|
+
const text = await res.text();
|
|
162
|
+
const doc = parseYaml(text);
|
|
163
|
+
if (!doc || typeof doc !== 'object')
|
|
164
|
+
throw new Error('manifiesto YAML inválido');
|
|
165
|
+
const version = String(doc.version ?? '');
|
|
166
|
+
const filePath = String(doc.path ?? '');
|
|
167
|
+
const base = this.manifestUrl().replace(/\/[^/]+$/, '');
|
|
168
|
+
const rawBlockmap = doc.blockmap != null ? String(doc.blockmap) : undefined;
|
|
169
|
+
const info = {
|
|
170
|
+
version,
|
|
171
|
+
notes: doc.notes != null ? String(doc.notes) : undefined,
|
|
172
|
+
releaseDate: doc.releaseDate != null ? String(doc.releaseDate) : undefined,
|
|
173
|
+
mandatory: doc.mandatory === true,
|
|
174
|
+
sha256: doc.sha256 != null ? String(doc.sha256) : undefined,
|
|
175
|
+
sha512: doc.sha512 != null ? String(doc.sha512) : undefined,
|
|
176
|
+
size: typeof doc.size === 'number' ? doc.size : undefined,
|
|
177
|
+
blockSize: typeof doc.blockSize === 'number' ? doc.blockSize : undefined,
|
|
178
|
+
// un blockmap relativo se resuelve contra el directorio del manifiesto
|
|
179
|
+
blockmap: rawBlockmap && !/^https?:\/\//i.test(rawBlockmap) ? `${base}/${rawBlockmap}` : rawBlockmap,
|
|
180
|
+
signature: doc.signature != null ? String(doc.signature) : undefined,
|
|
181
|
+
binarySig: doc.binarySig != null ? String(doc.binarySig) : undefined,
|
|
182
|
+
file: {
|
|
183
|
+
url: `${base}/${filePath}`,
|
|
184
|
+
sha256: doc.sha256 != null ? String(doc.sha256) : undefined,
|
|
185
|
+
},
|
|
186
|
+
};
|
|
187
|
+
if (!version)
|
|
188
|
+
throw new Error('manifiesto sin version');
|
|
189
|
+
if (!this.verifyManifest(info))
|
|
190
|
+
throw new Error('firma del manifiesto inválida');
|
|
191
|
+
// sin clave pública la firma es best-effort (documentado): no abortamos.
|
|
192
|
+
// Si hay clave, `verifyManifest` ya exigió `signature`.
|
|
193
|
+
this.info = info;
|
|
194
|
+
const isNew = semverGt(version, this.currentVersion);
|
|
195
|
+
const preBlocked = isPrerelease(version) && !this.allowPrerelease && !isPrerelease(this.currentVersion);
|
|
196
|
+
if (isNew && !preBlocked) {
|
|
197
|
+
this.emit('update-available', info);
|
|
198
|
+
if (this.autoDownload)
|
|
199
|
+
void this.downloadUpdate().catch((e) => this.emit('error', e));
|
|
200
|
+
return { updateInfo: info };
|
|
201
|
+
}
|
|
202
|
+
this.emit('update-not-available', info);
|
|
203
|
+
return null;
|
|
204
|
+
}
|
|
205
|
+
catch (e) {
|
|
206
|
+
this.emit('error', e instanceof Error ? e : new Error(String(e)));
|
|
207
|
+
return null;
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
async fetchRange(url, r) {
|
|
211
|
+
const res = await this.request(url, { headers: { Range: `bytes=${r.start}-${r.end}` } }, 'rango');
|
|
212
|
+
if (res.status !== 206)
|
|
213
|
+
throw new Error(`el servidor no soporta Range (${res.status} al pedir ${r.start}-${r.end})`);
|
|
214
|
+
const buf = Buffer.from(await res.arrayBuffer());
|
|
215
|
+
if (buf.length !== r.end - r.start + 1)
|
|
216
|
+
throw new Error(`rango ${r.start}-${r.end}: recibidos ${buf.length} bytes`);
|
|
217
|
+
return buf;
|
|
218
|
+
}
|
|
219
|
+
/** Delta por bloques: blockmap + reutilización local + rangos. */
|
|
220
|
+
async downloadDelta(info, local) {
|
|
221
|
+
const bmRes = await this.request(info.blockmap, {}, 'blockmap');
|
|
222
|
+
if (!bmRes.ok)
|
|
223
|
+
throw new Error(`blockmap ${bmRes.status}`);
|
|
224
|
+
const bm = JSON.parse(await bmRes.text());
|
|
225
|
+
const plan = planDelta(blockHashes(local, bm.blockSize), bm.blocks, bm.blockSize, bm.size);
|
|
226
|
+
this.emit('download-progress', { total: plan.totalBytes, transferred: 0, percent: 0, bytesPerSecond: 0 });
|
|
227
|
+
const started = Date.now();
|
|
228
|
+
let done = 0;
|
|
229
|
+
const out = await assemble(local, bm.blocks, plan, bm.size, async (r) => {
|
|
230
|
+
const buf = await this.fetchRange(info.file.url, r);
|
|
231
|
+
done += buf.length;
|
|
232
|
+
const elapsed = (Date.now() - started) / 1000 || 0.001;
|
|
233
|
+
this.emit('download-progress', {
|
|
234
|
+
total: plan.totalBytes,
|
|
235
|
+
transferred: done,
|
|
236
|
+
percent: plan.totalBytes ? Math.round((done / plan.totalBytes) * 100) : 100,
|
|
237
|
+
bytesPerSecond: Math.round(done / elapsed),
|
|
238
|
+
});
|
|
239
|
+
return buf;
|
|
240
|
+
});
|
|
241
|
+
// cierre: el "100%" del delta es sobre los bloques descargados, no sobre el
|
|
242
|
+
// total del artefacto; al terminar lo reflejamos explícitamente.
|
|
243
|
+
const elapsed = (Date.now() - started) / 1000 || 0.001;
|
|
244
|
+
this.emit('download-progress', {
|
|
245
|
+
total: plan.totalBytes,
|
|
246
|
+
transferred: plan.totalBytes,
|
|
247
|
+
percent: 100,
|
|
248
|
+
bytesPerSecond: Math.round(plan.totalBytes / elapsed),
|
|
249
|
+
});
|
|
250
|
+
return out;
|
|
251
|
+
}
|
|
252
|
+
/**
|
|
253
|
+
* Descarga completa con **reanudación**: si la conexión cae a mitad, reintenta
|
|
254
|
+
* con `Range: bytes=<recibido>-` y continúa en vez de empezar de cero.
|
|
255
|
+
*/
|
|
256
|
+
async downloadFull(info) {
|
|
257
|
+
const url = info.file.url;
|
|
258
|
+
const started = Date.now();
|
|
259
|
+
let chunks = [];
|
|
260
|
+
let got = 0;
|
|
261
|
+
let lastErr;
|
|
262
|
+
for (let attempt = 0; attempt <= this.maxRetries; attempt++) {
|
|
263
|
+
try {
|
|
264
|
+
const headers = {};
|
|
265
|
+
if (got > 0)
|
|
266
|
+
headers.Range = `bytes=${got}-`;
|
|
267
|
+
const res = await this.request(url, { headers }, 'descarga');
|
|
268
|
+
if (!res.ok && res.status !== 206)
|
|
269
|
+
throw new Error(`descarga ${res.status}`);
|
|
270
|
+
if (got > 0 && res.status !== 206) {
|
|
271
|
+
chunks = [];
|
|
272
|
+
got = 0; // el servidor ignoró el Range
|
|
273
|
+
}
|
|
274
|
+
const total = Number(res.headers.get('content-length') ?? 0) + got || info.size || 0;
|
|
275
|
+
const reader = res.body?.getReader();
|
|
276
|
+
if (!reader) {
|
|
277
|
+
const buf = Buffer.from(await res.arrayBuffer());
|
|
278
|
+
chunks.push(buf);
|
|
279
|
+
got += buf.length;
|
|
280
|
+
return Buffer.concat(chunks);
|
|
281
|
+
}
|
|
282
|
+
for (;;) {
|
|
283
|
+
const { done, value } = await reader.read();
|
|
284
|
+
if (done)
|
|
285
|
+
break;
|
|
286
|
+
const b = Buffer.from(value);
|
|
287
|
+
chunks.push(b);
|
|
288
|
+
got += b.length;
|
|
289
|
+
const elapsed = (Date.now() - started) / 1000 || 0.001;
|
|
290
|
+
this.emit('download-progress', {
|
|
291
|
+
total,
|
|
292
|
+
transferred: got,
|
|
293
|
+
percent: total ? Math.min(100, Math.round((got / total) * 100)) : 0,
|
|
294
|
+
bytesPerSecond: Math.round(got / elapsed),
|
|
295
|
+
});
|
|
296
|
+
}
|
|
297
|
+
return Buffer.concat(chunks);
|
|
298
|
+
}
|
|
299
|
+
catch (e) {
|
|
300
|
+
lastErr = e;
|
|
301
|
+
if (attempt >= this.maxRetries)
|
|
302
|
+
break;
|
|
303
|
+
await this.backoff(attempt + 1); // continúa desde `got` (resume)
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
throw lastErr instanceof Error ? lastErr : new Error('descarga falló');
|
|
307
|
+
}
|
|
308
|
+
/** Descarga el update (delta si hay blockmap; si no, completa con resume). */
|
|
309
|
+
async downloadUpdate() {
|
|
310
|
+
const info = this.info;
|
|
311
|
+
if (!info)
|
|
312
|
+
throw new Error('checkForUpdates() primero');
|
|
313
|
+
const state = await invokeNative('updater', 'state').catch(() => ({}));
|
|
314
|
+
const dest = path.join(os.tmpdir(), `owear-update-${info.version}${path.extname(info.file.url) || '.bin'}`);
|
|
315
|
+
let local = Buffer.alloc(0);
|
|
316
|
+
if (info.blockmap && info.blockSize && state.exe) {
|
|
317
|
+
try {
|
|
318
|
+
local = fs.readFileSync(state.exe);
|
|
319
|
+
}
|
|
320
|
+
catch {
|
|
321
|
+
local = Buffer.alloc(0);
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
let data;
|
|
325
|
+
if (info.blockmap && info.blockSize && local.length > 0) {
|
|
326
|
+
try {
|
|
327
|
+
data = await this.downloadDelta(info, local);
|
|
328
|
+
}
|
|
329
|
+
catch {
|
|
330
|
+
// sin Range / blockmap inválido → descarga completa
|
|
331
|
+
data = await this.downloadFull(info);
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
else {
|
|
335
|
+
data = await this.downloadFull(info);
|
|
336
|
+
}
|
|
337
|
+
// Integridad (sha256; sha512 opcional) y firma del payload (binarySig).
|
|
338
|
+
if (info.sha256) {
|
|
339
|
+
const got = crypto.createHash('sha256').update(data).digest('hex');
|
|
340
|
+
if (got !== info.sha256)
|
|
341
|
+
throw new Error('sha256 del update no coincide');
|
|
342
|
+
}
|
|
343
|
+
if (info.sha512) {
|
|
344
|
+
const got = crypto.createHash('sha512').update(data).digest('hex');
|
|
345
|
+
if (got !== info.sha512)
|
|
346
|
+
throw new Error('sha512 del update no coincide');
|
|
347
|
+
}
|
|
348
|
+
if (info.binarySig) {
|
|
349
|
+
const key = this.publicKey();
|
|
350
|
+
if (key) {
|
|
351
|
+
const ok = crypto.verify(null, data, key, Buffer.from(info.binarySig, 'base64'));
|
|
352
|
+
if (!ok)
|
|
353
|
+
throw new Error('firma del binario (binarySig) inválida');
|
|
354
|
+
}
|
|
355
|
+
// sin clave pública: best-effort (el sha256 ya garantiza integridad)
|
|
356
|
+
}
|
|
357
|
+
fs.writeFileSync(dest, data);
|
|
358
|
+
this.newFile = dest;
|
|
359
|
+
this.emit('update-downloaded', info);
|
|
360
|
+
if (this.autoInstallOnAppQuit) {
|
|
361
|
+
app.once('before-quit', () => void this.quitAndInstall().catch(() => undefined));
|
|
362
|
+
}
|
|
363
|
+
return [dest];
|
|
364
|
+
}
|
|
365
|
+
/**
|
|
366
|
+
* Aplica el update (reemplazo atómico + relaunch) vía el módulo nativo.
|
|
367
|
+
*
|
|
368
|
+
* El kernel se reemplaza a sí mismo y se re-ejecuta (`execv`) dentro de esta
|
|
369
|
+
* llamada, así que **no habrá respuesta**: la conexión cae. Lo tratamos como
|
|
370
|
+
* éxito (el proceso nuevo arranca con el binario actualizado).
|
|
371
|
+
*/
|
|
372
|
+
async quitAndInstall(opts = {}) {
|
|
373
|
+
if (!this.newFile)
|
|
374
|
+
throw new Error('downloadUpdate() primero');
|
|
375
|
+
const backup = opts.backup !== false;
|
|
376
|
+
try {
|
|
377
|
+
await invokeNative('updater', 'apply', { path: this.newFile, backup });
|
|
378
|
+
}
|
|
379
|
+
catch {
|
|
380
|
+
// esperado: el proceso fue reemplazado y relanzado
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
/**
|
|
384
|
+
* Revierte al binario anterior (`<exe>.owprev`) y relanza. Solo tiene efecto
|
|
385
|
+
* si hay una copia de seguridad pendiente (p. ej. tras un update con backup).
|
|
386
|
+
*/
|
|
387
|
+
async rollback() {
|
|
388
|
+
try {
|
|
389
|
+
await invokeNative('updater', 'rollback');
|
|
390
|
+
}
|
|
391
|
+
catch {
|
|
392
|
+
// el proceso fue reemplazado y relanzado
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
/** Confirma el update (borra la copia de seguridad) → desarma el rollback. */
|
|
396
|
+
async commitUpdate() {
|
|
397
|
+
if (this.healthTimer) {
|
|
398
|
+
clearTimeout(this.healthTimer);
|
|
399
|
+
this.healthTimer = null;
|
|
400
|
+
}
|
|
401
|
+
await invokeNative('updater', 'commit').catch(() => undefined);
|
|
402
|
+
}
|
|
403
|
+
/**
|
|
404
|
+
* Arma el **guard de arranque** tras un update: si el binario nuevo se
|
|
405
|
+
* reinicia sin llegar a "sano" (crash loop) más de `threshold` veces,
|
|
406
|
+
* revierte. Si arranca bien `healthDelayMs`, confirma (borra el backup).
|
|
407
|
+
*
|
|
408
|
+
* app.whenReady().then(() => autoUpdater.armBootGuard())
|
|
409
|
+
*/
|
|
410
|
+
async armBootGuard(opts = {}) {
|
|
411
|
+
const threshold = opts.threshold ?? 3;
|
|
412
|
+
const healthDelayMs = opts.healthDelayMs ?? 10_000;
|
|
413
|
+
const st = await invokeNative('updater', 'state').catch(() => ({}));
|
|
414
|
+
if (!st.hasRollback)
|
|
415
|
+
return 'idle'; // sin update pendiente
|
|
416
|
+
const file = bootRecordPath(app.getPath('userData'));
|
|
417
|
+
const next = nextBoot(readBoot(file), this.currentVersion, threshold);
|
|
418
|
+
if (next.shouldRollback) {
|
|
419
|
+
await this.rollback();
|
|
420
|
+
return 'rolled-back';
|
|
421
|
+
}
|
|
422
|
+
writeBoot(file, next.record);
|
|
423
|
+
this.healthTimer = setTimeout(() => void this.commitUpdate(), healthDelayMs);
|
|
424
|
+
this.healthTimer.unref?.();
|
|
425
|
+
return 'armed';
|
|
426
|
+
}
|
|
427
|
+
}
|
|
428
|
+
export const autoUpdater = new AutoUpdater();
|
|
429
|
+
export { semverGt, isPrerelease };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@owear/core",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.5",
|
|
4
4
|
"description": "Owear core SDK — controla el kernel nativo desde JavaScript",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/index.js",
|
|
@@ -33,6 +33,6 @@
|
|
|
33
33
|
"scripts": {
|
|
34
34
|
"build": "tsc -p tsconfig.json",
|
|
35
35
|
"typecheck": "tsc -p tsconfig.json --noEmit",
|
|
36
|
-
"test": "node --test test/worker.test.mjs test/port.test.mjs test/app.test.mjs test/nativeimage.test.mjs test/menu.test.mjs test/c10.test.mjs"
|
|
36
|
+
"test": "node --test test/worker.test.mjs test/port.test.mjs test/app.test.mjs test/nativeimage.test.mjs test/menu.test.mjs test/c10.test.mjs test/updater.test.mjs test/updater-feed.test.mjs test/updater-boot.test.mjs test/updater-sign.test.mjs test/updater-retry.test.mjs"
|
|
37
37
|
}
|
|
38
38
|
}
|