primocode 7.15.0 → 7.17.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.
- package/bin/primocode.js +139 -51
- package/lib/act.js +10 -15
- package/lib/catalogo.js +1 -0
- package/lib/config.js +5 -2
- package/lib/desktop.js +4 -4
- package/lib/effort.js +51 -0
- package/lib/referencia.js +287 -0
- package/lib/tools.js +10 -0
- package/lib/ui.js +6 -6
- package/package.json +2 -2
- package/lib/streaming.js +0 -661
package/lib/streaming.js
DELETED
|
@@ -1,661 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* streaming.js — o painel "ao vivo": ver o agente mexendo no computador.
|
|
3
|
-
*
|
|
4
|
-
* O agente age no desktop do usuário (clica, digita, abre janela), mas rápido
|
|
5
|
-
* demais para acompanhar a olho nu — e às vezes numa janela que não está na
|
|
6
|
-
* frente. O /streaming liga um servidorzinho HTTP local que abre um site com
|
|
7
|
-
* a cara do PrimoCode: mascote + nome no cabeçalho e, abaixo, uma "janela"
|
|
8
|
-
* com ABAS — "Ao vivo" mostra a tela agora, e cada app/janela em que o agente
|
|
9
|
-
* mexeu vira uma aba própria, com os passos e as fotos daquele app. Dá para
|
|
10
|
-
* voltar e rever o que ele já fez, passo a passo.
|
|
11
|
-
*
|
|
12
|
-
* Zero dependências: http nativo, uma página HTML embutida que consulta
|
|
13
|
-
* /state a cada 700ms. Só escuta em 127.0.0.1 — nada disso sai da máquina.
|
|
14
|
-
*/
|
|
15
|
-
|
|
16
|
-
'use strict';
|
|
17
|
-
|
|
18
|
-
const http = require('http');
|
|
19
|
-
const fs = require('fs');
|
|
20
|
-
const path = require('path');
|
|
21
|
-
const os = require('os');
|
|
22
|
-
const { exec } = require('child_process');
|
|
23
|
-
const desktop = require('./desktop');
|
|
24
|
-
|
|
25
|
-
const DIR = path.join(os.homedir(), '.primocode', 'streaming');
|
|
26
|
-
const MAX_LOG = 300;
|
|
27
|
-
// Fotos guardadas em disco para o "voltar no tempo" das abas. Além disso a
|
|
28
|
-
// pasta viraria um álbum infinito — as mais velhas saem.
|
|
29
|
-
const MAX_FRAMES = 80;
|
|
30
|
-
const MAX_ABAS = 12;
|
|
31
|
-
// Uma foto por ação no máximo a cada 600ms: digitar letra por letra não pode
|
|
32
|
-
// virar uma rajada de screenshots.
|
|
33
|
-
const INTERVALO_FOTO_MS = 600;
|
|
34
|
-
|
|
35
|
-
const state = {
|
|
36
|
-
on: false,
|
|
37
|
-
server: null,
|
|
38
|
-
url: '',
|
|
39
|
-
frames: [], // [{ id, hora, acao, aba }] — id é o nome do arquivo
|
|
40
|
-
proximoFrame: 1,
|
|
41
|
-
abas: [], // [{ id, nome, passos: 0 }] — "Ao vivo" é virtual, não entra aqui
|
|
42
|
-
abaAtual: null,
|
|
43
|
-
log: [],
|
|
44
|
-
instrucoes: [], // o que o usuário pediu — a página autopreenche digitando
|
|
45
|
-
erroFoto: null,
|
|
46
|
-
ultimaFoto: 0,
|
|
47
|
-
passo: 0,
|
|
48
|
-
// ── vídeo ao vivo ──
|
|
49
|
-
liveSeq: 0, // sobe a cada frame novo; a página troca a imagem quando muda
|
|
50
|
-
liveAt: 0, // quando o último frame de vídeo foi capturado
|
|
51
|
-
viewerAt: 0, // último sinal de que alguém está com a página aberta
|
|
52
|
-
liveTimer: null,
|
|
53
|
-
liveBusy: false, // uma captura em voo por vez — sem isso cada poll da página
|
|
54
|
-
// abria OUTRA cadeia de captura e elas se multiplicavam
|
|
55
|
-
};
|
|
56
|
-
|
|
57
|
-
const LIVE = () => path.join(DIR, 'live.png');
|
|
58
|
-
const LIVE_TMP = () => path.join(DIR, 'live-tmp.png');
|
|
59
|
-
|
|
60
|
-
// ── O vídeo: um loop de captura enquanto alguém assiste ──────────────────
|
|
61
|
-
// "Vídeo" aqui é o jeito honesto de fazer sem dependência: fotografar a tela
|
|
62
|
-
// em sequência (2 a 3 quadros por segundo, conforme a máquina aguenta) e a
|
|
63
|
-
// página trocar a imagem no ritmo — como um VNC simples. O loop é assíncrono
|
|
64
|
-
// (exec, nunca execSync) para não roubar tempo do agente, escreve num tmp e
|
|
65
|
-
// renomeia (troca atômica: o navegador nunca lê um PNG pela metade), e SÓ
|
|
66
|
-
// roda enquanto a página deu sinal de vida há menos de 8s — painel fechado
|
|
67
|
-
// não custa nada.
|
|
68
|
-
function viuAgora() {
|
|
69
|
-
state.viewerAt = Date.now();
|
|
70
|
-
agendarVideo(0);
|
|
71
|
-
}
|
|
72
|
-
|
|
73
|
-
function agendarVideo(ms) {
|
|
74
|
-
if (!state.on || state.liveTimer || state.liveBusy) return;
|
|
75
|
-
state.liveTimer = setTimeout(cicloVideo, ms);
|
|
76
|
-
state.liveTimer.unref();
|
|
77
|
-
}
|
|
78
|
-
|
|
79
|
-
function cicloVideo() {
|
|
80
|
-
state.liveTimer = null;
|
|
81
|
-
if (!state.on || state.liveBusy) return;
|
|
82
|
-
if (Date.now() - state.viewerAt > 8000) return; // ninguém olhando — o próximo /state religa
|
|
83
|
-
|
|
84
|
-
const cmd = desktop.screenshotCommand(LIVE_TMP());
|
|
85
|
-
if (!cmd) { if (!state.erroFoto) state.erroFoto = 'screenshot indisponível nesta máquina'; return; }
|
|
86
|
-
|
|
87
|
-
const t0 = Date.now();
|
|
88
|
-
state.liveBusy = true;
|
|
89
|
-
exec(cmd, { timeout: 8000 }, (err) => {
|
|
90
|
-
state.liveBusy = false;
|
|
91
|
-
const dur = Date.now() - t0;
|
|
92
|
-
if (!err) {
|
|
93
|
-
try {
|
|
94
|
-
// Rename é atômico; no Windows ele pode falhar se o navegador
|
|
95
|
-
// estiver lendo o arquivo NAQUELE instante — aí copia por cima.
|
|
96
|
-
try { fs.renameSync(LIVE_TMP(), LIVE()); }
|
|
97
|
-
catch { fs.copyFileSync(LIVE_TMP(), LIVE()); try { fs.unlinkSync(LIVE_TMP()); } catch {} }
|
|
98
|
-
state.liveSeq++;
|
|
99
|
-
state.liveAt = Date.now();
|
|
100
|
-
state.erroFoto = null;
|
|
101
|
-
} catch {}
|
|
102
|
-
} else if (!state.erroFoto) {
|
|
103
|
-
state.erroFoto = corta(String(err.message || err), 120);
|
|
104
|
-
}
|
|
105
|
-
// Máquina rápida ≈ 3 quadros/s; máquina lenta desacelera sozinha.
|
|
106
|
-
agendarVideo(Math.max(280, Math.min(1200, dur)));
|
|
107
|
-
});
|
|
108
|
-
}
|
|
109
|
-
|
|
110
|
-
// Ações cujo efeito aparece NA TELA — são as que merecem uma foto nova.
|
|
111
|
-
const ACOES_NA_TELA = new Set([
|
|
112
|
-
'desktop_click', 'desktop_double_click', 'desktop_click_element',
|
|
113
|
-
'desktop_move', 'desktop_drag', 'desktop_type', 'desktop_key',
|
|
114
|
-
'desktop_scroll', 'desktop_open_app', 'desktop_focus', 'desktop_screenshot',
|
|
115
|
-
'browser_open', 'browser_click', 'browser_type', 'browser_key',
|
|
116
|
-
'browser_scroll',
|
|
117
|
-
]);
|
|
118
|
-
|
|
119
|
-
function isOn() { return state.on; }
|
|
120
|
-
function url() { return state.url; }
|
|
121
|
-
|
|
122
|
-
const corta = (s, n) => { const t = String(s ?? ''); return t.length > n ? t.slice(0, n - 1) + '…' : t; };
|
|
123
|
-
|
|
124
|
-
// A frase humana de cada ação — é o que aparece na linha do tempo.
|
|
125
|
-
function descrever(name, args = {}) {
|
|
126
|
-
switch (name) {
|
|
127
|
-
case 'desktop_click': return `clique em (${args.x}, ${args.y})`;
|
|
128
|
-
case 'desktop_double_click': return `clique duplo em (${args.x}, ${args.y})`;
|
|
129
|
-
case 'desktop_click_element': return `clique em “${corta(args.name, 40)}”${args.double ? ' (duplo)' : ''}`;
|
|
130
|
-
case 'desktop_move': return `mouse para (${args.x}, ${args.y})`;
|
|
131
|
-
case 'desktop_drag': return `arrastou (${args.from_x}, ${args.from_y}) → (${args.to_x}, ${args.to_y})`;
|
|
132
|
-
case 'desktop_type': return `digitou “${corta(args.text, 40)}”`;
|
|
133
|
-
case 'desktop_key': return `tecla ${args.key}`;
|
|
134
|
-
case 'desktop_scroll': return `rolou a tela (${args.dy ?? 300})`;
|
|
135
|
-
case 'desktop_open_app': return `abriu ${corta(args.app || args.name, 40)}`;
|
|
136
|
-
case 'desktop_focus': return `focou a janela “${corta(args.window || args.title, 40)}”`;
|
|
137
|
-
case 'desktop_windows': return 'listou as janelas abertas';
|
|
138
|
-
case 'desktop_elements': return `leu os elementos${args.window ? ` de “${corta(args.window, 30)}”` : ' da janela em foco'}`;
|
|
139
|
-
case 'desktop_screenshot': return 'tirou um print da tela';
|
|
140
|
-
case 'desktop_size': return 'mediu a tela';
|
|
141
|
-
case 'browser_open': return `abriu ${corta(args.url, 60)} no navegador`;
|
|
142
|
-
case 'browser_click': return `clicou em “${corta(args.text || args.selector || `${args.x},${args.y}`, 40)}” na página`;
|
|
143
|
-
case 'browser_type': return `digitou “${corta(args.text, 40)}” na página`;
|
|
144
|
-
case 'browser_key': return `tecla ${args.key} na página`;
|
|
145
|
-
case 'browser_scroll': return 'rolou a página';
|
|
146
|
-
case 'browser_read': return 'leu a página';
|
|
147
|
-
case 'browser_screenshot': return 'print da página';
|
|
148
|
-
case 'browser_wait': return `esperou ${corta(args.selector, 40)}`;
|
|
149
|
-
case 'browser_eval': return 'rodou JavaScript na página';
|
|
150
|
-
case 'write_file': return `criou/reescreveu ${corta(args.path, 50)}`;
|
|
151
|
-
case 'edit_file': return `editou ${corta(args.path, 50)}`;
|
|
152
|
-
case 'multi_edit': return `editou ${corta(args.path, 50)} (várias trocas)`;
|
|
153
|
-
case 'read_file': return `leu ${corta(args.path, 50)}`;
|
|
154
|
-
case 'run_shell': return `rodou: ${corta(args.command, 60)}`;
|
|
155
|
-
case 'run_in_new_terminal': return `terminal novo: ${corta(args.command, 50)}`;
|
|
156
|
-
case 'write_todos': return 'atualizou o plano';
|
|
157
|
-
default: return name.replace(/_/g, ' ');
|
|
158
|
-
}
|
|
159
|
-
}
|
|
160
|
-
|
|
161
|
-
// ── Abas: uma por app/janela em que o agente mexeu ────────────────────────
|
|
162
|
-
// Abrir um app, focar uma janela ou abrir um site começa (ou reencontra) a
|
|
163
|
-
// aba daquele contexto. As ações seguintes caem nela — é o que deixa o
|
|
164
|
-
// "o que a IA já fez" navegável depois, em vez de um rolo único.
|
|
165
|
-
|
|
166
|
-
function nomeDeAba(name, args = {}) {
|
|
167
|
-
if (name === 'desktop_open_app') return String(args.app || args.name || '').trim();
|
|
168
|
-
if (name === 'desktop_focus') return String(args.window || args.title || '').trim();
|
|
169
|
-
if (name === 'browser_open') {
|
|
170
|
-
try { return new URL(String(args.url)).hostname || 'navegador'; } catch { return 'navegador'; }
|
|
171
|
-
}
|
|
172
|
-
if (args && args.window) return String(args.window).trim();
|
|
173
|
-
return '';
|
|
174
|
-
}
|
|
175
|
-
|
|
176
|
-
function entrarNaAba(nome) {
|
|
177
|
-
const alvo = corta(nome, 24);
|
|
178
|
-
if (!alvo) return;
|
|
179
|
-
let aba = state.abas.find((a) => a.nome.toLowerCase() === alvo.toLowerCase());
|
|
180
|
-
if (!aba) {
|
|
181
|
-
aba = { id: state.abas.length + 1, nome: alvo, passos: 0 };
|
|
182
|
-
state.abas.push(aba);
|
|
183
|
-
if (state.abas.length > MAX_ABAS) state.abas.shift();
|
|
184
|
-
}
|
|
185
|
-
state.abaAtual = aba;
|
|
186
|
-
}
|
|
187
|
-
|
|
188
|
-
/**
|
|
189
|
-
* A instrução que o usuário acabou de dar. A página autopreenche na barra de
|
|
190
|
-
* comando, letra por letra, e marca o começo dela na linha do tempo.
|
|
191
|
-
*/
|
|
192
|
-
function instrucao(texto) {
|
|
193
|
-
if (!state.on) return;
|
|
194
|
-
const t = String(texto || '').trim();
|
|
195
|
-
if (!t) return;
|
|
196
|
-
state.instrucoes.push({ n: state.instrucoes.length + 1, hora: new Date().toTimeString().slice(0, 8), texto: corta(t, 300) });
|
|
197
|
-
if (state.instrucoes.length > 40) state.instrucoes.splice(0, state.instrucoes.length - 40);
|
|
198
|
-
|
|
199
|
-
state.passo++;
|
|
200
|
-
state.log.push({ n: state.passo, hora: new Date().toTimeString().slice(0, 8), tipo: 'instrucao', acao: corta(t, 160), ok: true, aba: null });
|
|
201
|
-
if (state.log.length > MAX_LOG) state.log.splice(0, state.log.length - MAX_LOG);
|
|
202
|
-
}
|
|
203
|
-
|
|
204
|
-
/**
|
|
205
|
-
* Chamada pelo loop do agente depois de CADA tool. Quando o streaming está
|
|
206
|
-
* desligado é um no-op — o caminho normal não paga nada por isto.
|
|
207
|
-
*/
|
|
208
|
-
function step(name, args, result) {
|
|
209
|
-
if (!state.on) return;
|
|
210
|
-
|
|
211
|
-
const troca = nomeDeAba(name, args || {});
|
|
212
|
-
if (troca) entrarNaAba(troca);
|
|
213
|
-
if (!state.abaAtual && ACOES_NA_TELA.has(name)) entrarNaAba('Tela');
|
|
214
|
-
|
|
215
|
-
state.passo++;
|
|
216
|
-
if (state.abaAtual) state.abaAtual.passos++;
|
|
217
|
-
|
|
218
|
-
const entrada = {
|
|
219
|
-
n: state.passo,
|
|
220
|
-
hora: new Date().toTimeString().slice(0, 8),
|
|
221
|
-
acao: descrever(name, args || {}),
|
|
222
|
-
ok: !(result && result.ok === false),
|
|
223
|
-
erro: result && result.ok === false ? corta(result.error, 120) : undefined,
|
|
224
|
-
aba: state.abaAtual ? state.abaAtual.id : null,
|
|
225
|
-
};
|
|
226
|
-
|
|
227
|
-
if (ACOES_NA_TELA.has(name)) {
|
|
228
|
-
const frame = capturar(entrada);
|
|
229
|
-
if (frame) entrada.frame = frame;
|
|
230
|
-
}
|
|
231
|
-
|
|
232
|
-
state.log.push(entrada);
|
|
233
|
-
if (state.log.length > MAX_LOG) state.log.splice(0, state.log.length - MAX_LOG);
|
|
234
|
-
}
|
|
235
|
-
|
|
236
|
-
function capturar(entrada) {
|
|
237
|
-
const agora = Date.now();
|
|
238
|
-
if (agora - state.ultimaFoto < INTERVALO_FOTO_MS) return null;
|
|
239
|
-
state.ultimaFoto = agora;
|
|
240
|
-
|
|
241
|
-
const id = state.proximoFrame;
|
|
242
|
-
const arquivo = path.join(DIR, `frame-${id}.png`);
|
|
243
|
-
|
|
244
|
-
// Com o vídeo rodando, o quadro mais novo já está no disco — copiar é
|
|
245
|
-
// grátis e não fotografa duas vezes a mesma tela.
|
|
246
|
-
if (agora - state.liveAt < 900) {
|
|
247
|
-
try { fs.copyFileSync(LIVE(), arquivo); } catch { return null; }
|
|
248
|
-
state.proximoFrame++;
|
|
249
|
-
state.frames.push({ id, hora: entrada.hora, acao: entrada.acao, aba: entrada.aba });
|
|
250
|
-
while (state.frames.length > MAX_FRAMES) {
|
|
251
|
-
const velho = state.frames.shift();
|
|
252
|
-
try { fs.unlinkSync(path.join(DIR, `frame-${velho.id}.png`)); } catch {}
|
|
253
|
-
}
|
|
254
|
-
return id;
|
|
255
|
-
}
|
|
256
|
-
|
|
257
|
-
const r = desktop.screenshot(arquivo);
|
|
258
|
-
if (!r.ok) { if (!state.erroFoto) state.erroFoto = r.error; return null; }
|
|
259
|
-
|
|
260
|
-
state.erroFoto = null;
|
|
261
|
-
state.proximoFrame++;
|
|
262
|
-
state.frames.push({ id, hora: entrada.hora, acao: entrada.acao, aba: entrada.aba });
|
|
263
|
-
while (state.frames.length > MAX_FRAMES) {
|
|
264
|
-
const velho = state.frames.shift();
|
|
265
|
-
try { fs.unlinkSync(path.join(DIR, `frame-${velho.id}.png`)); } catch {}
|
|
266
|
-
}
|
|
267
|
-
return id;
|
|
268
|
-
}
|
|
269
|
-
|
|
270
|
-
// ── Página ────────────────────────────────────────────────────────────────
|
|
271
|
-
// O desenho combinado: mascote + PrimoCode no cabeçalho; abaixo, uma "janela"
|
|
272
|
-
// de borda branca com as três bolinhas e as ABAS; dentro, a tela que a IA
|
|
273
|
-
// está usando (ou usou, nas abas antigas), com os passos daquela aba embaixo.
|
|
274
|
-
|
|
275
|
-
// O mascote do CLI (▐▛███▜▌ …) redesenhado em SVG, célula a célula, na mesma
|
|
276
|
-
// paleta: bloco cheio na cor viva, contorno na apagada.
|
|
277
|
-
// Olhos e pés têm classe própria: a página anima (piscada e passinhos) por CSS.
|
|
278
|
-
const MASCOTE_SVG = `<svg viewBox="0 0 9 3.6" aria-hidden="true">
|
|
279
|
-
<g fill="#0891b2"><rect x="1" y="0" width="1" height="1"/><rect x="7" y="0" width="1" height="1"/><rect x="0" y="1" width="1" height="1" opacity=".7"/><rect x="8" y="1" width="1" height="1" opacity=".7"/><rect x="1" y="1" width="1" height="1"/><rect x="7" y="1" width="1" height="1"/></g>
|
|
280
|
-
<g fill="#22d3ee"><rect x="2" y="0" width="5" height="1" rx=".12"/><rect x="2" y="1" width="5" height="1"/><rect class="pe pe1" x="1.9" y="2.2" width=".9" height=".5" rx=".1"/><rect class="pe pe2" x="3.4" y="2.2" width=".9" height=".5" rx=".1"/><rect class="pe pe1" x="4.9" y="2.2" width=".9" height=".5" rx=".1"/><rect class="pe pe2" x="6.4" y="2.2" width=".9" height=".5" rx=".1"/></g>
|
|
281
|
-
<g fill="#0b1220"><rect class="olho" x="3" y=".35" width=".8" height=".5" rx=".1"/><rect class="olho" x="5.2" y=".35" width=".8" height=".5" rx=".1"/><rect x="3.9" y="1.25" width="1.2" height=".28" rx=".14"/></g>
|
|
282
|
-
</svg>`;
|
|
283
|
-
|
|
284
|
-
// A tela do painel, na identidade do app — a MESMA paleta do design system do
|
|
285
|
-
// CLI (lib/ui.js): primária cyan #22d3ee, neutros slate, rose para falha. A
|
|
286
|
-
// estrutura é a do desenho do dono: mascote inclinado no canto, "Primocode"
|
|
287
|
-
// no alto, a janela com as bolinhas e as abas separadas por "|", a tela
|
|
288
|
-
// embaixo. Dentro da tela roda o VÍDEO ao vivo (o loop de captura acima); as
|
|
289
|
-
// abas em cima são tudo que o agente abriu — clicáveis, cada uma com seus
|
|
290
|
-
// quadros e passos; e a barra de comando autopreenche a instrução digitando.
|
|
291
|
-
const FAVICON = 'data:image/svg+xml,' + encodeURIComponent(
|
|
292
|
-
`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 9 3.6">${MASCOTE_SVG.replace(/^<svg[^>]*>|<\/svg>$/g, '')}</svg>`,
|
|
293
|
-
);
|
|
294
|
-
|
|
295
|
-
const PAGINA = `<!doctype html>
|
|
296
|
-
<html lang="pt-BR"><head><meta charset="utf-8">
|
|
297
|
-
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
298
|
-
<title>Primocode · Ao vivo</title>
|
|
299
|
-
<link rel="icon" href="${FAVICON}">
|
|
300
|
-
<style>
|
|
301
|
-
* { box-sizing: border-box; margin: 0; }
|
|
302
|
-
::selection { background: rgba(34,211,238,.28); }
|
|
303
|
-
body {
|
|
304
|
-
background: radial-gradient(1100px 480px at 50% -120px, #12263f 0%, #0d1729 46%, #0b1220 100%) fixed #0b1220;
|
|
305
|
-
color: #e2e8f0; font-family: 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
|
|
306
|
-
min-height: 100vh; display: flex; flex-direction: column; align-items: center; padding: 0 18px 34px;
|
|
307
|
-
}
|
|
308
|
-
|
|
309
|
-
/* ── Cabeçalho: mascote inclinado + nome, como no desenho ── */
|
|
310
|
-
#topo { position: relative; width: min(1120px, 100%); padding: 44px 0 26px; text-align: center; }
|
|
311
|
-
#topo .mascote { position: absolute; left: 4px; top: 24px; width: 104px; height: 44px; filter: drop-shadow(0 8px 20px rgba(34,211,238,.4)); animation: flutua 2.8s ease-in-out infinite; }
|
|
312
|
-
@keyframes flutua { 0%, 100% { transform: translateY(0) } 50% { transform: translateY(6px) } }
|
|
313
|
-
#topo .mascote svg { width: 100%; height: 100%; display: block; overflow: visible; }
|
|
314
|
-
/* vivo: pisca os olhos de vez em quando e dá passinhos com os pés */
|
|
315
|
-
.mascote .olho { transform-box: fill-box; transform-origin: center; animation: piscaOlho 3.6s infinite; }
|
|
316
|
-
@keyframes piscaOlho { 0%, 89%, 100% { transform: scaleY(1) } 93%, 95% { transform: scaleY(.12) } }
|
|
317
|
-
.mascote .pe { transform-box: fill-box; transform-origin: center; }
|
|
318
|
-
.mascote .pe1 { animation: passinho 1.4s ease-in-out infinite; }
|
|
319
|
-
.mascote .pe2 { animation: passinho 1.4s ease-in-out infinite; animation-delay: .7s; }
|
|
320
|
-
@keyframes passinho { 0%, 100% { transform: translateY(0) } 50% { transform: translateY(-.14px) } }
|
|
321
|
-
#topo h1 { font-size: clamp(34px, 5.6vw, 54px); font-weight: 650; letter-spacing: .4px; color: #f1f5f9; text-shadow: 0 4px 28px rgba(34,211,238,.22); }
|
|
322
|
-
#topo h1 span { color: #22d3ee; }
|
|
323
|
-
#topo .vivo { position: absolute; right: 2px; top: 48px; display: inline-flex; align-items: center; gap: 8px; color: #94a3b8; font-size: 12.5px; background: rgba(15,23,42,.85); border: 1px solid #1e293b; border-radius: 999px; padding: 6px 14px; }
|
|
324
|
-
.pulso { width: 8px; height: 8px; border-radius: 50%; background: #22d3ee; box-shadow: 0 0 10px #22d3ee99; animation: pulsa 1.4s infinite; }
|
|
325
|
-
@keyframes pulsa { 50% { opacity: .3 } }
|
|
326
|
-
|
|
327
|
-
/* ── A janela ── */
|
|
328
|
-
#janela {
|
|
329
|
-
width: min(1120px, 100%); background: #000; border: 1px solid #24344d; border-radius: 22px;
|
|
330
|
-
overflow: hidden; display: flex; flex-direction: column;
|
|
331
|
-
box-shadow: 0 30px 80px rgba(2,6,23,.75), 0 0 60px rgba(34,211,238,.07), inset 0 1px 0 rgba(226,232,240,.07);
|
|
332
|
-
}
|
|
333
|
-
#barra { position: relative; display: flex; align-items: center; background: linear-gradient(#152238, #101a2c); padding: 13px 22px; min-height: 54px; border-bottom: 1px solid #0a1120; }
|
|
334
|
-
#barra .b { flex: none; width: 13px; height: 13px; border-radius: 50%; margin-right: 9px; box-shadow: inset 0 -1.5px 3px rgba(0,0,0,.35); }
|
|
335
|
-
#barra .b.r { background: #ff5f57; } #barra .b.y { background: #febc2e; } #barra .b.g { background: #28c840; }
|
|
336
|
-
|
|
337
|
-
/* Abas: tudo que ele abriu, clicável, separado por | como no desenho */
|
|
338
|
-
#abas { position: absolute; left: 50%; transform: translateX(-50%); max-width: 62%; display: flex; align-items: center; gap: 11px; overflow-x: auto; white-space: nowrap; scrollbar-width: none; font-size: clamp(14px, 1.9vw, 18px); font-weight: 550; }
|
|
339
|
-
#abas::-webkit-scrollbar { display: none; }
|
|
340
|
-
.aba { position: relative; border: 0; background: none; cursor: pointer; font: inherit; color: #64748b; padding: 4px 3px 7px; transition: color .18s ease; }
|
|
341
|
-
.aba:hover { color: #cbd5e1; }
|
|
342
|
-
.aba.ativa { color: #22d3ee; text-shadow: 0 0 18px rgba(34,211,238,.5); }
|
|
343
|
-
.aba.ativa::after { content: ''; position: absolute; left: 15%; right: 15%; bottom: 0; height: 2px; border-radius: 2px; background: #22d3ee; box-shadow: 0 0 10px #22d3ee; }
|
|
344
|
-
.aba .qtd { color: #475569; font-size: .68em; vertical-align: super; margin-left: 4px; font-weight: 600; }
|
|
345
|
-
.sep { color: #334155; font-weight: 300; }
|
|
346
|
-
|
|
347
|
-
/* ── Barra de comando: a instrução autopreenchendo ── */
|
|
348
|
-
#comando { display: flex; align-items: center; gap: 11px; background: #0a1120; border-bottom: 1px solid #060b16; padding: 11px 22px; font-family: ui-monospace, 'Cascadia Code', Consolas, monospace; font-size: 14px; min-height: 44px; }
|
|
349
|
-
#comando .chevron { color: #22d3ee; font-weight: 700; flex: none; text-shadow: 0 0 10px rgba(34,211,238,.6); }
|
|
350
|
-
#comando .texto { color: #e2e8f0; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
|
351
|
-
#comando .texto.vazio { color: #475569; font-style: italic; }
|
|
352
|
-
#comando .cursor { flex: none; width: 8px; height: 16px; background: #22d3ee; margin-left: 2px; animation: pisca 1s steps(1) infinite; box-shadow: 0 0 8px rgba(34,211,238,.7); }
|
|
353
|
-
@keyframes pisca { 50% { opacity: 0 } }
|
|
354
|
-
#comando .hora { margin-left: auto; color: #475569; font-size: 11.5px; flex: none; }
|
|
355
|
-
|
|
356
|
-
/* ── A tela: o vídeo ao vivo ── */
|
|
357
|
-
#tela { position: relative; min-height: 440px; display: flex; align-items: center; justify-content: center; background: #000; }
|
|
358
|
-
#tela img { width: 100%; max-height: 60vh; object-fit: contain; display: none; }
|
|
359
|
-
#aviso { text-align: center; padding: 84px 24px; }
|
|
360
|
-
#aviso .titulo { font-size: clamp(19px, 3vw, 28px); font-weight: 550; color: #f1f5f9; }
|
|
361
|
-
#aviso .sub { color: #64748b; font-size: 14px; margin-top: 13px; line-height: 1.7; }
|
|
362
|
-
#badge { position: absolute; top: 14px; right: 16px; display: none; align-items: center; gap: 7px; background: rgba(8,12,22,.78); backdrop-filter: blur(6px); border: 1px solid #1e293b; color: #f1f5f9; font-size: 11.5px; font-weight: 700; letter-spacing: 1.2px; padding: 6px 12px; border-radius: 999px; }
|
|
363
|
-
#badge .rec { width: 8px; height: 8px; border-radius: 50%; background: #fb7185; box-shadow: 0 0 9px #fb7185; animation: pulsa 1.1s infinite; }
|
|
364
|
-
#carimbo { position: absolute; left: 16px; bottom: 13px; background: rgba(8,12,22,.78); backdrop-filter: blur(6px); border: 1px solid #1e293b; color: #94a3b8; font-size: 12px; padding: 5px 13px; border-radius: 999px; display: none; max-width: 82%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
365
|
-
#carimbo b { color: #22d3ee; font-weight: 600; }
|
|
366
|
-
|
|
367
|
-
/* ── Linha do tempo ── */
|
|
368
|
-
#historico { width: min(1120px, 100%); padding-top: 18px; }
|
|
369
|
-
#filme { display: flex; gap: 9px; padding: 4px 2px 13px; overflow-x: auto; scrollbar-width: thin; scrollbar-color: #1e293b transparent; }
|
|
370
|
-
.foto { flex: none; border: 1.5px solid #1e293b; border-radius: 9px; background: #000; cursor: pointer; padding: 0; width: 100px; height: 60px; overflow: hidden; opacity: .6; transition: transform .16s ease, opacity .16s ease, border-color .16s ease, box-shadow .16s ease; }
|
|
371
|
-
.foto:hover { opacity: 1; transform: translateY(-2px); border-color: #475569; }
|
|
372
|
-
.foto img { width: 100%; height: 100%; object-fit: cover; display: block; }
|
|
373
|
-
.foto.ativa { border-color: #22d3ee; opacity: 1; box-shadow: 0 0 0 2px rgba(34,211,238,.3), 0 6px 18px rgba(2,6,23,.6); }
|
|
374
|
-
|
|
375
|
-
#cartao { background: #0f172a; border: 1px solid #1e293b; border-radius: 16px; overflow: hidden; }
|
|
376
|
-
#cartao header { display: flex; align-items: center; justify-content: space-between; padding: 11px 17px; border-bottom: 1px solid #16223a; color: #94a3b8; font-size: 13px; font-weight: 600; letter-spacing: .3px; }
|
|
377
|
-
#cartao header span b { color: #22d3ee; }
|
|
378
|
-
#passos { max-height: 236px; overflow-y: auto; padding: 5px 0 9px; scrollbar-width: thin; scrollbar-color: #1e293b transparent; }
|
|
379
|
-
#passos::-webkit-scrollbar { width: 8px; } #passos::-webkit-scrollbar-thumb { background: #1e293b; border-radius: 4px; }
|
|
380
|
-
.item { padding: 5px 17px; display: flex; gap: 10px; font-size: 13.5px; align-items: baseline; transition: background .12s ease; }
|
|
381
|
-
.item:hover { background: #131f35; }
|
|
382
|
-
.item .n { color: #475569; min-width: 3ch; text-align: right; font-variant-numeric: tabular-nums; }
|
|
383
|
-
.item .ok { color: #22d3ee; } .item .falha { color: #fb7185; }
|
|
384
|
-
.item .txt { flex: 1; word-break: break-word; color: #cbd5e1; }
|
|
385
|
-
.item .txt .erro { color: #fb7185; font-size: 12px; display: block; margin-top: 1px; }
|
|
386
|
-
.item .hora { color: #475569; font-size: 11px; font-variant-numeric: tabular-nums; }
|
|
387
|
-
.item.temfoto { cursor: pointer; } .item.temfoto:hover .txt { color: #f1f5f9; }
|
|
388
|
-
.item.pedido { background: #0c1526; border-top: 1px solid #16223a; border-bottom: 1px solid #16223a; margin: 6px 0; padding: 8px 17px; }
|
|
389
|
-
.item.pedido .txt { color: #f1f5f9; font-weight: 600; }
|
|
390
|
-
.item.pedido .chev { color: #22d3ee; font-weight: 800; }
|
|
391
|
-
|
|
392
|
-
footer { color: #475569; font-size: 12px; padding-top: 17px; }
|
|
393
|
-
footer b { color: #64748b; font-weight: 600; }
|
|
394
|
-
</style></head>
|
|
395
|
-
<body>
|
|
396
|
-
<div id="topo">
|
|
397
|
-
<div class="mascote">${MASCOTE_SVG}</div>
|
|
398
|
-
<h1>Primo<span>code</span></h1>
|
|
399
|
-
<span class="vivo"><span class="pulso"></span><span id="st">conectando…</span></span>
|
|
400
|
-
</div>
|
|
401
|
-
|
|
402
|
-
<div id="janela">
|
|
403
|
-
<div id="barra">
|
|
404
|
-
<span class="b r"></span><span class="b y"></span><span class="b g"></span>
|
|
405
|
-
<nav id="abas"></nav>
|
|
406
|
-
</div>
|
|
407
|
-
<div id="comando">
|
|
408
|
-
<span class="chevron">❯</span>
|
|
409
|
-
<span class="texto vazio" id="cmdtexto">aguardando a sua instrução no terminal…</span>
|
|
410
|
-
<span class="cursor"></span>
|
|
411
|
-
<span class="hora" id="cmdhora"></span>
|
|
412
|
-
</div>
|
|
413
|
-
<section id="tela">
|
|
414
|
-
<img id="frame" alt="tela do computador, ao vivo">
|
|
415
|
-
<div id="aviso">
|
|
416
|
-
<div class="titulo">A tela do computador aparece aqui, em vídeo</div>
|
|
417
|
-
<div class="sub">Dê uma instrução no terminal do PrimoCode e assista ele trabalhando, ao vivo.<br>Cada app que ele abrir vira uma aba lá em cima — clique nelas para rever o que já foi feito.</div>
|
|
418
|
-
</div>
|
|
419
|
-
<div id="badge"><span class="rec"></span>AO VIVO</div>
|
|
420
|
-
<div id="carimbo"></div>
|
|
421
|
-
</section>
|
|
422
|
-
</div>
|
|
423
|
-
|
|
424
|
-
<div id="historico">
|
|
425
|
-
<div id="filme"></div>
|
|
426
|
-
<div id="cartao">
|
|
427
|
-
<header><span>Linha do tempo · <b id="abanome">Ao vivo</b></span><span id="resumo"></span></header>
|
|
428
|
-
<div id="passos"></div>
|
|
429
|
-
</div>
|
|
430
|
-
</div>
|
|
431
|
-
<footer>só nesta máquina <b>(127.0.0.1)</b> · <b>/streaming</b> desliga</footer>
|
|
432
|
-
|
|
433
|
-
<script>
|
|
434
|
-
const VIVO = 0; // id da aba "Ao vivo"
|
|
435
|
-
let dados = { abas: [], frames: [], log: [], instrucoes: [], liveSeq: 0 };
|
|
436
|
-
let abaSel = VIVO; // aba escolhida pelo usuário
|
|
437
|
-
let fotoSel = null; // frame fixado (null = vídeo/mais recente)
|
|
438
|
-
let ultimoEstado = '';
|
|
439
|
-
|
|
440
|
-
function esc(s) { return String(s ?? '').replace(/[&<>"]/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"' }[c])); }
|
|
441
|
-
|
|
442
|
-
// ── o vídeo: busca o quadro direto, desde o primeiro segundo ──
|
|
443
|
-
// Não espera o /state confirmar nada: enquanto não há seq, tenta com
|
|
444
|
-
// timestamp (um 404 só significa "primeiro quadro ainda não saiu" — tenta de
|
|
445
|
-
// novo). Assim a tela aparece assim que o painel abre, sem depender de o
|
|
446
|
-
// agente ter feito qualquer coisa.
|
|
447
|
-
let quadroNaTela = '';
|
|
448
|
-
let buscandoQuadro = false;
|
|
449
|
-
function modoVideo() { return abaSel === VIVO && fotoSel == null; }
|
|
450
|
-
function videoTick() {
|
|
451
|
-
if (modoVideo() && !buscandoQuadro) {
|
|
452
|
-
const marca = dados.liveSeq > 0 ? 's' + dados.liveSeq : 't' + Date.now();
|
|
453
|
-
if (marca !== quadroNaTela) {
|
|
454
|
-
buscandoQuadro = true;
|
|
455
|
-
const pre = new Image(); // pré-carrega fora da tela:
|
|
456
|
-
pre.onload = () => { // o quadro só entra inteiro
|
|
457
|
-
buscandoQuadro = false;
|
|
458
|
-
if (!modoVideo()) return;
|
|
459
|
-
const img = document.getElementById('frame');
|
|
460
|
-
img.src = pre.src; img.style.display = 'block';
|
|
461
|
-
document.getElementById('aviso').style.display = 'none';
|
|
462
|
-
document.getElementById('badge').style.display = 'flex';
|
|
463
|
-
document.getElementById('carimbo').style.display = 'none';
|
|
464
|
-
quadroNaTela = marca;
|
|
465
|
-
};
|
|
466
|
-
pre.onerror = () => { buscandoQuadro = false; };
|
|
467
|
-
pre.src = '/live?' + marca;
|
|
468
|
-
}
|
|
469
|
-
}
|
|
470
|
-
setTimeout(videoTick, 300);
|
|
471
|
-
}
|
|
472
|
-
|
|
473
|
-
// ── a instrução autopreenchendo, letra por letra ──
|
|
474
|
-
let digitando = null, instrucaoNaTela = 0;
|
|
475
|
-
function autopreencher(inst) {
|
|
476
|
-
if (!inst || inst.n === instrucaoNaTela) return;
|
|
477
|
-
instrucaoNaTela = inst.n;
|
|
478
|
-
clearInterval(digitando);
|
|
479
|
-
const el = document.getElementById('cmdtexto');
|
|
480
|
-
el.classList.remove('vazio');
|
|
481
|
-
el.textContent = '';
|
|
482
|
-
document.getElementById('cmdhora').textContent = inst.hora;
|
|
483
|
-
let i = 0;
|
|
484
|
-
digitando = setInterval(() => {
|
|
485
|
-
i += 1 + Math.floor(Math.random() * 2); // cadência de gente digitando
|
|
486
|
-
el.textContent = inst.texto.slice(0, i);
|
|
487
|
-
if (i >= inst.texto.length) clearInterval(digitando);
|
|
488
|
-
}, 32);
|
|
489
|
-
}
|
|
490
|
-
|
|
491
|
-
function framesDaAba(id) {
|
|
492
|
-
return id === VIVO ? dados.frames : dados.frames.filter((f) => f.aba === id);
|
|
493
|
-
}
|
|
494
|
-
function logDaAba(id) {
|
|
495
|
-
return id === VIVO ? dados.log : dados.log.filter((e) => e.aba === id || e.tipo === 'instrucao');
|
|
496
|
-
}
|
|
497
|
-
|
|
498
|
-
function render() {
|
|
499
|
-
// Abas: "Ao vivo" + tudo que ele abriu, separadas por |
|
|
500
|
-
const nav = document.getElementById('abas');
|
|
501
|
-
const abas = [{ id: VIVO, nome: 'Ao vivo' }, ...dados.abas];
|
|
502
|
-
nav.innerHTML = abas.map((a) =>
|
|
503
|
-
'<button class="aba' + (a.id === abaSel ? ' ativa' : '') + '" data-id="' + a.id + '">' + esc(a.nome)
|
|
504
|
-
+ (a.passos ? '<span class="qtd">' + a.passos + '</span>' : '') + '</button>'
|
|
505
|
-
).join('<span class="sep">|</span>');
|
|
506
|
-
nav.querySelectorAll('.aba').forEach((b) => b.onclick = () => {
|
|
507
|
-
abaSel = Number(b.dataset.id); fotoSel = null; quadroNaTela = -1; render();
|
|
508
|
-
});
|
|
509
|
-
const abaObj = abas.find((a) => a.id === abaSel) || abas[0];
|
|
510
|
-
document.getElementById('abanome').textContent = abaObj.nome;
|
|
511
|
-
|
|
512
|
-
// Fora do modo vídeo (aba antiga ou foto fixada): mostra o quadro daquela aba
|
|
513
|
-
const fs = framesDaAba(abaSel);
|
|
514
|
-
const img = document.getElementById('frame');
|
|
515
|
-
const aviso = document.getElementById('aviso');
|
|
516
|
-
const badge = document.getElementById('badge');
|
|
517
|
-
const carimbo = document.getElementById('carimbo');
|
|
518
|
-
if (!modoVideo()) {
|
|
519
|
-
const atual = fotoSel != null ? fs.find((f) => f.id === fotoSel) : fs[fs.length - 1];
|
|
520
|
-
badge.style.display = 'none';
|
|
521
|
-
if (atual) {
|
|
522
|
-
if (img.dataset.n !== 'f' + atual.id) { img.src = '/frame?n=' + atual.id; img.dataset.n = 'f' + atual.id; }
|
|
523
|
-
img.style.display = 'block'; aviso.style.display = 'none';
|
|
524
|
-
carimbo.style.display = 'block';
|
|
525
|
-
carimbo.innerHTML = '<b>#' + atual.id + '</b> · ' + atual.hora + ' · ' + esc(atual.acao)
|
|
526
|
-
+ (fotoSel != null ? ' — <b>clique na miniatura de novo para voltar</b>' : '');
|
|
527
|
-
} else {
|
|
528
|
-
img.style.display = 'none'; aviso.style.display = 'block'; carimbo.style.display = 'none';
|
|
529
|
-
}
|
|
530
|
-
} else {
|
|
531
|
-
carimbo.style.display = 'none';
|
|
532
|
-
if (dados.erroFoto) { aviso.style.display = 'block'; aviso.querySelector('.sub').textContent = 'Sem imagem da tela: ' + dados.erroFoto; }
|
|
533
|
-
}
|
|
534
|
-
|
|
535
|
-
// Filmstrip da aba (as últimas 16)
|
|
536
|
-
const filme = document.getElementById('filme');
|
|
537
|
-
const tira = fs.slice(-16);
|
|
538
|
-
const marcada = fotoSel != null ? fotoSel : -1;
|
|
539
|
-
filme.innerHTML = tira.map((f) =>
|
|
540
|
-
'<button class="foto' + (f.id === marcada ? ' ativa' : '') + '" data-id="' + f.id + '" title="' + esc(f.acao) + '"><img loading="lazy" src="/frame?n=' + f.id + '"></button>').join('');
|
|
541
|
-
filme.querySelectorAll('.foto').forEach((b) => b.onclick = () => {
|
|
542
|
-
const id = Number(b.dataset.id);
|
|
543
|
-
fotoSel = (fotoSel === id) ? null : id; // clicar de novo volta ao vídeo
|
|
544
|
-
quadroNaTela = -1;
|
|
545
|
-
render();
|
|
546
|
-
});
|
|
547
|
-
if (fotoSel == null) filme.scrollLeft = filme.scrollWidth;
|
|
548
|
-
|
|
549
|
-
// Linha do tempo da aba (mais novos primeiro); pedidos viram divisórias
|
|
550
|
-
const passos = logDaAba(abaSel).slice(-60).reverse();
|
|
551
|
-
document.getElementById('resumo').textContent = passos.filter((e) => e.tipo !== 'instrucao').length + ' passos';
|
|
552
|
-
document.getElementById('passos').innerHTML = passos.map((e) => {
|
|
553
|
-
if (e.tipo === 'instrucao') {
|
|
554
|
-
return '<div class="item pedido"><span class="n">' + e.n + '</span><span class="chev">❯</span>'
|
|
555
|
-
+ '<span class="txt">' + esc(e.acao) + '</span><span class="hora">' + e.hora + '</span></div>';
|
|
556
|
-
}
|
|
557
|
-
return '<div class="item' + (e.frame ? ' temfoto" data-frame="' + e.frame : '') + '">'
|
|
558
|
-
+ '<span class="n">' + e.n + '</span>'
|
|
559
|
-
+ '<span class="' + (e.ok ? 'ok' : 'falha') + '">' + (e.ok ? '✔' : '✗') + '</span>'
|
|
560
|
-
+ '<span class="txt">' + esc(e.acao) + (e.erro ? '<span class="erro">' + esc(e.erro) + '</span>' : '') + '</span>'
|
|
561
|
-
+ '<span class="hora">' + e.hora + '</span></div>';
|
|
562
|
-
}).join('');
|
|
563
|
-
document.querySelectorAll('.item.temfoto').forEach((el) => el.onclick = () => { fotoSel = Number(el.dataset.frame); quadroNaTela = -1; render(); });
|
|
564
|
-
}
|
|
565
|
-
|
|
566
|
-
async function tick() {
|
|
567
|
-
try {
|
|
568
|
-
const s = await (await fetch('/state')).json();
|
|
569
|
-
dados = s;
|
|
570
|
-
document.getElementById('st').textContent = 'ao vivo';
|
|
571
|
-
autopreencher(s.instrucoes[s.instrucoes.length - 1]);
|
|
572
|
-
const assinatura = JSON.stringify([s.frames.length, s.log.length, s.abas.length, s.erroFoto]);
|
|
573
|
-
if (assinatura !== ultimoEstado) { ultimoEstado = assinatura; render(); }
|
|
574
|
-
} catch (e) {
|
|
575
|
-
document.getElementById('st').textContent = 'reconectando…';
|
|
576
|
-
}
|
|
577
|
-
setTimeout(tick, 600);
|
|
578
|
-
}
|
|
579
|
-
tick();
|
|
580
|
-
videoTick();
|
|
581
|
-
</script>
|
|
582
|
-
</body></html>`;
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
function start() {
|
|
586
|
-
if (state.on) return Promise.resolve(state.url);
|
|
587
|
-
try { fs.mkdirSync(DIR, { recursive: true }); } catch {}
|
|
588
|
-
// Fotos de sessões passadas não pertencem a esta linha do tempo.
|
|
589
|
-
try { for (const f of fs.readdirSync(DIR)) if (/^(frame-\d+|live|live-tmp)\.png$/.test(f)) fs.unlinkSync(path.join(DIR, f)); } catch {}
|
|
590
|
-
|
|
591
|
-
return new Promise((resolve, reject) => {
|
|
592
|
-
const server = http.createServer((req, res) => {
|
|
593
|
-
const u = new URL(req.url || '/', 'http://x');
|
|
594
|
-
if (u.pathname === '/state') {
|
|
595
|
-
viuAgora(); // página aberta = vídeo ligado
|
|
596
|
-
res.writeHead(200, { 'Content-Type': 'application/json; charset=utf-8', 'Cache-Control': 'no-store' });
|
|
597
|
-
res.end(JSON.stringify({
|
|
598
|
-
erroFoto: state.erroFoto,
|
|
599
|
-
abas: state.abas,
|
|
600
|
-
frames: state.frames,
|
|
601
|
-
log: state.log.slice(-120),
|
|
602
|
-
instrucoes: state.instrucoes.slice(-20),
|
|
603
|
-
liveSeq: state.liveSeq,
|
|
604
|
-
}));
|
|
605
|
-
return;
|
|
606
|
-
}
|
|
607
|
-
if (u.pathname === '/live') {
|
|
608
|
-
viuAgora();
|
|
609
|
-
fs.readFile(LIVE(), (err, buf) => {
|
|
610
|
-
if (err) { res.writeHead(404); res.end(); return; }
|
|
611
|
-
res.writeHead(200, { 'Content-Type': 'image/png', 'Cache-Control': 'no-store' });
|
|
612
|
-
res.end(buf);
|
|
613
|
-
});
|
|
614
|
-
return;
|
|
615
|
-
}
|
|
616
|
-
if (u.pathname === '/frame') {
|
|
617
|
-
// O id vem de fora: só serve o arquivo se for número — nada de
|
|
618
|
-
// path traversal por aqui.
|
|
619
|
-
const id = Math.round(Number(u.searchParams.get('n')));
|
|
620
|
-
const conhecido = Number.isFinite(id) && state.frames.some((f) => f.id === id);
|
|
621
|
-
if (!conhecido) { res.writeHead(404); res.end(); return; }
|
|
622
|
-
fs.readFile(path.join(DIR, `frame-${id}.png`), (err, buf) => {
|
|
623
|
-
if (err) { res.writeHead(404); res.end(); return; }
|
|
624
|
-
// Cada frame é imutável: o navegador pode guardar à vontade.
|
|
625
|
-
res.writeHead(200, { 'Content-Type': 'image/png', 'Cache-Control': 'max-age=3600' });
|
|
626
|
-
res.end(buf);
|
|
627
|
-
});
|
|
628
|
-
return;
|
|
629
|
-
}
|
|
630
|
-
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
|
|
631
|
-
res.end(PAGINA);
|
|
632
|
-
});
|
|
633
|
-
|
|
634
|
-
server.on('error', reject);
|
|
635
|
-
// unref: o painel não pode segurar o processo vivo — /exit tem de sair
|
|
636
|
-
// mesmo com o streaming ligado.
|
|
637
|
-
server.on('connection', (s) => s.unref());
|
|
638
|
-
// Só na própria máquina: isto é a tela do usuário, não um serviço.
|
|
639
|
-
server.listen(0, '127.0.0.1', () => {
|
|
640
|
-
server.unref();
|
|
641
|
-
state.server = server;
|
|
642
|
-
state.on = true;
|
|
643
|
-
state.url = `http://127.0.0.1:${server.address().port}`;
|
|
644
|
-
// O vídeo começa JÁ: quando o navegador terminar de abrir, o
|
|
645
|
-
// primeiro quadro está no ar — nada de tela vazia esperando ação.
|
|
646
|
-
state.viewerAt = Date.now();
|
|
647
|
-
agendarVideo(50);
|
|
648
|
-
resolve(state.url);
|
|
649
|
-
});
|
|
650
|
-
});
|
|
651
|
-
}
|
|
652
|
-
|
|
653
|
-
function stop() {
|
|
654
|
-
if (state.server) { try { state.server.close(); } catch {} }
|
|
655
|
-
if (state.liveTimer) { clearTimeout(state.liveTimer); state.liveTimer = null; }
|
|
656
|
-
state.server = null;
|
|
657
|
-
state.on = false;
|
|
658
|
-
state.url = '';
|
|
659
|
-
}
|
|
660
|
-
|
|
661
|
-
module.exports = { start, stop, step, instrucao, isOn, url, descrever };
|