@owear/core 0.1.2 → 0.1.4
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 +128 -0
- package/dist/app.js +285 -0
- package/dist/appicon.d.ts +4 -0
- package/dist/appicon.js +16 -0
- package/dist/bridge.d.ts +79 -0
- package/dist/bridge.js +55 -0
- package/dist/browserwindow.d.ts +115 -0
- package/dist/browserwindow.js +237 -0
- package/dist/channel.d.ts +22 -0
- package/dist/channel.js +114 -0
- package/dist/dialog.d.ts +47 -0
- package/dist/dialog.js +22 -0
- package/dist/index.d.ts +83 -96
- package/dist/index.js +26 -253
- package/dist/installer.d.ts +126 -0
- package/dist/installer.js +84 -0
- package/dist/ipc/node.d.ts +7 -0
- package/dist/ipc/node.js +25 -0
- package/dist/menu.d.ts +56 -0
- package/dist/menu.js +208 -0
- package/dist/modules.d.ts +16 -0
- package/dist/modules.js +13 -0
- package/dist/nativeimage.d.ts +41 -0
- package/dist/nativeimage.js +376 -0
- package/dist/node/worker-runner.d.ts +1 -0
- package/dist/node/worker-runner.js +35 -0
- package/dist/node/worker.d.ts +29 -0
- package/dist/node/worker.js +61 -0
- package/dist/port.d.ts +28 -0
- package/dist/port.js +42 -0
- package/dist/power.d.ts +33 -0
- package/dist/power.js +99 -0
- package/dist/protocol.d.ts +14 -0
- package/dist/protocol.js +46 -0
- package/dist/safestorage.d.ts +16 -0
- package/dist/safestorage.js +21 -0
- package/dist/screen.d.ts +92 -0
- package/dist/screen.js +121 -0
- package/dist/session.d.ts +24 -0
- package/dist/session.js +42 -0
- package/dist/theme.d.ts +30 -0
- package/dist/theme.js +34 -0
- package/dist/tray.d.ts +30 -0
- package/dist/tray.js +69 -0
- 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/dist/webcontents.d.ts +44 -0
- package/dist/webcontents.js +109 -0
- package/dist/webrequest.d.ts +23 -0
- package/dist/webrequest.js +76 -0
- package/package.json +7 -6
- package/LICENSE +0 -201
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 };
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { EventEmitter } from 'node:events';
|
|
2
|
+
import { NativeImage } from './nativeimage.js';
|
|
3
|
+
export interface WindowOpenDetails {
|
|
4
|
+
url: string;
|
|
5
|
+
}
|
|
6
|
+
export type WindowOpenHandler = (details: WindowOpenDetails) => {
|
|
7
|
+
action: 'allow' | 'deny';
|
|
8
|
+
overrideBrowserWindowOptions?: Record<string, unknown>;
|
|
9
|
+
} | void;
|
|
10
|
+
/** Eventos de ventana → nombres estilo Electron de webContents. */
|
|
11
|
+
export declare const WC_EVENT_NAMES: Record<string, string>;
|
|
12
|
+
/** Eventos de ventana → nombres estilo Electron (dash) que emite BrowserWindow. */
|
|
13
|
+
export declare const WIN_EVENT_NAMES: Record<string, string>;
|
|
14
|
+
/**
|
|
15
|
+
* Vista del contenido web de una ventana (estilo Electron).
|
|
16
|
+
* Eventos: `did-finish-load`, `did-fail-load`, `did-start-navigation`,
|
|
17
|
+
* `did-navigate`, `page-title-updated`, `before-input-event`.
|
|
18
|
+
* `setWindowOpenHandler` controla `window.open`/`target=_blank`.
|
|
19
|
+
*/
|
|
20
|
+
export declare class WebContents extends EventEmitter {
|
|
21
|
+
private _windowId;
|
|
22
|
+
constructor(windowId?: number | null);
|
|
23
|
+
_setWindowId(id: number): void;
|
|
24
|
+
get id(): number;
|
|
25
|
+
/** Envía un evento SOLO a esta ventana (renderer: `ow.on(name, cb)`). */
|
|
26
|
+
send(name: string, payload?: unknown): Promise<void>;
|
|
27
|
+
loadURL(url: string): Promise<void>;
|
|
28
|
+
reload(): Promise<void>;
|
|
29
|
+
openDevTools(): Promise<void>;
|
|
30
|
+
getURL(): Promise<string>;
|
|
31
|
+
getTitle(): Promise<string>;
|
|
32
|
+
executeJavaScript<T = unknown>(js: string): Promise<T>;
|
|
33
|
+
/** Captura la página (PNG) como `NativeImage` (resizable, toPNG/toDataURL). */
|
|
34
|
+
capturePage(): Promise<NativeImage>;
|
|
35
|
+
/** Exporta la página a PDF (Buffer). */
|
|
36
|
+
printToPDF(_options?: Record<string, unknown>): Promise<Buffer>;
|
|
37
|
+
/** Abre el diálogo de impresión del sistema. */
|
|
38
|
+
print(_options?: Record<string, unknown>): void;
|
|
39
|
+
/**
|
|
40
|
+
* Controla `window.open`/`target=_blank`. Devuelve `{action:'deny'}` para
|
|
41
|
+
* bloquear o `{action:'allow'}` para permitir (por defecto).
|
|
42
|
+
*/
|
|
43
|
+
setWindowOpenHandler(handler: WindowOpenHandler | null): void;
|
|
44
|
+
}
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
// Copyright 2026 Owear Contributors
|
|
2
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
//
|
|
4
|
+
// @owear/core/src/webcontents.ts — objeto webContents (C3).
|
|
5
|
+
import { EventEmitter } from 'node:events';
|
|
6
|
+
import { channel, invokeNative } from './channel.js';
|
|
7
|
+
import { nativeImage } from './nativeimage.js';
|
|
8
|
+
/** Eventos de ventana → nombres estilo Electron de webContents. */
|
|
9
|
+
export const WC_EVENT_NAMES = {
|
|
10
|
+
didFinishLoad: 'did-finish-load',
|
|
11
|
+
didFailLoad: 'did-fail-load',
|
|
12
|
+
navigationStarted: 'did-start-navigation',
|
|
13
|
+
loadCommitted: 'did-navigate',
|
|
14
|
+
pageTitleUpdated: 'page-title-updated',
|
|
15
|
+
beforeInput: 'before-input-event',
|
|
16
|
+
};
|
|
17
|
+
/** Eventos de ventana → nombres estilo Electron (dash) que emite BrowserWindow. */
|
|
18
|
+
export const WIN_EVENT_NAMES = {
|
|
19
|
+
pageTitleUpdated: 'page-title-updated',
|
|
20
|
+
enterFullScreen: 'enter-full-screen',
|
|
21
|
+
leaveFullScreen: 'leave-full-screen',
|
|
22
|
+
alwaysOnTopChanged: 'always-on-top-changed',
|
|
23
|
+
};
|
|
24
|
+
let windowOpenHandler = null;
|
|
25
|
+
channel.on('webContents.windowOpen', (params) => {
|
|
26
|
+
const { id, url } = params ?? {};
|
|
27
|
+
let action = 'allow';
|
|
28
|
+
try {
|
|
29
|
+
const r = windowOpenHandler?.({ url });
|
|
30
|
+
if (r && r.action === 'deny')
|
|
31
|
+
action = 'deny';
|
|
32
|
+
}
|
|
33
|
+
catch {
|
|
34
|
+
/* handler lanzó → allow */
|
|
35
|
+
}
|
|
36
|
+
void channel.call('webContents.respondWindowOpen', { id, action });
|
|
37
|
+
});
|
|
38
|
+
/**
|
|
39
|
+
* Vista del contenido web de una ventana (estilo Electron).
|
|
40
|
+
* Eventos: `did-finish-load`, `did-fail-load`, `did-start-navigation`,
|
|
41
|
+
* `did-navigate`, `page-title-updated`, `before-input-event`.
|
|
42
|
+
* `setWindowOpenHandler` controla `window.open`/`target=_blank`.
|
|
43
|
+
*/
|
|
44
|
+
export class WebContents extends EventEmitter {
|
|
45
|
+
_windowId;
|
|
46
|
+
constructor(windowId = null) {
|
|
47
|
+
super();
|
|
48
|
+
this._windowId = windowId;
|
|
49
|
+
}
|
|
50
|
+
_setWindowId(id) {
|
|
51
|
+
this._windowId = id;
|
|
52
|
+
}
|
|
53
|
+
get id() {
|
|
54
|
+
return this._windowId ?? -1;
|
|
55
|
+
}
|
|
56
|
+
/** Envía un evento SOLO a esta ventana (renderer: `ow.on(name, cb)`). */
|
|
57
|
+
send(name, payload) {
|
|
58
|
+
if (this._windowId == null)
|
|
59
|
+
return Promise.reject(new Error('ventana aún no creada'));
|
|
60
|
+
return channel.call('node.emit', { name, payload, windowId: this._windowId });
|
|
61
|
+
}
|
|
62
|
+
loadURL(url) {
|
|
63
|
+
return channel.call('window.loadURL', { windowId: this.id, url });
|
|
64
|
+
}
|
|
65
|
+
reload() {
|
|
66
|
+
return invokeNative('window', 'reload', this.id);
|
|
67
|
+
}
|
|
68
|
+
openDevTools() {
|
|
69
|
+
return invokeNative('window', 'openDevTools', this.id);
|
|
70
|
+
}
|
|
71
|
+
getURL() {
|
|
72
|
+
return invokeNative('window', 'getURL', this.id);
|
|
73
|
+
}
|
|
74
|
+
getTitle() {
|
|
75
|
+
return invokeNative('window', 'getTitle', this.id);
|
|
76
|
+
}
|
|
77
|
+
executeJavaScript(js) {
|
|
78
|
+
return channel.call('window.eval', { windowId: this.id, js });
|
|
79
|
+
}
|
|
80
|
+
/** Captura la página (PNG) como `NativeImage` (resizable, toPNG/toDataURL). */
|
|
81
|
+
async capturePage() {
|
|
82
|
+
const r = await channel.call('window.capturePage', {
|
|
83
|
+
windowId: this.id,
|
|
84
|
+
base64: true,
|
|
85
|
+
});
|
|
86
|
+
return nativeImage.createFromBuffer(Buffer.from(r.data, 'base64'));
|
|
87
|
+
}
|
|
88
|
+
/** Exporta la página a PDF (Buffer). */
|
|
89
|
+
async printToPDF(_options = {}) {
|
|
90
|
+
const r = await channel.call('window.printToPDF', {
|
|
91
|
+
windowId: this.id,
|
|
92
|
+
});
|
|
93
|
+
return Buffer.from(r.data, 'base64');
|
|
94
|
+
}
|
|
95
|
+
/** Abre el diálogo de impresión del sistema. */
|
|
96
|
+
print(_options = {}) {
|
|
97
|
+
void invokeNative('window', 'print', this.id).catch(() => undefined);
|
|
98
|
+
}
|
|
99
|
+
/**
|
|
100
|
+
* Controla `window.open`/`target=_blank`. Devuelve `{action:'deny'}` para
|
|
101
|
+
* bloquear o `{action:'allow'}` para permitir (por defecto).
|
|
102
|
+
*/
|
|
103
|
+
setWindowOpenHandler(handler) {
|
|
104
|
+
windowOpenHandler = handler ?? null;
|
|
105
|
+
void channel
|
|
106
|
+
.call('webContents.setWindowOpenHandler', { windowId: this.id, enabled: !!handler })
|
|
107
|
+
.catch(() => undefined);
|
|
108
|
+
}
|
|
109
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
export interface WebRequestDetails {
|
|
2
|
+
id: number;
|
|
3
|
+
url: string;
|
|
4
|
+
method: string;
|
|
5
|
+
headers: Record<string, string>;
|
|
6
|
+
}
|
|
7
|
+
export type WebRequestResult = {
|
|
8
|
+
cancel?: boolean;
|
|
9
|
+
redirectURL?: string;
|
|
10
|
+
} | void;
|
|
11
|
+
export type WebRequestHandler = (details: WebRequestDetails) => WebRequestResult | Promise<WebRequestResult>;
|
|
12
|
+
/**
|
|
13
|
+
* Intercepción de requests (webRequest). `onBeforeRequest` puede cancelar o
|
|
14
|
+
* redirigir. En **Windows** intercepta todos los requests; en **Linux** solo
|
|
15
|
+
* navegaciones (WebKitGTK 2.52 ya no expone `send-request` para subrecursos).
|
|
16
|
+
*
|
|
17
|
+
* webRequest.onBeforeRequest({ urls: ['https://ads.example.com/w'] }, () => ({ cancel: true }))
|
|
18
|
+
*/
|
|
19
|
+
export declare const webRequest: {
|
|
20
|
+
onBeforeRequest(filter: {
|
|
21
|
+
urls?: string[];
|
|
22
|
+
} | string[], handler: WebRequestHandler): () => void;
|
|
23
|
+
};
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
// Copyright 2026 Owear Contributors
|
|
2
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
//
|
|
4
|
+
// @owear/core/src/webrequest.ts — intercepción de requests (webRequest).
|
|
5
|
+
import { channel } from './channel.js';
|
|
6
|
+
import { app } from './index.js';
|
|
7
|
+
const webRequestRegistrations = [];
|
|
8
|
+
function globMatch(pattern, url) {
|
|
9
|
+
if (pattern === '<all_urls>' || pattern === '*')
|
|
10
|
+
return true;
|
|
11
|
+
const re = '^' +
|
|
12
|
+
pattern
|
|
13
|
+
.replace(/[.+^${}()|[\]\\]/g, '\\$&')
|
|
14
|
+
.replace(/\*/g, '.*')
|
|
15
|
+
.replace(/\?/g, '.') +
|
|
16
|
+
'$';
|
|
17
|
+
try {
|
|
18
|
+
return new RegExp(re).test(url);
|
|
19
|
+
}
|
|
20
|
+
catch {
|
|
21
|
+
return false;
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
function webRequestPatterns() {
|
|
25
|
+
const set = new Set();
|
|
26
|
+
for (const r of webRequestRegistrations)
|
|
27
|
+
for (const p of r.patterns)
|
|
28
|
+
set.add(p);
|
|
29
|
+
return [...set];
|
|
30
|
+
}
|
|
31
|
+
function syncWebRequest() {
|
|
32
|
+
app
|
|
33
|
+
.whenReady()
|
|
34
|
+
.then(() => channel.call('webRequest.register', { urls: webRequestPatterns() }))
|
|
35
|
+
.catch(() => undefined);
|
|
36
|
+
}
|
|
37
|
+
channel.on('webRequest.request', (params) => {
|
|
38
|
+
const { id, url, method, headers } = params ?? {};
|
|
39
|
+
const matched = webRequestRegistrations.filter((r) => r.patterns.some((p) => globMatch(p, url)));
|
|
40
|
+
Promise.resolve()
|
|
41
|
+
.then(async () => {
|
|
42
|
+
for (const r of matched) {
|
|
43
|
+
const res = await r.handler({ id, url, method, headers: headers ?? {} });
|
|
44
|
+
if (res && (res.cancel || res.redirectURL))
|
|
45
|
+
return res;
|
|
46
|
+
}
|
|
47
|
+
return null;
|
|
48
|
+
})
|
|
49
|
+
.then((res) => channel.call('webRequest.respond', {
|
|
50
|
+
id,
|
|
51
|
+
cancel: !!(res && res.cancel),
|
|
52
|
+
redirectURL: res && res.redirectURL,
|
|
53
|
+
}))
|
|
54
|
+
.catch(() => channel.call('webRequest.respond', { id, cancel: false }));
|
|
55
|
+
});
|
|
56
|
+
/**
|
|
57
|
+
* Intercepción de requests (webRequest). `onBeforeRequest` puede cancelar o
|
|
58
|
+
* redirigir. En **Windows** intercepta todos los requests; en **Linux** solo
|
|
59
|
+
* navegaciones (WebKitGTK 2.52 ya no expone `send-request` para subrecursos).
|
|
60
|
+
*
|
|
61
|
+
* webRequest.onBeforeRequest({ urls: ['https://ads.example.com/w'] }, () => ({ cancel: true }))
|
|
62
|
+
*/
|
|
63
|
+
export const webRequest = {
|
|
64
|
+
onBeforeRequest(filter, handler) {
|
|
65
|
+
const patterns = Array.isArray(filter) ? filter : filter?.urls ?? ['<all_urls>'];
|
|
66
|
+
const reg = { patterns, handler };
|
|
67
|
+
webRequestRegistrations.push(reg);
|
|
68
|
+
syncWebRequest();
|
|
69
|
+
return () => {
|
|
70
|
+
const i = webRequestRegistrations.indexOf(reg);
|
|
71
|
+
if (i >= 0)
|
|
72
|
+
webRequestRegistrations.splice(i, 1);
|
|
73
|
+
syncWebRequest();
|
|
74
|
+
};
|
|
75
|
+
},
|
|
76
|
+
};
|