
Eén codebase, twee apps: white-labelen zonder te forken
GeekBye en Pavleur zijn twee verschillend gebrande desktop-apps gebouwd vanuit één repository — geen fork, geen gedupliceerde codebase. Dit is de build-machinerie die één codebase naar twee producten laat compileren, en de éénregelige bug waardoor onze tweede app zich onder de verkeerde naam voorstelde.
GeekBye is een meeting-notetaker. Pavleur is een AI-app om sollicitatiegesprekken te oefenen. Ze zien eruit als twee verschillende producten omdat het ook twee verschillende producten zijn — maar ze worden gebouwd vanuit één codebase, zonder fork en zonder branch die wegdrijft. Dit is de engineeringkant van white-labelen: niet "waarom zou je een gebrande app verkopen" (dat is een andere post), maar hoe je één repository daadwerkelijk naar twee apps compileert — en de weinig glamoureuze bug die zich in elk white-labelsysteem verstopt totdat je ernaar op zoek gaat.
Genereer, branch niet
De verkeerde manier om twee producten vanuit één codebase te draaien is if (product === 'pavleur') door de hele app strooien, of forken en bidden dat de twee kopieën nooit uit elkaar groeien. Beide rotten snel weg.
De aanpak van GeekBye is codegeneratie op buildtijd. Elk product heeft een configbestand — configs/geekbye/config.ts, configs/pavleur/config.ts — met zijn identiteit: naam, bundle-id, URL-protocol, de releases-repository voor auto-updates, enzovoort. Vóór een build draait één script:
node scripts/build-product.js pavleur
Dat script leest de config van het gekozen product en doet drie concrete dingen:
- Genereert twee configmodules — één voor de renderer (
src/config/product.ts), één voor het main-proces (electron/config/product.ts) — elk gestempeld met een "DO NOT EDIT MANUALLY — changes will be overwritten"-header. De rest van de app importeert gewoonproductName,applicationIdenprotocoluit de config en denkt nooit na over welk merk het is. - Kopieert alleen de assets van dat merk — iconen, het kleine logo, het toast-notificatie-icoon. Een GeekBye-build bevat fysiek nul Pavleur-artwork en vice versa. Niets om te lekken.
- Herschrijft
package.json(bij release-builds) — de app-id, de productnaam, het URL-protocol, de GitHub-releases-repo om naar te publiceren, en, mooi meegenomen, de gebrande microfoonpermissietekst, zodat macOS vraagt "Pavleur wil toegang tot de microfoon", niet het verkeerde merk.
De hele app is merk-agnostisch; het merk wordt op buildtijd geïnjecteerd. Er is geen runtime-productswitch om fout te zetten, want op runtime is er altijd maar één product ingebakken.
Eén backend, twee merken
Beide apps praten met dezelfde backend. Dus hoe weet de server bij welk product — en welke prijzen, prompts en limieten — een request hoort?
Twee signalen, beide gezet op buildtijd. Ten eerste een application id (geekbye of pavleur) ingebakken in de config en meegestuurd met requests, zodat de backend naar het gedrag van het juiste product kan routeren. Ten tweede een User-Agent die de client aan elke backend-call hangt, van de vorm Product/version (platform) — bijvoorbeeld Pavleur/1.2.5 (win32) of GeekBye/1.7.3 (darwin). Die ene header geeft de backend gratis attributie per product én per OS, zonder apart analytics-veld. Auto-updates blijven intussen geïsoleerd: elk product publiceert naar zijn eigen releases-repository, dus een GeekBye-update kan nooit aan een Pavleur-installatie worden geserveerd.
De onboarding verschilt ook per product — de wizard van GeekBye gaat over meetingtranscriptie, die van Pavleur over interviewoefening — en de juiste wordt op runtime gekozen op basis van het ene ingebakken application id. Dezelfde machinerie, twee voordeuren.
De bug: toen Pavleur zichzelf GeekBye noemde
Hier is het oorlogsverhaal, en het is er een waarvan elk white-labelsysteem een eigen versie heeft.
De configgeneratie werkte. De iconen wisselden. De microfoonprompt was gebrand. De protocollen en bundle-ids klopten. Op elke zichtbare maatstaf was een Pavleur-build Pavleur. En toch belde elke Pavleur-build een tijdlang naar huis bij de backend onder de naam GeekBye.
De boosdoener was één hardcoded string, en hij verstopte zich op de minst glamoureuze plek denkbaar: de User-Agent-builder, weggestopt in een netwerk-retry-helper. Alles wat de gebruiker te zien kreeg liep via de config, maar deze diep-in-het-loodgieterswerk-utility had nog steeds een letterlijke `GeekBye/${version}` in zich. Niemand denkt bij een retry-utility aan "branding", dus niemand had hem gecheckt. De fix was vier regels — de dynamische productName uit de config importeren en die interpoleren in plaats van het hardcoded merk — maar de les is waar het om draait:
White-labelen is niet "is het logo veranderd". Het is "loopt elke identiteitsstring door de config". En de laatste achterblijvers zitten nooit in de UI, waar je zou kijken. Ze zitten in de User-Agent, de deep-link-protocolhandler, de tekst van de permissieprompt, de foutmeldingen — het loodgieterswerk dat niemand onder "merk" archiveert. De enige betrouwbare verdediging is de merkliteral behandelen als iets waar je actief op jaagt: grep de codebase op de hardcoded naam en laat de build falen als hij ergens opduikt buiten de config die hem hoort te definiëren.
Drie dingen die white-labelen ons leerde
- Genereer, branch niet. Buildtijd-codegen van twee kleine configmodules plus selectieve asset-copy verslaat zowel forken als een runtime-woud aan productchecks. De app blijft merk-agnostisch; het merk is een build-input.
- Identiteitsstrings lekken naar het loodgieterswerk. De UI is de makkelijke 90%. De moeilijke 10% is de User-Agent, het URL-protocol, de microfoonpermissiestring, de fouttekst. Leid ze allemaal door één
productName/applicationId-export — en grep op de kale merknaam als CI-gate zodat een verdwaalde literal nooit kan shippen. - Eén backend, veel merken, onderscheid via id en header. Een application id van buildtijd plus een
Product/version (platform)User-Agent geeft attributie per product en per platform met nul gedupliceerde infrastructuur — en scheidt de onveranderlijke buildtijd-identiteit netjes van runtime-gedrag.
Voor de product-en-privacykant van een white-label notetaker onder je eigen merk draaien, zie de white-label AI-notetaker. Dit is het zesde hoofdstuk van het v1-verhaal dat GeekBye v2 wordt — voor het vorige hoofdstuk, wat een release van 127 commits eigenlijk is (v1.7.0); en voor de hele boog, de anatomie van software tot in de perfectie uitbrengen.