primocode 8.27.0 → 8.31.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/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 };
@@ -0,0 +1,231 @@
1
+ /**
2
+ * instalar-app.js — o PrimoCode como aplicativo do sistema.
3
+ *
4
+ * "Ele está abrindo no navegador, pode ser — só que eu gostaria que fosse um
5
+ * aplicativo mesmo desktop, tenha um ícone, e o ícone tem que ser o mascote
6
+ * do PrimoCode."
7
+ *
8
+ * ── O QUE ESTE ARQUIVO ENTREGA, E O QUE ELE NÃO ENTREGA ──────────────────
9
+ * Ele entrega o que faz uma coisa SER um aplicativo para quem usa: entrada no
10
+ * Launchpad e no Dock do Mac, no menu Iniciar e na barra de tarefas do Windows,
11
+ * no menu de aplicativos do Linux. Ícone próprio, nome próprio, abre com dois
12
+ * cliques, aparece no Spotlight e no Alt-Tab com a cara dele.
13
+ *
14
+ * O que ele NÃO faz é trocar o motor da janela. Dentro, continua sendo o
15
+ * Chromium da máquina em modo `--app`, sem barra de endereço e sem abas — é o
16
+ * mesmo desenho de sempre. Trocar isso por uma janela nativa exigiria Electron
17
+ * ou Tauri: um download de centenas de MB, uma cadeia de compilação por
18
+ * sistema e assinatura de código para o macOS e o Windows não recusarem o
19
+ * binário. Isso derruba a promessa de "já instala junto com o npm", que foi
20
+ * pedida na mesma frase.
21
+ *
22
+ * Está escrito aqui porque é a diferença que importa se um dia alguém quiser
23
+ * mudar: o obstáculo não é técnico, é o tamanho da instalação.
24
+ *
25
+ * ── POR QUE NÃO NO `postinstall` ─────────────────────────────────────────
26
+ * Um `npm i -g` que cria pasta em ~/Applications e escreve no menu Iniciar sem
27
+ * ninguém pedir é um instalador se comportando mal. Quem quiser o aplicativo
28
+ * digita `/app --instalar` uma vez, e o `/app` sozinho continua abrindo a
29
+ * janela sem instalar nada.
30
+ */
31
+
32
+ 'use strict';
33
+
34
+ const fs = require('fs');
35
+ const os = require('os');
36
+ const path = require('path');
37
+ const { execFile, execFileSync } = require('child_process');
38
+
39
+ const icone = require('./icone.js');
40
+
41
+ const NOME = 'PrimoCode';
42
+ const CASA = path.join(os.homedir(), '.primocode');
43
+
44
+ function escrever(destino, dados) {
45
+ fs.mkdirSync(path.dirname(destino), { recursive: true });
46
+ fs.writeFileSync(destino, dados);
47
+ }
48
+
49
+ /* O caminho do Node e o do CLI, absolutos.
50
+ *
51
+ * Aplicativo aberto pelo Finder ou pelo menu Iniciar NÃO herda o PATH do
52
+ * terminal: o `.zshrc` não roda, e num Mac com Node do Homebrew ou do nvm o
53
+ * comando `primo` simplesmente não existe para quem clicou no ícone. O
54
+ * aplicativo abria e fechava na hora, sem dizer nada. Com os dois caminhos
55
+ * escritos no lançador, ele funciona vindo de qualquer lugar. */
56
+ function caminhos() {
57
+ return {
58
+ node: process.execPath,
59
+ cli: path.resolve(__dirname, '..', 'bin', 'primocode.js'),
60
+ };
61
+ }
62
+
63
+ // ── macOS ────────────────────────────────────────────────────────────────
64
+
65
+ const PLIST = (versao) => `<?xml version="1.0" encoding="UTF-8"?>
66
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
67
+ <plist version="1.0">
68
+ <dict>
69
+ <key>CFBundleName</key><string>${NOME}</string>
70
+ <key>CFBundleDisplayName</key><string>${NOME}</string>
71
+ <key>CFBundleIdentifier</key><string>ai.conectaprimo.primocode</string>
72
+ <key>CFBundleVersion</key><string>${versao}</string>
73
+ <key>CFBundleShortVersionString</key><string>${versao}</string>
74
+ <key>CFBundlePackageType</key><string>APPL</string>
75
+ <key>CFBundleExecutable</key><string>${NOME}</string>
76
+ <key>CFBundleIconFile</key><string>${NOME}</string>
77
+ <key>NSHighResolutionCapable</key><true/>
78
+ <!-- Sem isto o ícone do Dock vira um foguete genérico e o app aparece com
79
+ o nome do processo. "LSUIElement" NÃO entra: com ele o aplicativo some
80
+ do Dock, e o Dock é metade do que foi pedido aqui.
81
+
82
+ (Aspas e não acento grave neste comentário de propósito: ele mora dentro
83
+ de um template literal, e um acento grave ali FECHA a string — o erro
84
+ sai vinte linhas abaixo, apontando para o lugar errado.) -->
85
+ <key>LSMinimumSystemVersion</key><string>10.15</string>
86
+ </dict>
87
+ </plist>
88
+ `;
89
+
90
+ function instalarMac(versao) {
91
+ const { node, cli } = caminhos();
92
+ const raiz = path.join(os.homedir(), 'Applications', `${NOME}.app`);
93
+ const conteudo = path.join(raiz, 'Contents');
94
+
95
+ escrever(path.join(conteudo, 'Info.plist'), PLIST(versao));
96
+ escrever(path.join(conteudo, 'Resources', `${NOME}.icns`), icone.icns());
97
+
98
+ /* O lançador. `exec` no fim para o processo do Node HERDAR o lugar do
99
+ script: sem ele o shell fica de pé só esperando, e o macOS mostra dois
100
+ processos no monitor de atividade para um aplicativo só. */
101
+ const bin = path.join(conteudo, 'MacOS', NOME);
102
+ escrever(bin, `#!/bin/sh
103
+ # Gerado pelo PrimoCode (/app --instalar). Não edite: rode o comando de novo.
104
+ exec ${JSON.stringify(node)} ${JSON.stringify(cli)} --app "$@"
105
+ `);
106
+ fs.chmodSync(bin, 0o755);
107
+
108
+ /* O macOS guarda em cache o ícone e o nome pelo caminho do bundle. Sem
109
+ tocar na pasta, um app reinstalado continua mostrando o ícone velho até
110
+ o sistema reiniciar — o que parece, de fora, o comando não ter
111
+ funcionado. `touch` na pasta invalida esse cache. */
112
+ try { fs.utimesSync(raiz, new Date(), new Date()); } catch {}
113
+
114
+ return { ok: true, caminho: raiz, onde: 'Launchpad e pasta Aplicativos' };
115
+ }
116
+
117
+ // ── Windows ──────────────────────────────────────────────────────────────
118
+
119
+ /* O atalho do Windows (.lnk) é um formato binário com estrutura própria. Em
120
+ * vez de montá-lo byte a byte, quem o escreve é o próprio Windows, pelo
121
+ * WScript.Shell — que existe em toda instalação desde sempre e é a API que os
122
+ * instaladores usam. */
123
+ function instalarWindows(versao) {
124
+ const { node, cli } = caminhos();
125
+ const recursos = path.join(CASA, 'app');
126
+ const ico = path.join(recursos, 'primocode.ico');
127
+ escrever(ico, icone.ico());
128
+
129
+ /* Um .cmd abriria uma janela preta de console junto do aplicativo. O .vbs
130
+ com `Run ..., 0` lança o Node com a janela escondida — é o que faz o
131
+ aplicativo abrir só a janela dele, como qualquer programa. */
132
+ const vbs = path.join(recursos, 'primocode.vbs');
133
+ escrever(vbs, `' Gerado pelo PrimoCode (/app --instalar). Não edite: rode o comando de novo.\r
134
+ Set sh = CreateObject("WScript.Shell")\r
135
+ sh.Run """${node}"" ""${cli}"" --app", 0, False\r
136
+ `);
137
+
138
+ const menu = path.join(process.env.APPDATA || path.join(os.homedir(), 'AppData', 'Roaming'),
139
+ 'Microsoft', 'Windows', 'Start Menu', 'Programs', `${NOME}.lnk`);
140
+ const area = path.join(os.homedir(), 'Desktop', `${NOME}.lnk`);
141
+
142
+ const ps = [
143
+ '$w = New-Object -ComObject WScript.Shell',
144
+ ...[menu, area].map((alvo) => [
145
+ `$s = $w.CreateShortcut(${JSON.stringify(alvo)})`,
146
+ '$s.TargetPath = "wscript.exe"',
147
+ `$s.Arguments = '"${vbs}"'`,
148
+ `$s.IconLocation = ${JSON.stringify(ico)}`,
149
+ `$s.Description = "PrimoCode ${versao}"`,
150
+ `$s.WorkingDirectory = ${JSON.stringify(path.dirname(cli))}`,
151
+ '$s.Save()',
152
+ ].join('; ')),
153
+ ].join('; ');
154
+
155
+ try {
156
+ fs.mkdirSync(path.dirname(menu), { recursive: true });
157
+ execFileSync('powershell', ['-NoProfile', '-NonInteractive', '-Command', ps],
158
+ { stdio: 'ignore', timeout: 30000 });
159
+ } catch (e) {
160
+ return { ok: false, error: `não consegui criar o atalho: ${e.message}` };
161
+ }
162
+ return { ok: true, caminho: menu, onde: 'menu Iniciar e Área de Trabalho' };
163
+ }
164
+
165
+ // ── Linux ────────────────────────────────────────────────────────────────
166
+
167
+ function instalarLinux(versao) {
168
+ const { node, cli } = caminhos();
169
+ const icones = path.join(os.homedir(), '.local', 'share', 'icons', 'hicolor');
170
+ for (const t of [16, 32, 48, 64, 128, 256, 512]) {
171
+ escrever(path.join(icones, `${t}x${t}`, 'apps', 'primocode.png'), icone.png(t));
172
+ }
173
+ const arquivo = path.join(os.homedir(), '.local', 'share', 'applications', 'primocode.desktop');
174
+ escrever(arquivo, `[Desktop Entry]
175
+ Type=Application
176
+ Name=${NOME}
177
+ Comment=Agente de engenharia em português
178
+ Exec=${node} ${cli} --app
179
+ Icon=primocode
180
+ Terminal=false
181
+ Categories=Development;Utility;
182
+ StartupWMClass=primocode
183
+ X-PrimoCode-Version=${versao}
184
+ `);
185
+ fs.chmodSync(arquivo, 0o755);
186
+ // Sem atualizar o banco, o menu só mostra o aplicativo na sessão seguinte.
187
+ try {
188
+ execFileSync('update-desktop-database', [path.dirname(arquivo)],
189
+ { stdio: 'ignore', timeout: 15000 });
190
+ } catch { /* nem toda distribuição tem, e a entrada funciona sem */ }
191
+ return { ok: true, caminho: arquivo, onde: 'menu de aplicativos' };
192
+ }
193
+
194
+ // ── porta única ──────────────────────────────────────────────────────────
195
+
196
+ function instalar(versao) {
197
+ try {
198
+ const p = os.platform();
199
+ if (p === 'darwin') return instalarMac(versao);
200
+ if (p === 'win32') return instalarWindows(versao);
201
+ return instalarLinux(versao);
202
+ } catch (e) {
203
+ return { ok: false, error: e.message };
204
+ }
205
+ }
206
+
207
+ /** Já instalado? Serve para o /app oferecer a instalação uma vez só. */
208
+ function instalado() {
209
+ const p = os.platform();
210
+ const alvo = p === 'darwin'
211
+ ? path.join(os.homedir(), 'Applications', `${NOME}.app`)
212
+ : p === 'win32'
213
+ ? path.join(process.env.APPDATA || path.join(os.homedir(), 'AppData', 'Roaming'),
214
+ 'Microsoft', 'Windows', 'Start Menu', 'Programs', `${NOME}.lnk`)
215
+ : path.join(os.homedir(), '.local', 'share', 'applications', 'primocode.desktop');
216
+ try { return fs.existsSync(alvo) ? alvo : null; } catch { return null; }
217
+ }
218
+
219
+ function desinstalar() {
220
+ const alvo = instalado();
221
+ if (!alvo) return { ok: true, jaSaiu: true };
222
+ try {
223
+ fs.rmSync(alvo, { recursive: true, force: true });
224
+ if (os.platform() === 'win32') {
225
+ try { fs.rmSync(path.join(os.homedir(), 'Desktop', `${NOME}.lnk`), { force: true }); } catch {}
226
+ }
227
+ return { ok: true, caminho: alvo };
228
+ } catch (e) { return { ok: false, error: e.message }; }
229
+ }
230
+
231
+ module.exports = { instalar, instalado, desinstalar, NOME };