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 1471Shell 173Rust 105Markdown 943545 Sutichaykuna

atuq 0.2.0: branding real — el zip, el .ini, los binarios y un icono que no es de otro

Hash b3:7eff4ba4. La v0.1 dejó el branding fuera porque había que MIRAR el árbol antes de adivinar.
Se miró, y lo que apareció decidió la forma de esta versión:

- `application.ini` del appdir arranca diciendo «This file is not used». Es herencia del árbol de
  desarrollo: en un build empaquetado el launcher SÍ lo lee. `rebrand.py` no le cree al comentario:
  comprueba que las claves estén y falla si no.
- Las cadenas que la gente VE están DENTRO de `browser/omni.ja` ⇒ sin re-empacar el zip no hay
  branding. Y el original no dice «Firefox», dice **Nightly**, porque construimos con
  `--with-branding=unofficial`. Sin tocarlo, atuq se presentaría como lo peor de los dos mundos.
- Los ICONOS, en cambio, viven FUERA del zip ⇒ se reemplazan sin abrirlo.

EL RE-EMPAQUE PRESERVA TRES PROPIEDADES DEL ORIGINAL, Y CADA UNA POR UNA RAZÓN MEDIDA:
orden de las 5306 entradas (es el que Gecko lee al arrancar, y es lo que el jarlog del PGO va a
refinar), `compress_type=0` (Mozilla lo deja SIN COMPRIMIR para poder mapearlo) y `date_time`
2010-01-01 (ya venía normalizado: Mozilla también persigue reproducibilidad).

VERIFICADO CONTRA EL ARTEFACTO, NO CONTRA EL LOG:
- diff entrada por entrada entre el omni.ja de firefox y el de atuq: **5306 → 5306, mismo orden,
  todo STORED, mismas fechas, y UNA SOLA entrada con contenido distinto**: brand.ftl.
- re-empacar dos veces el mismo zip da el mismo sha256 ⇒ el paso es determinista.
- application.ini: Vendor=tawasuyu, Name/RemotingName/CodeName=atuq. `ID` NO se toca: es el GUID
  con el que las extensiones declaran compatibilidad, y cambiarlo dejaría a atuq fuera del
  ecosistema de complementos.

EL BuildID SE DERIVA DEL CONTENIDO DEL OVERLAY, no de la fecha. 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ó. Derivarlo del contenido lo mueve
exactamente cuando hace falta y dos builds del mismo atuq dan el mismo número — con la fecha
pasaría lo contrario en los dos sentidos. Se vio funcionar: al sumar `CodeName` el BuildID cambió
solo, de 81921491609672 a 24128696592342.

El icono se DIBUJA en código (PNG en Python puro, zlib + struct) y no se commitea como binario:
un PNG en el árbol no se puede revisar en un diff, el código sí, y el resultado es idéntico en cada
build. ⚠ Es un marcador de posición declarado —una marca geométrica plana, 5 tamaños—; lo correcto
es que alguien dibuje el zorro. Su única función irrenunciable ya la cumple: que la ventana y el
lanzador NO muestren el icono de otro producto.

Binarios: firefox → atuq y firefox-bin → atuq-bin, con symlink `firefox` → `atuq` dentro del appdir
porque hay scripts de terceros que invocan por el nombre histórico y Gecko resuelve su directorio
por /proc/self/exe (entrar por el symlink resuelve al mismo sitio). Más `.desktop` con
StartupWMClass=atuq, que casa con el app_id que da RemotingName bajo Wayland.

6f6c32bb9de3e075153204f6149feaf56bdbbb7f · ruwaq Sergio · (1 killa ñawpaq) · tayta 484375675 · kay commitpi sach'ata qhaway

Semántico sayk'uy · 14

recipes/atuq/tools/rebrand.py

4 qillqakuna · +341 −21

diff --git a/recipes/atuq.toml b/recipes/atuq.toml
index 6a243c75..59ec0bb1 100644
--- a/recipes/atuq.toml
+++ b/recipes/atuq.toml
@@ -15,14 +15,24 @@
 # vive en `recipes/atuq/` y se hashea por CONTENIDO con `ArtifactHash::of_tree`, igual que ya se
 # hacía con los `patches`. No hay commit que pinear y no hay fetch: editar un CSS mueve el hash.
 #
