Steven
Steven1 分钟阅读

一套代码库,两个应用:如何在不分叉的情况下做白标

GeekBye 和 Pavleur 是两个品牌各异的桌面应用,却构建自同一个仓库——没有分叉,没有重复的代码库。这就是让一套代码库编译出两个产品的构建期机制,以及那个让我们第二个应用用错名字自我介绍的一行 bug。

工程
架构
构建
GeekBye 发布
一套代码库,两个应用:如何在不分叉的情况下做白标

GeekBye 是一款会议记录工具。Pavleur 是一款 AI 面试练习应用。它们看起来像两个不同的产品——因为它们确实是两个不同的产品——但它们构建自一套代码库,没有分叉,也没有会渐渐偏离的分支。这就是白标的工程一面:不是"为什么要卖一个品牌应用"(那是另一篇文章),而是你到底怎么把一个仓库编译成两个应用——以及那个藏在每一套白标系统里、不去找就发现不了的、并不光鲜的 bug。

要生成,别分支

用一套代码库跑两个产品的错误做法,是在应用各处撒上 if (product === 'pavleur'),或者分叉之后祈祷两份拷贝永远不会分道扬镳。两种都烂得很快。

GeekBye 的做法是构建期代码生成。每个产品都有一个 config 文件——configs/geekbye/config.tsconfigs/pavleur/config.ts——保存着它的身份:名字、bundle id、URL 协议、用于自动更新的发布仓库,等等。构建之前,会先跑一个脚本:

node scripts/build-product.js pavleur

这个脚本读取所选产品的 config,做三件具体的事:

  1. 生成两个 config 模块——一个给渲染进程(src/config/product.ts),一个给主进程(electron/config/product.ts)——每个都盖上一句*"请勿手动编辑——改动会被覆盖"*的头注。应用其余部分只是从 config 里导入 productNameapplicationIdprotocol,从不去想自己是哪个品牌。
  2. 只拷贝该品牌的资源——图标、小 logo、toast 通知图标。GeekBye 构建物里实打实地零 Pavleur 素材,反之亦然。没有东西会泄露。
  3. 重写 package.json(在发布构建时)——app id、产品名、URL 协议、要发布到的 GitHub 发布仓库,还有很贴心的一点,带品牌的麦克风权限文案,好让 macOS 询问"Pavleur 想要访问麦克风",而不是弹出错误的品牌。

整个应用与品牌无关;品牌是在构建期注入的。没有运行期的产品切换可以搞错,因为运行期永远只烘焙进了一个产品。

一个 backend,两个品牌

两个应用都与同一个 backend通信。那么服务器怎么知道某个请求属于哪个产品——以及哪套定价、提示词和限额?

有两个信号,都在构建期设定。第一个是烘焙进 config 并随请求发出的 application idgeekbyepavleur),好让 backend 路由到正确产品的行为。第二个是客户端附在每一次 backend 调用上的 User-Agent,形如 Product/version (platform)——例如 Pavleur/1.2.5 (win32)GeekBye/1.7.3 (darwin)。仅这一个 header,就让 backend 免费获得按产品以及按操作系统的归因,无需另设一个分析字段。与此同时,自动更新保持隔离:每个产品都发布到自己的发布仓库,所以 GeekBye 的更新绝不会被送到 Pavleur 的安装上。

引导流程也是按产品不同的——GeekBye 的向导讲的是会议转录,Pavleur 的讲的是面试练习——而正确的那一个是运行期从那唯一烘焙进去的 application id 里选出来的。同一套机制,两扇前门。

那个 bug:当 Pavleur 自称 GeekBye

这就是那段战事,也是每一套白标系统都会有一个版本的故事。

config 生成起作用了。图标换掉了。麦克风提示带上了品牌。协议和 bundle id 都对。以每一个看得见的标准来衡量,一个 Pavleur 构建就是 Pavleur。然而有那么一阵子,每一个 Pavleur 构建都用 GeekBye 的名字给 backend 打电话报到

罪魁祸首是一个硬编码的字符串,它藏在你能想象到的最不起眼的地方:塞在一个网络重试辅助工具里的 User-Agent 构建器。所有面向用户的东西都已经走了 config,唯独这一个深层管道工具里,还留着一句字面量 `GeekBye/${version}`。没有人会把重试工具当成"品牌",所以没有人去查过它。修复只有四行——从 config 里导入动态的 productName,用它去插值,替换掉硬编码的品牌——但教训才是全部重点:

白标不是"logo 换了没有"。而是"是不是每一个身份字符串都经过了 config"。 而最后的漏网之鱼永远不住在你会去看的 UI 里。它们住在 User-Agent、深链协议处理器、权限提示文案、错误消息里——那些没人会归到"品牌"名下的管道里。唯一可靠的防线,是把品牌字面量当作一个你主动去猎的东西:在代码库里 grep 那个硬编码的名字,一旦它出现在那个本该定义它的 config 之外的任何地方,就让构建失败。

白标教会我们的三件事

  1. 要生成,别分支。 对两个很小的 config 模块做构建期代码生成,加上按需拷贝资源,胜过分叉,也胜过运行期一丛丛的产品判断。应用保持与品牌无关;品牌是一个构建输入。
  2. 身份字符串会渗进管道。 UI 是容易的那 90%。难的那 10% 是 User-Agent、URL 协议、麦克风权限字符串、错误文案。把它们全都走同一个 productName / applicationId 导出——并把原始品牌名当作一道 CI 门去 grep,好让一个游离的字面量永远发布不出去。
  3. 一个 backend,多个品牌,靠 id 和 header 区分。 构建期的 application id 加上 Product/version (platform) 的 User-Agent,就能在零重复基础设施的情况下拿到按产品、按平台的归因——干净地把不可变的构建期身份,与运行期行为分离开来。

关于用自己的品牌运营一款白标记录工具的产品与隐私一面,见白标 AI 记录工具。这是那个将成为 GeekBye v2 的 v1 故事的第六章——上一章见127 次提交的发布到底是什么(v1.7.0);整段弧线则见把软件打磨到完美地发布出去的解剖学