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.
This commit is contained in:
Sergio
2026-09-05 08:49:46 +00:00
parent 4843756757
commit 6f6c32bb9d
4 changed files with 341 additions and 21 deletions
+39 -21
View File
@@ -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
# ── 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.
# ── 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 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"]
+14
View File
@@ -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;
+16
View File
@@ -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 = { " " }
+272
View File
@@ -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()