sergio/takana
> Una distribución Linux construida con un laboratorio funcional y hermético en el > sótano, y una terminal mutable, clásica y anárquica en el piso de arriba — diseñada > desde el suelo para que una IA programadora entienda, modifique y co…
⎇ 02513b861f6fbb462045ba411c6bec0a2cae7e93
recipes/atuq/tools/rebrand.py
Historia
Quién tocó qué
crudo
atuq: `sct` v1 — el navegador avisa cuando un sitio ya estable ejecuta código que nadie vio nunca
1#!/usr/bin/env python3
2"""Convierte el árbol de `firefox` ya copiado en el árbol de `atuq`.
3
4Uso: rebrand.py <appdir> <overlay> <outroot>
5 appdir = /out/usr/lib/atuq (la copia del árbol de firefox)
6 overlay = /src (este directorio, `recipes/atuq/`)
7 outroot = /out
8
9POR QUÉ UNA HERRAMIENTA Y NO CUATRO LÍNEAS DE SHELL
10---------------------------------------------------
11El branding de verdad vive DENTRO de `browser/omni.ja`, que es un zip, y re-empacarlo desde shell
12exige `zip`, que el lab no trae. `zipfile` de la stdlib, además de estar disponible, da control
13sobre las tres cosas que el CLI no deja fijar y de las que depende que el artefacto REPRODUZCA:
14el orden de las entradas, su `date_time` y sus permisos.
15
16TODO CAMBIO ES VERIFICADO, NINGUNO ES SILENCIOSO
17------------------------------------------------
18Cada reemplazo declara qué entrada espera encontrar y falla si no está. Un rebranding que no
19encuentra la cadena que iba a cambiar produce un navegador que se sigue llamando Nightly y sella
20igual de bien: sería exactamente la clase de fallo que llega hasta el final diciendo que todo fue
21bien (regla 3 del CLAUDE.md).
22"""
23import hashlib
24import json
25import os
26import sys
27import zipfile
28
29APPDIR, OVERLAY, OUTROOT = sys.argv[1], sys.argv[2], sys.argv[3]
30
31MARCA = "atuq"
32VENDOR = "tawasuyu"
33TAMANOS = (16, 32, 48, 64, 128)
34
35
36def fatal(msg):
37 print(f"rebrand: {msg}", file=sys.stderr)
38 raise SystemExit(1)
39
40
41# ── 1. La huella del overlay, que es también el BuildID ────────────────────────────────────────
42# El `BuildID` es la clave con la que Gecko invalida su startup cache. Si atuq cambia su chrome y
43# el BuildID no se mueve, el navegador arranca con la interfaz vieja cacheada y PARECE que el
44# overlay no agarró — el falso negativo que el SDD 26 §2 anota como gotcha del camino derivado.
45#
46# Se deriva del CONTENIDO del overlay y no de la fecha: cambia exactamente cuando cambia lo que
47# tiene que invalidar, y dos builds del mismo atuq dan el mismo número. La fecha haría lo contrario
48# en los dos sentidos.
49def huella_overlay():
50 h = hashlib.sha256()
51 for raiz, dirs, ficheros in os.walk(OVERLAY):
52 dirs.sort()
53 for f in sorted(ficheros):
54 p = os.path.join(raiz, f)
55 h.update(os.path.relpath(p, OVERLAY).encode())
56 h.update(b"\0")
57 with open(p, "rb") as fh:
58 h.update(fh.read())
59 h.update(b"\0")
60 return h.hexdigest()
61
62
63def build_id(huella):
64 # 14 dígitos decimales: la forma que Gecko espera (nació de un `YYYYMMDDHHMMSS`).
65 return str(int(huella[:16], 16))[:14].rjust(14, "0")
66
67
68# ── 2. application.ini — la identidad de la aplicación ─────────────────────────────────────────
69# ⚠ La cabecera de este fichero dice «This file is not used», y es un comentario HEREDADO del árbol
70# de desarrollo: en un build empaquetado como éste el launcher SÍ lo lee. Se comprueba en vez de
71# creerle al comentario — si no encontramos las claves, fallamos.
72#
73# `ID` NO se toca a propósito: es el GUID con el que las extensiones declaran compatibilidad
74# (`gecko.id` de Firefox). Cambiarlo convertiría a atuq en una aplicación desconocida para todo el
75# ecosistema de complementos, que es justo lo que un envoltorio no quiere.
76def rebrandear_application_ini(bid):
77 ruta = os.path.join(APPDIR, "application.ini")
78 if not os.path.isfile(ruta):
79 fatal(f"no está {ruta}")
80 with open(ruta, encoding="utf-8") as fh:
81 lineas = fh.read().splitlines()
82 # `CodeName` también: es el residuo de `--with-branding=unofficial` (dice «Nightly») y aparece
83 # en about:buildconfig y en los informes de fallo. Dejarlo sería exactamente el rincón por el
84 # que atuq se sigue presentando como otro producto.
85 cambios = {
86 "Vendor": VENDOR,
87 "Name": MARCA,
88 "RemotingName": MARCA,
89 "CodeName": MARCA,
90 "BuildID": bid,
91 }
92 vistas = set()
93 salida = []
94 for ln in lineas:
95 clave = ln.split("=", 1)[0] if "=" in ln else None
96 if clave in cambios:
97 salida.append(f"{clave}={cambios[clave]}")
98 vistas.add(clave)
99 else:
100 salida.append(ln)
101 faltan = set(cambios) - vistas
102 if faltan:
103 fatal(f"application.ini no tenía las claves {sorted(faltan)} — ¿cambió el formato?")
104 with open(ruta, "w", encoding="utf-8") as fh:
105 fh.write("\n".join(salida) + "\n")
106 return vistas
107
108
109# ── 3. omni.ja — el re-empaque determinista ────────────────────────────────────────────────────
110# TRES PROPIEDADES DEL ORIGINAL QUE SE PRESERVAN, Y POR QUÉ:
111#
112# · EL ORDEN de las entradas. Gecko lee este zip en el arranque y el orden es el que Mozilla
113# eligió para que las primeras lecturas caigan juntas. Reordenarlo (p.ej. alfabético, que es lo
114# que sale «gratis» al recomprimir) no rompe nada visible: sólo hace el arranque más lento, en
115# silencio. Cuando exista el PGO, el `jarlog` refina justamente este orden — tirarlo sería
116# tirar media optimización de arranque.
117# · `compress_type = 0` (STORED). El omni.ja de Mozilla va SIN COMPRIMIR a propósito, para poder
118# mapearlo en memoria. Comprimirlo achica el artefacto y encarece cada lectura.
119# · `date_time`. El original ya viene normalizado a 2010-01-01 —Mozilla también persigue
120# reproducibilidad— así que copiarlo tal cual mantiene el zip byte-idéntico salvo por lo que
121# cambiamos de verdad.
122def repack_omni(ruta_ja, reemplazos):
123 if not os.path.isfile(ruta_ja):
124 fatal(f"no está {ruta_ja}")
125 pendientes = dict(reemplazos)
126 tmp = ruta_ja + ".nuevo"
127 with zipfile.ZipFile(ruta_ja) as orig:
128 infos = orig.infolist()
129 with zipfile.ZipFile(tmp, "w") as nuevo:
130 for i in infos:
131 datos = pendientes.pop(i.filename, None)
132 if datos is None:
133 datos = orig.read(i.filename)
134 ni = zipfile.ZipInfo(i.filename, date_time=i.date_time)
135 ni.compress_type = i.compress_type
136 ni.external_attr = i.external_attr
137 ni.create_system = i.create_system
138 nuevo.writestr(ni, datos)
139 if pendientes:
140 os.unlink(tmp)
141 fatal(f"{ruta_ja}: estas entradas no existían y no se reemplazó nada: {sorted(pendientes)}")
142 os.replace(tmp, ruta_ja)
143 return len(infos)
144
145
146# ── 4. El icono ────────────────────────────────────────────────────────────────────────────────
147# Hasta la v0.2 esto DIBUJABA una marca geométrica en código, con el argumento de que un PNG en el
148# árbol no se puede revisar en un diff. El argumento valía mientras el icono era un marcador de
149# posición inventado por falta de arte. **Ya hay arte** —el zorro en medallón con cenefa andina— y
150# entonces el criterio se invierte: un asset de diseño ES la fuente, no un derivado, y meterlo en el
151# repo es lo correcto. Lo que se conserva es la disciplina: los tamaños salen del MISMO master, se
152# generan una vez y se copian, así que dos builds ponen los mismos bytes.
153#
154# ⚠ A 16 px el medallón pierde el detalle y queda como una mancha cálida redonda. Es intrínseco a un
155# logo con cenefa, no un defecto del escalado: si molesta, la salida es un dibujo SIMPLIFICADO para
156# 16 px, y eso lo decide quien dibujó el zorro, no este script.
157def escribir_iconos():
158 origen = os.path.join(OVERLAY, "branding", "icons")
159 dir_app = os.path.join(APPDIR, "browser", "chrome", "icons", "default")
160 if not os.path.isdir(dir_app):
161 fatal(f"no está {dir_app} — el árbol de firefox no tiene la forma esperada")
162 escritos = []
163 for s in TAMANOS:
164 src = os.path.join(origen, f"atuq{s}.png")
165 if not os.path.isfile(src) or os.path.getsize(src) == 0:
166 fatal(f"falta o está vacío el icono {src}")
167 with open(src, "rb") as fh:
168 datos = fh.read()
169 # (a) el icono de la VENTANA, que Gecko lee del appdir
170 p = os.path.join(dir_app, f"default{s}.png")
171 with open(p, "wb") as fh:
172 fh.write(datos)
173 escritos.append(p)
174 # (b) el icono del LANZADOR, que lo lee el escritorio por el tema hicolor
175 q = os.path.join(OUTROOT, "usr/share/icons/hicolor", f"{s}x{s}", "apps", f"{MARCA}.png")
176 os.makedirs(os.path.dirname(q), exist_ok=True)
177 with open(q, "wb") as fh:
178 fh.write(datos)
179 escritos.append(q)
180 return escritos
181
182
183# ── 3.bis QUÉ SE REEMPLAZA DENTRO DE browser/omni.ja ───────────────────────────────────────────
184# La v0.2 cambió sólo `brand.ftl` y el resultado se vio en pantalla: el TÍTULO de la ventana decía
185# «New Tab — atuq» y la PÁGINA seguía mostrando el logo y la palabra «Nightly». La lección es que el
186# branding de Gecko vive en tres sitios y sólo uno es texto FTL:
187#
188# · `brand.ftl` — las cadenas nuevas (título de ventana, menús). Ya estaba.
189# · `brand.properties` — las cadenas VIEJAS, formato heredado. Seguía diciendo Nightly.
190# · los PNG y SVG de `chrome/browser/content/branding/` — TODO lo que se ve: el favicon de las
191# páginas internas (ese orbe azul en la pestaña), el logo de about:, y el «wordmark» que es una
192# IMAGEN con la palabra dibujada, no un texto traducible.
193#
194# Cada imagen se reemplaza por otra de LAS MISMAS DIMENSIONES: son tamaños que el CSS del chrome da
195# por sentados, y meter un PNG de otro tamaño descoloca la página en vez de rebrandearla.
196IMAGENES = {
197 "chrome/browser/content/branding/icon16.png": "atuq16.png",
198 "chrome/browser/content/branding/icon32.png": "atuq32.png",
199 "chrome/browser/content/branding/icon48.png": "atuq48.png",
200 "chrome/browser/content/branding/icon64.png": "atuq64.png",
201 "chrome/browser/content/branding/icon128.png": "atuq128.png",
202 "chrome/browser/content/branding/about-logo.png": "atuq192.png",
203 "chrome/browser/content/branding/about-logo@2x.png": "atuq384.png",
204 # La variante «private» existe para distinguir la ventana privada. Se usa el mismo medallón a
205 # propósito: inventar una segunda marca para un modo del navegador es ruido, y el modo ya se
206 # distingue por su propio chrome.
207 "chrome/browser/content/branding/about-logo-private.png": "atuq192.png",
208 "chrome/browser/content/branding/about-logo-private@2x.png": "atuq384.png",
209 "chrome/browser/content/branding/about.png": "atuq-about-300x236.png",
210}
211
212
213def reemplazos_branding():
214 r = {}
215 for destino, fuente in IMAGENES.items():
216 ruta = os.path.join(OVERLAY, "branding", "icons", fuente)
217 if not os.path.isfile(ruta) or os.path.getsize(ruta) == 0:
218 fatal(f"falta o está vacío el asset {ruta}")
219 with open(ruta, "rb") as fh:
220 r[destino] = fh.read()
221 for destino, fuente in (
222 ("localization/en-US/branding/brand.ftl", "brand.ftl"),
223 ("chrome/en-US/locale/branding/brand.properties", "brand.properties"),
224 ("chrome/browser/content/branding/about-wordmark.svg", "wordmark.svg"),
225 ("chrome/browser/content/branding/firefox-wordmark.svg", "wordmark.svg"),
226 ):
227 with open(os.path.join(OVERLAY, "branding", fuente), "rb") as fh:
228 r[destino] = fh.read()
229 return r
230
231
232# ── 4.bis Las extensiones de sistema ──────────────────────────────────────────────────────────
233# POR QUÉ EXTENSIONES Y NO PREFS. La página de inicio se podría poner con
234# `browser.startup.homepage`, pero la pestaña NUEVA no se puede redirigir con una pref desde hace
235# años: el único mecanismo soportado es `chrome_url_overrides.newtab`. Y el ruteo por proxy de cada
236# contenedor (SDD 26 §6.8) directamente no tiene forma de pref: es la API `proxy.onRequest`, que
237# sólo existe para una extensión.
238#
239# Esto sólo funciona porque `recipes/firefox.toml` construye con
240# `--with-unsigned-addon-scopes=app,system` y `atuq.cfg` pone `extensions.autoDisableScopes = 0` y
241# `xpinstall.signatures.required = false`. Son piezas en ficheros distintos y **todas tienen que
242# estar**: sin la primera Gecko rechaza el XPI sin firmar, sin la segunda lo instala DESACTIVADO.
243#
244# EL ZIP VA `STORED` Y CON FECHA FIJA, igual que el `omni.ja`: un XPI comprimido metería la versión
245# de zlib como variable del artefacto, y son ficheros de texto — no hay nada que ahorrar.
246#
247# ── EL ID SALE DEL MANIFIESTO, NO DE UNA CONSTANTE DE ACÁ ─────────────────────────────────────
248# La v0.4 tenía el id escrito en dos sitios: `EXT_ID` en este fichero y
249# `browser_specific_settings.gecko.id` en el manifiesto. Dos dueños para un valor es la forma
250# conocida de que se desincronicen, y el fallo que producen es de los caros: el XPI se escribiría
251# con un nombre y Gecko, al abrirlo, encontraría otro id adentro ⇒ la política de instalación
252# apuntaría a un fichero que para Firefox no es el complemento que pidió. Ahora hay un solo dueño
253# —el manifiesto— y el nombre del fichero se DERIVA de él.
254EXT_DIR = "extensions"
255# El icono se INYECTA desde `branding/icons/` en vez de vivir copiado dentro de cada extensión. La
256# v0.4 tenía `extension/atuq128.png` byte a byte igual a `branding/icons/atuq128.png`; con dos
257# extensiones habrían sido tres copias del mismo PNG en el repo, y la marca se desalinea el día que
258# alguien actualiza una y no las otras.
259EXT_ICONO = "atuq128.png"
260
261
262def _leer_id(manifiesto, donde):
263 try:
264 datos = json.loads(manifiesto)
265 except ValueError as e:
266 fatal(f"{donde}: el manifest.json no es JSON válido — {e}")
267 try:
268 ext_id = datos["browser_specific_settings"]["gecko"]["id"]
269 except (KeyError, TypeError):
270 fatal(f"{donde}: el manifest.json no declara browser_specific_settings.gecko.id")
271 if not isinstance(ext_id, str) or "@" not in ext_id:
272 fatal(f"{donde}: el id {ext_id!r} no parece un id de extensión")
273 return ext_id
274
275
276def empaquetar_extensiones():
277 base = os.path.join(OVERLAY, EXT_DIR)
278 if not os.path.isdir(base):
279 fatal(f"no está el árbol de las extensiones en {base}")
280 dirs = sorted(d for d in os.listdir(base) if os.path.isdir(os.path.join(base, d)))
281 if not dirs:
282 fatal(f"{base} no tiene ninguna extensión")
283
284 with open(os.path.join(OVERLAY, "branding", "icons", EXT_ICONO), "rb") as fh:
285 icono = fh.read()
286
287 destino_dir = os.path.join(APPDIR, "distribution", "extensions")
288 os.makedirs(destino_dir, exist_ok=True)
289
290 resultados = []
291 for d in dirs:
292 origen = os.path.join(base, d)
293 ficheros = {}
294 for nombre in sorted(os.listdir(origen)):
295 ruta = os.path.join(origen, nombre)
296 if not os.path.isfile(ruta):
297 continue
298 with open(ruta, "rb") as fh:
299 datos = fh.read()
300 if not datos:
301 fatal(f"la extensión {d} trae {nombre} VACÍO")
302 ficheros[nombre] = datos
303 if "manifest.json" not in ficheros:
304 fatal(f"la extensión {d} no tiene manifest.json")
305 ficheros[EXT_ICONO] = icono
306
307 ext_id = _leer_id(ficheros["manifest.json"], d)
308 destino = os.path.join(destino_dir, f"{ext_id}.xpi")
309 # Orden alfabético y no el del `listdir`: el orden del directorio depende del sistema de
310 # ficheros, y un zip cuyas entradas cambian de orden entre máquinas ya no reproduce.
311 with zipfile.ZipFile(destino, "w") as z:
312 for nombre in sorted(ficheros):
313 zi = zipfile.ZipInfo(nombre, date_time=(2010, 1, 1, 0, 0, 0))
314 zi.compress_type = zipfile.ZIP_STORED
315 zi.external_attr = 0o644 << 16
316 z.writestr(zi, ficheros[nombre])
317 # La ruta con la que la POLÍTICA va a pedir este XPI, derivada de dónde lo acabamos de
318 # escribir. Es lo que hace que el cruce de abajo compruebe algo de verdad y no una constante
319 # contra otra constante.
320 url = "file:///" + os.path.relpath(destino, OUTROOT)
321 resultados.append((d, ext_id, len(ficheros), os.path.getsize(destino), url))
322 return resultados
323
324
325# ── 4.ter El cruce contra la política ─────────────────────────────────────────────────────────
326# ⚠ ESTE COMENTARIO DECÍA QUE EL SIDELOADING «YA NO EXISTE» Y QUE INSTALABA LA POLÍTICA. Es falso, y
327# está medido en tres escenarios por `scripts/test-atuq-instalacion.py` (SDD 26 §7.ter): **instala el
328# escaneo de `distribution/extensions/`**; con la política sola —`install_url` con `file://`— no se
329# instala NADA. La política FIJA el modo y CONFIGURA; instalar, no.
330#
331# El cruce sigue valiendo igual, y por eso no se toca: el id y la ruta tienen que coincidir en los dos
332# lados, porque una extensión empaquetada que la política no declara queda instalada pero sin su
333# `installation_mode` ni su `storage.managed`, y una política que apunta a un XPI que no está deja un
334# `Download failed` que no nombra la causa. Se cruza acá, en el build, donde falla gratis.
335def verificar_politica(resultados):
336 ruta = os.path.join(APPDIR, "distribution", "policies.json")
337 try:
338 with open(ruta, encoding="utf-8") as fh:
339 pol = json.load(fh)["policies"]
340 except (OSError, ValueError, KeyError) as e:
341 fatal(f"no pude leer {ruta}: {e}")
342
343 ajustes = pol.get("ExtensionSettings", {})
344 declarados = {k for k in ajustes if k != "*"}
345 empaquetados = {r[1] for r in resultados}
346
347 faltan = empaquetados - declarados
348 if faltan:
349 fatal(f"empaquetadas pero NO declaradas en ExtensionSettings: {sorted(faltan)}")
350 sobran = declarados - empaquetados
351 if sobran:
352 fatal(f"declaradas en ExtensionSettings sin XPI que las respalde: {sorted(sobran)}")
353
354 for _, ext_id, _, _, url in resultados:
355 esperada = ajustes[ext_id].get("install_url")
356 if esperada != url:
357 fatal(f"{ext_id}: la política instala desde {esperada!r} y el XPI quedó en {url!r}")
358
359 # Los contenedores de la política son la mitad de abajo del §6.8: sin ellos, la extensión del
360 # proxy arranca, no encuentra a qué contenedor aplicar nada y queda inerte sin decir por qué.
361 contenedores = [c.get("name") for c in pol.get("Containers", {}).get("Default", [])]
362 terceros = pol.get("3rdparty", {}).get("Extensions", {})
363 for ext_id, conf in terceros.items():
364 if ext_id not in empaquetados:
365 fatal(f"3rdparty configura {ext_id}, que no es ninguna extensión de atuq")
366 huerfanos = sorted(set(conf.get("proxies", {})) - set(contenedores))
367 if huerfanos:
368 fatal(f"{ext_id}: proxies para contenedores que la política no crea: {huerfanos}")
369 return contenedores
370
371
372# ── 4.quater Los manifiestos de native messaging ──────────────────────────────────────────────
373# DÓNDE VAN, Y POR QUÉ NO EN EL APPDIR. Gecko los busca en `XRESysNativeManifests`, que en Linux es
374# un `/usr/lib/mozilla` **compilado dentro del binario**: el rebranding a `atuq` no lo mueve. Se
375# midió con sonda y control negativo (SDD 26 §7.bis) — el control decía, con su nombre,
376# «No such native application».
377#
378# QUÉ SE CRUZA, y por qué las dos cosas: un manifiesto tiene DOS referencias a cosas que pueden no
379# existir, y las dos fallan igual de calladas.
380# · `path` — si el ejecutable no está, el navegador arranca, la extensión conecta, el puerto se
381# cierra «sin error y sin mensaje», y la función simplemente no existe;
382# · `allowed_extensions` — si nombra un id que no empaquetamos, el host es inalcanzable para
383# siempre y nada lo dice.
384# Las dos se comprueban contra el árbol REAL (el fichero que acabamos de escribir y la lista de XPI
385# que acabamos de empaquetar), no contra constantes de este script.
386#
387# Y se ESCRIBEN acá en vez de copiarse desde el overlay para que el JSON quede canónico —claves
388# ordenadas, sangría fija, un `\n` final—: un fichero copiado tal cual haría del formato del editor
389# de quien lo tocó una variable del artefacto.
390NM_DIR = "native-messaging"
391NM_DESTINO = os.path.join("usr", "lib", "mozilla", "native-messaging-hosts")
392
393
394def instalar_manifiestos_nativos(resultados):
395 base = os.path.join(OVERLAY, NM_DIR)
396 if not os.path.isdir(base):
397 return []
398 empaquetadas = {r[1] for r in resultados}
399 destino_dir = os.path.join(OUTROOT, NM_DESTINO)
400 os.makedirs(destino_dir, exist_ok=True)
401
402 salida = []
403 for nombre in sorted(os.listdir(base)):
404 if not nombre.endswith(".json"):
405 continue
406 ruta = os.path.join(base, nombre)
407 try:
408 with open(ruta, encoding="utf-8") as fh:
409 man = json.load(fh)
410 except (OSError, ValueError) as e:
411 fatal(f"{NM_DIR}/{nombre}: no pude leerlo — {e}")
412
413 # El nombre del host ES el del fichero: Gecko lo busca por `<name>.json` y además compara el
414 # campo `name` contra el que la extensión pidió. Dos dueños para un valor otra vez, así que
415 # se exige que coincidan acá.
416 esperado = nombre[:-len(".json")]
417 if man.get("name") != esperado:
418 fatal(f"{NM_DIR}/{nombre}: el campo name es {man.get('name')!r} y el fichero se llama "
419 f"{esperado!r} — Gecko busca por nombre de fichero y compara el campo")
420 if man.get("type") != "stdio":
421 fatal(f"{NM_DIR}/{nombre}: type={man.get('type')!r}, se esperaba 'stdio'")
422 ejecutable = man.get("path", "")
423 if not ejecutable.startswith("/"):
424 fatal(f"{NM_DIR}/{nombre}: path {ejecutable!r} no es absoluto (Gecko lo exige en Linux)")
425 real = os.path.join(OUTROOT, ejecutable.lstrip("/"))
426 if not os.path.isfile(real):
427 fatal(f"{NM_DIR}/{nombre}: el host {ejecutable} NO está en el artefacto")
428 if not os.access(real, os.X_OK):
429 fatal(f"{NM_DIR}/{nombre}: el host {ejecutable} no es ejecutable")
430 permitidas = man.get("allowed_extensions") or []
431 if not permitidas:
432 fatal(f"{NM_DIR}/{nombre}: sin allowed_extensions cualquier extensión podría hablarle")
433 huerfanas = sorted(set(permitidas) - empaquetadas)
434 if huerfanas:
435 fatal(f"{NM_DIR}/{nombre}: allowed_extensions nombra extensiones que atuq no empaqueta: "
436 f"{huerfanas}")
437
438 with open(os.path.join(destino_dir, nombre), "w", encoding="utf-8") as fh:
439 json.dump(man, fh, ensure_ascii=False, indent=2, sort_keys=True)
440 fh.write("\n")
441 salida.append((esperado, ejecutable, permitidas))
442 return salida
443
444
445# ── 5. Los binarios ────────────────────────────────────────────────────────────────────────────
446# Se renombran los dos y se deja un symlink `firefox` → `atuq` DENTRO del appdir. El symlink no es
447# indecisión: hay código y scripts de terceros que invocan el binario por su nombre histórico, y
448# como Gecko resuelve su directorio por `/proc/self/exe`, entrar por el symlink resuelve al mismo
449# sitio y no cambia nada del comportamiento.
450def renombrar_binarios():
451 hechos = []
452 for viejo, nuevo in (("firefox", MARCA), ("firefox-bin", f"{MARCA}-bin")):
453 vp = os.path.join(APPDIR, viejo)
454 if not os.path.isfile(vp):
455 fatal(f"no está {vp}")
456 os.rename(vp, os.path.join(APPDIR, nuevo))
457 hechos.append(f"{viejo} → {nuevo}")
458 os.symlink(MARCA, os.path.join(APPDIR, "firefox"))
459 return hechos
460
461
462# ── 6. La marca COMPILADA dentro del binario ───────────────────────────────────────────────────
463# `application.ini` dice `RemotingName=atuq` y aun así el proceso se anunciaba como
464# `firefox-default`: esa cadena es un `MOZ_APP_REMOTINGNAME` horneado en el ELF en tiempo de
465# compilación, y es la que Gecko pasa a `g_set_prgname()` ⇒ es el **app_id de Wayland**. O sea que
466# el escritorio no casaba la ventana con nuestro `.desktop` (`StartupWMClass=atuq`): icono genérico
467# y sin agrupar con el lanzador. Se ve en el log de cualquier arranque: `(firefox-default:7048)`.
468#
469# Se parchea EN SITIO y no se recompila: cambiarlo en `recipes/firefox.toml` brandearía la BASE de
470# atuq como atuq, que es justo lo que un derivado no debe hacer —firefox tiene que seguir siendo
471# firefox para los otros forks—.
472#
473# Es seguro porque es una cadena C terminada en NUL dentro del pool de `.rodata`: acortarla deja
474# NULs muertos detrás y la siguiente cadena empieza donde empezaba. `firefox-default\0` son 16
475# bytes exactos y se escriben 16. Y se EXIGE una única ocurrencia: si upstream la duplica o la
476# renombra, esto falla en vez de parchear el sitio equivocado.
477VIEJA = b"firefox-default\0"
478NUEVA = MARCA.encode() + b"\0" * (len(VIEJA) - len(MARCA))
479
480
481def rebrandear_binario(rel):
482 ruta = os.path.join(APPDIR, rel)
483 with open(ruta, "rb") as fh:
484 datos = fh.read()
485 n = datos.count(VIEJA)
486 if n != 1:
487 fatal(f"{rel}: esperaba 1 ocurrencia de {VIEJA!r} y hay {n} — ¿cambió upstream?")
488 modo = os.stat(ruta).st_mode
489 with open(ruta, "wb") as fh:
490 fh.write(datos.replace(VIEJA, NUEVA))
491 os.chmod(ruta, modo)
492 with open(ruta, "rb") as fh:
493 if fh.read().count(VIEJA) != 0:
494 fatal(f"{rel}: el parche no agarró")
495 return f"{rel}: app_id firefox-default → {MARCA}"
496
497
498def main():
499 huella = huella_overlay()
500 bid = build_id(huella)
501 claves = rebrandear_application_ini(bid)
502 n = repack_omni(os.path.join(APPDIR, "browser", "omni.ja"), reemplazos_branding())
503 iconos = escribir_iconos()
504 binarios = renombrar_binarios()
505 # Después del renombrado: los ficheros ya se llaman atuq/atuq-bin.
506 parches = [rebrandear_binario(MARCA), rebrandear_binario(f"{MARCA}-bin")]
507 exts = empaquetar_extensiones()
508 contenedores = verificar_politica(exts)
509 nativos = instalar_manifiestos_nativos(exts)
510
511 print(f"rebrand: overlay {huella[:16]}… ⇒ BuildID {bid}")
512 print(f"rebrand: application.ini {sorted(claves)}")
513 print(f"rebrand: browser/omni.ja re-empacado, {n} entradas, orden y STORED preservados")
514 print(f"rebrand: branding dentro del zip — {len(IMAGENES)} imágenes + brand.ftl + brand.properties + 2 wordmarks")
515 print(f"rebrand: {len(iconos)} iconos escritos ({', '.join(str(s) for s in TAMANOS)} px)")
516 print(f"rebrand: binarios {', '.join(binarios)} (+ symlink firefox → {MARCA})")
517 for l in parches:
518 print(f"rebrand: {l}")
519 for d, ext_id, n, size, _ in exts:
520 print(f"rebrand: extensión de sistema {ext_id}.xpi ← {EXT_DIR}/{d} ({n} ficheros, {size} bytes)")
521 print(f"rebrand: política cruzada — {len(exts)} extensión(es), contenedores {contenedores}")
522 for nombre, ejecutable, permitidas in nativos:
523 print(f"rebrand: host nativo {nombre} → {ejecutable} para {', '.join(permitidas)}")
524
525
526main()