primocode 10.15.0 → 10.15.1

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.
@@ -3,6 +3,9 @@
3
3
  <head>
4
4
  <meta charset="utf-8">
5
5
  <title>Primo</title>
6
+ <link rel="preload" as="script" href="vendor/spline-runtime.min.js">
7
+ <link rel="preload" as="fetch" crossorigin href="https://prod.spline.design/kZDDjO5HuC9GJUM2/scene.splinecode">
8
+ <script src="vendor/spline-runtime.min.js"></script>
6
9
  <link href="https://fonts.googleapis.com/css2?family=Instrument+Serif:ital@0;1&family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
7
10
  <style>
8
11
  :root {
@@ -15,6 +18,10 @@
15
18
  html,body { height:100%; background:var(--fundo); color:var(--texto); font-family:var(--sans); -webkit-font-smoothing:antialiased; user-select:none; -webkit-user-select:none; }
16
19
  body { background:radial-gradient(ellipse 60% 55% at 50% 42%, #1a2a2c 0%, var(--fundo) 62%); overflow:hidden; }
17
20
  .tela { height:100vh; padding:38px 28px 24px; display:grid; gap:18px; grid-template-columns:minmax(250px,22%) 1fr minmax(280px,25%); grid-template-rows:minmax(0,1fr) auto auto; }
21
+ @property --v { syntax:'<number>'; inherits:true; initial-value:0; }
22
+ .cartao { transition:transform .28s cubic-bezier(.2,.8,.2,1), box-shadow .28s, border-color .28s; animation:sobe .75s cubic-bezier(.2,.8,.2,1) backwards; animation-delay:calc(var(--i,0)*70ms); }
23
+ .cartao:hover { transform:translateY(-3px); border-color:#3a3a36; box-shadow:0 1px 0 rgba(255,255,255,.05) inset, 0 28px 44px -22px rgba(0,0,0,.9); }
24
+ @keyframes sobe { from { opacity:0; transform:translateY(18px); } }
18
25
  .cartao { background:linear-gradient(180deg,var(--cartao2),var(--cartao)); border:1px solid var(--borda); border-radius:var(--raio); padding:18px 22px; box-shadow:0 1px 0 rgba(255,255,255,.04) inset, 0 20px 40px -24px rgba(0,0,0,.8); }
19
26
  .rotulo { font-size:11px; font-weight:600; letter-spacing:.12em; text-transform:uppercase; color:var(--fraco); }
20
27
 
@@ -26,7 +33,12 @@
26
33
  .cal .sem { color:var(--fraco); font-size:11px; font-weight:600; padding-bottom:3px; height:auto; }
27
34
  .cal span { height:30px; display:grid; place-items:center; color:var(--apagado); border-radius:50%; width:30px; margin:auto; position:relative; }
28
35
  .cal .fds { color:var(--fraco); }
29
- .cal .hoje { background:var(--marca); color:#06191c; font-weight:700; }
36
+ .cal .hoje { background:var(--marca); color:#06191c; font-weight:700; animation:pulso-dia 2.6s ease-in-out infinite; }
37
+ @keyframes pulso-dia { 0%,100% { box-shadow:0 0 0 0 rgba(34,211,238,.45); } 50% { box-shadow:0 0 0 7px rgba(34,211,238,0); } }
38
+ .capa.troca { animation:capa-troca .8s cubic-bezier(.2,.8,.2,1); } @keyframes capa-troca { from { opacity:0; transform:rotate(-140deg) scale(.6); } }
39
+ @keyframes fade { from { opacity:0; } }
40
+ .faixa.troca { animation:fade .6s ease; }
41
+ @media (prefers-reduced-motion: reduce) { *, *::before, *::after { animation:none !important; transition:none !important; } }
30
42
  .cal .tem::after { content:""; width:4px; height:4px; border-radius:50%; background:var(--marca); position:absolute; bottom:1px; }
31
43
  .cal .hoje.tem::after { background:#06191c; }
32
44
  .hoje-card { flex:1; min-height:0; overflow:hidden; }
@@ -49,13 +61,22 @@
49
61
  .nome p::before { content:""; display:inline-block; width:7px; height:7px; border-radius:50%; background:var(--ok); margin-right:8px; box-shadow:0 0 10px var(--ok); }
50
62
  .nome[data-e="pensando"] p::before, .nome[data-e="executando"] p::before { background:#f5b94a; box-shadow:0 0 10px #f5b94a; }
51
63
  .nome[data-e="dormindo"] p::before { background:var(--fraco); box-shadow:none; }
64
+ .nome .pasta { font:12px var(--mono); color:var(--fraco); margin-top:2px; }
65
+ .nome .pasta::before { display:none; }
66
+ .b-entrar { margin-top:8px; background:var(--marca); color:#06191c; border:0; border-radius:999px; padding:8px 18px; font:600 13px var(--sans); cursor:pointer; }
52
67
  .fala { position:absolute; top:0; left:50%; transform:translateX(-50%); max-width:70%; text-align:center; color:var(--apagado); font-size:13px; line-height:1.4; max-height:3em; overflow:hidden; }
53
68
  /* robô 3D: canvas transparente sobre o palco; o SVG só aparece se o WebGL falhar */
54
69
  #robo3d { width:100%; height:100%; display:none; -webkit-mask-image:linear-gradient(#000 82%,transparent); mask-image:linear-gradient(#000 82%,transparent); }
55
- .tres-d #robo3d { display:block; } .tres-d #svg-robo { display:none; }
70
+ #svg-robo { display:none; }
71
+ .tres-d #robo3d { display:block; animation:entra .6s ease both; } .svg #svg-robo { display:block; animation:entra .6s ease both; }
72
+ @keyframes entra { from { opacity:0; } }
73
+ /* carregando: nenhum robô, só o lugar dele com um brilho ciano suave pulsando */
74
+ #carrega { display:none; width:min(30vh,260px); height:min(48vh,420px); border-radius:50% 50% 42% 42% / 38% 38% 62% 62%; background:radial-gradient(closest-side,rgba(34,211,238,.22),rgba(34,211,238,.07) 62%,transparent); filter:blur(14px); animation:carrega 2.2s ease-in-out infinite; pointer-events:none; }
75
+ .carregando #carrega { display:block; }
76
+ @keyframes carrega { 0%,100% { opacity:.35; transform:scale(.94); } 50% { opacity:.95; transform:scale(1.04); } }
56
77
  /* robô do Spline (cena da comunidade): canvas transparente, brilho ciano por CSS, busto pelo zoom da câmera */
57
78
  #robo-spline { width:100% !important; height:100% !important; visibility:hidden; background:transparent; --nv:0; -webkit-mask-image:linear-gradient(#000 44%,rgba(0,0,0,.55) 58%,transparent 76%); mask-image:linear-gradient(#000 44%,rgba(0,0,0,.55) 58%,transparent 76%); filter:drop-shadow(0 0 18px rgba(34,211,238,.22)); transition:filter .4s; }
58
- .spline #robo-spline { visibility:visible; } .spline #robo3d, .spline #svg-robo { display:none; }
79
+ .spline #robo-spline { visibility:visible; animation:entra .6s ease both; } .spline #robo3d, .spline #svg-robo { display:none; }
59
80
  .palco > #robo-spline { grid-area:1/1; }
60
81
  .spline.e-ouvindo #robo-spline { filter:drop-shadow(0 0 26px rgba(34,211,238,.55)); }
61
82
  .spline.e-pensando #robo-spline, .spline.e-executando #robo-spline { filter:drop-shadow(0 0 22px rgba(34,211,238,.38)); animation:sp-pensa 1.8s ease-in-out infinite; }
@@ -97,16 +118,16 @@
97
118
  .capa::after { content:""; position:absolute; inset:44%; border-radius:50%; background:var(--fundo); box-shadow:0 0 0 2px #0b0b0a; }
98
119
  .faixa { text-align:center; max-width:100%; } .faixa b { display:block; font-size:16px; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; } .faixa small { color:var(--apagado); font-size:13px; }
99
120
  .prog { width:100%; display:flex; align-items:center; gap:10px; font:12px var(--mono); color:var(--fraco); }
100
- .prog .t { flex:1; height:4px; border-radius:2px; background:var(--borda); overflow:hidden; } .prog .t i { display:block; height:100%; width:0; background:var(--texto); border-radius:2px; }
121
+ .prog .t { flex:1; height:4px; border-radius:2px; background:var(--borda); overflow:hidden; } .prog .t i { display:block; height:100%; width:0; transition:width .3s linear; background:var(--texto); border-radius:2px; }
101
122
  .ctrl { display:flex; gap:30px; align-items:center; }
102
123
  .ctrl button { background:none; border:0; color:var(--texto); cursor:pointer; display:grid; place-items:center; }
103
124
  .ctrl button:active { transform:scale(.9); }
104
125
  .ctrl .play { width:50px; height:50px; border-radius:50%; background:var(--texto); color:var(--fundo); }
105
126
 
106
127
  /* medidores */
107
- .medidores { grid-column:1 / -1; display:grid; grid-template-columns:repeat(4,1fr) 1.6fr; gap:18px; }
128
+ .medidores { grid-column:1 / -1; display:grid; grid-template-columns:repeat(4,1fr); gap:18px; }
108
129
  .med { display:flex; align-items:center; gap:16px; padding:14px 20px; }
109
- .anel { --v:0; width:62px; height:62px; border-radius:50%; flex:none; display:grid; place-items:center; background:conic-gradient(var(--c,var(--marca)) calc(var(--v)*1%), var(--borda) 0); position:relative; }
130
+ .anel { transition:--v .9s cubic-bezier(.2,.8,.2,1); --v:0; width:62px; height:62px; border-radius:50%; flex:none; display:grid; place-items:center; background:conic-gradient(var(--c,var(--marca)) calc(var(--v)*1%), var(--borda) 0); position:relative; }
110
131
  .anel::before { content:""; position:absolute; inset:6px; border-radius:50%; background:var(--cartao); }
111
132
  .anel span { position:relative; font:600 15px var(--sans); font-variant-numeric:tabular-nums; }
112
133
  .med b { display:block; font-size:14px; } .med small { color:var(--apagado); font-size:12px; }
@@ -117,7 +138,7 @@
117
138
  /* voz */
118
139
  .voz { grid-column:1 / -1; display:flex; align-items:center; gap:18px; padding:12px 22px; }
119
140
  #barras { flex:1; height:46px; display:flex; align-items:center; gap:3px; }
120
- #barras i { flex:1; border-radius:3px; height:8%; background:var(--marca); transition:height .08s linear; }
141
+ #barras i { flex:1; border-radius:3px; height:8%; background:var(--marca); transition:height .12s ease-out; }
121
142
  #barras i.p { background:var(--ok); } #barras i.z { background:var(--borda); }
122
143
  .leg { display:flex; gap:16px; font-size:12px; color:var(--apagado); white-space:nowrap; }
123
144
  .leg span::before { content:""; display:inline-block; width:8px; height:8px; border-radius:50%; margin-right:6px; background:var(--marca); }
@@ -143,6 +164,7 @@
143
164
  <div class="fala" id="fala"></div>
144
165
  <div class="palco">
145
166
  <div class="chao"></div>
167
+ <div id="carrega"></div>
146
168
  <canvas id="robo3d" aria-label="Primo"></canvas>
147
169
  <canvas id="robo-spline" aria-label="Primo"></canvas>
148
170
  <svg id="svg-robo" viewBox="0 0 400 520" aria-label="Primo" class="desmontado">
@@ -216,7 +238,7 @@
216
238
  </g>
217
239
  </svg>
218
240
  </div>
219
- <div class="nome" id="nome" data-e="dormindo"><h1 id="nome-h">Primo</h1><p id="estado-t">em espera</p></div>
241
+ <div class="nome" id="nome" data-e="dormindo"><h1 id="nome-h">Primo</h1><p id="estado-t">em espera</p><p id="pasta" class="pasta"></p><button id="b-entrar" class="b-entrar" hidden>Entrar</button></div>
220
242
  </section>
221
243
 
222
244
  <section class="dir">
@@ -242,10 +264,6 @@
242
264
  <div class="cartao med"><div class="anel" id="a-mem" style="--c:var(--marca-clara)"><span>–</span></div><div><b>Memória</b><small id="s-mem">&nbsp;</small></div></div>
243
265
  <div class="cartao med"><div class="anel" id="a-disco" style="--c:var(--apagado)"><span>–</span></div><div><b>Disco</b><small id="s-disco">&nbsp;</small></div></div>
244
266
  <div class="cartao med"><div class="anel" id="a-bat" style="--c:var(--ok)"><span>–</span></div><div><b>Bateria</b><small id="s-bat">&nbsp;</small></div></div>
245
- <div class="cartao rede">
246
- <div class="lin"><span>REDE</span><span id="r-txt">↓ 0 KB/s · ↑ 0 KB/s</span></div>
247
- <svg viewBox="0 0 300 44" preserveAspectRatio="none"><path id="r-down" d="" fill="none" stroke="#22d3ee" stroke-width="2"/><path id="r-up" d="" fill="none" stroke="#7ad7a3" stroke-width="1.5" opacity=".85"/></svg>
248
- </div>
249
267
  </section>
250
268
 
251
269
  <section class="cartao voz">
@@ -256,7 +274,6 @@
256
274
  </main>
257
275
 
258
276
  <script src="vendor/three-robo.min.js"></script>
259
- <script src="vendor/spline-runtime.min.js"></script>
260
277
  <script>
261
278
  const $ = (id) => document.getElementById(id);
262
279
  const post = (o) => { try { window.webkit.messageHandlers.primo.postMessage(JSON.stringify(o)); } catch (e) {} };
@@ -353,12 +370,13 @@ const R3 = (function () {
353
370
  /* ── robô do Spline (runtime local, cena na rede). Falhou ou passou de 6 s: fica o RobotExpressive ── */
354
371
  const SP = (function () {
355
372
  const CENA = 'https://prod.spline.design/kZDDjO5HuC9GJUM2/scene.splinecode';
356
- const X = { ok: false, tentou: false, app: null, bot: null };
373
+ const X = { ok: false, tentou: false, app: null, bot: null, falhou: false };
357
374
  X.iniciar = () => {
358
375
  if (X.tentou || !window.SplineApp) return; X.tentou = true;
359
376
  let acabou = false;
360
- const cai = (why) => { if (acabou) return; acabou = true; try { X.app && X.app.stop(); } catch (e) {} X.ok = false; post({ acao: 'erro', arg: 'spline: ' + why + ' (fallback RobotExpressive)' }); classeTela(); };
377
+ const cai = (why) => { if (acabou) return; acabou = true; try { X.app && X.app.stop(); } catch (e) {} X.ok = false; X.falhou = true; post({ acao: 'erro', arg: 'spline: ' + why + ' (fallback RobotExpressive)' }); classeTela(); };
361
378
  const tempo = setTimeout(() => cai('mais de 6 s'), 6000);
379
+ X.cancela = () => { acabou = true; clearTimeout(tempo); X.falhou = true; try { X.app && X.app.stop(); } catch (e) {} };
362
380
  try {
363
381
  const app = new window.SplineApp($('robo-spline')); X.app = app;
364
382
  app.load(CENA).then(() => {
@@ -372,6 +390,7 @@ const SP = (function () {
372
390
  } catch (e) { clearTimeout(tempo); cai(String(e)); }
373
391
  };
374
392
  // leve: janela oculta/minimizada = para o render
393
+ setTimeout(() => X.iniciar(), 0); // já na carga da página, antes de a ponte do Python falar
375
394
  document.addEventListener('visibilitychange', () => { if (!X.app || !X.ok) return; if (document.hidden) X.app.stop(); else X.app.play(); });
376
395
  // falando: o robô respira no ritmo da voz (escala do objeto "Bot" da cena) + brilho ciano pelo CSS (--nv)
377
396
  X.quadro = (agora) => {
@@ -427,7 +446,7 @@ function desenharHoje() {
427
446
  function desenharClima(c) {
428
447
  if (!c || c.temp === undefined) return;
429
448
  $('c-cidade').textContent = c.cidade || '';
430
- $('c-temp').textContent = c.temp + '°';
449
+ rolar($('c-temp'), c.temp, '°');
431
450
  $('c-cond').textContent = c.icone + ' ' + c.cond;
432
451
  $('c-mm').textContent = 'Máx. ' + c.max + '° · Mín. ' + c.min + '°';
433
452
  $('c-dias').innerHTML = (c.dias || []).slice(0, 4).map((d) => {
@@ -444,6 +463,7 @@ $('m-play').innerHTML = ICO_PLAY;
444
463
  function desenharMusica(m) {
445
464
  S.musica = m && m.faixa ? m : null; S.musicaT = Date.now();
446
465
  if (!S.musica) { $('m-faixa').textContent = 'Nada tocando'; $('m-artista').textContent = 'abra o Spotify ou a Música'; $('disco').classList.remove('toca'); $('capa').style.backgroundImage = ''; $('m-dur').textContent = '0:00'; $('m-play').innerHTML = ICO_PLAY; return; }
466
+ if (m.faixa !== S.faixaAnt) { S.faixaAnt = m.faixa; for (const e of [$('capa'), document.querySelector('.faixa')]) { e.classList.remove('troca'); void e.offsetWidth; e.classList.add('troca'); } }
447
467
  $('m-faixa').textContent = m.faixa; $('m-artista').textContent = m.artista; $('m-dur').textContent = mmss(m.dur);
448
468
  const tocando = m.estado === 'playing';
449
469
  $('disco').classList.toggle('toca', tocando);
@@ -469,17 +489,23 @@ document.querySelectorAll('.ctrl button').forEach((b) => b.onclick = (e) => {
469
489
  const fmtBytes = (b) => b >= 1e6 ? (b / 1e6).toFixed(1) + ' MB/s' : Math.round(b / 1e3) + ' KB/s';
470
490
  const hist = { down: Array(60).fill(0), up: Array(60).fill(0) };
471
491
  function caminho(a, max) { return a.map((v, i) => (i ? 'L' : 'M') + (i * 300 / (a.length - 1)).toFixed(1) + ' ' + (40 - 36 * Math.min(1, v / max)).toFixed(1)).join(' '); }
472
- function anel(id, pct, texto, cor) { const e = $(id); e.style.setProperty('--v', Math.max(0, Math.min(100, pct))); e.firstChild.textContent = texto; if (cor) e.style.setProperty('--c', cor); }
492
+ const RM = window.matchMedia && matchMedia('(prefers-reduced-motion: reduce)').matches;
493
+ /* número que rola até o valor (60 fps, ease-out); sem animação se a pessoa pediu menos movimento */
494
+ function rolar(el, alvo, suf) {
495
+ suf = suf || ''; alvo = Number(alvo); const de = el._n === undefined ? 0 : el._n; el._n = alvo;
496
+ cancelAnimationFrame(el._r);
497
+ if (RM || de === alvo || isNaN(alvo)) { el.textContent = alvo + suf; return; }
498
+ const t0 = performance.now();
499
+ const f = (t) => { const k = Math.min(1, (t - t0) / 800), e = 1 - Math.pow(1 - k, 3); el.textContent = Math.round(de + (alvo - de) * e) + suf; if (k < 1) el._r = requestAnimationFrame(f); };
500
+ el._r = requestAnimationFrame(f);
501
+ }
502
+ function anel(id, pct, texto, cor) { const e = $(id); e.style.setProperty('--v', Math.max(0, Math.min(100, pct))); const m = String(texto).match(/^(-?\d+)(.*)$/); if (m) rolar(e.firstChild, m[1], m[2]); else e.firstChild.textContent = texto; if (cor) e.style.setProperty('--c', cor); }
473
503
  function desenharMaquina(m) {
474
504
  anel('a-cpu', m.cpu, m.cpu + '%'); $('s-cpu').textContent = m.nucleos + ' núcleos';
475
505
  if (m.mem && m.mem.total_gb) { anel('a-mem', m.mem.pct, m.mem.pct + '%'); $('s-mem').textContent = String(m.mem.usada_gb).replace('.', ',') + ' de ' + m.mem.total_gb + ' GB'; }
476
506
  if (m.disco && m.disco.livre_gb !== undefined) { anel('a-disco', m.disco.pct, m.disco.pct + '%'); $('s-disco').textContent = m.disco.livre_gb + ' GB livres'; }
477
507
  if (m.bat && m.bat.pct !== undefined) { anel('a-bat', m.bat.pct, String(m.bat.pct), m.bat.pct <= 20 && !m.bat.tomada ? 'var(--erro)' : 'var(--ok)'); $('s-bat').textContent = m.bat.tomada ? 'na tomada' : 'na bateria'; }
478
508
  else { $('s-bat').textContent = 'sem bateria'; }
479
- hist.down.push(m.down || 0); hist.down.shift(); hist.up.push(m.up || 0); hist.up.shift();
480
- const mx = Math.max(2e4, ...hist.down, ...hist.up);
481
- $('r-down').setAttribute('d', caminho(hist.down, mx)); $('r-up').setAttribute('d', caminho(hist.up, mx));
482
- $('r-txt').textContent = '↓ ' + fmtBytes(m.down || 0) + ' · ↑ ' + fmtBytes(m.up || 0);
483
509
  }
484
510
 
485
511
  /* ── barras da voz: o histórico rola; ciano = a pessoa falou, verde = o Primo falou ── */
@@ -495,7 +521,11 @@ function empurraBarra() {
495
521
  }
496
522
 
497
523
  /* ── o robô ── */
498
- function classeTela() { $('tela').className = 'tela e-' + S.estado + (SP.ok ? ' spline' : R3.ok ? ' tres-d' : ''); }
524
+ function classeTela() {
525
+ // enquanto o Spline carrega: só o brilho (nada de robô). O fallback só entra se o Spline falhou de verdade.
526
+ const f = SP.ok ? ' spline' : !SP.falhou ? ' carregando' : R3.ok ? ' tres-d' : ' svg';
527
+ $('tela').className = 'tela e-' + S.estado + f;
528
+ }
499
529
  function setEstado(e) {
500
530
  if (!e || e === S.estado) return;
501
531
  S.estado = e; classeTela(); $('nome').dataset.e = e;
@@ -506,6 +536,7 @@ function setEstado(e) {
506
536
  $('foco').style.opacity = e === 'falando' ? 0 : .9;
507
537
  }
508
538
  $('centro').onclick = () => post({ acao: 'ptt' });
539
+ $('b-entrar').onclick = (e) => { e.stopPropagation(); post({ acao: 'entrar' }); };
509
540
 
510
541
  /* simples: o visor vira equalizador de 13 barras quando fala; olha de lado quando pensa */
511
542
  const EQ = []; (function () { const g = $('eq'); for (let i = 0; i < 13; i++) { const r = document.createElementNS('http://www.w3.org/2000/svg', 'rect'); r.setAttribute('x', 136 + i * 9.8); r.setAttribute('width', 6); r.setAttribute('rx', 3); r.setAttribute('fill', '#a5f3fc'); g.appendChild(r); EQ.push(r); } })();
@@ -525,8 +556,10 @@ function laco(t) { SP.quadro(t); if (!R3.ok && !SP.ok) animaEq(t); requestAnimat
525
556
 
526
557
  /* ── a ponte com o Python ── */
527
558
  window.primo = (m) => {
559
+ if (m.pasta !== undefined) $('pasta').textContent = m.pasta;
560
+ if (m.login !== undefined) $('b-entrar').hidden = !!m.login;
528
561
  if (m.nome) { S.nome = m.nome; $('nome-h').textContent = m.nome; $('leg-nome').textContent = m.nome; if (S.estado === 'dormindo') $('estado-t').textContent = 'diga "' + m.nome + '"'; }
529
- if (m.robo === 'spline') SP.iniciar();
562
+ if (m.robo === 'expressive' && !SP.ok) { if (SP.cancela) SP.cancela(); SP.falhou = true; classeTela(); }
530
563
  if (m.evento) R3.evento(m.evento);
531
564
  if (m.boot) montar();
532
565
  if (m.estado) setEstado(m.estado);
@@ -539,7 +572,8 @@ window.primo = (m) => {
539
572
  if (m.maquina) desenharMaquina(m.maquina);
540
573
  };
541
574
 
542
- viraDia(); desenharHoje(); setEstado('dormindo');
575
+ document.querySelectorAll('.cartao').forEach((c, i) => c.style.setProperty('--i', i));
576
+ viraDia(); desenharHoje(); setEstado('dormindo'); classeTela();
543
577
  setInterval(() => { viraDia(); andarMusica(); }, 250);
544
578
  setInterval(empurraBarra, 50);
545
579
  setTimeout(pisca, 3000);
@@ -29,9 +29,10 @@ from primo_voz.sons import Sons
29
29
  AQUI = Path(__file__).resolve().parent
30
30
 
31
31
 
32
- def main(demo: bool = False) -> int:
32
+ def main(demo: bool = False, entrada=None, saida=None, ao_iniciar=None, ao_sair=None) -> int:
33
+ """entrada/saida/ao_iniciar/ao_sair: o modo app (app.py) liga o painel ao motor de voz sem passar por stdin/stdout."""
33
34
  try:
34
- from AppKit import (NSApplication, NSApplicationActivationPolicyAccessory, NSBackingStoreBuffered, NSColor,
35
+ from AppKit import (NSApplication, NSApplicationActivationPolicyRegular, NSImage, NSMenu, NSMenuItem, NSBackingStoreBuffered, NSColor,
35
36
  NSEvent, NSScreen, NSWindow)
36
37
  from Foundation import NSMakeRect, NSObject, NSTimer, NSURL
37
38
  import objc
@@ -44,6 +45,13 @@ def main(demo: bool = False) -> int:
44
45
  fila: "queue.Queue[str]" = queue.Queue()
45
46
  sons = Sons(os.environ.get("PRIMOCODE_SEM_SONS") != "1")
46
47
 
48
+ def emitir(obj: dict) -> None:
49
+ if saida:
50
+ saida(obj)
51
+ else:
52
+ sys.stdout.write(json.dumps(obj) + "\n")
53
+ sys.stdout.flush()
54
+
47
55
  def js(msg: dict) -> None:
48
56
  fila.put("window.primo(" + json.dumps(msg, ensure_ascii=False) + ")")
49
57
 
@@ -75,6 +83,22 @@ def main(demo: bool = False) -> int:
75
83
  else:
76
84
  self.jan.orderOut_(None)
77
85
 
86
+ class Dono(NSObject):
87
+ def windowShouldClose_(self, _): # fechar o painel só esconde; o PrimoCode segue
88
+ p.visivel = False
89
+ return True
90
+
91
+ def applicationWillTerminate_(self, _n):
92
+ if ao_sair:
93
+ try:
94
+ ao_sair()
95
+ except Exception:
96
+ pass
97
+
98
+ def applicationShouldHandleReopen_hasVisibleWindows_(self, _a, _v): # clique no Dock traz de volta
99
+ p.visivel = True
100
+ return True
101
+
78
102
  class Msg(NSObject, protocols=[objc.protocolNamed("WKScriptMessageHandler")]):
79
103
  def userContentController_didReceiveScriptMessage_(self, ucc, msg):
80
104
  try:
@@ -87,9 +111,8 @@ def main(demo: bool = False) -> int:
87
111
  sys.stderr.flush()
88
112
  elif a == "sair":
89
113
  p.visivel = False
90
- elif a == "ptt":
91
- sys.stdout.write(json.dumps({"acao": "ptt"}) + "\n")
92
- sys.stdout.flush()
114
+ elif a in ("ptt", "entrar"):
115
+ emitir({"acao": a})
93
116
  elif a == "print": # esconde o HUD para o print pegar a tela de verdade
94
117
  p.visivel = False
95
118
  threading.Timer(0.6, lambda: pn.executar_acao(m)).start()
@@ -105,7 +128,26 @@ def main(demo: bool = False) -> int:
105
128
  threading.Thread(target=_fazer, daemon=True).start()
106
129
 
107
130
  app = NSApplication.sharedApplication()
108
- app.setActivationPolicy_(NSApplicationActivationPolicyAccessory)
131
+ app.setActivationPolicy_(NSApplicationActivationPolicyRegular) # app de verdade: Dock e Cmd+Tab
132
+ try: # nome e ícone do Primo, mesmo fora do bundle
133
+ from AppKit import NSBundle
134
+ info = NSBundle.mainBundle().infoDictionary()
135
+ info["CFBundleName"] = info["CFBundleDisplayName"] = "Primo"
136
+ icone = AQUI / "vendor" / "primo-icone.png"
137
+ if icone.exists():
138
+ app.setApplicationIconImage_(NSImage.alloc().initWithContentsOfFile_(str(icone)))
139
+ except Exception as e:
140
+ sys.stderr.write(f"hud: ícone: {e}\n")
141
+ barra = NSMenu.alloc().init()
142
+ raiz = NSMenuItem.alloc().init()
143
+ barra.addItem_(raiz)
144
+ menu = NSMenu.alloc().init()
145
+ for rot, sel, tec in (("Sobre o Primo", "orderFrontStandardAboutPanel:", ""), (None, None, None),
146
+ ("Esconder o Primo", "hide:", "h"), (None, None, None), ("Sair do Primo", "terminate:", "q")):
147
+ menu.addItem_(NSMenuItem.separatorItem() if rot is None else
148
+ NSMenuItem.alloc().initWithTitle_action_keyEquivalent_(rot, sel, tec))
149
+ raiz.setSubmenu_(menu)
150
+ app.setMainMenu_(barra)
109
151
  mouse = NSEvent.mouseLocation()
110
152
  alvo_tela = next((s for s in NSScreen.screens() if s.frame().origin.x <= mouse.x < s.frame().origin.x + s.frame().size.width
111
153
  and s.frame().origin.y <= mouse.y < s.frame().origin.y + s.frame().size.height), NSScreen.mainScreen())
@@ -115,6 +157,10 @@ def main(demo: bool = False) -> int:
115
157
  jan.setTitlebarAppearsTransparent_(True)
116
158
  jan.setTitleVisibility_(1) # sem texto de título
117
159
  jan.setTitle_("Primo")
160
+ jan.setReleasedWhenClosed_(False)
161
+ dono = Dono.alloc().init()
162
+ jan.setDelegate_(dono)
163
+ app.setDelegate_(dono)
118
164
  jan.setMinSize_((900, 600))
119
165
  jan.setOpaque_(True)
120
166
  jan.setBackgroundColor_(NSColor.colorWithSRGBRed_green_blue_alpha_(0.043, 0.043, 0.039, 1.0))
@@ -176,7 +222,7 @@ def main(demo: bool = False) -> int:
176
222
  k = 0
177
223
  while True:
178
224
  try:
179
- if k % 12 == 0:
225
+ if k % 12 == 0 or evs["permissao"] != "ok": # sem acesso, olha a cada 5 s (a pessoa pode ter liberado)
180
226
  evs = td.eventos_do_mes()
181
227
  js({"calendario": {"permissao": evs["permissao"], "dias": sorted({e["dia"] for e in evs["eventos"]})}})
182
228
  if evs["permissao"] == "ok":
@@ -188,7 +234,7 @@ def main(demo: bool = False) -> int:
188
234
  threading.Thread(target=agenda, daemon=True).start()
189
235
 
190
236
  def ler():
191
- for l in sys.stdin:
237
+ for l in (entrada if entrada is not None else sys.stdin):
192
238
  try:
193
239
  m = json.loads(l)
194
240
  except ValueError:
@@ -197,7 +243,8 @@ def main(demo: bool = False) -> int:
197
243
  js(m)
198
244
  if "estado" in m or "nivel" in m:
199
245
  js({"barras": td.barras(p.estado, p.nivel)})
200
- os._exit(0)
246
+ if entrada is None:
247
+ os._exit(0)
201
248
 
202
249
  def encenar():
203
250
  roteiro = [("dormindo", "", "", 3), ("ouvindo", "roda os testes do projeto", "", 4),
@@ -214,8 +261,12 @@ def main(demo: bool = False) -> int:
214
261
  time.sleep(0.06)
215
262
  if est == "falando":
216
263
  js({"evento": "sucesso"})
217
- if "--livre" not in sys.argv:
264
+ if entrada is not None:
265
+ threading.Thread(target=ler, daemon=True).start()
266
+ elif "--livre" not in sys.argv:
218
267
  threading.Thread(target=encenar if demo else ler, daemon=True).start()
268
+ if ao_iniciar:
269
+ ao_iniciar(js, p)
219
270
  app.run()
220
271
  return 0
221
272
 
@@ -16,7 +16,7 @@ import re
16
16
  from pathlib import Path
17
17
  from typing import Optional
18
18
 
19
- PADRAO = {"nome": "Primo", "pessoa": "", "luz": True, "sons": True, "robo": "spline"}
19
+ PADRAO = {"nome": "Primo", "pessoa": "", "luz": True, "sons": True, "robo": "spline", "voz": "francisca", "tts": "edge"}
20
20
 
21
21
 
22
22
  def arquivo() -> Path:
@@ -116,68 +116,52 @@ def calendario_permissao() -> str:
116
116
  from EventKit import EKEventStore
117
117
  except ImportError:
118
118
  return "sem"
119
- st = EKEventStore.authorizationStatusForEntityType_(0)
120
- return {3: "ok", 0: "pendente", 4: "negada", 1: "negada", 2: "negada"}.get(int(st), "negada")
119
+ st = int(EKEventStore.authorizationStatusForEntityType_(0))
120
+ # 4 = só escrita (um pedido antigo concedeu menos do que o Primo precisa): o macOS não deixa "subir" por código,
121
+ # então tratamos como pendente e o pedido zera a decisão do próprio app antes de pedir o acesso completo.
122
+ return {3: "ok", 0: "pendente", 4: "pendente", 1: "negada", 2: "negada"}.get(st, "negada")
121
123
 
122
124
 
123
125
  def pedir_calendario() -> None:
124
- """Dispara o pedido do macOS (uma vez). Não bloqueia."""
126
+ """Dispara o pedido do macOS (uma vez). Não bloqueia. O resultado vai para o log (stderr)."""
127
+ if time.time() - _ek.get("pedindo", 0) < 120: # o aviso do macOS já está na tela esperando resposta
128
+ return
129
+ _ek["pedindo"] = time.time()
130
+
131
+ def fim(ok, err):
132
+ _ek["pedindo"] = 0
133
+ sys.stderr.write(f"calendario: pedido respondido ok={bool(ok)} erro={err} status={calendario_permissao()}\n")
134
+ sys.stderr.flush()
125
135
  try:
136
+ from EventKit import EKEventStore
137
+ if int(EKEventStore.authorizationStatusForEntityType_(0)) == 4:
138
+ r = subprocess.run(["tccutil", "reset", "Calendar", "ai.primocode.primo"], capture_output=True, text=True)
139
+ sys.stderr.write(f"calendario: acesso só de escrita; zerando a decisão do Primo ({r.returncode})\n")
140
+ _ek.pop("s", None)
126
141
  s = _store()
142
+ sys.stderr.write(f"calendario: pedindo acesso (status antes={calendario_permissao()}, bundle={_bundle()})\n")
143
+ sys.stderr.flush()
127
144
  if hasattr(s, "requestFullAccessToEventsWithCompletion_"):
128
- s.requestFullAccessToEventsWithCompletion_(lambda ok, err: None)
145
+ s.requestFullAccessToEventsWithCompletion_(fim)
129
146
  else:
130
- s.requestAccessToEntityType_completion_(0, lambda ok, err: None)
131
- except Exception:
132
- pass
147
+ s.requestAccessToEntityType_completion_(0, fim)
148
+ except Exception as e:
149
+ sys.stderr.write(f"calendario: falhou ao pedir: {e}\n")
133
150
 
134
151
 
135
- _OSA_CAL = '''set d1 to current date
136
- set day of d1 to 1
137
- set time of d1 to 0
138
- set d2 to d1 + 32 * days
139
- set day of d2 to 1
140
- set agora to current date
141
- set out to ""
142
- tell application "Calendar"
143
- repeat with c in calendars
144
- repeat with e in (every event of c whose start date >= d1 and start date < d2)
145
- set out to out & (summary of e) & "|||" & ((start date of e) - agora) & "|||" & ((end date of e) - agora) & "|||" & (allday event of e) & linefeed
146
- end repeat
147
- end repeat
148
- end tell
149
- return out'''
150
-
151
-
152
- def _eventos_osascript(agora: float) -> dict:
153
- """Plano B quando o EventKit não consegue pedir a permissão (Python sem pacote de app): o app Calendário via osascript."""
154
- r = subprocess.run(["osascript", "-e", _OSA_CAL], capture_output=True, text=True, timeout=25)
155
- if r.returncode != 0:
156
- return {"permissao": "negada" if "-1743" in r.stderr else "pendente", "eventos": []}
157
- out = []
158
- for l in r.stdout.splitlines():
159
- p = l.split("|||")
160
- if len(p) == 4:
161
- try:
162
- a, b = agora + float(p[1]), agora + float(p[2])
163
- except ValueError:
164
- continue
165
- out.append({"titulo": p[0] or "(sem título)", "inicio": a, "fim": b, "dia_todo": p[3].strip() == "true",
166
- "dia": time.localtime(a).tm_mday})
167
- out.sort(key=lambda e: e["inicio"])
168
- return {"permissao": "ok", "eventos": out}
152
+ def _bundle() -> str:
153
+ try:
154
+ from Foundation import NSBundle
155
+ return str(NSBundle.mainBundle().bundleIdentifier())
156
+ except Exception:
157
+ return "?"
169
158
 
170
159
 
171
160
  def eventos_do_mes(agora: Optional[float] = None) -> dict:
172
161
  """{'permissao','eventos':[{titulo,inicio,fim,dia_todo,dia}]} do mês inteiro (hoje e dias com ponto)."""
173
162
  perm = calendario_permissao()
174
- if perm in ("pendente", "negada") and sys.platform == "darwin":
175
- if perm == "pendente":
176
- pedir_calendario()
177
- try:
178
- return _eventos_osascript(agora or time.time())
179
- except Exception:
180
- return {"permissao": perm, "eventos": []}
163
+ if perm == "pendente":
164
+ pedir_calendario()
181
165
  if perm != "ok":
182
166
  return {"permissao": perm, "eventos": []}
183
167
  from Foundation import NSDate
@@ -327,11 +311,9 @@ def acao(m: dict) -> bool:
327
311
  """Ações da tela que só ela entende. True = tratada."""
328
312
  a, arg = m.get("acao"), str(m.get("arg") or "")
329
313
  if a == "calendario":
330
- try:
331
- r = _eventos_osascript(time.time()) # dispara o pedido do macOS (Automação > Calendário)
332
- except Exception:
333
- r = {"permissao": "pendente"}
334
- if r["permissao"] != "ok":
335
- subprocess.Popen(["open", "x-apple.systempreferences:com.apple.preference.security?Privacy_Automation"])
314
+ if calendario_permissao() == "pendente":
315
+ pedir_calendario() # o pedido do macOS sai com o nome do app (Primo)
316
+ else:
317
+ subprocess.Popen(["open", "x-apple.systempreferences:com.apple.preference.security?Privacy_Calendars"])
336
318
  return True
337
319
  return False
@@ -27,7 +27,7 @@ import threading
27
27
  from pathlib import Path
28
28
  from typing import Optional
29
29
 
30
- MODELO = os.environ.get("PRIMOCODE_WHISPER_MODELO", "small").strip() or "small"
30
+ MODELO = os.environ.get("PRIMOCODE_WHISPER_MODELO", "base").strip() or "base"
31
31
  PASTA = Path.home() / ".primocode" / "modelos" / "whisper"
32
32
 
33
33
  _modelo = None
@@ -69,6 +69,8 @@ def aquecer() -> None:
69
69
  def _ir():
70
70
  try:
71
71
  carregar()
72
+ import numpy as np # uma passada em silêncio: a primeira frase de verdade já não paga o aquecimento
73
+ list(_modelo.transcribe(np.zeros(16000, dtype=np.float32), language="pt", beam_size=1)[0])
72
74
  except Exception:
73
75
  pass
74
76
  threading.Thread(target=_ir, daemon=True).start()
@@ -112,9 +114,18 @@ def aproveitavel(segmento) -> bool:
112
114
 
113
115
  def transcrever(wav: Path, idioma: str = "pt") -> str:
114
116
  modelo = carregar()
117
+ entrada: object = str(wav)
118
+ try: # WAV 16 kHz mono: entrega o áudio já decodificado (não depende do `av`, que às vezes destoa do faster-whisper)
119
+ import wave
120
+ import numpy as np
121
+ with wave.open(str(wav), "rb") as w:
122
+ if w.getframerate() == 16000 and w.getnchannels() == 1 and w.getsampwidth() == 2:
123
+ entrada = np.frombuffer(w.readframes(w.getnframes()), dtype=np.int16).astype(np.float32) / 32768.0
124
+ except Exception:
125
+ entrada = str(wav)
115
126
  # vad_filter corta o silêncio das pontas: sem ele, o Whisper às vezes
116
127
  # "ouve" uma frase inteira inventada no silêncio ("Obrigado por assistir").
117
- segmentos, _ = modelo.transcribe(str(wav), language=idioma, beam_size=5,
128
+ segmentos, _ = modelo.transcribe(entrada, language=idioma, beam_size=1,
118
129
  vad_filter=True, condition_on_previous_text=False)
119
130
  return limpar(" ".join(s.text.strip() for s in segmentos if aproveitavel(s)))
120
131