primocode 8.27.0 → 8.30.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 CHANGED
@@ -26,6 +26,7 @@ const catalogo = require('../lib/catalogo');
26
26
  const effort = require('../lib/effort');
27
27
  const memoria = require('../lib/memoria-conversa');
28
28
  const appLib = require('../lib/app');
29
+ const conta = require('../lib/conta');
29
30
 
30
31
  // Rede de segurança de último recurso: sem isto, uma exceção fora de um
31
32
  // try/catch (ex.: dentro de um callback de socket, como o parser de SSE) não
@@ -81,6 +82,8 @@ ${c.bold('Comandos')}
81
82
  ${c.brand('/run')} <comando> executa no shell do projeto
82
83
  ${c.brand('/open')} <arquivo|url> abre no navegador
83
84
  ${c.brand('/app')} abre a janela do PrimoCode (chat, sem terminal)
85
+ ${c.brand('/entrar')} entra na sua conta Conecta Primo · ${c.brand('/conta')} mostra o plano
86
+ ${c.brand('/sair')} desconecta este computador
84
87
  ${c.brand('/status')} checa o servidor
85
88
  ${c.brand('/name')} <nome> como o Primo te chama
86
89
  ${c.brand('/mascot')} <estilo> mascote: primo|fox|cat
@@ -122,6 +125,9 @@ const SLASH_COMANDOS = [
122
125
  ['/run', 'roda um comando'],
123
126
  ['/open', 'abre no navegador'],
124
127
  ['/app', 'abre a janela do PrimoCode'],
128
+ ['/entrar', 'entra na sua conta Conecta Primo'],
129
+ ['/conta', 'sua conta, plano e computadores conectados'],
130
+ ['/sair', 'desconecta este computador'],
125
131
  ['/status', 'checa o servidor'],
126
132
  ['/name', 'como ele te chama'],
127
133
  ['/mascot', 'primo | fox | cat'],
@@ -939,6 +945,21 @@ async function handleSlash(text) {
939
945
  Uma janela por sessão: mandar /app de novo traz a que já existe
940
946
  para a frente em vez de abrir outra do lado. */
941
947
  case 'app': {
948
+ // `/app --instalar` põe o PrimoCode no Dock, no menu Iniciar ou no
949
+ // menu de aplicativos, com o ícone do mascote. Uma vez só.
950
+ if (/^--?(instalar|install)$/.test(rest)) {
951
+ const r = require('../lib/instalar-app.js').instalar(VERSION);
952
+ console.log(r.ok
953
+ ? c.ok(' Instalado.') + c.muted(` Procure "PrimoCode" no ${r.onde}.`)
954
+ + '\n' + c.dim(` ${r.caminho}`)
955
+ : c.err(` Não deu: ${r.error}`));
956
+ break;
957
+ }
958
+ if (/^--?(desinstalar|uninstall)$/.test(rest)) {
959
+ const r = require('../lib/instalar-app.js').desinstalar();
960
+ console.log(r.ok ? c.ok(' Removido do sistema.') : c.err(` ${r.error}`));
961
+ break;
962
+ }
942
963
  if (janelaAberta) {
943
964
  // Já existe: reabre no MESMO endereço. O navegador traz a
944
965
  // janela daquela URL para a frente em vez de fazer outra.
@@ -970,6 +991,12 @@ async function handleSlash(text) {
970
991
  console.log(c.warn(' Não consegui abrir sozinho — copie o endereço acima no navegador.'));
971
992
  }
972
993
  console.log(c.muted(' O terminal continua funcionando: os dois mostram a mesma coisa.'));
994
+ // Oferecido UMA vez, e só a quem ainda não tem: repetir todo
995
+ // /app viraria ruído para quem já instalou de propósito.
996
+ if (!require('../lib/instalar-app.js').instalado()) {
997
+ console.log(c.dim(' Quer o ícone no ') + c.dim(os.platform() === 'darwin' ? 'Dock' : 'menu Iniciar')
998
+ + c.dim('? ') + c.brand('/app --instalar'));
999
+ }
973
1000
  } catch (e) {
974
1001
  sp.stop();
975
1002
  console.log(c.err(` Não subiu: ${e.message}`));
@@ -977,6 +1004,86 @@ async function handleSlash(text) {
977
1004
  break;
978
1005
  }
979
1006
 
1007
+ /* A conta do Conecta Primo. O login inteiro mora em lib/conta.js;
1008
+ aqui é a tela dele. */
1009
+ case 'entrar': case 'login': {
1010
+ const jaEsta = conta.atual();
1011
+ if (jaEsta && rest !== 'trocar') {
1012
+ console.log(c.muted(' Já conectado como ') + c.brand(jaEsta.uid.slice(0, 12) + '…')
1013
+ + c.muted(` · plano ${jaEsta.plano}`));
1014
+ console.log(c.dim(' Para entrar com outra conta: ') + c.brand('/entrar trocar'));
1015
+ break;
1016
+ }
1017
+ if (rest === 'trocar') conta.sair();
1018
+
1019
+ const sp = new Spinner('abrindo o login').start();
1020
+ let primeiro = true;
1021
+ const r = await conta.entrar({
1022
+ abrir: (u) => tools.openInBrowser(u),
1023
+ contar: (msg) => {
1024
+ if (primeiro) {
1025
+ sp.stop();
1026
+ console.log(c.muted(' Abrindo no navegador:'));
1027
+ console.log(' ' + c.brand(msg));
1028
+ primeiro = false;
1029
+ } else {
1030
+ // O código aparece AQUI para a pessoa comparar com o
1031
+ // da página. Sem essa comparação, um link mandado por
1032
+ // outra pessoa conectaria o computador dela à conta
1033
+ // de quem clicou.
1034
+ console.log(c.muted(' Confira o código na página: ') + c.bold(c.warn(msg)));
1035
+ console.log(c.dim(' Esperando você aprovar…'));
1036
+ }
1037
+ },
1038
+ });
1039
+ sp.stop();
1040
+ if (!r.ok) { console.log(c.err(` ${r.error}`)); break; }
1041
+ console.log(c.ok(' Conectado.')
1042
+ + c.muted(` Plano ${r.plano}${r.pro ? '' : ' — assine Premium no app para liberar tudo'}`));
1043
+ break;
1044
+ }
1045
+
1046
+ case 'sair': case 'logout': {
1047
+ if (!conta.atual()) { console.log(c.muted(' Você não está conectado.')); break; }
1048
+ conta.sair();
1049
+ console.log(c.ok(' Desconectado deste computador.')
1050
+ + c.dim(' A conta continua intacta; entre de novo com /entrar.'));
1051
+ break;
1052
+ }
1053
+
1054
+ case 'conta': {
1055
+ const eu = conta.atual();
1056
+ if (!eu) {
1057
+ console.log(c.muted(' Não conectado. ') + c.brand('/entrar') + c.muted(' para entrar.'));
1058
+ break;
1059
+ }
1060
+ const sp = new Spinner('conferindo').start();
1061
+ // O plano guardado é a foto do dia do login: quem assinou ontem
1062
+ // continuaria gratuito. Quem sabe é o servidor.
1063
+ const s = await conta.sessao();
1064
+ sp.stop();
1065
+ console.log(c.white(' Conta ') + c.muted(eu.uid));
1066
+ if (s.ok) {
1067
+ console.log(c.white(' Plano ')
1068
+ + (s.pro ? c.brand(s.plano) : c.muted(s.plano + ' (gratuito)')));
1069
+ } else if (s.offline) {
1070
+ console.log(c.white(' Plano ') + c.muted(eu.plano) + c.dim(' (sem rede — último conhecido)'));
1071
+ } else {
1072
+ console.log(c.err(` ${s.error}`));
1073
+ break;
1074
+ }
1075
+ const d = await conta.dispositivos();
1076
+ if (d.ok && d.dispositivos.length) {
1077
+ console.log(c.white('\n Computadores conectados'));
1078
+ for (const x of d.dispositivos) {
1079
+ const quando = x.ultimo_uso
1080
+ ? new Date(x.ultimo_uso * 1000).toLocaleDateString('pt-BR') : '—';
1081
+ console.log(' ' + c.muted((x.apelido || 'Computador').padEnd(14)) + c.dim(quando));
1082
+ }
1083
+ }
1084
+ break;
1085
+ }
1086
+
980
1087
  case 'uninstall': {
981
1088
  if (await confirm('Remover o PrimoCode? Seus projetos em ~/primocode NÃO serão apagados.')) {
982
1089
  await tools.uninstall();
@@ -1049,6 +1156,53 @@ async function main() {
1049
1156
  process.exit(0);
1050
1157
  }
1051
1158
 
1159
+ /* `primo --app`: a janela SEM terminal.
1160
+ *
1161
+ * É por aqui que o aplicativo instalado entra. Quem clica no ícone do Dock
1162
+ * ou do menu Iniciar não tem terminal nenhum — não há readline, não há
1163
+ * stdin, e o processo precisa ficar de pé sozinho segurando a janela.
1164
+ *
1165
+ * O `/app` de dentro da sessão continua existindo e é outra coisa: ali há
1166
+ * um terminal, e a janela espelha o que ele mostra. Os dois chamam a MESMA
1167
+ * `appLib.abrir`, porque uma segunda montagem divergiria da primeira. */
1168
+ if (args[0] === '--app') {
1169
+ const instalador = require('../lib/instalar-app.js');
1170
+ if (args[1] === '--instalar') {
1171
+ const r = instalador.instalar(VERSION);
1172
+ console.log(r.ok
1173
+ ? c.ok(` Instalado.`) + c.muted(` Procure "PrimoCode" no ${r.onde}.`)
1174
+ + '\n' + c.dim(` ${r.caminho}`)
1175
+ : c.err(` Não deu: ${r.error}`));
1176
+ process.exit(r.ok ? 0 : 1);
1177
+ }
1178
+ if (args[1] === '--desinstalar') {
1179
+ const r = instalador.desinstalar();
1180
+ console.log(r.ok ? c.ok(' Removido do sistema.') : c.err(` ${r.error}`));
1181
+ process.exit(r.ok ? 0 : 1);
1182
+ }
1183
+
1184
+ const janela = await appLib.abrir({
1185
+ versao: VERSION,
1186
+ enviar: (t) => sendPrompt(t),
1187
+ barra: (t) => handleSlash(t),
1188
+ interromper,
1189
+ estado: () => ({
1190
+ pasta: state.workDir ? shortPath(state.workDir) : null,
1191
+ projeto: state.currentProject,
1192
+ modo: state.mode,
1193
+ esforco: state.config.effort,
1194
+ }),
1195
+ });
1196
+ janelaAberta = janela;
1197
+ console.log(c.brand(` PrimoCode`) + c.muted(` ${janela.url.split('?')[0]}`));
1198
+ // Sem readline não há nada segurando o laço de eventos além do
1199
+ // servidor — e é ele que segura, porque está escutando. Fechar a
1200
+ // janela NÃO derruba o processo de propósito: o navegador em modo app
1201
+ // reabre a mesma URL, e um servidor morto deixaria a pessoa com uma
1202
+ // aba de erro em vez do aplicativo.
1203
+ return;
1204
+ }
1205
+
1052
1206
  // Modo one-shot: primocode "crie um jogo de carro"
1053
1207
  if (args.length && !args[0].startsWith('-')) {
1054
1208
  const fileFlag = args.indexOf('-f');
package/lib/catalogo.js CHANGED
@@ -89,6 +89,10 @@ const GRUPOS = [
89
89
  { tool: 'studio_listar', escreve: false, faz: 'lista o que já existe no estúdio', args: null },
90
90
  { tool: 'studio_midia', escreve: false, faz: 'manda imagem, música ou vídeo seu para usar nas peças',
91
91
  args: 'arquivo (opcional): caminho no projeto — sem ele, lista a galeria. nome (opcional).' },
92
+ { tool: 'remotion_projeto', escreve: true, faz: 'transforma uma peça do estúdio em projeto Remotion (React) na pasta do usuário',
93
+ args: 'id da peça. pasta (opcional): nome da pasta. fps (opcional, padrão 30). renderizar: true só se o npm install já foi feito. '
94
+ + 'Use SÓ quando o usuário pedir Remotion pelo nome, ou quando precisar de render determinístico, vídeo longo, codec/fps específico ou o projeto em React. '
95
+ + 'O caminho normal continua sendo studio_criar + o botão de baixar, que sai em segundos sem instalar nada.' },
92
96
  { tool: 'studio_analisar', escreve: false, faz: 'ouve um vídeo já enviado e diz onde estão as pausas, para cortá-las',
93
97
  args: 'arquivo: a URL "/midia/..." do vídeo. pausa (opcional): segundos a partir dos quais conta como pausa (padrão 0.6). Devolve "trechos": vire cada um numa cena com {"tipo":"clipe","de":...,"ate":...}.' },
94
98
  ],
package/lib/conta.js ADDED
@@ -0,0 +1,197 @@
1
+ /**
2
+ * conta.js — entrar na conta Conecta Primo, de dentro do terminal.
3
+ *
4
+ * "Quando você dá `primo`, ele vai abrir pra primeiro você fazer o login. E
5
+ * aí ele vai abrir lá o Conecta Primo AI pra você fazer o login, com algum
6
+ * token que ele gera automaticamente. Você faz o login ali e aparece uma
7
+ * confirmação na tela; ela até fecha sozinha depois de 5 segundos. Daí no
8
+ * terminal ele vai ver que você fez login, e se a sua conta for Premium ele
9
+ * libera pra você usar."
10
+ *
11
+ * O desenho inteiro está no outro lado (backend/dispositivos.py do
12
+ * conecta-primo-ai). Daqui o que importa é: pedir um código, abrir o navegador,
13
+ * esperar, e guardar o que voltar.
14
+ *
15
+ * ── O ARQUIVO DA CREDENCIAL ──────────────────────────────────────────────
16
+ * Ele NÃO entra no config.json. Aquele arquivo guarda preferência — modo,
17
+ * esforço, mascote — e é o que alguém abre para conferir um ajuste, cola num
18
+ * chamado de suporte ou copia para outra máquina. Credencial junto de
19
+ * preferência é credencial que vaza por hábito, não por ataque.
20
+ *
21
+ * Mora em `~/.primocode/conta.json`, com permissão 600 onde o sistema tem
22
+ * permissão (no Windows o modo é ignorado, e está dito no comentário de lá).
23
+ */
24
+
25
+ 'use strict';
26
+
27
+ const fs = require('fs');
28
+ const os = require('os');
29
+ const path = require('path');
30
+ const https = require('https');
31
+ const http = require('http');
32
+
33
+ // O servidor do Conecta Primo — que NÃO é o servidor do PrimoCode. São dois
34
+ // serviços diferentes: um responde o chat do app e a conta, o outro é o motor
35
+ // do agente. Confundir os dois dá "não autenticado" sem explicação.
36
+ const CONECTA = process.env.PRIMOCODE_CONTA_SERVER || 'https://conecta-primo-api.onrender.com';
37
+
38
+ const ARQUIVO = path.join(os.homedir(), '.primocode', 'conta.json');
39
+
40
+ // Os planos que liberam tudo. A lista é a MESMA do servidor
41
+ // (`PLANOS_COM_PRO`), e quem decide de verdade é ele: isto aqui só evita uma
42
+ // ida à rede para mostrar o selo na abertura.
43
+ const PLANOS_PRO = ['premium', 'super'];
44
+
45
+ function pedir(rota, corpo, timeout = 30000) {
46
+ return new Promise((resolve, reject) => {
47
+ const url = new URL(CONECTA + rota);
48
+ const lib = url.protocol === 'https:' ? https : http;
49
+ const dados = corpo == null ? null : Buffer.from(JSON.stringify(corpo), 'utf8');
50
+ const cabecalhos = { Accept: 'application/json' };
51
+ if (dados) {
52
+ cabecalhos['Content-Type'] = 'application/json';
53
+ cabecalhos['Content-Length'] = dados.length;
54
+ }
55
+ const cred = ler();
56
+ if (cred && cred.token) cabecalhos.Authorization = `Bearer ${cred.token}`;
57
+
58
+ const req = lib.request({
59
+ hostname: url.hostname, port: url.port || undefined,
60
+ path: url.pathname + url.search,
61
+ method: dados ? 'POST' : 'GET', timeout, headers: cabecalhos,
62
+ }, (res) => {
63
+ let texto = '';
64
+ res.setEncoding('utf8');
65
+ res.on('data', (c) => { texto += c; });
66
+ res.on('end', () => {
67
+ try { resolve(JSON.parse(texto)); }
68
+ catch { reject(new Error(`resposta inesperada do servidor (${res.statusCode})`)); }
69
+ });
70
+ });
71
+ req.on('timeout', () => req.destroy(new Error('o servidor demorou demais')));
72
+ req.on('error', reject);
73
+ if (dados) req.write(dados);
74
+ req.end();
75
+ });
76
+ }
77
+
78
+ // ── o arquivo ────────────────────────────────────────────────────────────
79
+
80
+ function ler() {
81
+ try { return JSON.parse(fs.readFileSync(ARQUIVO, 'utf8')); }
82
+ catch { return null; }
83
+ }
84
+
85
+ function gravar(dados) {
86
+ fs.mkdirSync(path.dirname(ARQUIVO), { recursive: true });
87
+ fs.writeFileSync(ARQUIVO, JSON.stringify(dados, null, 2), 'utf8');
88
+ // 600 = só o dono lê. No Windows o modo é ignorado pelo sistema de
89
+ // arquivos e a chamada não falha — lá quem protege é a pasta do perfil.
90
+ try { fs.chmodSync(ARQUIVO, 0o600); } catch {}
91
+ }
92
+
93
+ function sair() {
94
+ try { fs.rmSync(ARQUIVO, { force: true }); return true; }
95
+ catch { return false; }
96
+ }
97
+
98
+ /** Quem está conectado, sem ir à rede. */
99
+ function atual() {
100
+ const c = ler();
101
+ if (!c || !c.token) return null;
102
+ return { uid: c.uid, plano: c.plano || 'free', email: c.email || null,
103
+ pro: PLANOS_PRO.includes(c.plano), desde: c.desde };
104
+ }
105
+
106
+ // ── entrar ───────────────────────────────────────────────────────────────
107
+
108
+ /**
109
+ * O login inteiro: pede o código, abre o navegador, espera a aprovação.
110
+ *
111
+ * @param {object} op
112
+ * @param {(url:string)=>any} op.abrir abre o navegador (vem do tools.js)
113
+ * @param {(msg:string)=>void} [op.contar] para a tela ir dizendo o que faz
114
+ * @param {number} [op.minutos] teto da espera
115
+ */
116
+ async function entrar({ abrir, contar = () => {}, minutos = 5 } = {}) {
117
+ let pedido;
118
+ try {
119
+ pedido = await pedir('/api/dispositivo/novo', { origem: `PrimoCode ${versao()}` });
120
+ } catch (e) {
121
+ return { ok: false, error: `não consegui falar com o Conecta Primo: ${e.message}` };
122
+ }
123
+ if (!pedido || !pedido.success) {
124
+ return { ok: false, error: (pedido && pedido.error) || 'o servidor recusou abrir o login' };
125
+ }
126
+
127
+ contar(pedido.url);
128
+ contar(pedido.codigo);
129
+ if (abrir) { try { await abrir(pedido.url); } catch { /* segue: o link está na tela */ } }
130
+
131
+ /* A espera. Dois segundos entre perguntas: mais rápido é marteladas no
132
+ servidor por uma pessoa que ainda está digitando a senha; mais devagar e
133
+ o terminal parece travado depois de a página já ter confirmado. */
134
+ const prazo = Date.now() + minutos * 60000;
135
+ while (Date.now() < prazo) {
136
+ await new Promise((ok) => setTimeout(ok, 2000));
137
+ let r;
138
+ try {
139
+ r = await pedir('/api/dispositivo/consultar',
140
+ { codigo: pedido.codigo, segredo: pedido.segredo }, 10000);
141
+ } catch { continue; } // rede tropeçou: pergunta de novo
142
+ if (!r || !r.success) continue;
143
+ if (r.estado === 'expirado') {
144
+ return { ok: false, error: 'o código venceu antes de você aprovar. Tente de novo.' };
145
+ }
146
+ if (r.estado !== 'aprovado') continue;
147
+
148
+ gravar({
149
+ token: r.token, uid: r.uid, plano: r.plano || 'free',
150
+ desde: new Date().toISOString(), servidor: CONECTA,
151
+ });
152
+ return { ok: true, uid: r.uid, plano: r.plano,
153
+ pro: PLANOS_PRO.includes(r.plano) };
154
+ }
155
+ return { ok: false, error: `passaram ${minutos} minutos sem aprovação. Rode /entrar de novo.` };
156
+ }
157
+
158
+ /**
159
+ * Confirma o plano no SERVIDOR e devolve a sessão para o Firestore.
160
+ *
161
+ * O plano guardado no arquivo é uma fotografia do dia do login: quem assinou
162
+ * ontem continuaria como gratuito, e quem cancelou continuaria Premium. Quem
163
+ * sabe é o servidor, e é ele quem diz — o arquivo local serve para mostrar o
164
+ * selo na abertura sem esperar a rede, e só.
165
+ */
166
+ async function sessao() {
167
+ const c = ler();
168
+ if (!c || !c.token) return { ok: false, error: 'não conectado' };
169
+ let r;
170
+ try { r = await pedir('/api/primocode/sessao', {}); }
171
+ catch (e) { return { ok: false, error: e.message, offline: true }; }
172
+ if (!r || !r.success) {
173
+ // Token revogado no painel, ou vencido: o arquivo local não vale mais.
174
+ // Apagar aqui evita a pessoa ficar vendo "conectado" para sempre com
175
+ // uma credencial morta.
176
+ if (r && /autenticado/i.test(r.error || '')) sair();
177
+ return { ok: false, error: (r && r.error) || 'o servidor recusou a sessão' };
178
+ }
179
+ // O plano volta atualizado: grava por cima, senão o selo da abertura
180
+ // continua mostrando o de ontem.
181
+ if (r.plano && r.plano !== c.plano) gravar({ ...c, plano: r.plano });
182
+ return { ok: true, uid: r.uid, plano: r.plano, pro: !!r.pro,
183
+ customToken: r.custom_token, apiKey: r.api_key, projeto: r.projeto };
184
+ }
185
+
186
+ /** Os computadores conectados nesta conta. */
187
+ async function dispositivos() {
188
+ const r = await pedir('/api/primocode/dispositivos').catch((e) => ({ error: e.message }));
189
+ if (!r || !r.success) return { ok: false, error: (r && r.error) || 'não deu para listar' };
190
+ return { ok: true, dispositivos: r.dispositivos || [] };
191
+ }
192
+
193
+ function versao() {
194
+ try { return require('../package.json').version; } catch { return '?'; }
195
+ }
196
+
197
+ module.exports = { entrar, sair, atual, sessao, dispositivos, ARQUIVO, CONECTA, PLANOS_PRO };
package/lib/icone.js ADDED
@@ -0,0 +1,247 @@
1
+ /**
2
+ * icone.js — o mascote do PrimoCode, desenhado em pixel e empacotado.
3
+ *
4
+ * "Tenha um ícone, e o ícone tem que ser o mascote do PrimoCode, tem que ter
5
+ * todas as cores do PrimoCode."
6
+ *
7
+ * ── POR QUE DESENHAR EM VEZ DE GUARDAR UM ARQUIVO ────────────────────────
8
+ * Um .icns e um .ico são formatos binários com várias resoluções dentro. Podia
9
+ * ter os dois no repositório, prontos — só que cada um seria uma cópia do
10
+ * desenho, e mudar a cor da marca passaria a exigir abrir um editor de imagem e
11
+ * lembrar dos dois. O mascote é blocado por natureza (ele nasceu de glifos de
12
+ * bloco no terminal), então desenhá-lo em código é curto e a fonte é UMA.
13
+ *
14
+ * ── POR QUE SEM BIBLIOTECA ───────────────────────────────────────────────
15
+ * O PrimoCode tem zero dependência, e isso não abre exceção para um ícone.
16
+ * PNG é um cabeçalho, os pixels passados pelo `zlib` (que vem no Node) e um
17
+ * CRC-32; .icns e .ico são containers que só empacotam PNGs prontos. São umas
18
+ * cem linhas somando os três, contra um pacote de imagem inteiro na árvore.
19
+ */
20
+
21
+ 'use strict';
22
+
23
+ const zlib = require('zlib');
24
+
25
+ // As cores são as MESMAS do lib/ui.js e do app/app.css — a marca é uma só.
26
+ const AZUL = [0x22, 0xd3, 0xee]; // cyan-400 — o azul do PrimoCode
27
+ const AZUL_FUNDO = [0x08, 0x91, 0xb2]; // cyan-600 — a mesma cor recuando
28
+ const FUNDO = [0x0a, 0x0f, 0x16]; // o quase-preto do app
29
+ const BRILHO = [0x67, 0xe8, 0xf9]; // cyan-300 — a luz de cima
30
+
31
+ // ── tela de pixels ───────────────────────────────────────────────────────
32
+
33
+ function tela(n) {
34
+ // RGBA, quatro bytes por pixel, começando transparente.
35
+ return { n, px: Buffer.alloc(n * n * 4) };
36
+ }
37
+
38
+ function ponto(t, x, y, cor, alfa) {
39
+ x = Math.round(x); y = Math.round(y);
40
+ if (x < 0 || y < 0 || x >= t.n || y >= t.n) return;
41
+ const i = (y * t.n + x) * 4;
42
+ const a = alfa == null ? 1 : alfa;
43
+ if (a >= 1) {
44
+ t.px[i] = cor[0]; t.px[i + 1] = cor[1]; t.px[i + 2] = cor[2]; t.px[i + 3] = 255;
45
+ return;
46
+ }
47
+ // Mistura sobre o que já está lá — é o que dá a borda macia do arredondado.
48
+ const de = t.px[i + 3] / 255;
49
+ const fora = a + de * (1 - a);
50
+ for (let k = 0; k < 3; k++) {
51
+ t.px[i + k] = Math.round((cor[k] * a + t.px[i + k] * de * (1 - a)) / (fora || 1));
52
+ }
53
+ t.px[i + 3] = Math.round(fora * 255);
54
+ }
55
+
56
+ /* Retângulo de cantos redondos, com a borda suavizada por amostragem.
57
+ *
58
+ * Sem a suavização o ícone fica com escada visível em 512px e, pior, some o
59
+ * arredondado em 16px — que é o tamanho em que ele mais aparece (aba, barra de
60
+ * tarefas, Dock recolhido). Três por três amostras bastam: nove níveis de
61
+ * cobertura é mais do que o olho separa numa borda de um pixel. */
62
+ function caixa(t, x0, y0, l, a, r, cor) {
63
+ const x1 = x0 + l, y1 = y0 + a;
64
+ for (let y = Math.floor(y0) - 1; y <= Math.ceil(y1) + 1; y++) {
65
+ for (let x = Math.floor(x0) - 1; x <= Math.ceil(x1) + 1; x++) {
66
+ let dentro = 0;
67
+ for (let sy = 0; sy < 3; sy++) {
68
+ for (let sx = 0; sx < 3; sx++) {
69
+ const px = x + (sx + 0.5) / 3;
70
+ const py = y + (sy + 0.5) / 3;
71
+ if (px < x0 || px > x1 || py < y0 || py > y1) continue;
72
+ // Só os quatro cantos têm curva; o miolo é reto.
73
+ const cx = px < x0 + r ? x0 + r : (px > x1 - r ? x1 - r : px);
74
+ const cy = py < y0 + r ? y0 + r : (py > y1 - r ? y1 - r : py);
75
+ if ((px - cx) ** 2 + (py - cy) ** 2 <= r * r) dentro++;
76
+ }
77
+ }
78
+ if (dentro) ponto(t, x, y, cor, dentro / 9);
79
+ }
80
+ }
81
+ }
82
+
83
+ // ── o mascote ────────────────────────────────────────────────────────────
84
+
85
+ /* O Primo, o mesmo do terminal: cabeça de topo arredondado, ombros largos,
86
+ * dois pezinhos. Lá ele é feito de glifos de bloco:
87
+ *
88
+ * ▐▛███▜▌ a cabeça
89
+ * ▝▜█████▛▘ o corpo, mais largo
90
+ * ▘▘ ▝▝ os pés
91
+ *
92
+ * Aqui são as mesmas três peças em proporção, e não uma transliteração dos
93
+ * glifos: meio bloco de terminal vira escada num ícone de 512px. O que faz
94
+ * reconhecer é a SILHUETA — topo redondo, ombro saliente, dois pés —, e ela é
95
+ * a mesma.
96
+ */
97
+ function desenharPrimo(n) {
98
+ const t = tela(n);
99
+ const u = n / 32; // a grade do desenho, em pixels
100
+
101
+ // A base: o quadrado escuro do app, com o mesmo arredondado do ícone de
102
+ // sistema (22% do lado é o raio que o macOS e o Windows usam hoje).
103
+ caixa(t, 0, 0, n, n, n * 0.22, FUNDO);
104
+
105
+ // Um brilho fraco atrás do bicho — o mesmo halo do fundo do app.
106
+ for (let y = 0; y < n; y++) {
107
+ for (let x = 0; x < n; x++) {
108
+ const dx = (x - n / 2) / (n * 0.6);
109
+ const dy = (y - n * 0.34) / (n * 0.6);
110
+ const d = Math.sqrt(dx * dx + dy * dy);
111
+ if (d < 1) ponto(t, x, y, AZUL, 0.11 * (1 - d) * (1 - d));
112
+ }
113
+ }
114
+
115
+ // Cabeça: topo arredondado, ombros retos embaixo (raio só em cima é o que
116
+ // dá a silhueta do ▐▛███▜▌ — redondo por cima, quadrado por baixo).
117
+ caixa(t, 9 * u, 7 * u, 14 * u, 11 * u, 3.4 * u, AZUL);
118
+ caixa(t, 9 * u, 14 * u, 14 * u, 4 * u, 0.6 * u, AZUL);
119
+
120
+ // Corpo: mais largo que a cabeça, que é o ombro marcado do desenho.
121
+ caixa(t, 6 * u, 17.6 * u, 20 * u, 7.6 * u, 2.2 * u, AZUL);
122
+
123
+ // Os dois pés.
124
+ caixa(t, 10 * u, 24.6 * u, 4.4 * u, 2.6 * u, 1 * u, AZUL_FUNDO);
125
+ caixa(t, 17.6 * u, 24.6 * u, 4.4 * u, 2.6 * u, 1 * u, AZUL_FUNDO);
126
+
127
+ // Os olhos, no negativo — é o que faz o bloco virar bicho.
128
+ caixa(t, 12.4 * u, 10.6 * u, 2.6 * u, 3.2 * u, 1.1 * u, FUNDO);
129
+ caixa(t, 17 * u, 10.6 * u, 2.6 * u, 3.2 * u, 1.1 * u, FUNDO);
130
+
131
+ // A luz de cima: uma faixa clara na testa e no ombro. Sem ela o bicho é
132
+ // uma silhueta chapada — é o mesmo bisel do "vidro" do estúdio.
133
+ caixa(t, 10.4 * u, 8.2 * u, 11.2 * u, 1.4 * u, 0.7 * u, BRILHO);
134
+ caixa(t, 7.6 * u, 18.6 * u, 16.8 * u, 1.2 * u, 0.6 * u, BRILHO);
135
+
136
+ return t;
137
+ }
138
+
139
+ // ── PNG ──────────────────────────────────────────────────────────────────
140
+
141
+ const TABELA_CRC = (() => {
142
+ const t = new Int32Array(256);
143
+ for (let n = 0; n < 256; n++) {
144
+ let c = n;
145
+ for (let k = 0; k < 8; k++) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1;
146
+ t[n] = c;
147
+ }
148
+ return t;
149
+ })();
150
+
151
+ function crc32(buf) {
152
+ let c = -1;
153
+ for (let i = 0; i < buf.length; i++) c = TABELA_CRC[(c ^ buf[i]) & 0xff] ^ (c >>> 8);
154
+ return (c ^ -1) >>> 0;
155
+ }
156
+
157
+ function pedaco(tipo, dados) {
158
+ const cabeca = Buffer.alloc(8);
159
+ cabeca.writeUInt32BE(dados.length, 0);
160
+ cabeca.write(tipo, 4, 'latin1');
161
+ const crc = Buffer.alloc(4);
162
+ crc.writeUInt32BE(crc32(Buffer.concat([Buffer.from(tipo, 'latin1'), dados])), 0);
163
+ return Buffer.concat([cabeca, dados, crc]);
164
+ }
165
+
166
+ function paraPng(t) {
167
+ const linha = t.n * 4;
168
+ // Cada linha do PNG começa com o byte do filtro. Zero — "sem filtro" — é o
169
+ // certo aqui: o desenho é de blocos chapados, o zlib já o comprime bem, e
170
+ // filtro melhoraria bytes num arquivo que já sai com poucos KB.
171
+ const cru = Buffer.alloc((linha + 1) * t.n);
172
+ for (let y = 0; y < t.n; y++) {
173
+ cru[y * (linha + 1)] = 0;
174
+ t.px.copy(cru, y * (linha + 1) + 1, y * linha, (y + 1) * linha);
175
+ }
176
+ const ihdr = Buffer.alloc(13);
177
+ ihdr.writeUInt32BE(t.n, 0);
178
+ ihdr.writeUInt32BE(t.n, 4);
179
+ ihdr[8] = 8; // 8 bits por canal
180
+ ihdr[9] = 6; // cor 6 = RGBA
181
+ return Buffer.concat([
182
+ Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]),
183
+ pedaco('IHDR', ihdr),
184
+ pedaco('IDAT', zlib.deflateSync(cru, { level: 9 })),
185
+ pedaco('IEND', Buffer.alloc(0)),
186
+ ]);
187
+ }
188
+
189
+ /** O mascote em PNG, no tamanho pedido. */
190
+ function png(tamanho) {
191
+ return paraPng(desenharPrimo(tamanho));
192
+ }
193
+
194
+ // ── .icns (macOS) e .ico (Windows) ───────────────────────────────────────
195
+
196
+ /* Os dois são containers de PNGs, e é por isso que cabem em vinte linhas cada.
197
+ * Os tamanhos não são gosto: são os que cada sistema procura. Faltando um, ele
198
+ * escala o vizinho e o ícone sai borrado justamente na barra de tarefas. */
199
+ const TIPOS_ICNS = [
200
+ ['icp4', 16], ['icp5', 32], ['icp6', 64],
201
+ ['ic07', 128], ['ic08', 256], ['ic09', 512], ['ic10', 1024],
202
+ ];
203
+
204
+ function icns() {
205
+ const partes = [];
206
+ for (const [tipo, tam] of TIPOS_ICNS) {
207
+ const dados = png(tam);
208
+ const cab = Buffer.alloc(8);
209
+ cab.write(tipo, 0, 'latin1');
210
+ cab.writeUInt32BE(dados.length + 8, 4);
211
+ partes.push(cab, dados);
212
+ }
213
+ const corpo = Buffer.concat(partes);
214
+ const cabeca = Buffer.alloc(8);
215
+ cabeca.write('icns', 0, 'latin1');
216
+ cabeca.writeUInt32BE(corpo.length + 8, 4);
217
+ return Buffer.concat([cabeca, corpo]);
218
+ }
219
+
220
+ const TAMANHOS_ICO = [16, 32, 48, 64, 128, 256];
221
+
222
+ function ico() {
223
+ const imagens = TAMANHOS_ICO.map((t) => ({ t, dados: png(t) }));
224
+ const cabeca = Buffer.alloc(6);
225
+ cabeca.writeUInt16LE(0, 0);
226
+ cabeca.writeUInt16LE(1, 2); // 1 = ícone
227
+ cabeca.writeUInt16LE(imagens.length, 4);
228
+
229
+ let deslocamento = 6 + imagens.length * 16;
230
+ const entradas = [];
231
+ for (const im of imagens) {
232
+ const e = Buffer.alloc(16);
233
+ // 256 se escreve como 0 no .ico — o campo tem um byte só.
234
+ e[0] = im.t >= 256 ? 0 : im.t;
235
+ e[1] = im.t >= 256 ? 0 : im.t;
236
+ e[2] = 0; // paleta: nenhuma
237
+ e.writeUInt16LE(1, 4); // planos
238
+ e.writeUInt16LE(32, 6); // bits por pixel
239
+ e.writeUInt32LE(im.dados.length, 8);
240
+ e.writeUInt32LE(deslocamento, 12);
241
+ deslocamento += im.dados.length;
242
+ entradas.push(e);
243
+ }
244
+ return Buffer.concat([cabeca, ...entradas, ...imagens.map((i) => i.dados)]);
245
+ }
246
+
247
+ module.exports = { png, icns, ico, AZUL, FUNDO, TAMANHOS_ICO, TIPOS_ICNS };