primocode 10.14.1 → 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.
@@ -0,0 +1,350 @@
1
+ """app.py — o Primo como app (Finder, Launchpad, Dock): abre sem terminal e funciona inteiro.
2
+
3
+ O processo do app é o painel (hud.py). Daqui sobem os filhos, todos mortos junto com o app:
4
+ · o motor de voz (`primo_voz`: microfone, Whisper, voz) — o painel dele é este processo (PRIMOCODE_HUD_PONTE);
5
+ · o cérebro: cada frase vira `primocode -p "<frase>" --output-format json` na pasta de trabalho, e o
6
+ resultado volta falado. Node e primocode vêm de ~/.primocode/primo-app.json (o app não tem o PATH do terminal).
7
+ Log: ~/.primocode/logs/primo-app.log
8
+ """
9
+ from __future__ import annotations
10
+
11
+ import json
12
+ import os
13
+ import queue
14
+ import re
15
+ import subprocess
16
+ import sys
17
+ import threading
18
+ import time
19
+ import unicodedata
20
+ from pathlib import Path
21
+
22
+ HOME = Path.home()
23
+ PASTA_PC = HOME / ".primocode"
24
+ LOG = PASTA_PC / "logs" / "primo-app.log"
25
+ AQUI = Path(__file__).resolve().parent
26
+ PATH_BASE = "/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin"
27
+
28
+
29
+ def log(msg: str) -> None:
30
+ try:
31
+ LOG.parent.mkdir(parents=True, exist_ok=True)
32
+ with open(LOG, "a", encoding="utf-8") as f:
33
+ f.write(time.strftime("%Y-%m-%d %H:%M:%S ") + str(msg).rstrip() + "\n")
34
+ except Exception:
35
+ pass
36
+
37
+
38
+ def _json(caminho: Path) -> dict:
39
+ try:
40
+ return json.loads(caminho.read_text(encoding="utf-8"))
41
+ except Exception:
42
+ return {}
43
+
44
+
45
+ def logado() -> bool:
46
+ c = _json(PASTA_PC / "conta.json")
47
+ return bool(c.get("refreshToken") or c.get("idToken") or c.get("token"))
48
+
49
+
50
+ def _sem_acento(t: str) -> str:
51
+ return "".join(ch for ch in unicodedata.normalize("NFD", t.lower()) if unicodedata.category(ch) != "Mn")
52
+
53
+
54
+ def pasta_atual() -> Path:
55
+ d = _json(PASTA_PC / "projeto-ativo.json").get("dir")
56
+ if d and Path(d).is_dir():
57
+ return Path(d)
58
+ pc = HOME / "primocode"
59
+ return pc if pc.is_dir() else HOME
60
+
61
+
62
+ def gravar_pasta(d: Path) -> None:
63
+ """Mesmo arquivo que o lib/config.js (lembrarProjeto) lê: a próxima sessão do PrimoCode cai aqui também."""
64
+ j = _json(PASTA_PC / "projeto-ativo.json")
65
+ rec = [str(d)] + [x for x in (j.get("recentes") or []) if x != str(d)][:9]
66
+ (PASTA_PC / "projeto-ativo.json").write_text(
67
+ json.dumps({"dir": str(d), "origem": "/dir", "quando": int(time.time() * 1000), "recentes": rec}, indent=2), encoding="utf-8")
68
+
69
+
70
+ def achar_projeto(nome: str) -> "Path | None":
71
+ palavras = _sem_acento(nome).replace("-", " ").split()
72
+ if not palavras:
73
+ return None
74
+ cand = [Path(x) for x in (_json(PASTA_PC / "projeto-ativo.json").get("recentes") or [])]
75
+ for base in (HOME / "primocode", HOME):
76
+ try:
77
+ cand += [c for c in base.iterdir() if c.is_dir() and not c.name.startswith(".")]
78
+ except OSError:
79
+ pass
80
+ for c in cand:
81
+ n = _sem_acento(c.name).replace("-", " ").replace("_", " ")
82
+ if c.is_dir() and all(p in n for p in palavras):
83
+ return c
84
+ return None
85
+
86
+
87
+ PEDIDO_PASTA = re.compile(r"^(?:abre|abrir|abra|troca|trocar|muda|mudar|vai|ir)\s+(?:para|pra|pro|no|na|o|a)?\s*(?:o\s+|a\s+)?(?:projeto|pasta)\s+(.+?)[.!?]*$", re.I)
88
+
89
+
90
+ def _hora_falada(t: "time.struct_time") -> str:
91
+ from primo_voz.fala import hora_falada
92
+ return hora_falada(t)
93
+
94
+
95
+ class App:
96
+ def __init__(self) -> None:
97
+ self.cfg = _json(PASTA_PC / "primo-app.json")
98
+ self.node = self.cfg.get("node") or ""
99
+ self.primocode = self.cfg.get("primocode") or ""
100
+ self.pasta = pasta_atual()
101
+ self.motor: "subprocess.Popen | None" = None
102
+ self.cerebro: "subprocess.Popen | None" = None
103
+ self.entrada: "queue.Queue" = queue.Queue()
104
+ self.js = lambda m: None
105
+ self.turnos = 0
106
+ self.vivo = True
107
+ self.trava = threading.Lock()
108
+ self.cache_clima: dict = {}
109
+
110
+ # ── entrada do painel (iterável de linhas JSON) ──
111
+ def __iter__(self):
112
+ while True:
113
+ yield self.entrada.get()
114
+
115
+ def painel(self, msg: dict) -> None:
116
+ self.entrada.put(json.dumps(msg, ensure_ascii=False))
117
+
118
+ def saida_do_painel(self, obj: dict) -> None:
119
+ a = obj.get("acao")
120
+ if a == "ptt":
121
+ self.motor_envia({"tipo": "acao", "acao": "ptt"})
122
+ elif a == "entrar":
123
+ self.entrar()
124
+
125
+ # ── login ──
126
+ def entrar(self) -> None:
127
+ raiz = Path(self.primocode).resolve().parent.parent if self.primocode else None
128
+ if not (self.node and raiz and (raiz / "lib" / "conta.js").exists()):
129
+ log("entrar: node/primocode não registrados")
130
+ self.painel({"resposta": "Não achei o PrimoCode instalado. Rode `primocode voz instalar-app` no terminal."})
131
+ return
132
+
133
+ def trabalho():
134
+ log("login: abrindo o navegador")
135
+ r = subprocess.run([self.node, "-e", "require('./lib/conta.js').entrarPeloNavegador({aviso:()=>{}}).then(r=>process.exit(r&&r.ok?0:1))"],
136
+ cwd=str(raiz), env={**self.ambiente(), "PRIMOCODE_SEM_NAVEGADOR": "0"}, capture_output=True, timeout=600)
137
+ log(f"login: saiu com {r.returncode}")
138
+ self.painel({"login": logado()})
139
+ threading.Thread(target=trabalho, daemon=True).start()
140
+
141
+ def ambiente(self) -> dict:
142
+ env = dict(os.environ)
143
+ extra = [str(Path(self.node).parent)] if self.node else []
144
+ env["PATH"] = ":".join([x for x in extra + [self.cfg.get("path") or "", PATH_BASE, env.get("PATH", "")] if x])
145
+ env["HOME"] = str(HOME)
146
+ env["PRIMOCODE_SEM_NAVEGADOR"] = "1" # o cérebro nunca abre o navegador sozinho
147
+ return env
148
+
149
+ # ── motor de voz ──
150
+ def motor_envia(self, obj: dict) -> None:
151
+ try:
152
+ self.motor.stdin.write(json.dumps(obj, ensure_ascii=False) + "\n")
153
+ self.motor.stdin.flush()
154
+ except Exception:
155
+ pass
156
+
157
+ def subir_motor(self) -> None:
158
+ env = self.ambiente()
159
+ cfg_srv = self.cfg.get("servidor") or ""
160
+ conta = _json(PASTA_PC / "conta.json")
161
+ env.update({"PRIMOCODE_HUD_PONTE": "1", "PRIMOCODE_SERVER": cfg_srv or "https://primocode.web.app",
162
+ "PRIMOCODE_TOKEN": str(conta.get("idToken") or conta.get("token") or ""),
163
+ "PRIMOCODE_VOZ": str(self.cfg.get("voz") or "antonio"), "PRIMOCODE_CWD": str(self.pasta),
164
+ "PYTHONUNBUFFERED": "1", "PRIMOCODE_LAT": "1", "PRIMOCODE_SEM_PARCIAL": "1", **({"PRIMOCODE_TESTE_WAV": str(self.cfg["teste_wav"]), "PRIMOCODE_TESTE_ATRASO": str(self.cfg.get("teste_atraso", 12))} if self.cfg.get("teste_wav") else {}), "PYTHONPATH": os.environ.get("PYTHONPATH", str(AQUI.parent))})
165
+ log(f"motor de voz: subindo ({sys.executable} -m primo_voz) pasta={self.pasta}")
166
+ self.motor = subprocess.Popen([sys.executable, "-m", "primo_voz"], cwd=str(AQUI.parent), env=env, text=True,
167
+ stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
168
+ threading.Thread(target=self.ler_motor, daemon=True).start()
169
+ threading.Thread(target=lambda: [log("motor: " + l) for l in self.motor.stderr], daemon=True).start()
170
+
171
+ def ler_motor(self) -> None:
172
+ for linha in self.motor.stdout:
173
+ try:
174
+ m = json.loads(linha)
175
+ except ValueError:
176
+ continue
177
+ t = m.get("tipo")
178
+ if t == "hud":
179
+ self.painel(m.get("msg") or {})
180
+ elif t == "pronto":
181
+ log("motor de voz: PRONTO (microfone aberto)")
182
+ self.painel({"pasta": self.pasta.name, "login": logado()})
183
+ elif t == "erro":
184
+ log("motor de voz: erro: " + str(m.get("texto")))
185
+ self.painel({"resposta": str(m.get("texto"))})
186
+ elif t == "parar":
187
+ self.matar_cerebro()
188
+ elif t == "ouvi" and str(m.get("texto") or "").strip():
189
+ threading.Thread(target=self.pensar, args=(str(m["texto"]).strip(),), daemon=True).start()
190
+ elif t == "fim":
191
+ break
192
+ log("motor de voz: saiu")
193
+ if self.vivo:
194
+ self.vivo = False
195
+ os._exit(0)
196
+
197
+ # ── cérebro ──
198
+ def matar_cerebro(self) -> None:
199
+ p = self.cerebro
200
+ if p and p.poll() is None:
201
+ p.terminate()
202
+
203
+ def dizer(self, texto: str) -> None:
204
+ self.motor_envia({"tipo": "falar", "texto": texto})
205
+ self.motor_envia({"tipo": "estado", "estado": "esperando"})
206
+
207
+ def responder_local(self, texto: str) -> "str | None":
208
+ """Pergunta simples respondida aqui, sem passar pelo agente (hora, data, clima, música, agenda, volume)."""
209
+ from primo_voz import painel as pn, tela as td
210
+ t = _sem_acento(texto).strip(" .?!")
211
+ agora = time.time()
212
+ if re.search(r"\bque horas\b|\bhoras sao\b|\bme (?:da|diz|diga) a hora\b|\bque hora (?:e|eh)\b", t):
213
+ return _hora_falada(time.localtime(agora))
214
+ if re.search(r"\bque dia (?:e|eh)\b|\bdata de hoje\b|\bhoje e que dia\b", t):
215
+ dias = ["segunda-feira", "terça-feira", "quarta-feira", "quinta-feira", "sexta-feira", "sábado", "domingo"]
216
+ meses = ["janeiro", "fevereiro", "março", "abril", "maio", "junho", "julho", "agosto", "setembro", "outubro", "novembro", "dezembro"]
217
+ lt = time.localtime(agora)
218
+ return f"Hoje é {dias[lt.tm_wday]}, {lt.tm_mday} de {meses[lt.tm_mon - 1]}."
219
+ if re.search(r"\bclima\b|\bvai chover\b|\btemperatura\b|\btempo (?:hoje|agora|la fora)\b", t):
220
+ c = td.clima_widget(self.cache_clima)
221
+ if c and c.get("temp") is not None:
222
+ return f"Em {c.get('cidade', 'sua cidade')} faz {c['temp']} graus, {str(c.get('cond', '')).lower()}. Máxima de {c['max']}, mínima de {c['min']}."
223
+ return None
224
+ if re.search(r"o que (?:esta|ta) tocando|que musica (?:e|eh) essa|qual (?:e )?a musica", t):
225
+ m = td.musica()
226
+ return f"Tocando {m['faixa']}, de {m['artista']}." if m and m.get("faixa") else "Não tem nada tocando agora."
227
+ if re.search(r"\b(?:pausa|pausar|pare|para) (?:a )?musica\b|\b(?:toca|tocar|continua|retoma) (?:a )?musica\b|\bpula (?:a )?musica\b|\bproxima musica\b|\bmusica anterior\b", t):
228
+ acao = "proxima" if re.search(r"pula|proxima", t) else "anterior" if "anterior" in t else "tocar"
229
+ pn.controle_midia(acao)
230
+ return {"proxima": "Pulei.", "anterior": "Voltei.", "tocar": "Feito."}[acao]
231
+ if re.search(r"\b(?:aumenta|sobe|aumentar|mais)\b.*\bvolume\b|\bvolume\b.*\b(?:mais alto|maior)\b", t):
232
+ pn.executar_acao({"acao": "volume", "arg": "mais"})
233
+ return "Aumentei."
234
+ if re.search(r"\b(?:abaixa|diminui|baixa|diminuir|menos)\b.*\bvolume\b|\bvolume\b.*\b(?:mais baixo|menor)\b", t):
235
+ pn.executar_acao({"acao": "volume", "arg": "menos"})
236
+ return "Abaixei."
237
+ if re.search(r"\bagenda\b|\bcompromissos?\b|o que (?:eu )?tenho hoje|\breunioes? hoje\b", t):
238
+ ev = td.eventos_do_mes()
239
+ if ev.get("permissao") != "ok":
240
+ return "Preciso do acesso ao Calendário. Clique em Liberar o Calendário no painel."
241
+ hj = td.hoje_lista(ev["eventos"], agora)
242
+ if not hj:
243
+ return "Você não tem mais nada marcado para hoje."
244
+ return "Hoje: " + "; ".join(f"{e['titulo']} às {e['hora']}" if e["hora"] != "dia todo" else f"{e['titulo']}, o dia todo" for e in hj[:4]) + "."
245
+ return None
246
+
247
+ def pensar(self, texto: str) -> None:
248
+ log(f"ouvi: {texto}")
249
+ m = PEDIDO_PASTA.match(texto.strip())
250
+ if m:
251
+ d = achar_projeto(m.group(1))
252
+ if d:
253
+ self.pasta = d
254
+ gravar_pasta(d)
255
+ self.turnos = 0
256
+ self.painel({"pasta": d.name})
257
+ log(f"pasta: {d}")
258
+ self.dizer(f"Fechado. Agora trabalho no {d.name}.")
259
+ else:
260
+ self.dizer(f"Não achei um projeto chamado {m.group(1)}.")
261
+ return
262
+ t_ini = time.time()
263
+ try:
264
+ local = self.responder_local(texto)
265
+ except Exception as e:
266
+ log(f"resposta local falhou: {e}")
267
+ local = None
268
+ if local:
269
+ log(f"lat: resposta LOCAL em {time.time() - t_ini:.2f}s: {local!r}")
270
+ self.dizer(local)
271
+ return
272
+ if len(texto.split()) >= 3: # pedido que vai ao agente: confirma na hora, enquanto ele pensa
273
+ self.motor_envia({"tipo": "falar", "texto": ["Já vejo.", "Deixa eu ver.", "Um instante.", "Pode deixar."][self.turnos % 4]})
274
+ if not (self.node and self.primocode and Path(self.primocode).exists()):
275
+ log("cérebro: node/primocode não registrados em primo-app.json")
276
+ self.dizer("Não achei o PrimoCode instalado. Rode primocode voz instalar-app no terminal.")
277
+ return
278
+ if not logado():
279
+ self.painel({"login": False})
280
+ self.dizer("Preciso que você entre na conta do PrimoCode. Clique em Entrar.")
281
+ return
282
+ with self.trava:
283
+ args = [self.node, self.primocode, "-p", texto, "--output-format", "json"] + (["-c"] if self.turnos else [])
284
+ log("cérebro: " + " ".join(args[:3]) + " ... cwd=" + str(self.pasta))
285
+ t0 = time.time()
286
+ try:
287
+ self.cerebro = subprocess.Popen(args, cwd=str(self.pasta), env=self.ambiente(), text=True,
288
+ stdin=subprocess.DEVNULL, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
289
+ out, err = self.cerebro.communicate(timeout=900)
290
+ except Exception as e:
291
+ log(f"cérebro: falhou: {e}")
292
+ self.dizer("O cérebro não respondeu.")
293
+ return
294
+ self.turnos += 1
295
+ resp = ""
296
+ for l in reversed((out or "").strip().splitlines()):
297
+ try:
298
+ j = json.loads(l)
299
+ resp = str(j.get("result") or "")
300
+ break
301
+ except ValueError:
302
+ continue
303
+ if err and err.strip():
304
+ log("cérebro stderr: " + err.strip()[-400:])
305
+ # tira os enfeites do terminal (⎿ linhas de status, ● marcador) — a voz fala só o texto
306
+ resp = "\n".join(l for l in resp.splitlines() if not l.lstrip().startswith("⎿")).replace("●", "").strip()
307
+ log(f"lat: cérebro (agente) {time.time() - t0:.1f}s, {len(resp)} caracteres: {resp[:160]!r}")
308
+ if not resp.strip():
309
+ self.dizer("Não consegui uma resposta agora.")
310
+ return
311
+ self.motor_envia({"tipo": "falar", "texto": resp, "bruto": True})
312
+ self.motor_envia({"tipo": "estado", "estado": "esperando"})
313
+
314
+ def sair(self) -> None:
315
+ self.vivo = False
316
+ self.matar_cerebro()
317
+ if self.motor and self.motor.poll() is None:
318
+ self.motor_envia({"tipo": "fim"})
319
+ try:
320
+ self.motor.wait(timeout=2)
321
+ except Exception:
322
+ self.motor.kill()
323
+ log("app: encerrado")
324
+
325
+ def iniciar(self, js, p) -> None:
326
+ self.js = js
327
+ threading.Timer(0.5, self.subir_motor).start()
328
+ threading.Timer(2.0, lambda: self.painel({"pasta": self.pasta.name, "login": logado()})).start()
329
+
330
+
331
+ def main() -> int:
332
+ import fcntl
333
+ import atexit
334
+ PASTA_PC.mkdir(exist_ok=True)
335
+ os.environ["PATH"] = PATH_BASE + ":" + os.environ.get("PATH", "")
336
+ lock = open(PASTA_PC / "primo-app.lock", "w")
337
+ try:
338
+ fcntl.flock(lock, fcntl.LOCK_EX | fcntl.LOCK_NB)
339
+ except OSError:
340
+ return 0 # já aberto: o macOS só traz a janela de volta
341
+ sys.stderr = open(LOG, "a", buffering=1) if (LOG.parent.mkdir(parents=True, exist_ok=True) or True) else sys.stderr
342
+ log("app: abrindo")
343
+ app = App()
344
+ atexit.register(app.sair)
345
+ from primo_voz import hud
346
+ return hud.main(entrada=iter(app), saida=app.saida_do_painel, ao_iniciar=app.iniciar, ao_sair=app.sair)
347
+
348
+
349
+ if __name__ == "__main__":
350
+ sys.exit(main())
@@ -0,0 +1,124 @@
1
+ """appmac.py — o Primo como app de verdade no macOS (Dock e Cmd+Tab dizem "Primo", com o ícone).
2
+
3
+ Gera sob demanda ~/.primocode/Primo.app (bundle leve: Info.plist, ícone .icns e um script que
4
+ roda o python do venv com `-m primo_voz.hud`). Quem abre o painel executa o binário do bundle
5
+ direto (spawn), então o canal stdin/stdout continua o mesmo.
6
+
7
+ python -m primo_voz.appmac # gera (se precisar) e imprime o caminho do executável
8
+ """
9
+ from __future__ import annotations
10
+
11
+ import os
12
+ import plistlib
13
+ import shutil
14
+ import subprocess
15
+ import sys
16
+ import tempfile
17
+ from pathlib import Path
18
+
19
+ AQUI = Path(__file__).resolve().parent
20
+ ICONE = AQUI / "vendor" / "primo-icone.png"
21
+ VERSAO = "6" # sobe quando o bundle muda: força regerar
22
+
23
+
24
+ def pasta_app() -> Path:
25
+ return Path(os.environ.get("PRIMOCODE_APP", Path.home() / "Applications" / "Primo.app"))
26
+
27
+
28
+ def _icns(destino: Path) -> bool:
29
+ with tempfile.TemporaryDirectory() as t:
30
+ iset = Path(t) / "Primo.iconset"
31
+ iset.mkdir()
32
+ for base in (16, 32, 128, 256, 512):
33
+ for k in (1, 2):
34
+ px = base * k
35
+ nome = f"icon_{base}x{base}{'@2x' if k == 2 else ''}.png"
36
+ subprocess.run(["sips", "-z", str(px), str(px), str(ICONE), "--out", str(iset / nome)],
37
+ stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, check=True)
38
+ r = subprocess.run(["iconutil", "-c", "icns", str(iset), "-o", str(destino)],
39
+ stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
40
+ return r.returncode == 0
41
+
42
+
43
+ def _no_dock(app: Path) -> None:
44
+ """Fixa o Primo no Dock (só com --dock)."""
45
+ item = ("<dict><key>tile-data</key><dict><key>file-data</key><dict><key>_CFURLString</key><string>%s</string>"
46
+ "<key>_CFURLStringType</key><integer>0</integer></dict></dict></dict>" % str(app))
47
+ subprocess.run(["defaults", "write", "com.apple.dock", "persistent-apps", "-array-add", item], check=False)
48
+ subprocess.run(["killall", "Dock"], check=False)
49
+
50
+
51
+ def pythonpath() -> str:
52
+ """O PYTHONPATH que o binário do bundle precisa (o primo_voz e os pacotes do venv, já que ele não é o python do venv)."""
53
+ import sysconfig
54
+ dirs = [str(AQUI.parent), sysconfig.get_path("purelib"), sysconfig.get_path("platlib")]
55
+ return os.pathsep.join(dict.fromkeys(d for d in dirs if d))
56
+
57
+
58
+ def preparar() -> str | None:
59
+ """Devolve o caminho do executável do Primo.app (criando o bundle se faltar); None se não deu."""
60
+ if sys.platform != "darwin" or not ICONE.exists():
61
+ return None
62
+ try:
63
+ app = pasta_app()
64
+ exe = app / "Contents" / "MacOS" / "Primo" # o binário de verdade (o processo se chama Primo); o lançador só dá exec nele
65
+ lanc = app / "Contents" / "MacOS" / "Primo-iniciar"
66
+ marca = app / "Contents" / ".versao"
67
+ py = sys.executable
68
+ chave = f"{VERSAO}|{py}|{AQUI.parent}"
69
+ if exe.exists() and marca.exists() and marca.read_text() == chave and (app / "Contents/Resources/Primo.icns").exists():
70
+ return str(exe)
71
+ if app.exists():
72
+ shutil.rmtree(app)
73
+ antigo = Path.home() / ".primocode" / "Primo.app" # onde ficava até a 10.15.1
74
+ if antigo.exists():
75
+ shutil.rmtree(antigo, ignore_errors=True)
76
+ app.parent.mkdir(parents=True, exist_ok=True)
77
+ (app / "Contents" / "MacOS").mkdir(parents=True)
78
+ (app / "Contents" / "Resources").mkdir()
79
+ if not _icns(app / "Contents" / "Resources" / "Primo.icns"):
80
+ return None
81
+ info = {
82
+ "CFBundleName": "Primo", "CFBundleDisplayName": "Primo", "CFBundleIdentifier": "ai.primocode.primo",
83
+ "CFBundleExecutable": "Primo-iniciar", "CFBundleIconFile": "Primo", "CFBundlePackageType": "APPL",
84
+ "CFBundleShortVersionString": "1.0", "CFBundleVersion": "1", "LSMinimumSystemVersion": "11.0",
85
+ "NSHighResolutionCapable": True, "NSPrincipalClass": "NSApplication",
86
+ "NSMicrophoneUsageDescription": "O Primo ouve você pelo microfone para atender os seus pedidos por voz.",
87
+ "NSCalendarsFullAccessUsageDescription": "O Primo mostra os compromissos de hoje no painel.",
88
+ "NSCalendarsUsageDescription": "O Primo mostra os compromissos de hoje no painel.",
89
+ "NSAppleEventsUsageDescription": "O Primo controla a música (Spotify/Music) pelo painel.",
90
+ }
91
+ with open(app / "Contents" / "Info.plist", "wb") as f:
92
+ plistlib.dump(info, f)
93
+ # binário de verdade (cópia do Python.app do framework): o macOS passa a chamar o processo de "Primo"
94
+ real = Path(os.path.realpath(py))
95
+ mach = next((c for c in (real.parents[1] / "Resources/Python.app/Contents/MacOS/Python",
96
+ real.parent / "../Resources/Python.app/Contents/MacOS/Python") if c.exists()), None)
97
+ if mach:
98
+ shutil.copy2(mach, exe)
99
+ # duplo clique no Finder/Launchpad: sem argumentos = sobe o app completo (voz + painel + cérebro)
100
+ lanc.write_text(f'#!/bin/bash\nexport PYTHONPATH="{pythonpath()}"\nD="$(cd "$(dirname "$0")" && pwd)"\n'
101
+ f'[ $# -eq 0 ] && set -- -m primo_voz.app\nexec "$D/Primo" "$@"\n')
102
+ lanc.chmod(0o755)
103
+ subprocess.run(["codesign", "--force", "--sign", "-", str(app)], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
104
+ else:
105
+ exe.write_text(f'#!/bin/bash\nexport PYTHONPATH="{pythonpath()}"\n[ $# -eq 0 ] && set -- -m primo_voz.app\nexec "{py}" "$@"\n')
106
+ exe.chmod(0o755)
107
+ shutil.copy2(exe, lanc)
108
+ marca.write_text(chave)
109
+ if os.environ.get("PRIMOCODE_APP_DOCK") == "1":
110
+ _no_dock(app)
111
+ subprocess.run(["/System/Library/Frameworks/CoreServices.framework/Frameworks/LaunchServices.framework/Support/lsregister",
112
+ "-f", str(app)], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, timeout=10)
113
+ return str(exe)
114
+ except Exception as e:
115
+ sys.stderr.write(f"appmac: {e}\n")
116
+ return None
117
+
118
+
119
+ if __name__ == "__main__":
120
+ r = preparar()
121
+ if r:
122
+ print(r)
123
+ print(pythonpath())
124
+ sys.exit(0 if r else 1)
@@ -0,0 +1 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="-1 -4 18 18" shape-rendering="crispEdges"><rect x="2" y="0" width="1" height="1" fill="#22d3ee"/><rect x="2" y="1" width="1" height="1" fill="#22d3ee"/><rect x="3" y="0" width="1" height="1" fill="#22d3ee"/><rect x="3" y="1" width="1" height="1" fill="#22d3ee"/><rect x="4" y="0" width="1" height="1" fill="#22d3ee"/><rect x="4" y="1" width="1" height="1" fill="#22d3ee"/><rect x="5" y="0" width="1" height="1" fill="#22d3ee"/><rect x="5" y="1" width="1" height="1" fill="#22d3ee"/><rect x="6" y="0" width="1" height="1" fill="#22d3ee"/><rect x="6" y="1" width="1" height="1" fill="#22d3ee"/><rect x="7" y="0" width="1" height="1" fill="#22d3ee"/><rect x="7" y="1" width="1" height="1" fill="#22d3ee"/><rect x="8" y="0" width="1" height="1" fill="#22d3ee"/><rect x="8" y="1" width="1" height="1" fill="#22d3ee"/><rect x="9" y="0" width="1" height="1" fill="#22d3ee"/><rect x="9" y="1" width="1" height="1" fill="#22d3ee"/><rect x="10" y="0" width="1" height="1" fill="#22d3ee"/><rect x="10" y="1" width="1" height="1" fill="#22d3ee"/><rect x="11" y="0" width="1" height="1" fill="#22d3ee"/><rect x="11" y="1" width="1" height="1" fill="#22d3ee"/><rect x="12" y="0" width="1" height="1" fill="#22d3ee"/><rect x="12" y="1" width="1" height="1" fill="#22d3ee"/><rect x="13" y="0" width="1" height="1" fill="#22d3ee"/><rect x="13" y="1" width="1" height="1" fill="#22d3ee"/><rect x="2" y="2" width="1" height="1" fill="#22d3ee"/><rect x="2" y="3" width="1" height="1" fill="#22d3ee"/><rect x="3" y="2" width="1" height="1" fill="#22d3ee"/><rect x="3" y="3" width="1" height="1" fill="#22d3ee"/><rect x="5" y="2" width="1" height="1" fill="#22d3ee"/><rect x="5" y="3" width="1" height="1" fill="#22d3ee"/><rect x="6" y="2" width="1" height="1" fill="#22d3ee"/><rect x="6" y="3" width="1" height="1" fill="#22d3ee"/><rect x="7" y="2" width="1" height="1" fill="#22d3ee"/><rect x="7" y="3" width="1" height="1" fill="#22d3ee"/><rect x="8" y="2" width="1" height="1" fill="#22d3ee"/><rect x="8" y="3" width="1" height="1" fill="#22d3ee"/><rect x="9" y="2" width="1" height="1" fill="#22d3ee"/><rect x="9" y="3" width="1" height="1" fill="#22d3ee"/><rect x="10" y="2" width="1" height="1" fill="#22d3ee"/><rect x="10" y="3" width="1" height="1" fill="#22d3ee"/><rect x="12" y="2" width="1" height="1" fill="#22d3ee"/><rect x="12" y="3" width="1" height="1" fill="#22d3ee"/><rect x="13" y="2" width="1" height="1" fill="#22d3ee"/><rect x="13" y="3" width="1" height="1" fill="#22d3ee"/><rect x="0" y="4" width="1" height="1" fill="#22d3ee"/><rect x="0" y="5" width="1" height="1" fill="#22d3ee"/><rect x="1" y="4" width="1" height="1" fill="#22d3ee"/><rect x="1" y="5" width="1" height="1" fill="#22d3ee"/><rect x="2" y="4" width="1" height="1" fill="#22d3ee"/><rect x="2" y="5" width="1" height="1" fill="#22d3ee"/><rect x="3" y="4" width="1" height="1" fill="#22d3ee"/><rect x="3" y="5" width="1" height="1" fill="#22d3ee"/><rect x="4" y="4" width="1" height="1" fill="#22d3ee"/><rect x="4" y="5" width="1" height="1" fill="#22d3ee"/><rect x="5" y="4" width="1" height="1" fill="#22d3ee"/><rect x="5" y="5" width="1" height="1" fill="#22d3ee"/><rect x="6" y="4" width="1" height="1" fill="#22d3ee"/><rect x="6" y="5" width="1" height="1" fill="#22d3ee"/><rect x="7" y="4" width="1" height="1" fill="#22d3ee"/><rect x="7" y="5" width="1" height="1" fill="#22d3ee"/><rect x="8" y="4" width="1" height="1" fill="#22d3ee"/><rect x="8" y="5" width="1" height="1" fill="#22d3ee"/><rect x="9" y="4" width="1" height="1" fill="#22d3ee"/><rect x="9" y="5" width="1" height="1" fill="#22d3ee"/><rect x="10" y="4" width="1" height="1" fill="#22d3ee"/><rect x="10" y="5" width="1" height="1" fill="#22d3ee"/><rect x="11" y="4" width="1" height="1" fill="#22d3ee"/><rect x="11" y="5" width="1" height="1" fill="#22d3ee"/><rect x="12" y="4" width="1" height="1" fill="#22d3ee"/><rect x="12" y="5" width="1" height="1" fill="#22d3ee"/><rect x="13" y="4" width="1" height="1" fill="#22d3ee"/><rect x="13" y="5" width="1" height="1" fill="#22d3ee"/><rect x="14" y="4" width="1" height="1" fill="#22d3ee"/><rect x="14" y="5" width="1" height="1" fill="#22d3ee"/><rect x="15" y="4" width="1" height="1" fill="#22d3ee"/><rect x="15" y="5" width="1" height="1" fill="#22d3ee"/><rect x="2" y="6" width="1" height="1" fill="#22d3ee"/><rect x="2" y="7" width="1" height="1" fill="#22d3ee"/><rect x="3" y="6" width="1" height="1" fill="#22d3ee"/><rect x="3" y="7" width="1" height="1" fill="#22d3ee"/><rect x="4" y="6" width="1" height="1" fill="#22d3ee"/><rect x="4" y="7" width="1" height="1" fill="#22d3ee"/><rect x="5" y="6" width="1" height="1" fill="#22d3ee"/><rect x="5" y="7" width="1" height="1" fill="#22d3ee"/><rect x="6" y="6" width="1" height="1" fill="#22d3ee"/><rect x="6" y="7" width="1" height="1" fill="#22d3ee"/><rect x="7" y="6" width="1" height="1" fill="#22d3ee"/><rect x="7" y="7" width="1" height="1" fill="#22d3ee"/><rect x="8" y="6" width="1" height="1" fill="#22d3ee"/><rect x="8" y="7" width="1" height="1" fill="#22d3ee"/><rect x="9" y="6" width="1" height="1" fill="#22d3ee"/><rect x="9" y="7" width="1" height="1" fill="#22d3ee"/><rect x="10" y="6" width="1" height="1" fill="#22d3ee"/><rect x="10" y="7" width="1" height="1" fill="#22d3ee"/><rect x="11" y="6" width="1" height="1" fill="#22d3ee"/><rect x="11" y="7" width="1" height="1" fill="#22d3ee"/><rect x="12" y="6" width="1" height="1" fill="#22d3ee"/><rect x="12" y="7" width="1" height="1" fill="#22d3ee"/><rect x="13" y="6" width="1" height="1" fill="#22d3ee"/><rect x="13" y="7" width="1" height="1" fill="#22d3ee"/><rect x="3" y="8" width="1" height="1" fill="#22d3ee"/><rect x="3" y="9" width="1" height="1" fill="#22d3ee"/><rect x="5" y="8" width="1" height="1" fill="#22d3ee"/><rect x="5" y="9" width="1" height="1" fill="#22d3ee"/><rect x="10" y="8" width="1" height="1" fill="#22d3ee"/><rect x="10" y="9" width="1" height="1" fill="#22d3ee"/><rect x="12" y="8" width="1" height="1" fill="#22d3ee"/><rect x="12" y="9" width="1" height="1" fill="#22d3ee"/></svg>