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 1299Shell 92Rust 63Markdown 561634 Definitions

⎇ 5d06a3b83dfd03b290a8bdf9c9985ea8fde1058d recipes/atuq/tools/rebrand.py Python · 312 lines · 14.1 KB History Who touched what raw

atuq ABRE: el app_id estaba compilado en el binario, y el árbol copiado llegaba de sólo lectura 5d06a3b83 · 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 os
25import struct
26import sys
27import zlib
28import zipfile
29
30APPDIR, OVERLAY, OUTROOT = sys.argv[1], sys.argv[2], sys.argv[3]
31
32MARCA = "atuq"
33VENDOR = "tawasuyu"
34ACENTO = (0xB5, 0x65, 0x1D)   # el mismo token que chrome/atuq.css
35CREMA = (0xF2, 0xE8, 0xD5)
36TAMANOS = (16, 32, 48, 64, 128)
37
38
39def fatal(msg):
40    print(f"rebrand: {msg}", file=sys.stderr)
41    raise SystemExit(1)
42
43
44# ── 1. La huella del overlay, que es también el BuildID ────────────────────────────────────────
45# El `BuildID` es la clave con la que Gecko invalida su startup cache. Si atuq cambia su chrome y
46# el BuildID no se mueve, el navegador arranca con la interfaz vieja cacheada y PARECE que el
47# overlay no agarró — el falso negativo que el SDD 26 §2 anota como gotcha del camino derivado.
48#
49# Se deriva del CONTENIDO del overlay y no de la fecha: cambia exactamente cuando cambia lo que
50# tiene que invalidar, y dos builds del mismo atuq dan el mismo número. La fecha haría lo contrario
51# en los dos sentidos.
52def huella_overlay():
53    h = hashlib.sha256()
54    for raiz, dirs, ficheros in os.walk(OVERLAY):
55        dirs.sort()
56        for f in sorted(ficheros):
57            p = os.path.join(raiz, f)
58            h.update(os.path.relpath(p, OVERLAY).encode())
59            h.update(b"\0")
60            with open(p, "rb") as fh:
61                h.update(fh.read())
62            h.update(b"\0")
63    return h.hexdigest()
64
65
66def build_id(huella):
67    # 14 dígitos decimales: la forma que Gecko espera (nació de un `YYYYMMDDHHMMSS`).
68    return str(int(huella[:16], 16))[:14].rjust(14, "0")
69
70
71# ── 2. application.ini — la identidad de la aplicación ─────────────────────────────────────────
72# ⚠ La cabecera de este fichero dice «This file is not used», y es un comentario HEREDADO del árbol
73# de desarrollo: en un build empaquetado como éste el launcher SÍ lo lee. Se comprueba en vez de
74# creerle al comentario — si no encontramos las claves, fallamos.
75#
76# `ID` NO se toca a propósito: es el GUID con el que las extensiones declaran compatibilidad
77# (`gecko.id` de Firefox). Cambiarlo convertiría a atuq en una aplicación desconocida para todo el
78# ecosistema de complementos, que es justo lo que un envoltorio no quiere.
79def rebrandear_application_ini(bid):
80    ruta = os.path.join(APPDIR, "application.ini")
81    if not os.path.isfile(ruta):
82        fatal(f"no está {ruta}")
83    with open(ruta, encoding="utf-8") as fh:
84        lineas = fh.read().splitlines()
85    # `CodeName` también: es el residuo de `--with-branding=unofficial` (dice «Nightly») y aparece
86    # en about:buildconfig y en los informes de fallo. Dejarlo sería exactamente el rincón por el
87    # que atuq se sigue presentando como otro producto.
88    cambios = {
89        "Vendor": VENDOR,
90        "Name": MARCA,
91        "RemotingName": MARCA,
92        "CodeName": MARCA,
93        "BuildID": bid,
94    }
95    vistas = set()
96    salida = []
97    for ln in lineas:
98        clave = ln.split("=", 1)[0] if "=" in ln else None
99        if clave in cambios:
100            salida.append(f"{clave}={cambios[clave]}")
101            vistas.add(clave)
102        else:
103            salida.append(ln)
104    faltan = set(cambios) - vistas
105    if faltan:
106        fatal(f"application.ini no tenía las claves {sorted(faltan)} — ¿cambió el formato?")
107    with open(ruta, "w", encoding="utf-8") as fh:
108        fh.write("\n".join(salida) + "\n")
109    return vistas
110
111
112# ── 3. omni.ja — el re-empaque determinista ────────────────────────────────────────────────────
113# TRES PROPIEDADES DEL ORIGINAL QUE SE PRESERVAN, Y POR QUÉ:
114#
115#   · EL ORDEN de las entradas. Gecko lee este zip en el arranque y el orden es el que Mozilla
116#     eligió para que las primeras lecturas caigan juntas. Reordenarlo (p.ej. alfabético, que es lo
117#     que sale «gratis» al recomprimir) no rompe nada visible: sólo hace el arranque más lento, en
118#     silencio. Cuando exista el PGO, el `jarlog` refina justamente este orden — tirarlo sería
119#     tirar media optimización de arranque.
120#   · `compress_type = 0` (STORED). El omni.ja de Mozilla va SIN COMPRIMIR a propósito, para poder
121#     mapearlo en memoria. Comprimirlo achica el artefacto y encarece cada lectura.
122#   · `date_time`. El original ya viene normalizado a 2010-01-01 —Mozilla también persigue
123#     reproducibilidad— así que copiarlo tal cual mantiene el zip byte-idéntico salvo por lo que
124#     cambiamos de verdad.
125def repack_omni(ruta_ja, reemplazos):
126    if not os.path.isfile(ruta_ja):
127        fatal(f"no está {ruta_ja}")
128    pendientes = dict(reemplazos)
129    tmp = ruta_ja + ".nuevo"
130    with zipfile.ZipFile(ruta_ja) as orig:
131        infos = orig.infolist()
132        with zipfile.ZipFile(tmp, "w") as nuevo:
133            for i in infos:
134                datos = pendientes.pop(i.filename, None)
135                if datos is None:
136                    datos = orig.read(i.filename)
137                ni = zipfile.ZipInfo(i.filename, date_time=i.date_time)
138                ni.compress_type = i.compress_type
139                ni.external_attr = i.external_attr
140                ni.create_system = i.create_system
141                nuevo.writestr(ni, datos)
142    if pendientes:
143        os.unlink(tmp)
144        fatal(f"{ruta_ja}: estas entradas no existían y no se reemplazó nada: {sorted(pendientes)}")
145    os.replace(tmp, ruta_ja)
146    return len(infos)
147
148
149# ── 4. El icono ────────────────────────────────────────────────────────────────────────────────
150# Se DIBUJA acá, en vez de commitear cinco PNG, por una razón práctica: un binario en el árbol de
151# fuentes no se puede revisar en un diff. El código sí, y el resultado es idéntico en cada build.
152#
153# ⚠ ES UN MARCADOR DE POSICIÓN, y conviene que se note: una marca geométrica plana, no un intento de
154# logo. Lo correcto es que alguien dibuje el zorro; hasta entonces esto cumple la única función que
155# no puede quedar vacía —que la ventana y el lanzador NO muestren el icono de otro producto—.
156def _en_rect_redondeado(px, py, n, r):
157    cx = min(max(px, r), n - r)
158    cy = min(max(py, r), n - r)
159    dx, dy = px - cx, py - cy
160    return dx * dx + dy * dy <= r * r
161
162
163def _en_triangulo(px, py, a, b, c):
164    def signo(p, q, r):
165        return (p[0] - r[0]) * (q[1] - r[1]) - (q[0] - r[0]) * (p[1] - r[1])
166    d1, d2, d3 = signo((px, py), a, b), signo((px, py), b, c), signo((px, py), c, a)
167    neg = d1 < 0 or d2 < 0 or d3 < 0
168    pos = d1 > 0 or d2 > 0 or d3 > 0
169    return not (neg and pos)
170
171
172def dibujar(size, ss=3):
173    n = size * ss
174    r = n * 0.22
175    # La «oreja»: un triángulo centrado, con la punta arriba. A 16 px cualquier cosa con más de
176    # tres vértices se convierte en barro, así que la forma es deliberadamente una sola.
177    a = (n * 0.50, n * 0.26)
178    b = (n * 0.74, n * 0.72)
179    c = (n * 0.26, n * 0.72)
180    filas = []
181    for y in range(size):
182        fila = []
183        for x in range(size):
184            acc = [0, 0, 0, 0]
185            for sy in range(ss):
186                for sx in range(ss):
187                    px, py = x * ss + sx + 0.5, y * ss + sy + 0.5
188                    if not _en_rect_redondeado(px, py, n, r):
189                        continue
190                    col = CREMA if _en_triangulo(px, py, a, b, c) else ACENTO
191                    acc[0] += col[0]
192                    acc[1] += col[1]
193                    acc[2] += col[2]
194                    acc[3] += 255
195            m = ss * ss
196            fila.append((acc[0] // m, acc[1] // m, acc[2] // m, acc[3] // m))
197        filas.append(fila)
198    return filas
199
200
201def png(filas):
202    size = len(filas)
203    crudo = b"".join(b"\x00" + bytes(v for px in f for v in px) for f in filas)
204
205    def trozo(tipo, datos):
206        cuerpo = tipo + datos
207        return struct.pack(">I", len(datos)) + cuerpo + struct.pack(">I", zlib.crc32(cuerpo) & 0xFFFFFFFF)
208
209    ihdr = struct.pack(">IIBBBBB", size, size, 8, 6, 0, 0, 0)
210    return (b"\x89PNG\r\n\x1a\n" + trozo(b"IHDR", ihdr)
211            + trozo(b"IDAT", zlib.compress(crudo, 9)) + trozo(b"IEND", b""))
212
213
214def escribir_iconos():
215    dir_app = os.path.join(APPDIR, "browser", "chrome", "icons", "default")
216    if not os.path.isdir(dir_app):
217        fatal(f"no está {dir_app} — el árbol de firefox no tiene la forma esperada")
218    escritos = []
219    for s in TAMANOS:
220        datos = png(dibujar(s))
221        # (a) el icono de la VENTANA, que Gecko lee del appdir
222        p = os.path.join(dir_app, f"default{s}.png")
223        with open(p, "wb") as fh:
224            fh.write(datos)
225        escritos.append(p)
226        # (b) el icono del LANZADOR, que lo lee el escritorio por el tema hicolor
227        q = os.path.join(OUTROOT, "usr/share/icons/hicolor", f"{s}x{s}", "apps", f"{MARCA}.png")
228        os.makedirs(os.path.dirname(q), exist_ok=True)
229        with open(q, "wb") as fh:
230            fh.write(datos)
231        escritos.append(q)
232    return escritos
233
234
235# ── 5. Los binarios ────────────────────────────────────────────────────────────────────────────
236# Se renombran los dos y se deja un symlink `firefox` → `atuq` DENTRO del appdir. El symlink no es
237# indecisión: hay código y scripts de terceros que invocan el binario por su nombre histórico, y
238# como Gecko resuelve su directorio por `/proc/self/exe`, entrar por el symlink resuelve al mismo
239# sitio y no cambia nada del comportamiento.
240def renombrar_binarios():
241    hechos = []
242    for viejo, nuevo in (("firefox", MARCA), ("firefox-bin", f"{MARCA}-bin")):
243        vp = os.path.join(APPDIR, viejo)
244        if not os.path.isfile(vp):
245            fatal(f"no está {vp}")
246        os.rename(vp, os.path.join(APPDIR, nuevo))
247        hechos.append(f"{viejo} → {nuevo}")
248    os.symlink(MARCA, os.path.join(APPDIR, "firefox"))
249    return hechos
250
251
252# ── 6. La marca COMPILADA dentro del binario ───────────────────────────────────────────────────
253# `application.ini` dice `RemotingName=atuq` y aun así el proceso se anunciaba como
254# `firefox-default`: esa cadena es un `MOZ_APP_REMOTINGNAME` horneado en el ELF en tiempo de
255# compilación, y es la que Gecko pasa a `g_set_prgname()` ⇒ es el **app_id de Wayland**. O sea que
256# el escritorio no casaba la ventana con nuestro `.desktop` (`StartupWMClass=atuq`): icono genérico
257# y sin agrupar con el lanzador. Se ve en el log de cualquier arranque: `(firefox-default:7048)`.
258#
259# Se parchea EN SITIO y no se recompila: cambiarlo en `recipes/firefox.toml` brandearía la BASE de
260# atuq como atuq, que es justo lo que un derivado no debe hacer —firefox tiene que seguir siendo
261# firefox para los otros forks—.
262#
263# Es seguro porque es una cadena C terminada en NUL dentro del pool de `.rodata`: acortarla deja
264# NULs muertos detrás y la siguiente cadena empieza donde empezaba. `firefox-default\0` son 16
265# bytes exactos y se escriben 16. Y se EXIGE una única ocurrencia: si upstream la duplica o la
266# renombra, esto falla en vez de parchear el sitio equivocado.
267VIEJA = b"firefox-default\0"
268NUEVA = MARCA.encode() + b"\0" * (len(VIEJA) - len(MARCA))
269
270
271def rebrandear_binario(rel):
272    ruta = os.path.join(APPDIR, rel)
273    with open(ruta, "rb") as fh:
274        datos = fh.read()
275    n = datos.count(VIEJA)
276    if n != 1:
277        fatal(f"{rel}: esperaba 1 ocurrencia de {VIEJA!r} y hay {n} — ¿cambió upstream?")
278    modo = os.stat(ruta).st_mode
279    with open(ruta, "wb") as fh:
280        fh.write(datos.replace(VIEJA, NUEVA))
281    os.chmod(ruta, modo)
282    with open(ruta, "rb") as fh:
283        if fh.read().count(VIEJA) != 0:
284            fatal(f"{rel}: el parche no agarró")
285    return f"{rel}: app_id firefox-default → {MARCA}"
286
287
288def main():
289    huella = huella_overlay()
290    bid = build_id(huella)
291    claves = rebrandear_application_ini(bid)
292    with open(os.path.join(OVERLAY, "branding", "brand.ftl"), "rb") as fh:
293        brand = fh.read()
294    n = repack_omni(
295        os.path.join(APPDIR, "browser", "omni.ja"),
296        {"localization/en-US/branding/brand.ftl": brand},
297    )
298    iconos = escribir_iconos()
299    binarios = renombrar_binarios()
300    # Después del renombrado: los ficheros ya se llaman atuq/atuq-bin.
301    parches = [rebrandear_binario(MARCA), rebrandear_binario(f"{MARCA}-bin")]
302
303    print(f"rebrand: overlay {huella[:16]}… ⇒ BuildID {bid}")
304    print(f"rebrand: application.ini {sorted(claves)}")
305    print(f"rebrand: browser/omni.ja re-empacado, {n} entradas, orden y STORED preservados")
306    print(f"rebrand: {len(iconos)} iconos escritos ({', '.join(str(s) for s in TAMANOS)} px)")
307    print(f"rebrand: binarios {', '.join(binarios)} (+ symlink firefox → {MARCA})")
308    for l in parches:
309        print(f"rebrand: {l}")
310
311
312main()