Monolyth: qué hace y cómo está hecha
Monolyth es el centro de control de mi día en el Mac. En una sola ventana junta lo que antes vivía repartido en una docena de pestañas: el correo importante de varias cuentas, los dominios que están a punto de caducar, los pedidos y el dinero de varias tiendas de impresión bajo demanda, las visitas de cada web, las tareas con su hora en el calendario, un diario y un registro de hábitos. Todo se maneja también desde Telegram, lejos del Mac.
La hice porque no encontré nada parecido. Para cada una de esas cosas hay buenas aplicaciones, pero ninguna las cruza. Saber si un pedido pagado ya tiene su orden de impresión, o cuánto deja de verdad después de comisiones, obliga a abrir Etsy, Stripe, el proveedor de impresión y el correo, cuatro servicios que no se hablan entre sí.
Son 12.852 líneas de Swift, 4.501 de Python y dos Workers de Cloudflare. Se compila sin Xcode y no usa paquetes de terceros.
1. Qué hay dentro
Today es la primera pantalla y responde a una sola pregunta: qué pide atención hoy. Ahí salen los pedidos urgentes, el correo importante sin leer, lo que se cobra en los próximos siete días, las tareas pendientes de cada proyecto y los dominios que caducan en menos de 30 días sin renovarse solos.
Orders y Money llevan las tiendas. Business enseña una tarjeta por web con las visitas, las búsquedas en Google, la caducidad del dominio y las ventas del mes. Tasks, Habits, Journal, Progress y Life son la parte personal. Health revisa el propio Mac: seguridad, copias, disco, batería y actualizaciones. Projects lleva la lista de proyectos de código y su copia privada en GitHub. Etsy Studio y Etsy Autopilot hacen los mockups y los anuncios de Etsy (apartado 8).
El bot de Telegram corre en el mismo Mac y tiene 51 órdenes: marca hábitos, apunta tareas con fecha, escribe en el diario, guarda notas, pone recordatorios, transcribe notas de voz, manda capturas de pantalla o ficheros y puede bloquear o dormir el Mac. Cuando el Mac está apagado, un Worker de Cloudflare sigue vigilando las webs, los dominios y los pedidos, y avisa por el mismo chat.
2. Dos procesos
La interfaz es SwiftUI. Cada lectura de datos es un script de Python que se ejecuta, imprime JSON y termina.
El contrato entre los dos lados es pequeño. Por la salida estándar solo sale JSON. Un error es {"error": "…"}, y Swift lo intenta decodificar antes que el tipo esperado:
struct HelperFailure: Decodable { let error: String }
let decoder = JSONDecoder()
if let failure = try? decoder.decode(HelperFailure.self, from: data) {
return .failure(HelperError(message: failure.error))
}
return .success(try decoder.decode(type, from: data))
Las claves entran por la entrada estándar, porque un argumento se ve en la lista de procesos, y se guardan en el Llavero. Los scripts usan solo la biblioteca estándar del Python 3.9 que trae macOS.
Compilar sin Xcode tiene una trampa en el SDK de macOS 27: @State y Swift Charts dependen de macros cuyo plugin solo viene con Xcode. @State se evita nombrando el tipo, typealias Local<Value> = SwiftUI.State<Value>, y los gráficos están dibujados a mano con formas.
3. Pedidos
Cada venta se cruza con su orden de impresión: mismo destinatario y país, creada después de la venta y antes de 30 días, y cada orden una sola vez. Lo pagado sin orden sale arriba en rojo.
El nombre del comprador solo existe en memoria para este cruce. Se borra del registro antes de que salga del módulo y nunca se escribe en disco.
Un pedido marcado como enviado en Etsy no sale de la lista hasta que la imprenta lo envía de verdad. A veces se marca antes, y entonces se perdía de vista.
Con la API de Etsy, Monolyth también escribe. Un botón pone en Etsy el número de seguimiento de la imprenta en los pedidos que no lo tienen: en los ya marcados, cuando el paquete ha salido; en los demás, en cuanto existe el número, para que cuenten como enviados antes del plazo. Etsy solo reconoce algunos transportistas por su nombre, y para el resto se manda el nombre escrito, que es lo que se hace a mano eligiendo «Otro». Cada número se comprueba releyendo el pedido. El mismo acceso sirvió para añadir un tamaño que faltaba en la mitad de los anuncios: se lee el inventario de cada uno, se guarda una copia y se vuelve a escribir entero con el tamaño nuevo.
4. Dinero
El beneficio de un pedido es lo cobrado, menos las comisiones de la plataforma, menos el coste de impresión, menos lo devuelto. Los gastos fijos se reparten por día según cada cuánto se pagan. El cálculo es una función pura, sin red ni claves, así que se prueba entero sin simular nada. La app tiene 136 pruebas.
Las comisiones de Etsy salen de su libro de pagos. Al conectarlo aparecieron tres fallos que con datos de prueba no se veían:
- Etsy no deja pedir más de 31 días del libro de una vez. Ahora se pide en tramos de 30.
- El abono de cada venta y su comisión de cobro llevan el número del pago, no el del pedido. Sin enlazarlos, el impuesto que paga el comprador, que Etsy entrega a Hacienda, se contaba como comisión, y las devoluciones se restaban dos veces. En seis meses, el beneficio salía alrededor de un tercio más bajo de lo real. Ahora se pregunta a Etsy a qué pedido pertenece cada pago.
- Los abonos de la plataforma no restaban de ningún sitio.
Con los pagos enlazados, la comisión de un pedido es una suma:
# Lo paga el comprador y Etsy lo entrega a Hacienda:
# ni es ingreso ni comisión.
ETSY_NO_COMISION = ("PAYMENT_GROSS", "sales_tax", "sales_tax_refund")
cobrado = round(-sum(i for i, _, t in libro
if t not in ETSY_NO_COMISION), 2)
Y el libro se pide en tramos, sin solapes:
while inicio < ahora:
fin = min(inicio + TRAMO_LIBRO_ETSY, ahora) # 30 días
apuntes += pedir_paginas(inicio, fin)
# Los dos extremos cuentan: el siguiente tramo
# empieza un segundo después.
inicio = fin + 1
Desde el primer pedido de la tienda, lo que Money suma de cobros de Etsy y lo que dice el libro difieren en un 0,1 %. Antes de tener la API, las ventas se leían de los avisos de venta por IMAP y las comisiones se estimaban. Comparado con los 43 ingresos que Etsy hizo a la cuenta, ese modelo acertaba a un ±2 %.
La API de la imprenta da presupuestos sin crear pedidos y, en cada pedido real, separa lámina, envío e IVA. Ahí apareció algo que los totales escondían: un pedido a la Unión Europea cuesta de media un 77 % más que el mismo a Estados Unidos, por el envío y el IVA. Dos tamaños en centímetros parecían caros de producir y eran el mismo producto que sus equivalentes en pulgadas; simplemente los compraba gente de Europa.
5. El día acaba a las cuatro
Algo apuntado a la una y media cuenta para el día anterior. Si las tareas de hoy pasaran a atrasadas a medianoche, la lista se equivocaría todas las noches. El día es la fecha en Madrid menos cuatro horas, y la misma regla está escrita en Swift, en Python y en JavaScript, porque cada pieza decide sola qué día es. En Swift:
static let cutoffHours = 4.0
static func day(_ date: Date = Date()) -> String {
let shifted = date.addingTimeInterval(-cutoffHours * 3600)
// calendar va en Europe/Madrid
let c = calendar.dateComponents([.year, .month, .day],
from: shifted)
return String(format: "%04d-%02d-%02d", c.year!, c.month!, c.day!)
}
6. Tareas, hábitos y diario
Una tarea se escribe en una línea: «enmarcar el pedido viernes 10:00 #shops», «el 1 de cada mes», «in 2 weeks». El intérprete entiende español e inglés, compara sin tildes y corta el trozo de la fecha del texto original, así que el título conserva sus acentos. Las tareas con fecha van al calendario con EventKit, por las cuentas que ya tiene el Mac. Google Tasks quedaba descartado porque guarda el día y tira la hora. El identificador de EventKit puede cambiar al sincronizar, así que cada evento lleva el id de la tarea en sus notas para encontrarlo después.
Los hábitos viven en un Worker de Cloudflare con un Durable Object en SQLite, detrás de Cloudflare Access. El Worker comprueba el JWT de Access en cada petición. La app y el bot de Telegram entran por otra puerta, con una clave que se compara en tiempo constante, así que marcar desde el Mac, el móvil o Telegram es lo mismo. timingSafeEqual exige dos entradas del mismo largo; pasar antes las dos por SHA-256 lo resuelve y no deja medir cuánto mide la clave buena:
export async function esElBot(env, request) {
const dada = request.headers.get("X-Bot-Key") || "";
const buena = String(env.BOT_KEY || "");
if (buena.length < 32) return false;
const [a, b] = await Promise.all([dada, buena].map(
(s) => crypto.subtle.digest("SHA-256", enc(s))));
return crypto.subtle.timingSafeEqual(a, b);
}
El diario es una página de Markdown por día que se guarda sola 1,2 segundos después de dejar de escribir. La primera línea la pone la app: hábitos, ventas y tareas cerradas ese día. Las palabras con almohadilla son etiquetas en todas las secciones. Para pintarlas mientras se escribe hizo falta un NSTextView de AppKit, porque el editor de SwiftUI no deja dar color a un trozo de texto. El bot puede añadir un párrafo a la página de hoy desde Telegram; si en ese momento se está escribiendo en la app, al guardar se juntan los dos textos en vez de pisarse.
Progress compara la semana en curso con los mismos días de las cuatro anteriores: un viernes, contra semanas enteras, todo parecía ir a peor.
Todo esto se guarda en una carpeta de ficheros JSON y Markdown, escritos de forma atómica. Un asistente la puede leer sin conectores, y las reglas de uso están escritas en la propia carpeta.
7. El notch y el vigía
El panel del notch es un NSPanel que ocupa justo el hueco entre las dos áreas laterales de la pantalla. macOS da esas dos áreas, así que el tamaño del notch no se adivina:
let conNotch = NSScreen.screens.first { $0.safeAreaInsets.top > 0 }
guard let screen = conNotch,
let left = screen.auxiliaryTopLeftArea,
let right = screen.auxiliaryTopRightArea else { return }
let height = screen.safeAreaInsets.top
closedFrame = NSRect(x: left.maxX, y: screen.frame.maxY - height,
width: right.minX - left.maxX, height: height)
Un NSTrackingArea avisa cuando el puntero entra o sale, sin temporizador. Abierto es un pequeño tablero: lo que espera respuesta (mensajes de Etsy y correo importante), las últimas ventas con lo que dejó cada una, lo ganado en el día y en el mes, los hábitos del día para marcarlos ahí mismo, accesos directos y un campo para apuntar una tarea sin abrir la app. Cuando entra una venta se abre solo unos segundos.
Con el Mac apagado vigila un Worker gratuito. Cada cinco minutos comprueba las webs (declara una caída tras dos rondas fallidas) y los pedidos pagados sin orden de impresión; una vez al día, la caducidad de los dominios. Avisa por Telegram.
8. De la imagen al borrador de Etsy
Cada póster nuevo pedía veinte mockups, un título, trece etiquetas, una descripción y un texto alternativo, y después rellenar el anuncio a mano. Ahora se suelta la imagen en Etsy Autopilot y acaba como borrador en Etsy.
Los mockups salían de un script de Photoshop. Monolyth lee el PSD directamente: las capas con sus píxeles y, de cada objeto inteligente, las cuatro esquinas del hueco. Luego compone las capas en orden y deforma el diseño hasta esas esquinas con el modo de fusión de la capa. Casi todos los huecos multiplican, y eso es lo que deja ver los reflejos del cristal encima del póster:
func p(_ i: Int) -> CIVector {
CIVector(x: q[i], y: height - q[i + 1])
}
let warped = art.applyingFilter("CIPerspectiveTransform",
parameters: ["inputTopLeft": p(0), "inputTopRight": p(2),
"inputBottomRight": p(4), "inputBottomLeft": p(6)])
canvas = warped.applyingFilter("CIMultiplyBlendMode",
parameters: [kCIInputBackgroundImageKey: canvas])
La imagen compuesta que guarda el PSD no sirve de fondo: en muchos mockups lleva pintado el contenido de muestra del hueco, y multiplicar encima tiñe el diseño. Tampoco bastan las esquinas. 239 de los 776 mockups tienen una máscara en el hueco que deja delante del póster unos dedos, una lámpara o una hoja; la primera versión no la leía y el diseño tapaba la mano. Y leer el diseño ya reducido a 3.000 px con ImageIO bajó cada tanda de 15 segundos a menos de uno.
Los textos los escribe una IA mirando la imagen, sin notas. Monolyth llama a la IA por la línea de órdenes una vez por póster, cada vez desde cero y en una carpeta temporal, con una estructura de respuesta fija. Arrancado de forma normal cargaba 116.000 tokens de contexto que no hacían falta (conectores, plugins, habilidades). En modo seguro y con sus propias instrucciones son unos 8.000 por póster:
subprocess.run([ia, "-p", pedido, "--safe-mode",
"--strict-mcp-config", "--disable-slash-commands",
"--system-prompt", SISTEMA,
"--json-schema", json.dumps(ESQUEMA),
"--tools", "Read", "--output-format", "json"])
Antes de subir nada se comprueban las reglas que Etsy publica en su especificación: un título de 140 caracteres como mucho, con %, :, & y + una vez cada uno, y trece etiquetas de 20 caracteres con solo letras, números, espacios, guiones y apóstrofos. Etsy las escribe con categorías Unicode (\p{L}, \p{Nd}), que el módulo re de Python no entiende, así que se comprueban con unicodedata. La primera revisión de un CSV de 196 anuncios ya encontró una etiqueta con «+».
Lo que es igual en todos los anuncios (categoría, perfil de envío, devoluciones, plazo de preparación y los quince tamaños con sus precios) se copia de un anuncio que hace de plantilla. El anuncio nace como borrador y solo se activa al final, si se pide; si algo falla a medias, se queda en borrador. Al terminar se relee de Etsy para comprobarlo.
9. Fallos encontrados en uso
Al reinstalar, la versión nueva arrancaba mientras el bot de la anterior se cerraba, lo daba por vivo y no lanzaba el suyo. Ahora espera a que el anterior termine.
Los argumentos llegaban a los scripts en Unicode descompuesto: «año» con la tilde como carácter aparte, y la comparación fallaba. Los scripts normalizan a NFC.
El proveedor de impresión devolvía 403 a Python con una clave válida, y la web de hábitos el error 1010 de Cloudflare: Cloudflare bloquea la firma de cliente de urllib. Se arregla mandando un User-Agent propio. La comprobación de conexión daba verde porque no hacía la misma petición que el uso real.
En macOS, si una vista pide más alto que la ventana, el contenido se sale por arriba y desaparecen la cabecera y la barra lateral. Lo arregló limitar a cero el alto mínimo de esa vista.
Al abrir una pantalla nueva, la app entera se congelaba sin gastar procesador. Con sample se veía el hilo principal parado en un open() de la carpeta de mockups, dentro de Documentos: tras cada reinstalación macOS vuelve a pedir ese permiso, y la llamada espera la respuesta. Ninguna vista lee ya el disco mientras se dibuja.
Un borrador correcto salía como «sin comprobar». Etsy devuelve los textos con entidades HTML (Children's), y comparado con el título enviado no coincidía. Ahora se comparan descodificados.
10. Cómo se comprueba
Cualquier pantalla se abre sin ratón: unas opciones de arranque eligen la sección, el detalle y el tamaño de la ventana, y los enlaces hub:// abren un elemento concreto. Un modo de muestra rellena todas las vistas con datos inventados, lo dice en pantalla y no toca el disco ni el bot. Cada cambio se revisa con capturas de la ventana.