#!/usr/bin/env python3 """Convierte el árbol de `firefox` ya copiado en el árbol de `atuq`. Uso: rebrand.py appdir = /out/usr/lib/atuq (la copia del árbol de firefox) overlay = /src (este directorio, `recipes/atuq/`) outroot = /out POR QUÉ UNA HERRAMIENTA Y NO CUATRO LÍNEAS DE SHELL --------------------------------------------------- El branding de verdad vive DENTRO de `browser/omni.ja`, que es un zip, y re-empacarlo desde shell exige `zip`, que el lab no trae. `zipfile` de la stdlib, además de estar disponible, da control sobre las tres cosas que el CLI no deja fijar y de las que depende que el artefacto REPRODUZCA: el orden de las entradas, su `date_time` y sus permisos. TODO CAMBIO ES VERIFICADO, NINGUNO ES SILENCIOSO ------------------------------------------------ Cada reemplazo declara qué entrada espera encontrar y falla si no está. Un rebranding que no encuentra la cadena que iba a cambiar produce un navegador que se sigue llamando Nightly y sella igual de bien: sería exactamente la clase de fallo que llega hasta el final diciendo que todo fue bien (regla 3 del CLAUDE.md). """ import hashlib import json import os import sys import zipfile APPDIR, OVERLAY, OUTROOT = sys.argv[1], sys.argv[2], sys.argv[3] MARCA = "atuq" VENDOR = "tawasuyu" TAMANOS = (16, 32, 48, 64, 128) def fatal(msg): print(f"rebrand: {msg}", file=sys.stderr) raise SystemExit(1) # ── 1. La huella del overlay, que es también el BuildID ──────────────────────────────────────── # El `BuildID` es la clave con la que Gecko invalida su startup cache. Si atuq cambia su chrome y # el BuildID no se mueve, el navegador arranca con la interfaz vieja cacheada y PARECE que el # overlay no agarró — el falso negativo que el SDD 26 §2 anota como gotcha del camino derivado. # # Se deriva del CONTENIDO del overlay y no de la fecha: cambia exactamente cuando cambia lo que # tiene que invalidar, y dos builds del mismo atuq dan el mismo número. La fecha haría lo contrario # en los dos sentidos. def huella_overlay(): h = hashlib.sha256() for raiz, dirs, ficheros in os.walk(OVERLAY): dirs.sort() for f in sorted(ficheros): p = os.path.join(raiz, f) h.update(os.path.relpath(p, OVERLAY).encode()) h.update(b"\0") with open(p, "rb") as fh: h.update(fh.read()) h.update(b"\0") return h.hexdigest() def build_id(huella): # 14 dígitos decimales: la forma que Gecko espera (nació de un `YYYYMMDDHHMMSS`). return str(int(huella[:16], 16))[:14].rjust(14, "0") # ── 2. application.ini — la identidad de la aplicación ───────────────────────────────────────── # ⚠ La cabecera de este fichero dice «This file is not used», y es un comentario HEREDADO del árbol # de desarrollo: en un build empaquetado como éste el launcher SÍ lo lee. Se comprueba en vez de # creerle al comentario — si no encontramos las claves, fallamos. # # `ID` NO se toca a propósito: es el GUID con el que las extensiones declaran compatibilidad # (`gecko.id` de Firefox). Cambiarlo convertiría a atuq en una aplicación desconocida para todo el # ecosistema de complementos, que es justo lo que un envoltorio no quiere. def rebrandear_application_ini(bid): ruta = os.path.join(APPDIR, "application.ini") if not os.path.isfile(ruta): fatal(f"no está {ruta}") with open(ruta, encoding="utf-8") as fh: lineas = fh.read().splitlines() # `CodeName` también: es el residuo de `--with-branding=unofficial` (dice «Nightly») y aparece # en about:buildconfig y en los informes de fallo. Dejarlo sería exactamente el rincón por el # que atuq se sigue presentando como otro producto. cambios = { "Vendor": VENDOR, "Name": MARCA, "RemotingName": MARCA, "CodeName": MARCA, "BuildID": bid, } vistas = set() salida = [] for ln in lineas: clave = ln.split("=", 1)[0] if "=" in ln else None if clave in cambios: salida.append(f"{clave}={cambios[clave]}") vistas.add(clave) else: salida.append(ln) faltan = set(cambios) - vistas if faltan: fatal(f"application.ini no tenía las claves {sorted(faltan)} — ¿cambió el formato?") with open(ruta, "w", encoding="utf-8") as fh: fh.write("\n".join(salida) + "\n") return vistas # ── 3. omni.ja — el re-empaque determinista ──────────────────────────────────────────────────── # TRES PROPIEDADES DEL ORIGINAL QUE SE PRESERVAN, Y POR QUÉ: # # · EL ORDEN de las entradas. Gecko lee este zip en el arranque y el orden es el que Mozilla # eligió para que las primeras lecturas caigan juntas. Reordenarlo (p.ej. alfabético, que es lo # que sale «gratis» al recomprimir) no rompe nada visible: sólo hace el arranque más lento, en # silencio. Cuando exista el PGO, el `jarlog` refina justamente este orden — tirarlo sería # tirar media optimización de arranque. # · `compress_type = 0` (STORED). El omni.ja de Mozilla va SIN COMPRIMIR a propósito, para poder # mapearlo en memoria. Comprimirlo achica el artefacto y encarece cada lectura. # · `date_time`. El original ya viene normalizado a 2010-01-01 —Mozilla también persigue # reproducibilidad— así que copiarlo tal cual mantiene el zip byte-idéntico salvo por lo que # cambiamos de verdad. def repack_omni(ruta_ja, reemplazos): if not os.path.isfile(ruta_ja): fatal(f"no está {ruta_ja}") pendientes = dict(reemplazos) tmp = ruta_ja + ".nuevo" with zipfile.ZipFile(ruta_ja) as orig: infos = orig.infolist() with zipfile.ZipFile(tmp, "w") as nuevo: for i in infos: datos = pendientes.pop(i.filename, None) if datos is None: datos = orig.read(i.filename) ni = zipfile.ZipInfo(i.filename, date_time=i.date_time) ni.compress_type = i.compress_type ni.external_attr = i.external_attr ni.create_system = i.create_system nuevo.writestr(ni, datos) if pendientes: os.unlink(tmp) fatal(f"{ruta_ja}: estas entradas no existían y no se reemplazó nada: {sorted(pendientes)}") os.replace(tmp, ruta_ja) return len(infos) # ── 4. El icono ──────────────────────────────────────────────────────────────────────────────── # Hasta la v0.2 esto DIBUJABA una marca geométrica en código, con el argumento de que un PNG en el # árbol no se puede revisar en un diff. El argumento valía mientras el icono era un marcador de # posición inventado por falta de arte. **Ya hay arte** —el zorro en medallón con cenefa andina— y # entonces el criterio se invierte: un asset de diseño ES la fuente, no un derivado, y meterlo en el # repo es lo correcto. Lo que se conserva es la disciplina: los tamaños salen del MISMO master, se # generan una vez y se copian, así que dos builds ponen los mismos bytes. # # ⚠ A 16 px el medallón pierde el detalle y queda como una mancha cálida redonda. Es intrínseco a un # logo con cenefa, no un defecto del escalado: si molesta, la salida es un dibujo SIMPLIFICADO para # 16 px, y eso lo decide quien dibujó el zorro, no este script. def escribir_iconos(): origen = os.path.join(OVERLAY, "branding", "icons") dir_app = os.path.join(APPDIR, "browser", "chrome", "icons", "default") if not os.path.isdir(dir_app): fatal(f"no está {dir_app} — el árbol de firefox no tiene la forma esperada") escritos = [] for s in TAMANOS: src = os.path.join(origen, f"atuq{s}.png") if not os.path.isfile(src) or os.path.getsize(src) == 0: fatal(f"falta o está vacío el icono {src}") with open(src, "rb") as fh: datos = fh.read() # (a) el icono de la VENTANA, que Gecko lee del appdir p = os.path.join(dir_app, f"default{s}.png") with open(p, "wb") as fh: fh.write(datos) escritos.append(p) # (b) el icono del LANZADOR, que lo lee el escritorio por el tema hicolor q = os.path.join(OUTROOT, "usr/share/icons/hicolor", f"{s}x{s}", "apps", f"{MARCA}.png") os.makedirs(os.path.dirname(q), exist_ok=True) with open(q, "wb") as fh: fh.write(datos) escritos.append(q) return escritos # ── 3.bis QUÉ SE REEMPLAZA DENTRO DE browser/omni.ja ─────────────────────────────────────────── # La v0.2 cambió sólo `brand.ftl` y el resultado se vio en pantalla: el TÍTULO de la ventana decía # «New Tab — atuq» y la PÁGINA seguía mostrando el logo y la palabra «Nightly». La lección es que el # branding de Gecko vive en tres sitios y sólo uno es texto FTL: # # · `brand.ftl` — las cadenas nuevas (título de ventana, menús). Ya estaba. # · `brand.properties` — las cadenas VIEJAS, formato heredado. Seguía diciendo Nightly. # · los PNG y SVG de `chrome/browser/content/branding/` — TODO lo que se ve: el favicon de las # páginas internas (ese orbe azul en la pestaña), el logo de about:, y el «wordmark» que es una # IMAGEN con la palabra dibujada, no un texto traducible. # # Cada imagen se reemplaza por otra de LAS MISMAS DIMENSIONES: son tamaños que el CSS del chrome da # por sentados, y meter un PNG de otro tamaño descoloca la página en vez de rebrandearla. IMAGENES = { "chrome/browser/content/branding/icon16.png": "atuq16.png", "chrome/browser/content/branding/icon32.png": "atuq32.png", "chrome/browser/content/branding/icon48.png": "atuq48.png", "chrome/browser/content/branding/icon64.png": "atuq64.png", "chrome/browser/content/branding/icon128.png": "atuq128.png", "chrome/browser/content/branding/about-logo.png": "atuq192.png", "chrome/browser/content/branding/about-logo@2x.png": "atuq384.png", # La variante «private» existe para distinguir la ventana privada. Se usa el mismo medallón a # propósito: inventar una segunda marca para un modo del navegador es ruido, y el modo ya se # distingue por su propio chrome. "chrome/browser/content/branding/about-logo-private.png": "atuq192.png", "chrome/browser/content/branding/about-logo-private@2x.png": "atuq384.png", "chrome/browser/content/branding/about.png": "atuq-about-300x236.png", } def reemplazos_branding(): r = {} for destino, fuente in IMAGENES.items(): ruta = os.path.join(OVERLAY, "branding", "icons", fuente) if not os.path.isfile(ruta) or os.path.getsize(ruta) == 0: fatal(f"falta o está vacío el asset {ruta}") with open(ruta, "rb") as fh: r[destino] = fh.read() for destino, fuente in ( ("localization/en-US/branding/brand.ftl", "brand.ftl"), ("chrome/en-US/locale/branding/brand.properties", "brand.properties"), ("chrome/browser/content/branding/about-wordmark.svg", "wordmark.svg"), ("chrome/browser/content/branding/firefox-wordmark.svg", "wordmark.svg"), ): with open(os.path.join(OVERLAY, "branding", fuente), "rb") as fh: r[destino] = fh.read() return r # ── 4.bis Las extensiones de sistema ────────────────────────────────────────────────────────── # POR QUÉ EXTENSIONES Y NO PREFS. La página de inicio se podría poner con # `browser.startup.homepage`, pero la pestaña NUEVA no se puede redirigir con una pref desde hace # años: el único mecanismo soportado es `chrome_url_overrides.newtab`. Y el ruteo por proxy de cada # contenedor (SDD 26 §6.8) directamente no tiene forma de pref: es la API `proxy.onRequest`, que # sólo existe para una extensión. # # Esto sólo funciona porque `recipes/firefox.toml` construye con # `--with-unsigned-addon-scopes=app,system` y `atuq.cfg` pone `extensions.autoDisableScopes = 0` y # `xpinstall.signatures.required = false`. Son piezas en ficheros distintos y **todas tienen que # estar**: sin la primera Gecko rechaza el XPI sin firmar, sin la segunda lo instala DESACTIVADO. # # EL ZIP VA `STORED` Y CON FECHA FIJA, igual que el `omni.ja`: un XPI comprimido metería la versión # de zlib como variable del artefacto, y son ficheros de texto — no hay nada que ahorrar. # # ── EL ID SALE DEL MANIFIESTO, NO DE UNA CONSTANTE DE ACÁ ───────────────────────────────────── # La v0.4 tenía el id escrito en dos sitios: `EXT_ID` en este fichero y # `browser_specific_settings.gecko.id` en el manifiesto. Dos dueños para un valor es la forma # conocida de que se desincronicen, y el fallo que producen es de los caros: el XPI se escribiría # con un nombre y Gecko, al abrirlo, encontraría otro id adentro ⇒ la política de instalación # apuntaría a un fichero que para Firefox no es el complemento que pidió. Ahora hay un solo dueño # —el manifiesto— y el nombre del fichero se DERIVA de él. EXT_DIR = "extensions" # El icono se INYECTA desde `branding/icons/` en vez de vivir copiado dentro de cada extensión. La # v0.4 tenía `extension/atuq128.png` byte a byte igual a `branding/icons/atuq128.png`; con dos # extensiones habrían sido tres copias del mismo PNG en el repo, y la marca se desalinea el día que # alguien actualiza una y no las otras. EXT_ICONO = "atuq128.png" def _leer_id(manifiesto, donde): try: datos = json.loads(manifiesto) except ValueError as e: fatal(f"{donde}: el manifest.json no es JSON válido — {e}") try: ext_id = datos["browser_specific_settings"]["gecko"]["id"] except (KeyError, TypeError): fatal(f"{donde}: el manifest.json no declara browser_specific_settings.gecko.id") if not isinstance(ext_id, str) or "@" not in ext_id: fatal(f"{donde}: el id {ext_id!r} no parece un id de extensión") return ext_id def empaquetar_extensiones(): base = os.path.join(OVERLAY, EXT_DIR) if not os.path.isdir(base): fatal(f"no está el árbol de las extensiones en {base}") dirs = sorted(d for d in os.listdir(base) if os.path.isdir(os.path.join(base, d))) if not dirs: fatal(f"{base} no tiene ninguna extensión") with open(os.path.join(OVERLAY, "branding", "icons", EXT_ICONO), "rb") as fh: icono = fh.read() destino_dir = os.path.join(APPDIR, "distribution", "extensions") os.makedirs(destino_dir, exist_ok=True) resultados = [] for d in dirs: origen = os.path.join(base, d) ficheros = {} for nombre in sorted(os.listdir(origen)): ruta = os.path.join(origen, nombre) if not os.path.isfile(ruta): continue with open(ruta, "rb") as fh: datos = fh.read() if not datos: fatal(f"la extensión {d} trae {nombre} VACÍO") ficheros[nombre] = datos if "manifest.json" not in ficheros: fatal(f"la extensión {d} no tiene manifest.json") ficheros[EXT_ICONO] = icono ext_id = _leer_id(ficheros["manifest.json"], d) destino = os.path.join(destino_dir, f"{ext_id}.xpi") # Orden alfabético y no el del `listdir`: el orden del directorio depende del sistema de # ficheros, y un zip cuyas entradas cambian de orden entre máquinas ya no reproduce. with zipfile.ZipFile(destino, "w") as z: for nombre in sorted(ficheros): zi = zipfile.ZipInfo(nombre, date_time=(2010, 1, 1, 0, 0, 0)) zi.compress_type = zipfile.ZIP_STORED zi.external_attr = 0o644 << 16 z.writestr(zi, ficheros[nombre]) # La ruta con la que la POLÍTICA va a pedir este XPI, derivada de dónde lo acabamos de # escribir. Es lo que hace que el cruce de abajo compruebe algo de verdad y no una constante # contra otra constante. url = "file:///" + os.path.relpath(destino, OUTROOT) resultados.append((d, ext_id, len(ficheros), os.path.getsize(destino), url)) return resultados # ── 4.ter El cruce contra la política ───────────────────────────────────────────────────────── # ⚠ ESTE COMENTARIO DECÍA QUE EL SIDELOADING «YA NO EXISTE» Y QUE INSTALABA LA POLÍTICA. Es falso, y # está medido en tres escenarios por `scripts/test-atuq-instalacion.py` (SDD 26 §7.ter): **instala el # escaneo de `distribution/extensions/`**; con la política sola —`install_url` con `file://`— no se # instala NADA. La política FIJA el modo y CONFIGURA; instalar, no. # # El cruce sigue valiendo igual, y por eso no se toca: el id y la ruta tienen que coincidir en los dos # lados, porque una extensión empaquetada que la política no declara queda instalada pero sin su # `installation_mode` ni su `storage.managed`, y una política que apunta a un XPI que no está deja un # `Download failed` que no nombra la causa. Se cruza acá, en el build, donde falla gratis. def verificar_politica(resultados): ruta = os.path.join(APPDIR, "distribution", "policies.json") try: with open(ruta, encoding="utf-8") as fh: pol = json.load(fh)["policies"] except (OSError, ValueError, KeyError) as e: fatal(f"no pude leer {ruta}: {e}") ajustes = pol.get("ExtensionSettings", {}) declarados = {k for k in ajustes if k != "*"} empaquetados = {r[1] for r in resultados} faltan = empaquetados - declarados if faltan: fatal(f"empaquetadas pero NO declaradas en ExtensionSettings: {sorted(faltan)}") sobran = declarados - empaquetados if sobran: fatal(f"declaradas en ExtensionSettings sin XPI que las respalde: {sorted(sobran)}") for _, ext_id, _, _, url in resultados: esperada = ajustes[ext_id].get("install_url") if esperada != url: fatal(f"{ext_id}: la política instala desde {esperada!r} y el XPI quedó en {url!r}") # Los contenedores de la política son la mitad de abajo del §6.8: sin ellos, la extensión del # proxy arranca, no encuentra a qué contenedor aplicar nada y queda inerte sin decir por qué. contenedores = [c.get("name") for c in pol.get("Containers", {}).get("Default", [])] terceros = pol.get("3rdparty", {}).get("Extensions", {}) for ext_id, conf in terceros.items(): if ext_id not in empaquetados: fatal(f"3rdparty configura {ext_id}, que no es ninguna extensión de atuq") huerfanos = sorted(set(conf.get("proxies", {})) - set(contenedores)) if huerfanos: fatal(f"{ext_id}: proxies para contenedores que la política no crea: {huerfanos}") return contenedores # ── 4.quater Los manifiestos de native messaging ────────────────────────────────────────────── # DÓNDE VAN, Y POR QUÉ NO EN EL APPDIR. Gecko los busca en `XRESysNativeManifests`, que en Linux es # un `/usr/lib/mozilla` **compilado dentro del binario**: el rebranding a `atuq` no lo mueve. Se # midió con sonda y control negativo (SDD 26 §7.bis) — el control decía, con su nombre, # «No such native application». # # QUÉ SE CRUZA, y por qué las dos cosas: un manifiesto tiene DOS referencias a cosas que pueden no # existir, y las dos fallan igual de calladas. # · `path` — si el ejecutable no está, el navegador arranca, la extensión conecta, el puerto se # cierra «sin error y sin mensaje», y la función simplemente no existe; # · `allowed_extensions` — si nombra un id que no empaquetamos, el host es inalcanzable para # siempre y nada lo dice. # Las dos se comprueban contra el árbol REAL (el fichero que acabamos de escribir y la lista de XPI # que acabamos de empaquetar), no contra constantes de este script. # # Y se ESCRIBEN acá en vez de copiarse desde el overlay para que el JSON quede canónico —claves # ordenadas, sangría fija, un `\n` final—: un fichero copiado tal cual haría del formato del editor # de quien lo tocó una variable del artefacto. NM_DIR = "native-messaging" NM_DESTINO = os.path.join("usr", "lib", "mozilla", "native-messaging-hosts") def instalar_manifiestos_nativos(resultados): base = os.path.join(OVERLAY, NM_DIR) if not os.path.isdir(base): return [] empaquetadas = {r[1] for r in resultados} destino_dir = os.path.join(OUTROOT, NM_DESTINO) os.makedirs(destino_dir, exist_ok=True) salida = [] for nombre in sorted(os.listdir(base)): if not nombre.endswith(".json"): continue ruta = os.path.join(base, nombre) try: with open(ruta, encoding="utf-8") as fh: man = json.load(fh) except (OSError, ValueError) as e: fatal(f"{NM_DIR}/{nombre}: no pude leerlo — {e}") # El nombre del host ES el del fichero: Gecko lo busca por `.json` y además compara el # campo `name` contra el que la extensión pidió. Dos dueños para un valor otra vez, así que # se exige que coincidan acá. esperado = nombre[:-len(".json")] if man.get("name") != esperado: fatal(f"{NM_DIR}/{nombre}: el campo name es {man.get('name')!r} y el fichero se llama " f"{esperado!r} — Gecko busca por nombre de fichero y compara el campo") if man.get("type") != "stdio": fatal(f"{NM_DIR}/{nombre}: type={man.get('type')!r}, se esperaba 'stdio'") ejecutable = man.get("path", "") if not ejecutable.startswith("/"): fatal(f"{NM_DIR}/{nombre}: path {ejecutable!r} no es absoluto (Gecko lo exige en Linux)") real = os.path.join(OUTROOT, ejecutable.lstrip("/")) if not os.path.isfile(real): fatal(f"{NM_DIR}/{nombre}: el host {ejecutable} NO está en el artefacto") if not os.access(real, os.X_OK): fatal(f"{NM_DIR}/{nombre}: el host {ejecutable} no es ejecutable") permitidas = man.get("allowed_extensions") or [] if not permitidas: fatal(f"{NM_DIR}/{nombre}: sin allowed_extensions cualquier extensión podría hablarle") huerfanas = sorted(set(permitidas) - empaquetadas) if huerfanas: fatal(f"{NM_DIR}/{nombre}: allowed_extensions nombra extensiones que atuq no empaqueta: " f"{huerfanas}") with open(os.path.join(destino_dir, nombre), "w", encoding="utf-8") as fh: json.dump(man, fh, ensure_ascii=False, indent=2, sort_keys=True) fh.write("\n") salida.append((esperado, ejecutable, permitidas)) return salida # ── 5. Los binarios ──────────────────────────────────────────────────────────────────────────── # Se renombran los dos y se deja un symlink `firefox` → `atuq` DENTRO del appdir. El symlink no es # indecisión: hay código y scripts de terceros que invocan el binario por su nombre histórico, y # como Gecko resuelve su directorio por `/proc/self/exe`, entrar por el symlink resuelve al mismo # sitio y no cambia nada del comportamiento. def renombrar_binarios(): hechos = [] for viejo, nuevo in (("firefox", MARCA), ("firefox-bin", f"{MARCA}-bin")): vp = os.path.join(APPDIR, viejo) if not os.path.isfile(vp): fatal(f"no está {vp}") os.rename(vp, os.path.join(APPDIR, nuevo)) hechos.append(f"{viejo} → {nuevo}") os.symlink(MARCA, os.path.join(APPDIR, "firefox")) return hechos # ── 6. La marca COMPILADA dentro del binario ─────────────────────────────────────────────────── # `application.ini` dice `RemotingName=atuq` y aun así el proceso se anunciaba como # `firefox-default`: esa cadena es un `MOZ_APP_REMOTINGNAME` horneado en el ELF en tiempo de # compilación, y es la que Gecko pasa a `g_set_prgname()` ⇒ es el **app_id de Wayland**. O sea que # el escritorio no casaba la ventana con nuestro `.desktop` (`StartupWMClass=atuq`): icono genérico # y sin agrupar con el lanzador. Se ve en el log de cualquier arranque: `(firefox-default:7048)`. # # Se parchea EN SITIO y no se recompila: cambiarlo en `recipes/firefox.toml` brandearía la BASE de # atuq como atuq, que es justo lo que un derivado no debe hacer —firefox tiene que seguir siendo # firefox para los otros forks—. # # Es seguro porque es una cadena C terminada en NUL dentro del pool de `.rodata`: acortarla deja # NULs muertos detrás y la siguiente cadena empieza donde empezaba. `firefox-default\0` son 16 # bytes exactos y se escriben 16. Y se EXIGE una única ocurrencia: si upstream la duplica o la # renombra, esto falla en vez de parchear el sitio equivocado. VIEJA = b"firefox-default\0" NUEVA = MARCA.encode() + b"\0" * (len(VIEJA) - len(MARCA)) def rebrandear_binario(rel): ruta = os.path.join(APPDIR, rel) with open(ruta, "rb") as fh: datos = fh.read() n = datos.count(VIEJA) if n != 1: fatal(f"{rel}: esperaba 1 ocurrencia de {VIEJA!r} y hay {n} — ¿cambió upstream?") modo = os.stat(ruta).st_mode with open(ruta, "wb") as fh: fh.write(datos.replace(VIEJA, NUEVA)) os.chmod(ruta, modo) with open(ruta, "rb") as fh: if fh.read().count(VIEJA) != 0: fatal(f"{rel}: el parche no agarró") return f"{rel}: app_id firefox-default → {MARCA}" def main(): huella = huella_overlay() bid = build_id(huella) claves = rebrandear_application_ini(bid) n = repack_omni(os.path.join(APPDIR, "browser", "omni.ja"), reemplazos_branding()) iconos = escribir_iconos() binarios = renombrar_binarios() # Después del renombrado: los ficheros ya se llaman atuq/atuq-bin. parches = [rebrandear_binario(MARCA), rebrandear_binario(f"{MARCA}-bin")] exts = empaquetar_extensiones() contenedores = verificar_politica(exts) nativos = instalar_manifiestos_nativos(exts) print(f"rebrand: overlay {huella[:16]}… ⇒ BuildID {bid}") print(f"rebrand: application.ini {sorted(claves)}") print(f"rebrand: browser/omni.ja re-empacado, {n} entradas, orden y STORED preservados") print(f"rebrand: branding dentro del zip — {len(IMAGENES)} imágenes + brand.ftl + brand.properties + 2 wordmarks") print(f"rebrand: {len(iconos)} iconos escritos ({', '.join(str(s) for s in TAMANOS)} px)") print(f"rebrand: binarios {', '.join(binarios)} (+ symlink firefox → {MARCA})") for l in parches: print(f"rebrand: {l}") for d, ext_id, n, size, _ in exts: print(f"rebrand: extensión de sistema {ext_id}.xpi ← {EXT_DIR}/{d} ({n} ficheros, {size} bytes)") print(f"rebrand: política cruzada — {len(exts)} extensión(es), contenedores {contenedores}") for nombre, ejecutable, permitidas in nativos: print(f"rebrand: host nativo {nombre} → {ejecutable} para {', '.join(permitidas)}") main()