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…
⎇ 5d06a3b83dfd03b290a8bdf9c9985ea8fde1058d
recipes/atuq/tools/rebrand.py
History
Who touched what
raw
atuq ABRE: el app_id estaba compilado en el binario, y el árbol copiado llegaba de sólo lectura
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()