@owear/cli 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.
Files changed (34) hide show
  1. package/package.json +10 -8
  2. package/src/ow.js +548 -25
  3. package/template/README.md +34 -28
  4. package/template/app/main.ts +273 -4
  5. package/template/index.html +117 -28
  6. package/template/package.json +1 -1
  7. package/template/public/favicon.svg +4 -0
  8. package/template/src/assets/dancing-script.woff2 +0 -0
  9. package/template/src/assets/jetbrains-mono.woff2 +0 -0
  10. package/template/src/ow.d.ts +9 -1
  11. package/template/src/renderer.ts +489 -1
  12. package/template/src/style.css +280 -56
  13. package/template-installer/README.md +38 -0
  14. package/template-installer/app/main.ts +22 -0
  15. package/template-installer/index.html +70 -0
  16. package/template-installer/package.json +22 -0
  17. package/template-installer/src/assets/dancing-script.woff2 +0 -0
  18. package/template-installer/src/assets/jetbrains-mono.woff2 +0 -0
  19. package/template-installer/src/installer.ts +171 -0
  20. package/template-installer/src/style.css +442 -0
  21. package/template-installer/tsconfig.json +13 -0
  22. package/template-installer/uninstaller/app/main.ts +16 -0
  23. package/template-installer/uninstaller/index.html +54 -0
  24. package/template-installer/uninstaller/package.json +22 -0
  25. package/template-installer/uninstaller/src/uninstaller.ts +101 -0
  26. package/template-installer/uninstaller/vite.config.ts +10 -0
  27. package/template-installer/vite.config.ts +15 -0
  28. package/tools/owear-installer.mjs +86 -0
  29. package/tools/owear-linux.mjs +158 -0
  30. package/tools/owear-msi.mjs +113 -0
  31. package/tools/owear-pack.mjs +93 -0
  32. package/tools/owear-sign.mjs +204 -0
  33. package/tools/owear-update.mjs +150 -0
  34. package/LICENSE +0 -201
@@ -45,6 +45,30 @@ function renderEnvironment(): void {
45
45
  $('#env-ua').textContent = ua
46
46
  }
47
47
 
48
+ // ── App (C1): rutas/identidad vía el puente Node + eventos de ciclo de vida ─
49
+ async function renderApp(): Promise<void> {
50
+ try {
51
+ const info = await invoke<Record<string, unknown>>('node', 'call', {
52
+ fn: 'starter.appInfo',
53
+ args: [],
54
+ })
55
+ $('#app-name').textContent = String(info.name ?? '—')
56
+ $('#app-version').textContent = String(info.version ?? '—')
57
+ $('#app-packaged').textContent = info.packaged ? 'sí' : 'no'
58
+
59
+ const paths = await invoke<Record<string, string>>('node', 'call', {
60
+ fn: 'starter.appPaths',
61
+ args: [],
62
+ })
63
+ for (const name of ['userData', 'exe', 'appPath']) {
64
+ const el = document.getElementById(`app-path-${name}`)
65
+ if (el) el.textContent = paths[name] ?? '—'
66
+ }
67
+ } catch (e) {
68
+ log(`app.* no disponible: ${e}`, 'err')
69
+ }
70
+ }
71
+
48
72
  // ── Acciones (módulos nativos) ──────────────────────────────────────────