-# ══ LO QUE ESTA v0.1 NO HACE, A PROPÓSITO ══════════════════════════════════════════════════════
-# No renombra el binario ni toca `application.ini`. El branding necesita mirar la forma real del
-# árbol que sella `firefox` —qué es `firefox-bin`, cómo se resuelve el appdir— y adivinarlo desde
-# acá produciría un artefacto que arranca en la máquina de quien lo escribió y en ninguna otra.
-# Tampoco re-empaqueta `omni.ja`, que es donde vive el chrome de verdad; eso es la v0.2 y trae dos
-# condiciones ya sabidas (re-empacar determinista y preservar el orden del `jarlog` del PGO).
+# ══ v0.2: EL BRANDING VIVE DENTRO DEL ZIP ══════════════════════════════════════════════════════
+# La v0.1 dejó el branding fuera porque había que mirar el árbol real antes de adivinar. Ya se miró,
+# y lo que se encontró decide la forma de esta versión:
+#
+#   · `application.ini` del appdir arranca con el comentario «This file is not used». Es HERENCIA
+#     del árbol de desarrollo: en un build empaquetado el launcher SÍ lo lee. `rebrand.py` no le
+#     cree al comentario, comprueba que las claves estén y falla si no.
+#   · Las cadenas que la gente VE están dentro de `browser/omni.ja` (`brand.ftl`), así que sin
+#     re-empacar el zip no hay branding posible. Y el original dice **Nightly**, no «Firefox»,
+#     porque construimos con `--with-branding=browser/branding/unofficial`.
+#   · Los ICONOS, en cambio, están FUERA del zip (`browser/chrome/icons/default/`) ⇒ se reemplazan
+#     sin tocarlo.
+#   · `browser/omni.ja` son 56 MB con TODAS sus entradas sin comprimir y con `date_time` ya
+#     normalizado a 2010-01-01: Mozilla también persigue reproducibilidad. El re-empaque preserva
+#     orden, método y fecha — el orden porque es el que Gecko lee al arrancar, y es lo que el
+#     `jarlog` del PGO refinará cuando exista.
 name    = "atuq"
-version = "0.1.0"
+version = "0.2.0"
 # El artefacto CONTIENE Firefox, así que hereda su licencia. El overlay de `recipes/atuq/` es
 # nuestro, pero eso no cambia lo que se distribuye.
 license = "MPL-2.0"
@@ -51,34 +61,42 @@ DST=/out/usr/lib/atuq
 # hasta el final diciendo que todo fue bien (regla 3 del CLAUDE.md).
 [ -d "$SRC" ] || { echo "atuq: no está el árbol de firefox en $SRC — ¿la dep se materializó?" >&2; exit 1; }
 [ -x "$SRC/firefox" ] || { echo "atuq: $SRC/firefox no existe o no es ejecutable" >&2; exit 1; }
-[ -d "$SRC/browser" ] || { echo "atuq: falta $SRC/browser — el árbol no tiene la forma esperada" >&2; exit 1; }
+[ -f "$SRC/browser/omni.ja" ] || { echo "atuq: falta $SRC/browser/omni.ja" >&2; exit 1; }
 
 mkdir -p "$DST"
 cp -a "$SRC"/. "$DST"/
 
-# ── LA CAPA DE atuq ───────────────────────────────────────────────────────────────────────────
+# ── LA CAPA DE CONFIGURACIÓN ──────────────────────────────────────────────────────────────────
 # `defaults/pref/` es el único sitio desde el que se puede pedir el autoconfig ANTES de que exista
 # un perfil; el resto cuelga de ahí.
 mkdir -p "$DST/defaults/pref" "$DST/chrome" "$DST/distribution"
-cp /src/prefs/autoconfig.js       "$DST/defaults/pref/autoconfig.js"
-cp /src/atuq.cfg                  "$DST/atuq.cfg"
-cp /src/chrome/atuq.css           "$DST/chrome/atuq.css"
+cp /src/prefs/autoconfig.js        "$DST/defaults/pref/autoconfig.js"
+cp /src/atuq.cfg                   "$DST/atuq.cfg"
+cp /src/chrome/atuq.css            "$DST/chrome/atuq.css"
 cp /src/distribution/policies.json "$DST/distribution/policies.json"
 
-# El comando que teclea la gente. Apunta al binario SIN renombrar (ver la nota de la cabecera):
-# renombrarlo es branding y branding es la v0.2.
-mkdir -p /out/usr/bin
-ln -sf ../lib/atuq/firefox /out/usr/bin/atuq
+# ── EL BRANDING ───────────────────────────────────────────────────────────────────────────────
+# Todo lo que exige entrar al zip, editar el .ini o dibujar un PNG está en una herramienta aparte:
+# el shell no tiene `zip` en este lab, y `zipfile` además deja fijar orden, fecha y permisos, que
+# es de lo que depende que el artefacto reproduzca. La herramienta VERIFICA cada cambio y falla si
+# la entrada que iba a reemplazar no estaba.
+python3 /src/tools/rebrand.py "$DST" /src /out
+
+# ── INTEGRACIÓN CON EL ESCRITORIO ─────────────────────────────────────────────────────────────
+mkdir -p /out/usr/bin /out/usr/share/applications
+cp /src/atuq.desktop /out/usr/share/applications/atuq.desktop
+ln -sf ../lib/atuq/atuq /out/usr/bin/atuq
 
