--- title: 'One Codebase, Two Apps: How to White-Label Without Forking' excerpt: 'GeekBye and Pavleur are two differently-branded desktop apps built from a single repository — no fork, no duplicated codebase. Here is the build-time machinery that makes one codebase compile into two products, and the one-line bug that made our second app introduce itself by the wrong name.' date: '2026-07-06' author: 'Steven' authorAvatar: '/images/blog/authors/steven.jpg' coverImage: '/images/blog/covers/how-to-white-label-an-app-without-forking.svg' tags: ['Engineering', 'Architecture', 'Build', 'GeekBye Releases'] keywords: - 'white label desktop app one codebase' - 'build multiple branded apps from one repo' - 'build time config generation electron' - 'ship two products without forking' - 'per product build script assets' - 'shared backend multiple client brands' lastModified: '2026-07-06' tldr: "GeekBye v1.7.1 and v1.7.4 hardened the white-label system that builds two branded desktop apps — GeekBye and Pavleur — from one codebase without forking. A build script reads a per-product config and generates two tiny config modules, copies only that brand's assets, and rewrites package.json (bundle id, protocol, even the branded microphone-permission text). Both apps hit the same backend, disambiguated by an application id and a Product/version/platform User-Agent. The war story: a single hardcoded brand string in a networking helper made every Pavleur build introduce itself to the backend as GeekBye." keyTakeaways: - 'Two branded apps ship from one repository via build-time config generation, not a fork or a runtime pile of if-product checks' - "A build script generates two small config modules, copies only the active brand's icons and logos, and rewrites package.json including the branded microphone-permission text" - 'Both products talk to one backend, disambiguated by a build-time application id plus a Product/version/(platform) User-Agent for per-product and per-platform attribution' - 'The hard part of white-labeling is not the UI — it is making every identity string route through config; the last holdouts hide in plumbing like the User-Agent builder' - 'A single hardcoded brand literal in a retry helper made Pavleur builds identify as GeekBye until it was routed through the dynamic product name' --- GeekBye is a meeting notetaker. [Pavleur](https://pavleur.com) is an AI interview-practice app. They look like two different products because they _are_ two different products — but they're built from **one codebase**, with no fork and no branch that drifts. This is the engineering side of white-labeling: not "why sell a branded app" (that's [a different post](/blog/white-label-ai-notetaker)), but _how you actually compile one repository into two apps_ — and the unglamorous bug that hides in every white-label system until you go looking for it. ## Generate, don't branch The wrong way to run two products from one codebase is to sprinkle `if (product === 'pavleur')` throughout the app, or to fork and pray the two copies never diverge. Both rot fast. GeekBye's approach is **build-time code generation.** Each product has a config file — `configs/geekbye/config.ts`, `configs/pavleur/config.ts` — holding its identity: name, bundle id, URL protocol, the releases repository for auto-updates, and so on. Before a build, one script runs: ```bash node scripts/build-product.js pavleur ``` That script reads the chosen product's config and does three concrete things: 1. **Generates two config modules** — one for the renderer (`src/config/product.ts`), one for the main process (`electron/config/product.ts`) — each stamped with a _"DO NOT EDIT MANUALLY — changes will be overwritten"_ header. The rest of the app just imports `productName`, `applicationId`, and `protocol` from config and never thinks about which brand it is. 2. **Copies only that brand's assets** — icons, the small logo, the toast-notification icon. A GeekBye build physically contains zero Pavleur art and vice-versa. Nothing to leak. 3. **Rewrites `package.json`** (on release builds) — the app id, product name, URL protocol, the GitHub releases repo to publish to, and, nicely, the **branded microphone-permission text** so macOS asks "_Pavleur_ would like to access the microphone," not the wrong brand. The whole app is brand-agnostic; the brand is injected at build time. There's no runtime product-switch to get wrong, because at runtime there's only ever _one_ product baked in. ## One backend, two brands Both apps talk to the **same backend.** So how does the server know which product — and which pricing, prompts, and limits — a request belongs to? Two signals, both set at build time. First, an **application id** (`geekbye` or `pavleur`) baked into the config and sent with requests, so the backend can route to the right product's behavior. Second, a **User-Agent** the client attaches to every backend call, of the shape `Product/version (platform)` — e.g. `Pavleur/1.2.5 (win32)` or `GeekBye/1.7.3 (darwin)`. That one header gives the backend per-product _and_ per-OS attribution for free, without a separate analytics field. Auto-updates, meanwhile, stay isolated: each product publishes to its own releases repository, so a GeekBye update can never be served to a Pavleur install. The onboarding differs per product too — GeekBye's wizard is about meeting transcription, Pavleur's is about interview practice — and the right one is chosen at runtime from the single baked-in application id. Same machinery, two front doors. ## The bug: when Pavleur called itself GeekBye Here's the war story, and it's the one every white-label system has a version of. The config generation worked. The icons swapped. The mic prompt was branded. The protocols and bundle ids were right. By every visible measure, a Pavleur build _was_ Pavleur. And yet, for a while, every Pavleur build **phoned home to the backend calling itself GeekBye.** The culprit was a single hardcoded string, and it was hiding in the least glamorous place imaginable: the **User-Agent builder**, tucked inside a network _retry helper_. Everything user-facing had been routed through config, but this one deep-plumbing utility still had a literal `` `GeekBye/${version}` `` in it. Nobody thinks of a retry utility as "branding," so nobody had checked it. The fix was four lines — import the dynamic `productName` from config and interpolate it instead of the hardcoded brand — but the lesson is the whole point: **White-labeling isn't "did the logo change." It's "does _every_ identity string route through config."** And the last holdouts never live in the UI, where you'd look. They live in the User-Agent, the deep-link protocol handler, the permission-prompt copy, the error messages — the plumbing nobody files under "brand." The only reliable defense is to treat the brand literal as something you actively hunt: grep the codebase for the hardcoded name and fail the build if it appears anywhere outside the config that's _supposed_ to define it. ## Three things white-labeling taught us 1. **Generate, don't branch.** Build-time codegen of two tiny config modules plus selective asset copy beats both forking and a runtime thicket of product checks. The app stays brand-agnostic; the brand is a build input. 2. **Identity strings leak into plumbing.** The UI is the easy 90%. The hard 10% is the User-Agent, the URL protocol, the mic-permission string, the error copy. Route them all through one `productName` / `applicationId` export — and grep for the raw brand name as a CI gate so a stray literal can never ship. 3. **One backend, many brands, disambiguate by id and header.** A build-time application id plus a `Product/version (platform)` User-Agent gives per-product and per-platform attribution with zero duplicated infrastructure — cleanly separating immutable build-time identity from runtime behavior. For the product-and-privacy side of running a white-label notetaker under your own brand, see [the white-label AI notetaker](/blog/white-label-ai-notetaker). This is the sixth chapter of the v1 story that becomes GeekBye v2 — for the previous chapter, [what a 127-commit release actually is](/blog/what-a-127-commit-release-actually-is) (v1.7.0); and for the whole arc, [the anatomy of shipping software to perfection](/blog/anatomy-of-perfection).