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 -# ── 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"] 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 = /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()