-# ── INVENTARIO: LO QUE SE MIRA CUANDO «ATUQ SE VE COMO FIREFOX» ───────────────────────────────
-# Los cuatro ficheros de la capa son el 100% de lo que distingue este artefacto de su base, y son
-# los que un `cp` silencioso podría no haber puesto. Se listan para que el log del build responda
-# solo la primera pregunta del diagnóstico.
+# ── INVENTARIO ────────────────────────────────────────────────────────────────────────────────
+# Lo que distingue este artefacto de su base, listado con tamaños, para que el log del build
+# responda solo la primera pregunta del diagnóstico cuando «atuq se ve como Firefox».
 echo "atuq: capa aplicada —"
 for f in defaults/pref/autoconfig.js atuq.cfg chrome/atuq.css distribution/policies.json; do
   [ -s "$DST/$f" ] || { echo "atuq: $f quedó VACÍO o ausente" >&2; exit 1; }
   echo "  $f ($(wc -c < "$DST/$f") bytes)"
 done
+[ -x "$DST/atuq" ] || { echo "atuq: el binario renombrado no quedó ejecutable" >&2; exit 1; }
+grep -q '^Name=atuq' "$DST/application.ini" || { echo "atuq: application.ini no quedó rebrandeado" >&2; exit 1; }
 '''
 
 [deps]
@@ -86,4 +104,4 @@ done
 # dentro del artefacto de atuq y a partir de ahí atuq no lo necesita más. El precio es que el
 # artefacto pesa lo que pesa Firefox otra vez; la salida barata (una granja de symlinks) rompe la
 # resolución del appdir por /proc/self/exe, así que la v0.1 paga el disco y lo dice.
-build = ["firefox"]
+build = ["firefox", "python3"]
diff --git a/recipes/atuq/atuq.desktop b/recipes/atuq/atuq.desktop
new file mode 100644
index 00000000..e590a407
--- /dev/null
+++ b/recipes/atuq/atuq.desktop
@@ -0,0 +1,14 @@
+[Desktop Entry]
+Type=Application
+Name=atuq
+GenericName=Navegador web
+GenericName[en]=Web Browser
+Comment=Navegá la web con el navegador de la distro
+Comment[en]=Browse the web with the distro's browser
+Exec=atuq %u
+Icon=atuq
+Terminal=false
+Categories=Network;WebBrowser;
+StartupNotify=true
+StartupWMClass=atuq
+MimeType=text/html;text/xml;application/xhtml+xml;x-scheme-handler/http;x-scheme-handler/https;
diff --git a/recipes/atuq/branding/brand.ftl b/recipes/atuq/branding/brand.ftl
new file mode 100644
index 00000000..ca5511dc
--- /dev/null
+++ b/recipes/atuq/branding/brand.ftl
@@ -0,0 +1,16 @@
+# Reemplaza a `localization/en-US/branding/brand.ftl` dentro de `browser/omni.ja`.
+#
+# Éstas son las cadenas que la GENTE ve: el título de la ventana, «Acerca de», el menú. El original
+# de nuestra base dice «Nightly» —no «Firefox»— porque `recipes/firefox.toml` construye con
+# `--with-branding=browser/branding/unofficial`, cuyo nombre en clave es ése. Sin este fichero, atuq
+# se presentaría como Nightly, que es lo peor de los dos mundos: ni es Firefox ni es nuestro.
+#
+# `-brand-product-name` se mantiene distinto de `-brand-short-name` a propósito: hay cadenas de la
+# UI que hablan del PRODUCTO y otras de la MARCA, y aplanarlas produce frases raras en español.
+-brand-shorter-name = atuq
+-brand-short-name = atuq
+-brand-shortcut-name = atuq
+-brand-full-name = atuq
+-brand-product-name = atuq
+-vendor-short-name = tawasuyu
+trademarkInfo = { " " }
diff --git a/recipes/atuq/tools/rebrand.py b/recipes/atuq/tools/rebrand.py
new file mode 100644
index 00000000..04c21348
--- /dev/null
+++ b/recipes/atuq/tools/rebrand.py
@@ -0,0 +1,272 @@
+#!/usr/bin/env python3
+"""Convierte el árbol de `firefox` ya copiado en el árbol de `atuq`.
+
+Uso:  rebrand.py <appdir> <overlay> <outroot>
+        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 os
+import struct
+import sys
+import zlib
+import zipfile
+
+APPDIR, OVERLAY, OUTROOT = sys.argv[1], sys.argv[2], sys.argv[3]
+
+MARCA = "atuq"
+VENDOR = "tawasuyu"
+ACENTO = (0xB5, 0x65, 0x1D)   # el mismo token que chrome/atuq.css
+CREMA = (0xF2, 0xE8, 0xD5)
+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 ────────────────────────────────────────────────────────────────────────────────
+# Se DIBUJA acá, en vez de commitear cinco PNG, por una razón práctica: un binario en el árbol de
+# fuentes no se puede revisar en un diff. El código sí, y el resultado es idéntico en cada build.
+#
+# ⚠ ES UN MARCADOR DE POSICIÓN, y conviene que se note: una marca geométrica plana, no un intento de
+# logo. Lo correcto es que alguien dibuje el zorro; hasta entonces esto cumple la única función que
+# no puede quedar vacía —que la ventana y el lanzador NO muestren el icono de otro producto—.
+def _en_rect_redondeado(px, py, n, r):
+    cx = min(max(px, r), n - r)
+    cy = min(max(py, r), n - r)
+    dx, dy = px - cx, py - cy
+    return dx * dx + dy * dy <= r * r
+
+
+def _en_triangulo(px, py, a, b, c):
+    def signo(p, q, r):
+        return (p[0] - r[0]) * (q[1] - r[1]) - (q[0] - r[0]) * (p[1] - r[1])
+    d1, d2, d3 = signo((px, py), a, b), signo((px, py), b, c), signo((px, py), c, a)
+    neg = d1 < 0 or d2 < 0 or d3 < 0
+    pos = d1 > 0 or d2 > 0 or d3 > 0
+    return not (neg and pos)
+
+
+def dibujar(size, ss=3):
+    n = size * ss
+    r = n * 0.22
+    # La «oreja»: un triángulo centrado, con la punta arriba. A 16 px cualquier cosa con más de
+    # tres vértices se convierte en barro, así que la forma es deliberadamente una sola.
+    a = (n * 0.50, n * 0.26)
+    b = (n * 0.74, n * 0.72)
+    c = (n * 0.26, n * 0.72)
+    filas = []
+    for y in range(size):
+        fila = []
+        for x in range(size):
+            acc = [0, 0, 0, 0]
+            for sy in range(ss):
+                for sx in range(ss):
+                    px, py = x * ss + sx + 0.5, y * ss + sy + 0.5
+                    if not _en_rect_redondeado(px, py, n, r):
+                        continue
+                    col = CREMA if _en_triangulo(px, py, a, b, c) else ACENTO
+                    acc[0] += col[0]
+                    acc[1] += col[1]
+                    acc[2] += col[2]
+                    acc[3] += 255
+            m = ss * ss
+            fila.append((acc[0] // m, acc[1] // m, acc[2] // m, acc[3] // m))
+        filas.append(fila)
+    return filas
+
+
+def png(filas):
+    size = len(filas)
+    crudo = b"".join(b"\x00" + bytes(v for px in f for v in px) for f in filas)
+
+    def trozo(tipo, datos):
+        cuerpo = tipo + datos
+        return struct.pack(">I", len(datos)) + cuerpo + struct.pack(">I", zlib.crc32(cuerpo) & 0xFFFFFFFF)
+
+    ihdr = struct.pack(">IIBBBBB", size, size, 8, 6, 0, 0, 0)
+    return (b"\x89PNG\r\n\x1a\n" + trozo(b"IHDR", ihdr)
+            + trozo(b"IDAT", zlib.compress(crudo, 9)) + trozo(b"IEND", b""))
+
+
+def escribir_iconos():
+    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:
+        datos = png(dibujar(s))
+        # (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
+
+
+# ── 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
+
+
+def main():
+    huella = huella_overlay()
+    bid = build_id(huella)
+    claves = rebrandear_application_ini(bid)
+    with open(os.path.join(OVERLAY, "branding", "brand.ftl"), "rb") as fh:
+        brand = fh.read()
+    n = repack_omni(
+        os.path.join(APPDIR, "browser", "omni.ja"),
+        {"localization/en-US/branding/brand.ftl": brand},
+    )
+    iconos = escribir_iconos()
+    binarios = renombrar_binarios()
+
+    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: {len(iconos)} iconos escritos ({', '.join(str(s) for s in TAMANOS)} px)")
+    print(f"rebrand: binarios {', '.join(binarios)} (+ symlink firefox → {MARCA})")
+
+
+main()