gioser

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…

TOML 815Rust 56Shell 37Markdown 291229 Definiciones

⎇ 02513b861f6fbb462045ba411c6bec0a2cae7e93 recipes/atuq/tools/rebrand.py Python · 526 líneas · 27.3 KB Historia Quién tocó qué crudo

atuq: `sct` v1 — el navegador avisa cuando un sitio ya estable ejecuta código que nadie vio nunca 02513b861 · Sergio ·

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()