@acertijosapps/electron-viewer 0.0.0-stage → 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,234 @@
1
+ :root {
2
+ --bg: #f3f3f1;
3
+ --surface: #ffffff;
4
+ --border: #d9d9d4;
5
+ --text: #1d1d1b;
6
+ --muted: #6b6b66;
7
+ --accent: #2f6f4f;
8
+ --accent-text: #ffffff;
9
+ --stage: #e6e6e2;
10
+ color-scheme: light dark;
11
+ }
12
+
13
+ @media (prefers-color-scheme: dark) {
14
+ :root {
15
+ --bg: #171716;
16
+ --surface: #222220;
17
+ --border: #3a3a37;
18
+ --text: #ececea;
19
+ --muted: #9a9a94;
20
+ --accent: #5fae86;
21
+ --accent-text: #0f1a14;
22
+ --stage: #0f0f0e;
23
+ }
24
+ }
25
+
26
+ * {
27
+ box-sizing: border-box;
28
+ }
29
+
30
+ [hidden] {
31
+ display: none !important;
32
+ }
33
+
34
+ html,
35
+ body {
36
+ height: 100%;
37
+ margin: 0;
38
+ }
39
+
40
+ body {
41
+ display: flex;
42
+ flex-direction: column;
43
+ background: var(--bg);
44
+ color: var(--text);
45
+ font: 13px/1.3 -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
46
+ }
47
+
48
+ .bar {
49
+ display: flex;
50
+ flex-wrap: wrap;
51
+ align-items: center;
52
+ gap: 8px;
53
+ padding: 8px 12px;
54
+ border-bottom: 1px solid var(--border);
55
+ background: var(--surface);
56
+ }
57
+
58
+ select,
59
+ button {
60
+ height: 30px;
61
+ border: 1px solid var(--border);
62
+ border-radius: 6px;
63
+ background: var(--surface);
64
+ color: var(--text);
65
+ font: inherit;
66
+ }
67
+
68
+ select {
69
+ min-width: 0;
70
+ max-width: 320px;
71
+ padding: 0 6px;
72
+ }
73
+
74
+ .controls {
75
+ justify-content: center;
76
+ gap: 12px;
77
+ border-top: 1px solid var(--border);
78
+ border-bottom: 0;
79
+ }
80
+
81
+ button.primary {
82
+ padding: 0 14px;
83
+ border-color: var(--accent);
84
+ background: var(--accent);
85
+ color: var(--accent-text);
86
+ cursor: pointer;
87
+ }
88
+
89
+ button:disabled {
90
+ opacity: 0.5;
91
+ cursor: default;
92
+ }
93
+
94
+ .group {
95
+ display: flex;
96
+ }
97
+
98
+ .group button {
99
+ min-width: 34px;
100
+ padding: 0 10px;
101
+ border-radius: 0;
102
+ cursor: pointer;
103
+ }
104
+
105
+ .group button + button {
106
+ margin-left: -1px;
107
+ }
108
+
109
+ .group button:first-child {
110
+ border-radius: 6px 0 0 6px;
111
+ }
112
+
113
+ .group button:last-child {
114
+ border-radius: 0 6px 6px 0;
115
+ }
116
+
117
+ .group button:hover {
118
+ background: var(--bg);
119
+ }
120
+
121
+ .group button[aria-pressed='true'] {
122
+ position: relative;
123
+ border-color: var(--accent);
124
+ background: var(--accent);
125
+ color: var(--accent-text);
126
+ }
127
+
128
+ .group svg {
129
+ display: block;
130
+ width: 16px;
131
+ height: 16px;
132
+ fill: none;
133
+ stroke: currentColor;
134
+ stroke-width: 2;
135
+ stroke-linecap: round;
136
+ stroke-linejoin: round;
137
+ }
138
+
139
+ #status {
140
+ min-width: 0;
141
+ margin-left: auto;
142
+ overflow: hidden;
143
+ color: var(--muted);
144
+ font-variant-numeric: tabular-nums;
145
+ text-overflow: ellipsis;
146
+ white-space: nowrap;
147
+ }
148
+
149
+ #stage {
150
+ position: relative;
151
+ flex: 1;
152
+ min-height: 0;
153
+ padding: 12px;
154
+ background: var(--stage);
155
+ }
156
+
157
+ #screen {
158
+ display: block;
159
+ width: 100%;
160
+ height: 100%;
161
+ object-fit: contain;
162
+ touch-action: none;
163
+ cursor: default;
164
+ }
165
+
166
+ #placeholder {
167
+ position: absolute;
168
+ inset: 0;
169
+ display: flex;
170
+ flex-direction: column;
171
+ align-items: center;
172
+ justify-content: center;
173
+ gap: 12px;
174
+ color: var(--muted);
175
+ }
176
+
177
+ #placeholder p {
178
+ margin: 0;
179
+ }
180
+
181
+ /* Recibe el teclado (y el texto de IME o dictado) mientras se usa la app; no se ve. */
182
+ #keys {
183
+ position: absolute;
184
+ top: 0;
185
+ left: 0;
186
+ width: 1px;
187
+ height: 1px;
188
+ padding: 0;
189
+ border: 0;
190
+ opacity: 0;
191
+ resize: none;
192
+ pointer-events: none;
193
+ }
194
+
195
+ #stage:focus-within #screen {
196
+ outline: 2px solid var(--accent);
197
+ outline-offset: -2px;
198
+ }
199
+
200
+ #screen.native {
201
+ cursor: default;
202
+ }
203
+
204
+ .group select {
205
+ border-radius: 6px;
206
+ cursor: pointer;
207
+ }
208
+
209
+ #placeholder form {
210
+ display: flex;
211
+ align-items: center;
212
+ gap: 8px;
213
+ }
214
+
215
+ #placeholder input {
216
+ width: 80px;
217
+ height: 30px;
218
+ padding: 0 6px;
219
+ border: 1px solid var(--border);
220
+ border-radius: 6px;
221
+ background: var(--surface);
222
+ color: var(--text);
223
+ font: inherit;
224
+ }
225
+
226
+ #placeholder form button {
227
+ padding: 0 12px;
228
+ cursor: pointer;
229
+ }
230
+
231
+ #placeholder code {
232
+ font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
233
+ font-size: 12px;
234
+ }
@@ -0,0 +1,435 @@
1
+ const $ = (s) => document.querySelector(s);
2
+ const canvas = $('#screen');
3
+ const keys = $('#keys');
4
+ const ctx = canvas.getContext('2d');
5
+ const windowSelect = $('#window');
6
+ const statusEl = $('#status');
7
+ const placeholder = $('#placeholder');
8
+ const placeholderText = $('#placeholder-text');
9
+ const launchButton = $('#launch');
10
+ const launchTop = $('#launch-top');
11
+ const attachForm = $('#attach');
12
+ const sizeSelect = $('#size');
13
+ const frameButton = $('#frame');
14
+ const controls = $('.controls');
15
+
16
+ const TARGETS_REFRESH_MS = 3000;
17
+ const DOUBLE_CLICK_MS = 500;
18
+ const DOUBLE_CLICK_SLOP_PX = 5;
19
+ const BUTTONS = ['left', 'middle', 'right'];
20
+
21
+ let ws = null;
22
+ let windowId = null;
23
+ let apps = [];
24
+ let project = null;
25
+ let meta = null;
26
+ let launching = false;
27
+ let framesThisSecond = 0;
28
+ let fps = 0;
29
+
30
+ function send(obj) {
31
+ if (ws?.readyState === WebSocket.OPEN) ws.send(JSON.stringify(obj));
32
+ }
33
+
34
+ async function api(method, route, body) {
35
+ const init = { method };
36
+ if (body) {
37
+ init.headers = { 'content-type': 'application/json' };
38
+ init.body = JSON.stringify(body);
39
+ }
40
+ const res = await fetch(`api/${route}`, init);
41
+ if (!res.ok) throw new Error((await res.json().catch(() => ({}))).error || `HTTP ${res.status}`);
42
+ return res;
43
+ }
44
+
45
+ // ---------- Video ----------
46
+
47
+ // Solo se decodifica el frame más reciente: si llegan más rápido de lo que se pintan, se saltan.
48
+ let pendingFrame = null;
49
+ let decoding = false;
50
+
51
+ async function drawLoop() {
52
+ decoding = true;
53
+ while (pendingFrame) {
54
+ const data = pendingFrame;
55
+ pendingFrame = null;
56
+ try {
57
+ const bitmap = await createImageBitmap(new Blob([data], { type: 'image/jpeg' }));
58
+ if (canvas.width !== bitmap.width || canvas.height !== bitmap.height) {
59
+ canvas.width = bitmap.width;
60
+ canvas.height = bitmap.height;
61
+ }
62
+ ctx.drawImage(bitmap, 0, 0);
63
+ bitmap.close();
64
+ framesThisSecond++;
65
+ placeholder.hidden = true;
66
+ } catch (err) {
67
+ console.warn('frame:', err);
68
+ }
69
+ }
70
+ decoding = false;
71
+ }
72
+
73
+ function onFrame(buf) {
74
+ pendingFrame = buf.subarray(1);
75
+ if (!decoding) drawLoop();
76
+ }
77
+
78
+ setInterval(() => {
79
+ fps = framesThisSecond;
80
+ framesThisSecond = 0;
81
+ renderStatus();
82
+ }, 1000);
83
+
84
+ // ---------- Estado ----------
85
+
86
+ // Un aviso (error, «guardando…») se queda unos segundos antes de volver a tamaño y fps.
87
+ const NOTE_MS = 6000;
88
+ let noteUntil = 0;
89
+
90
+ function renderStatus(text) {
91
+ if (typeof text === 'string') {
92
+ statusEl.textContent = text;
93
+ statusEl.title = text;
94
+ noteUntil = text ? performance.now() + NOTE_MS : 0;
95
+ return;
96
+ }
97
+ if (!meta || performance.now() < noteUntil) return;
98
+ statusEl.title = '';
99
+ statusEl.textContent = `${meta.width}×${meta.height}${meta.frame ? ' · frame' : ''} · ${fps} fps`;
100
+ }
101
+
102
+ function onJson(msg) {
103
+ if (msg.type === 'meta') {
104
+ meta = msg;
105
+ frameButton.setAttribute('aria-pressed', String(Boolean(msg.frame)));
106
+ frameButton.disabled = !msg.app?.main;
107
+ canvas.classList.toggle('native', Boolean(msg.frame));
108
+ sizeSelect.options[0].textContent = `Window size (${msg.width} × ${msg.height})`;
109
+ renderStatus();
110
+ } else if (msg.type === 'error') {
111
+ renderStatus(`Error: ${msg.message}`);
112
+ } else if (msg.type === 'closed') {
113
+ disconnect();
114
+ refreshTargets();
115
+ }
116
+ }
117
+
118
+ // ---------- Ventanas ----------
119
+
120
+ const allWindows = () => apps.flatMap((app) => app.windows.map((w) => ({ ...w, app })));
121
+ const selected = () => allWindows().find((w) => w.id === windowId);
122
+
123
+ function showMessage(text, { launch = false } = {}) {
124
+ placeholder.hidden = false;
125
+ placeholderText.textContent = text;
126
+ launchButton.hidden = !launch;
127
+ launchButton.disabled = launching;
128
+ attachForm.hidden = !launch && !!apps.length;
129
+ }
130
+
131
+ function setControlsEnabled(enabled) {
132
+ controls.querySelectorAll('button, select').forEach((b) => (b.disabled = !enabled));
133
+ if (enabled) frameButton.disabled = !selected()?.app.main;
134
+ }
135
+
136
+ function disconnect() {
137
+ const socket = ws;
138
+ ws = null;
139
+ socket?.close();
140
+ meta = null;
141
+ canvas.width = canvas.height = 0;
142
+ setControlsEnabled(false);
143
+ }
144
+
145
+ function connect(id) {
146
+ disconnect();
147
+ windowId = id;
148
+ showMessage('Connecting…');
149
+ setControlsEnabled(true);
150
+ const socket = new WebSocket(`ws://${location.host}/ws?window=${encodeURIComponent(id)}`);
151
+ socket.binaryType = 'arraybuffer';
152
+ socket.onmessage = (e) => (typeof e.data === 'string' ? onJson(JSON.parse(e.data)) : onFrame(new Uint8Array(e.data)));
153
+ socket.onclose = () => {
154
+ if (ws !== socket) return;
155
+ ws = null;
156
+ renderStatus('Disconnected; retrying…');
157
+ setTimeout(() => refreshTargets(true), 1500);
158
+ };
159
+ ws = socket;
160
+ }
161
+
162
+ function renderLaunch() {
163
+ const can = project?.launchable && !launching;
164
+ launchTop.hidden = !project?.launchable;
165
+ launchTop.disabled = !can;
166
+ launchTop.textContent = launching ? 'Launching…' : `Launch ${project?.name ?? 'app'}`;
167
+ launchTop.title = project ? `electron . in ${project.dir}` : '';
168
+ launchButton.textContent = launchTop.textContent;
169
+ }
170
+
171
+ async function refreshTargets(reconnect = false) {
172
+ try {
173
+ apps = await (await api('GET', 'windows')).json();
174
+ } catch {
175
+ renderStatus('The viewer is not responding');
176
+ return;
177
+ }
178
+ const windows = allWindows();
179
+ windowSelect.replaceChildren(
180
+ ...apps.map((app) => {
181
+ const group = document.createElement('optgroup');
182
+ group.label = `${app.name} · :${app.cdpPort}${app.main ? '' : ' · no main'}`;
183
+ group.append(...app.windows.map((w) => new Option(w.title || w.url || w.id, w.id)));
184
+ return group;
185
+ }),
186
+ );
187
+ windowSelect.hidden = !windows.length;
188
+ renderLaunch();
189
+ if (!windows.length) {
190
+ launchTop.hidden = true; // el placeholder ya tiene su botón
191
+ disconnect();
192
+ windowId = null;
193
+ renderStatus('');
194
+ if (launching) showMessage('Starting the app…');
195
+ else if (apps.length) showMessage(`${apps[0].name} has no windows open.`);
196
+ else showMessage('No Electron app found.', { launch: project?.launchable });
197
+ if (!project?.launchable && !apps.length) {
198
+ placeholderText.textContent = 'No Electron app found. Start your app with --remote-debugging-port=9222, or run the viewer from your project folder.';
199
+ }
200
+ return;
201
+ }
202
+ if (!selected()) {
203
+ const wanted = new URLSearchParams(location.search).get('window');
204
+ windowId = (windows.find((w) => w.id === wanted) || windows[0]).id;
205
+ }
206
+ windowSelect.value = windowId;
207
+ if (reconnect || !ws) connect(windowId);
208
+ }
209
+
210
+ windowSelect.onchange = () => {
211
+ history.replaceState(null, '', `?window=${encodeURIComponent(windowSelect.value)}`);
212
+ connect(windowSelect.value);
213
+ };
214
+
215
+ async function launch() {
216
+ launching = true;
217
+ renderLaunch();
218
+ showMessage(`Starting ${project.name}…`);
219
+ try {
220
+ const app = await (await api('POST', 'launch', {})).json();
221
+ launching = false;
222
+ windowId = app?.windows?.[0]?.id ?? null;
223
+ if (windowId) history.replaceState(null, '', `?window=${encodeURIComponent(windowId)}`);
224
+ await refreshTargets(true);
225
+ } catch (err) {
226
+ launching = false;
227
+ renderLaunch();
228
+ showMessage(`Could not start ${project.name}: ${err.message}`, { launch: true });
229
+ }
230
+ }
231
+
232
+ launchButton.onclick = launch;
233
+ launchTop.onclick = launch;
234
+
235
+ attachForm.onsubmit = async (e) => {
236
+ e.preventDefault();
237
+ try {
238
+ await api('POST', 'attach', { port: Number($('#attach-port').value) });
239
+ await refreshTargets(true);
240
+ } catch (err) {
241
+ placeholderText.textContent = err.message;
242
+ }
243
+ };
244
+
245
+ async function loadProject() {
246
+ try {
247
+ project = await (await api('GET', 'project')).json();
248
+ } catch {
249
+ project = null;
250
+ }
251
+ }
252
+
253
+ setInterval(() => refreshTargets(), TARGETS_REFRESH_MS);
254
+
255
+ // ---------- Controles ----------
256
+
257
+ async function control(route, body = {}) {
258
+ try {
259
+ return await (await api('POST', route, { window: windowId, ...body })).json();
260
+ } catch (err) {
261
+ renderStatus(`${route} failed: ${err.message}`);
262
+ return null;
263
+ }
264
+ }
265
+
266
+ $('#reload').onclick = () => control('reload');
267
+
268
+ $('#devtools').onclick = async () => {
269
+ const result = await control('devtools');
270
+ // Sin proceso main: se abre el frontend de DevTools que sirve el propio puerto CDP.
271
+ if (result?.url) window.open(result.url, '_blank');
272
+ };
273
+
274
+ sizeSelect.onchange = async () => {
275
+ const [width, height] = sizeSelect.value ? sizeSelect.value.split('x').map(Number) : [0, 0];
276
+ sizeSelect.value = '';
277
+ await control('resize', { width, height });
278
+ };
279
+
280
+ frameButton.onclick = () => send({ type: 'frame', on: !meta?.frame });
281
+
282
+ $('#screenshot').onclick = async () => {
283
+ if (!windowId) return;
284
+ renderStatus('Saving screenshot…');
285
+ try {
286
+ const res = await api('GET', `screenshot?window=${encodeURIComponent(windowId)}${meta?.frame ? '&frame=1' : ''}`);
287
+ const a = document.createElement('a');
288
+ a.href = URL.createObjectURL(await res.blob());
289
+ const name = (selected()?.app.name || 'electron').replace(/[^\w.-]+/g, '-');
290
+ a.download = `${name}-${new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19)}.png`;
291
+ a.click();
292
+ setTimeout(() => URL.revokeObjectURL(a.href), 1000);
293
+ renderStatus('');
294
+ renderStatus();
295
+ } catch (err) {
296
+ renderStatus(`Screenshot failed: ${err.message}`);
297
+ }
298
+ };
299
+
300
+ // ---------- Input ----------
301
+
302
+ // Posición normalizada (0–1) dentro del contenido de la ventana, descontando el letterbox de
303
+ // object-fit y, en modo marco, la barra de título y los bordes.
304
+ function toNorm(e) {
305
+ const r = canvas.getBoundingClientRect();
306
+ const ar = canvas.width / canvas.height;
307
+ let w = r.width;
308
+ let h = r.height;
309
+ let ox = 0;
310
+ let oy = 0;
311
+ if (r.width / r.height > ar) {
312
+ w = h * ar;
313
+ ox = (r.width - w) / 2;
314
+ } else {
315
+ h = w / ar;
316
+ oy = (r.height - h) / 2;
317
+ }
318
+ let x = (e.clientX - r.left - ox) / w;
319
+ let y = (e.clientY - r.top - oy) / h;
320
+ const c = meta?.frame && meta.content;
321
+ if (c) {
322
+ x = (x - c.x) / c.width;
323
+ y = (y - c.y) / c.height;
324
+ }
325
+ return { x, y };
326
+ }
327
+
328
+ const inside = (p) => p.x >= 0 && p.x <= 1 && p.y >= 0 && p.y <= 1;
329
+
330
+ function modifiersOf(e) {
331
+ return (e.altKey ? 1 : 0) | (e.ctrlKey ? 2 : 0) | (e.metaKey ? 4 : 0) | (e.shiftKey ? 8 : 0);
332
+ }
333
+
334
+ let lastDown = null;
335
+ let pressed = false;
336
+
337
+ canvas.addEventListener('pointerdown', (e) => {
338
+ if (!meta || !canvas.width) return;
339
+ keys.focus({ preventScroll: true });
340
+ const p = toNorm(e);
341
+ if (!inside(p)) return;
342
+ e.preventDefault();
343
+ canvas.setPointerCapture(e.pointerId);
344
+ pressed = true;
345
+ const button = BUTTONS[e.button] || 'left';
346
+ const now = performance.now();
347
+ const near = lastDown && Math.hypot(e.clientX - lastDown.cx, e.clientY - lastDown.cy) < DOUBLE_CLICK_SLOP_PX;
348
+ const clickCount = near && lastDown.button === button && now - lastDown.t < DOUBLE_CLICK_MS ? Math.min(3, lastDown.count + 1) : 1;
349
+ lastDown = { t: now, cx: e.clientX, cy: e.clientY, button, count: clickCount };
350
+ send({ type: 'mouse', action: 'down', ...p, button, buttons: e.buttons, clickCount, modifiers: modifiersOf(e) });
351
+ });
352
+
353
+ canvas.addEventListener('pointerup', (e) => {
354
+ if (!pressed) return;
355
+ pressed = false;
356
+ const p = toNorm(e);
357
+ send({ type: 'mouse', action: 'up', x: p.x, y: p.y, button: BUTTONS[e.button] || 'left', buttons: e.buttons, clickCount: lastDown?.count || 1, modifiers: modifiersOf(e) });
358
+ });
359
+
360
+ // Movimiento (hover y arrastre) a lo sumo una vez por frame de pantalla.
361
+ let moveEvent = null;
362
+ canvas.addEventListener('pointermove', (e) => {
363
+ if (!meta || !canvas.width) return;
364
+ if (!moveEvent) requestAnimationFrame(flushMove);
365
+ moveEvent = e;
366
+ });
367
+
368
+ function flushMove() {
369
+ const e = moveEvent;
370
+ moveEvent = null;
371
+ if (!e) return;
372
+ const p = toNorm(e);
373
+ if (!pressed && !inside(p)) return;
374
+ send({ type: 'mouse', action: 'move', ...p, buttons: e.buttons, modifiers: modifiersOf(e) });
375
+ }
376
+
377
+ canvas.addEventListener('pointercancel', () => (pressed = false));
378
+ canvas.addEventListener('contextmenu', (e) => e.preventDefault());
379
+
380
+ let wheel = null;
381
+ canvas.addEventListener(
382
+ 'wheel',
383
+ (e) => {
384
+ e.preventDefault();
385
+ if (!meta) return;
386
+ const p = toNorm(e);
387
+ if (!inside(p)) return;
388
+ const k = e.deltaMode === 1 ? 16 : e.deltaMode === 2 ? canvas.clientHeight : 1;
389
+ if (!wheel) {
390
+ wheel = { ...p, dx: 0, dy: 0, modifiers: modifiersOf(e) };
391
+ requestAnimationFrame(() => {
392
+ send({ type: 'wheel', ...wheel });
393
+ wheel = null;
394
+ });
395
+ }
396
+ wheel.dx += e.deltaX * k;
397
+ wheel.dy += e.deltaY * k;
398
+ },
399
+ { passive: false },
400
+ );
401
+
402
+ // Teclado: un textarea invisible tiene el foco mientras se usa la app. Cada tecla va a la app
403
+ // (también ⌘C/⌘V, que en macOS se convierten en comandos de edición, y Tab). El texto que entra sin
404
+ // keydown (IME, dictado, herramientas que insertan texto) se manda entero.
405
+ function onKey(e, action) {
406
+ if (!meta || e.isComposing || e.keyCode === 229) return;
407
+ e.preventDefault();
408
+ const printable = [...e.key].length === 1 && !e.ctrlKey && !e.metaKey;
409
+ send({
410
+ type: 'key',
411
+ action,
412
+ key: e.key,
413
+ code: e.code,
414
+ keyCode: e.keyCode,
415
+ text: action === 'down' && printable ? e.key : undefined,
416
+ repeat: e.repeat,
417
+ modifiers: modifiersOf(e),
418
+ });
419
+ }
420
+
421
+ keys.addEventListener('keydown', (e) => onKey(e, 'down'));
422
+ keys.addEventListener('keyup', (e) => onKey(e, 'up'));
423
+ keys.addEventListener('input', (e) => {
424
+ if (e.isComposing) return;
425
+ if (keys.value) send({ type: 'text', text: keys.value });
426
+ keys.value = '';
427
+ });
428
+ keys.addEventListener('compositionend', () => {
429
+ if (keys.value) send({ type: 'text', text: keys.value });
430
+ keys.value = '';
431
+ });
432
+
433
+ setControlsEnabled(false);
434
+ await loadProject();
435
+ refreshTargets();