49
73
  async function openFile(): Promise<void> {
50
74
  const path = await invoke<string | null>('dialog', 'open', 'open', 'Abrir un archivo')
@@ -99,7 +123,315 @@ async function about(): Promise<void> {
99
123
  log('dialog.messageBox → cerrado', 'ok')
100
124
  }
101
125
 
126
+ // ── C2: diálogos completos (showOpenDialog/showSaveDialog/showMessageBox) ──
127
+ async function openMultiple(): Promise<void> {
128
+ const r = await invoke<{ canceled: boolean; filePaths: string[] }>(
129
+ 'dialog',
130
+ 'showOpenDialog',
131
+ {
132
+ title: 'Abrir varios',
133
+ properties: ['openFile', 'multiSelections'],
134
+ filters: [{ name: 'Texto', extensions: ['txt', 'md', 'json'] }],
135
+ },
136
+ )
137
+ if (r.canceled) return log('showOpenDialog → cancelado')
138
+ log(`showOpenDialog → ${r.filePaths.length} archivo(s)`, 'ok')
139
+ for (const p of r.filePaths.slice(0, 4)) log(` · ${p}`)
140
+ }
141
+
142
+ async function saveAs(): Promise<void> {
143
+ const r = await invoke<{ canceled: boolean; filePath: string }>('dialog', 'showSaveDialog', {
144
+ title: 'Guardar como',
145
+ defaultPath: `owear-${Date.now()}.txt`,
146
+ filters: [{ name: 'Texto', extensions: ['txt', 'md'] }],
147
+ })
148
+ if (r.canceled) return log('showSaveDialog → cancelado')
149
+ lastPath = r.filePath
150
+ log(`showSaveDialog → ${r.filePath}`, 'ok')
151
+ }
152
+
153
+ // ── C3: webContents ──────────────────────────────────────────────────────
154
+ async function capturePage(): Promise<void> {
155
+ const r = await invoke<{ data: string }>('node', 'call', {
156
+ fn: 'starter.capture',
157
+ args: [],
158
+ })
159
+ log(`webContents.capturePage → ${Math.round((r.data.length * 3) / 4)} bytes PNG`, 'ok')
160
+ }
161
+
162
+ // ── C4: nativeImage ──────────────────────────────────────────────────────
163
+ async function nativeImageTest(): Promise<void> {
164
+ const r = await invoke<{
165
+ original: { width: number; height: number }
166
+ resized: { width: number; height: number }
167
+ cropped: { width: number; height: number }
168
+ pngBytes: number
169
+ fromDataURL: { width: number; height: number }
170
+ }>('node', 'call', { fn: 'starter.nativeImage', args: [] })
171
+ log(
172
+ `nativeImage → ${r.original.width}×${r.original.height} · resize ${r.resized.width}×${r.resized.height} · crop ${r.cropped.width}×${r.cropped.height} · ${r.pngBytes} B · dataURL ${r.fromDataURL.width}×${r.fromDataURL.height}`,
173
+ 'ok',
174
+ )
175
+ }
176
+
177
+ // ── C5: menú contextual ──────────────────────────────────────────────────
178
+ async function showMenu(): Promise<void> {
179
+ await invoke('node', 'call', { fn: 'starter.menu', args: [] })
180
+ log('menu.popup → abierto (elige una opción)', 'ok')
181
+ }
182
+
183
+ // ── C6: tray ─────────────────────────────────────────────────────────────
184
+ async function toggleTray(): Promise<void> {
185
+ const r = await invoke<{ created: boolean; id: string }>('node', 'call', {
186
+ fn: 'starter.tray',
187
+ args: [],
188
+ })
189
+ log(r.created ? `tray creado (${r.id})` : `tray ya existe (${r.id})`, 'ok')
190
+ }
191
+
192
+ // ── C7: nativeTheme ──────────────────────────────────────────────────────
193
+ function applyTheme(dark: boolean): void {
194
+ document.documentElement.dataset.theme = dark ? 'dark' : 'light'
195
+ }
196
+
197
+ async function loadTheme(): Promise<void> {
198
+ try {
199
+ const info = await invoke<{ dark: boolean; source: string }>('node', 'call', {
200
+ fn: 'starter.themeGet',
201
+ args: [],
202
+ })
203
+ applyTheme(!!info.dark)
204
+ log(`theme.get → dark=${info.dark} source=${info.source}`, 'muted')
205
+ } catch (e) {
206
+ logError(e)
207
+ }
208
+ }
209
+
210
+ async function setTheme(source: string): Promise<void> {
211
+ const info = await invoke<{ dark: boolean; source: string }>('node', 'call', {
212
+ fn: 'starter.themeSet',
213
+ args: [source],
214
+ })
215
+ applyTheme(!!info.dark)
216
+ log(`theme.setSource('${source}') → dark=${info.dark}`, 'ok')
217
+ }
218
+
219
+ // ── C8: print / printToPDF ───────────────────────────────────────────────
220
+ async function exportPdf(): Promise<void> {
221
+ const r = await invoke<{ ok: boolean; bytes?: number; path?: string; error?: string }>(
222
+ 'node',
223
+ 'call',
224
+ { fn: 'starter.printPdf', args: [] },
225
+ )
226
+ if (r.ok) log(`printToPDF → ${r.bytes} bytes → ${r.path}`, 'ok')
227
+ else log(`printToPDF no disponible: ${r.error}`, 'err')
228
+ }
229
+
230
+ async function printDoc(): Promise<void> {
231
+ await invoke('node', 'call', { fn: 'starter.print', args: [] })
232
+ log('print → diálogo de impresión abierto', 'ok')
233
+ }
234
+
235
+ // ── C9: BrowserWindow completo ───────────────────────────────────────────
236
+ async function childWindow(): Promise<void> {
237
+ const r = await invoke<{ id: number }>('node', 'call', {
238
+ fn: 'starter.childWindow',
239
+ args: [],
240
+ })
241
+ log(`ventana secundaria → #${r.id} (parent + backgroundColor)`, 'ok')
242
+ }
243
+ async function toggleOnTop(): Promise<void> {
244
+ const r = await invoke<{ on: boolean }>('node', 'call', {
245
+ fn: 'starter.toggleOnTop',
246
+ args: [],
247
+ })
248
+ log(`setAlwaysOnTop(${r.on})`, 'ok')
249
+ }
250
+ async function progressBar(): Promise<void> {
251
+ await invoke('node', 'call', { fn: 'starter.progress', args: [] })
252
+ log('setProgressBar(0.5) → barra de tareas', 'ok')
253
+ }
254
+ async function ignoreMouse(): Promise<void> {
255
+ await invoke('node', 'call', { fn: 'starter.ignoreMouse', args: [] })
256
+ log('setIgnoreMouseEvents(true) 3 s…', 'muted')
257
+ }
258
+ async function listWindows(): Promise<void> {
259
+ const r = await invoke<{ focused: number | null; count: number; ids: number[] }>(
260
+ 'node',
261
+ 'call',
262
+ { fn: 'starter.windows', args: [] },
263
+ )
264
+ log(`windows → foco=#${r.focused} total=${r.count} ids=[${r.ids}]`, 'ok')
265
+ }
266
+
267
+ // ── C10: pantallas (multi-monitor) y energía ─────────────────────────────
268
+ interface DisplayRect {
269
+ x: number
270
+ y: number
271
+ width: number
272
+ height: number
273
+ }
274
+ interface Display {
275
+ id: number
276
+ primary: boolean
277
+ label: string
278
+ bounds: DisplayRect
279
+ size: { width: number; height: number }
280
+ workArea: DisplayRect
281
+ workAreaSize: { width: number; height: number }
282
+ scaleFactor: number
283
+ rotation: number
284
+ internal: boolean
285
+ }
286
+
287
+ function renderMonitorMap(displays: Display[], cursor: { x: number; y: number }): void {
288
+ const map = $('#mon-map')
289
+ map.textContent = ''
290
+ if (!displays.length) {
291
+ map.innerHTML = '<span class="monmap__hint">Sin monitores.</span>'
292
+ return
293
+ }
294
+ const minX = Math.min(...displays.map((d) => d.bounds.x))
295
+ const minY = Math.min(...displays.map((d) => d.bounds.y))
296
+ const maxX = Math.max(...displays.map((d) => d.bounds.x + d.bounds.width))
297
+ const maxY = Math.max(...displays.map((d) => d.bounds.y + d.bounds.height))
298
+ const w = maxX - minX || 1
299
+ const h = maxY - minY || 1
300
+ const pad = 6
301
+ const span = 100 - pad * 2
302
+ const sx = (px: number): number => pad + ((px - minX) / w) * span
303
+ const sy = (py: number): number => pad + ((py - minY) / h) * span
304
+
305
+ for (const d of displays) {
306
+ const cell = document.createElement('div')
307
+ cell.className = `monmap__cell${d.primary ? ' monmap__cell--primary' : ''}`
308
+ cell.style.left = `${sx(d.bounds.x)}%`
309
+ cell.style.top = `${sy(d.bounds.y)}%`
310
+ cell.style.width = `${(d.bounds.width / w) * span}%`
311
+ cell.style.height = `${(d.bounds.height / h) * span}%`
312
+ const rot = d.rotation ? ` · ${d.rotation}°` : ''
313
+ cell.innerHTML =
314
+ `<span class="monmap__id">#${d.id}${d.primary ? ' ★' : ''}</span>` +
315
+ `<span class="monmap__meta">${d.size.width}×${d.size.height} · ${d.scaleFactor}×${rot}</span>`
316
+ map.appendChild(cell)
317
+ }
318
+
319
+ const cur = document.createElement('span')
320
+ cur.className = 'monmap__cursor'
321
+ cur.style.left = `${sx(cursor.x)}%`
322
+ cur.style.top = `${sy(cursor.y)}%`
323
+ map.appendChild(cur)
324
+
325
+ $('#mon-count').textContent = `${displays.length} monitor(es)`
326
+ const p = displays.find((d) => d.primary)
327
+ $('#mon-primary').textContent = p ? `primario #${p.id}` : '—'
328
+ $('#mon-list').innerHTML = displays
329
+ .map(
330
+ (d) =>
331
+ `<div class="monlist__row"><code>#${d.id}</code> ${d.label ? `${d.label} · ` : ''}` +
332
+ `${d.bounds.x},${d.bounds.y} · ${d.bounds.width}×${d.bounds.height}` +
333
+ ` · work ${d.workArea.width}×${d.workArea.height} · ${d.scaleFactor}×` +
334
+ `${d.internal ? ' · interno' : ''}</div>`,
335
+ )
336
+ .join('')
337
+ }
338
+
339
+ async function showScreens(): Promise<void> {
340
+ const r = await invoke<{
341
+ displays: Display[]
342
+ primaryId: number | null
343
+ cursor: { x: number; y: number }
344
+ }>('node', 'call', { fn: 'starter.screens', args: [] })
345
+ renderMonitorMap(r.displays, r.cursor)
346
+ log(`screen → ${r.displays.length} monitor(es), primario #${r.primaryId}`, 'ok')
347
+ }
348
+
349
+ async function nearestDisplay(): Promise<void> {
350
+ const r = await invoke<{ cursor: { x: number; y: number }; display: Display | null }>(
351
+ 'node',
352
+ 'call',
353
+ { fn: 'starter.nearest', args: [] },
354
+ )
355
+ log(
356
+ `screen.nearest → cursor ${r.cursor.x},${r.cursor.y} ⇒ display #${r.display?.id ?? '—'}`,
357
+ 'ok',
358
+ )
359
+ }
360
+
361
+ let powerLogReady = false
362
+ function appendPower(ev: string): void {
363
+ const el = $('#power-log')
364
+ if (!powerLogReady) {
365
+ el.textContent = ''
366
+ powerLogReady = true
367
+ }
368
+ el.textContent += `[${new Date().toLocaleTimeString()}] ${ev}\n`
369
+ el.scrollTop = el.scrollHeight
370
+ }
371
+
372
+ async function powerOn(): Promise<void> {
373
+ const r = await invoke<{ onBattery: boolean; idle: number }>('node', 'call', {
374
+ fn: 'starter.powerOn',
375
+ args: [],
376
+ })
377
+ $('#power-status').textContent = r.onBattery ? 'batería' : 'AC'
378
+ appendPower(`powerMonitor ON · onBattery=${r.onBattery} idle=${r.idle}s`)
379
+ log(`powerMonitor → onBattery=${r.onBattery} idle=${r.idle}s`, 'ok')
380
+ }
381
+
382
+ async function toggleBlocker(): Promise<void> {
383
+ const r = await invoke<{ on: boolean; id?: number }>('node', 'call', {
384
+ fn: 'starter.blocker',
385
+ args: [],
386
+ })
387
+ $('#power-status').textContent = r.on ? 'pantalla activa' : 'bloqueador OFF'
388
+ log(`powerSaveBlocker → ${r.on ? `ON (id ${r.id})` : 'OFF'}`, 'ok')
389
+ }
390
+
391
+ async function idleInfo(): Promise<void> {
392
+ const r = await invoke<{ idleTime: number; state: string }>('node', 'call', {
393
+ fn: 'starter.idle',
394
+ args: [],
395
+ })
396
+ log(`powerMonitor.idle → ${r.idleTime}s · estado «${r.state}»`, 'ok')
397
+ }
398
+
399
+ async function askDialog(): Promise<void> {
400
+ const r = await invoke<{ response: number; checkboxChecked: boolean }>(
401
+ 'dialog',
402
+ 'showMessageBox',
403
+ {
404
+ type: 'question',
405
+ title: 'C2',
406
+ message: '¿Continuar?',
407
+ detail: 'showMessageBox con botones y checkbox.',
408
+ buttons: ['Sí', 'No', 'Cancelar'],
409
+ defaultId: 0,
410
+ cancelId: 2,
411
+ checkboxLabel: 'No volver a preguntar',
412
+ },
413
+ )
414
+ log(`showMessageBox → response=${r.response} checkbox=${r.checkboxChecked}`, 'ok')
415
+ }
416
+
102
417
  // ── Titlebar (builtin ow-window) ────────────────────────────────────────
418
+ /**
419
+ * Si el SO provee botones de ventana nativos (titleBarOverlay), ocultamos los
420
+ * del web y reservamos su hueco. La clase y las CSS vars las consume style.css.
421
+ */
422
+ function applyTitleBarOverlay(): void {
423
+ const ov = window.__owTitlebarOverlay
424
+ const root = document.documentElement
425
+ if (ov?.enabled) {
426
+ root.classList.add('ow-native-overlay')
427
+ root.style.setProperty('--ow-overlay-height', `${ov.height}px`)
428
+ root.style.setProperty('--ow-overlay-width', `${ov.width}px`)
429
+ } else {
430
+ // Sin botones nativos: mostramos los del web (.ow-web-controls).
431
+ root.classList.add('ow-web-controls')
432
+ }
433
+ }
434
+
103
435
  function wireTitlebar(): void {
104
436
  const id = window.__owWindowId
105
437
  $('#win-min').addEventListener('click', () => void invoke('ow-window', 'minimize', id))
@@ -113,6 +445,28 @@ function wireTitlebar(): void {
113
445
  // ── Arranque ────────────────────────────────────────────────────────────
114
446
  const actions: Record<string, () => void | Promise<void>> = {
115
447
  open: openFile,
448
+ multi: openMultiple,
449
+ save: saveAs,
450
+ capture: capturePage,
451
+ nimage: nativeImageTest,
452
+ menu: showMenu,
453
+ tray: toggleTray,
454
+ 'theme-system': () => setTheme('system'),
455
+ 'theme-light': () => setTheme('light'),
456
+ 'theme-dark': () => setTheme('dark'),
457
+ pdf: exportPdf,
458
+ print: printDoc,
459
+ child: childWindow,
460
+ ontop: toggleOnTop,
461
+ prog: progressBar,
462
+ ignore: ignoreMouse,
463
+ wins: listWindows,
464
+ screens: showScreens,
465
+ nearest: nearestDisplay,
466
+ 'power-on': powerOn,
467
+ blocker: toggleBlocker,
468
+ idle: idleInfo,
469
+ ask: askDialog,
116
470
  notify: notifyUser,
117
471
  copy: copyPath,
118
472
  reveal: revealPath,
@@ -123,15 +477,149 @@ const actions: Record<string, () => void | Promise<void>> = {
123
477
  },
124
478
  }
125
479
 
480
+ // ── Navegador embebido (webview nativa vía API `webview.*`) ─────────────
481
+ let wvId: number | null = null
482
+
483
+ /** Coloca la webview nativa sobre #wv-slot (y la oculta si el slot sale de vista). */
484
+ function syncBrowserBounds(): void {
485
+ if (wvId == null) return
486
+ const slot = document.getElementById('wv-slot')
487
+ if (!slot) return
488
+ const r = slot.getBoundingClientRect()
489
+ const onScreen =
490
+ r.bottom > 0 && r.top < window.innerHeight && r.width > 8 && r.height > 8
491
+ void invoke('webview', 'setBounds', wvId, {
492
+ x: Math.round(r.left),
493
+ y: Math.round(r.top),
494
+ width: Math.round(r.width),
495
+ height: Math.round(r.height),
496
+ })
497
+ void invoke('webview', 'setVisible', wvId, onScreen)
498
+ }
499
+
500
+ async function wireBrowser(): Promise<void> {
501
+ const urlInput = $('#wv-url') as HTMLInputElement
502
+ const status = $('#wv-status')
503
+ try {
504
+ const res = await invoke<{ id: number }>('webview', 'create', {
505
+ url: urlInput.value,
506
+ x: 0,
507
+ y: 0,
508
+ width: 100,
509
+ height: 100,
510
+ })
511
+ wvId = res.id
512
+ } catch (e) {
513
+ log(`webview no disponible: ${e}`, 'err')
514
+ status.textContent = 'no disponible'
515
+ return
516
+ }
517
+
518
+ const nav = (op: string, ...args: unknown[]): void => {
519
+ if (wvId != null) void invoke('webview', op, wvId, ...args)
520
+ }
521
+ const go = (): void => {
522
+ let u = urlInput.value.trim()
523
+ if (u && !/^[a-z][a-z0-9+.-]*:\/\//i.test(u)) u = `https://${u}`
524
+ nav('load', u)
525
+ }
526
+
527
+ $('#wv-back').addEventListener('click', () => nav('back'))
528
+ $('#wv-fwd').addEventListener('click', () => nav('forward'))
529
+ $('#wv-reload').addEventListener('click', () => nav('reload'))
530
+ $('#wv-go').addEventListener('click', go)
531
+ urlInput.addEventListener('keydown', (e) => {
532
+ if (e.key === 'Enter') go()
533
+ })
534
+
535
+ window.ow.on('webview.urlChanged', (p) => {
536
+ const d = p as { id: number; url: string }
537
+ if (d.id === wvId && d.url) urlInput.value = d.url
538
+ })
539
+ window.ow.on('webview.titleChanged', (p) => {
540
+ const d = p as { id: number; title: string }
541
+ if (d.id === wvId) status.textContent = d.title || '—'
542
+ })
543
+ window.ow.on('webview.loadChanged', (p) => {
544
+ const d = p as { id: number; state: string }
545
+ if (d.id === wvId) status.textContent = d.state
546
+ })
547
+ window.ow.on('webview.loadFailed', (p) => {
548
+ const d = p as { id: number; message: string }
549
+ if (d.id === wvId) log(`webview error: ${d.message}`, 'err')
550
+ })
551
+
552
+ const page = $('.page')
553
+ page.addEventListener('scroll', syncBrowserBounds, { passive: true } as AddEventListenerOptions)
554
+ window.addEventListener('resize', syncBrowserBounds)
555
+ requestAnimationFrame(syncBrowserBounds)
556
+ }
557
+
126
558
  function boot(): void {
559
+ applyTitleBarOverlay()
127
560
  wireTitlebar()
128
561
  renderEnvironment()
562
+ void renderApp()
563
+ void loadTheme()
564
+ void wireBrowser()
565
+
566
+ // Eventos de ciclo de vida de la app (C1): pueden llegar en cualquier momento.
567
+ window.ow.on('app.event', (name) => log(`app.event → ${name}`, 'ok'))
568
+ // Eventos de webContents (C3) reenviados por el main.
569
+ window.ow.on('webContents.event', (name) => log(`webContents → ${name}`, 'ok'))
570
+ // Clicks de menú (C5).
571
+ window.ow.on('menu.event', (name) => log(`menu → ${name}`, 'ok'))
572
+ // Eventos del tray (C6).
573
+ window.ow.on('tray.event', (b) => log(`tray → ${b}`, 'ok'))
574
+ // Cambios de tema (C7).
575
+ window.ow.on('theme.changed', (t) => {
576
+ const dark = (t as { dark?: boolean })?.dark
577
+ applyTheme(!!dark)
578
+ log(`theme.changed → dark=${dark}`, 'ok')
579
+ })
580
+
581
+ // C10 — pantallas: eventos nativos (requieren haber pulsado «Pantallas»).
582
+ window.ow.on('screen.added', (p) => {
583
+ log(`screen.added → #${(p as { display?: { id?: number } })?.display?.id}`, 'ok')
584
+ void showScreens()
585
+ })
586
+ window.ow.on('screen.removed', (p) => {
587
+ log(`screen.removed → #${(p as { display?: { id?: number } })?.display?.id}`, 'ok')
588
+ void showScreens()
589
+ })
590
+ window.ow.on('screen.changed', (p) => {
591
+ const d = p as { display?: { id?: number }; metrics?: string[] }
592
+ log(`screen.changed → #${d?.display?.id} [${(d?.metrics ?? []).join(', ')}]`, 'ok')
593
+ void showScreens()
594
+ })
595
+
596
+ // C10 — energía.
597
+ window.ow.on('power.suspend', () => appendPower('suspend'))
598
+ window.ow.on('power.resume', () => appendPower('resume'))
599
+ window.ow.on('power.ac', () => {
600
+ $('#power-status').textContent = 'AC'
601
+ appendPower('on-ac')
602
+ })
603
+ window.ow.on('power.battery', () => {
604
+ $('#power-status').textContent = 'batería'
605
+ appendPower('on-battery')
606
+ })
607
+ window.ow.on('power.shutdown', () => appendPower('shutdown'))
608
+ window.ow.on('power.lock', () => appendPower('lock-screen'))
609
+ window.ow.on('power.unlock', () => appendPower('unlock-screen'))
129
610
 
130
611
  for (const btn of document.querySelectorAll<HTMLElement>('[data-action]')) {
131
612
  const action = actions[btn.dataset.action ?? '']
132
613
  if (!action) continue
614
+ let busy = false
133
615
  btn.addEventListener('click', () => {
134
- Promise.resolve(action()).catch(logError)
616
+ if (busy) return
617
+ busy = true
618
+ Promise.resolve(action())
619
+ .catch(logError)
620
+ .finally(() => {
621
+ busy = false
622
+ })
135
623
  })
136
624
  }
137
625