agiCode Desktop 是个 Electron 应用通过 Microsoft Store 分发。商业化上其实也就两类产品一类是 Sponsor Plan赞助者订阅Store ID9N0BTGWV23M1按月、按年续费像一段需要不断浇水的感情另一类是 TurboEngine永久授权的 DLCStore ID9NSD809W18Z6一次买断倒像是那本放在书架上再没翻动过的旧书但总归是你的。问题在于Electron 运行时本身并没有直接调用 Microsoft Store 商业化 API 的本事。Store 的购买、许可证查询全都依赖 WinRT 的Windows.Services.Store命名空间这套 API 只能在原生代码里用。可 Electron 主进程偏偏是 Node.js 环境你没法在它身上import一个 WinRT 类型——就像你想握住月光手心里却总是一空。更麻烦的是商业化状态这东西并不是查一次就能心安的。用户可能在 Store 客户端里退订、续费、换设备应用里的功能开关也得跟着变。要每次都等用户自己去点刷新体验自然是难看的可若频繁去查又会撞上 Store 的限流网络一抖好好的一段订阅愣是被查成未订阅把付费用户的功能给关了——这种事做出来真叫人想笑来掩饰掉下的泪。还有个容易被忽略的角落不同分发渠道的行为并不一样。非 Store 版本比如便携版压根没有 Store 运行时调用StoreContext会直接失败。这种情况下不能让应用崩掉也不能假装用户有订阅总得给一个明确的不支持状态。毕竟假装拥有终究比诚实承认更让人难过。为了这些我们做了一套分层架构。后来这套方案沉淀成了 HagiCode 的两个 OpenSpec 提案desktop-subscription-entitlements订阅许可证的持久化、标准化、权益派生和desktop-turboengine-msstore-licenseTurboEngine 永久许可证的购买、刷新、DLC 注入。下面慢慢说。关于 HagiCode本文分享的方案来自我们在 HagiCode 项目里的实践。HagiCode 是一个 AI 代码助手项目涵盖 Web、Desktop、CLI 等多个端。HagiCode Desktop 这条桌面产品线便是这篇文章讨论的对象完整源码可以看 HagiCode-org/site。分层是关键直接在 Electron 主进程里写 Store 调用会很乱。WinRT 的异步对象、COM 线程模型、窗口句柄传递这些东西和业务逻辑搅在一起几乎没法维护。我们的做法是把整条链路切成四层每一层只担一份责渲染进程 (React)↕ IPC bridgeElectron 主进程 (TypeScript)↕ broker 接口原生 Node addon (C)↕ WinRTWindows.Services.Store最底下是一层 C 原生 addon名字叫hagicode_store_purchase_addon.node。它其实只露两个方法requestPurchase(storeId, windowHandle)和queryStoreStatus(storeId, productName, productKinds)。这两个对应 WinRT 的RequestPurchaseAsync和GetAssociatedStoreProductsAsync/GetUserCollectionAsync。addon 的全部工作不过是把 WinRT 异步的结果转成 JSON再通过Napi::ThreadSafeFunction送回 JavaScript 线程罢了。中间一层是一个 TypeScript 的StoreLicenseService。它不关心 WinRT只关心业务语义刷新、重试、缓存、权益派生、状态广播。它通过一个StoreLicensePlatformBroker接口与底层通信这个接口也不过三个方法queryStatus()、purchase()、dispose()。最上面一层是SubscriptionService和TurboEngineLicenseService它们其实只是StoreLicenseService的薄薄一层封装各自绑了具体产品的配置Store ID、产品名、权益名称而已。这样的分层带来的一个直接好处是订阅和永久许可证可以共用同一套引擎。StoreLicenseService是个泛型类参数化的是快照类型和权益名称。加一个新产品只需要再写一份StoreLicenseProductConfig不必把整个服务复制粘贴一遍。HagiCode 日后若要接入 macOS 的 StoreKit 或别的商业化渠道理论上也只要换一个 broker 实现业务层一行都不用动——这大概就是分层的温柔之处吧。标准化把 Store 的脏数据洗干净WinRT 返回的数据是很原始的。StoreProductQueryResult里嵌着IVectorView、IMapSKU 的CollectionData.EndDate是 Windows DateTime ticks以 1601 年为起点100 纳秒为单位错误码是 HRESULT。这些东西若直接丢给渲染进程前端的代码大概是要垮的。所以 broker 层做了一道标准化把原始 WinRT 对象拍平成RawStoreLicenseStateexport interface RawStoreLicenseState {fetchedAt: string;availability: supported | store-unavailable | error;appLicenseActive: boolean;product: RawStoreLicenseProduct | null;sku: RawStoreLicenseSku | null;license: RawStoreLicense | null;purchaseEligibility: licensable | not-licensable | license-action-not-applicable | network-error | server-error | unknown;errorCode: string | null;errorMessage: string | null;}这里有个细节值得一说查询其实用了两次 Store 调用。一次是GetAssociatedStoreProductsAsync与当前应用关联的产品一次是GetUserCollectionAsync用户已经拥有的产品。原因无他订阅产品可能出现在关联列表里、但用户还没买也可能早就在用户集合里了。两个结果交叉比对才能准确判断是否拥有——这就像隔着距离看一个人从两个角度看过去才不至于看走眼。ticks 转 ISO 日期的代码值得留意const WINDOWS_EPOCH_OFFSET_MILLISECONDS 11644473600000n;const HUNDRED_NANOSECONDS_PER_MILLISECOND 10000n;// ticks 是 1601 起点的 100 纳秒单位先换算成毫秒再减去 Windows/Unix 纪元差const unixMilliseconds ticks / HUNDRED_NANOSECONDS_PER_MILLISECOND - WINDOWS_EPOCH_OFFSET_MILLISECONDS;11644473600000是 1601-01-01 到 1970-01-01 之间的毫秒数。这个转换在 C addon 里也做了一遍用FileTimeToSystemTime两边结果必须一致不然便会出现主进程看是今天、addon 看是昨天这种诡异的错位——时间和感情一样错位了就什么都说不清了。状态机从原始数据到业务状态标准化之后还得再抽象一层。业务代码其实并不需要知道purchaseEligibility是个什么东西它只关心订阅到底有没有效。normalize.ts里的deriveStatus函数做的就是这一层翻译function deriveStatus(raw: RawStoreLicenseState,productConfig: StoreLicenseProductConfig): StoreLicenseStatus {if (raw.availability ! supported) {return unknown;}const expirationDate raw.license?.expirationDate ?? raw.sku?.collectionEndDate ?? null;const expirationTime expirationDate ? Date.parse(expirationDate) : Number.NaN;const hasExpired Number.isFinite(expirationTime) expirationTime Date.now();const isOwned Boolean(raw.license?.isActive ||raw.sku?.isInUserCollection ||raw.product?.isInUserCollection);if (isOwned !hasExpired) {return active;}if (hasExpired) {return expired;}// ...其它分支inactive / canceled / grace-period / pending}最终的业务状态有七个active、inactive、expired、canceled、grace-period、pending、unknown。渲染进程只看这一个字段不再去碰那些原始数据。这里有个设计上的取舍active的判定并不去看expirationDate是否存在。原因无他——永久许可证TurboEngine压根没有过期时间这一说Store 返回的license.isActive为 true 便足够了。若硬要要求有过期时间才算 active反倒会把买断用户误判成未订阅那就太伤人了。这个细节在 spec 里写得很明白永久许可证在没有过期元数据时仍保持 active。容错网络不好时别把订阅搞丢Store API 在网络抖动的时候会返回错误或超时。如果每次失败都把状态清空付费用户的权限就会频繁地掉下去——这无异于废话可它确实是会发生的。HagiCode 的策略是失败时保留上次已知状态标记为 stale。StoreLicenseService.refresh内部有一个重试循环默认 3 次间隔 350ms还会做状态回归检测如果上一次是 active这次查出来却不是 active那就当成一次临时错误重试而不是直接接受这个退化的结果。private getRetryReason(snapshot: TSnapshot,recoverySnapshot: TSnapshot | null): store-unavailable | status-regression | null {if (snapshot.availability ! supported) {return store-unavailable;}if (recoverySnapshot?.status active snapshot.status ! active) {return status-regression;}return null;}只有当重试全部失败之后才会用createStaleSnapshot把上次的好状态标成 stale 返回同时附上一条store-refresh-failed的诊断。渲染进程可以自己决定 stale 状态下要不要禁用功能——通常的做法是继续放行给用户一个缓冲毕竟谁也不想在网不好的那天连自己花钱买的东西都用不上。另一个细节是refreshInFlight的去重。如果一次刷新已经在进行新的 refresh 调用会复用同一个 Promise避免并发请求把 Store 打爆——这道理和排队一样挤成一团反而谁也过不去。权益派生状态和功能开关解耦订阅状态回答的是订阅有没有效但功能开关关心的却是用户能不能用某个功能。这两者其实并不是一一对应的。一个 active 的订阅可能对应好几个权益赞助者徽章、高级功能开关未来说不定还要按档位区分。所以中间多了一层EntitlementEvaluatorevaluate(snapshot: TSnapshot): TEntitlement[] {if (snapshot.availability ! supported || snapshot.status ! active) {return [];}return [...this.activeEntitlements];}订阅产品配置里声明它在激活时会授予哪些权益export const subscriptionEntitlementNames [sponsorBadge,premiumFeatureGate,] as const;这样一来功能代码只依赖entitlements这个数组不再去直接读status。以后想加档位、拆分权益只改配置和 evaluator 就好不必去动消费方。这种解耦在 HagiCode 这种多产品线的项目里尤其重要——订阅和永久许可证共享同一套权益模型前端只需要查一个数组就够了世界一下子清爽了许多。运行时降级没有 Store 怎么办非 Store 分发的版本便携版、开发环境去调 addon是会失败的。HagiCode 用MicrosoftStoreSubscriptionBroker做了延迟初始化和降级private async initializeBroker(): PromiseStoreLicensePlatformBroker {try {return this.setBroker(await this.adapterFactory(this.windowHandle, this.productConfig));} catch (error) {// 找不到 Store 运行时就降级到一个什么都不支持的 brokerreturn this.setBroker(new UnavailableSubscriptionPlatformBroker(error));}}UnavailableSubscriptionPlatformBroker实现的是同一个接口只是它的queryStatus永远返回store-unavailablepurchase永远返回not-supported。上层代码完全无感知只是状态变成了不支持渲染进程据此显示一句请通过 Microsoft Store 获取的引导而已。这个设计让整个商业化模块可以在任何分发渠道下安全运行不会因为缺了 Store 运行时就崩溃。如果你也在做多渠道分发的 Electron 应用这点特别值得抄一份——别让环境不支持变成一次崩溃毕竟有些事承认下来反而体面。启动流程与 IPC 通道应用启动时main.ts会根据--desktop-subscription-enabled1参数决定要不要初始化订阅服务。这个参数只在 Store 版本的启动命令里带避免非 Store 版本白白加载——能省的力气总是要省的。function initializeSubscriptionService(): void {if (!subscriptionFeatureEnabled || subscriptionService) {return;}subscriptionService new SubscriptionService({broker: new MicrosoftStoreSubscriptionBroker({windowHandle: mainWindow?.getNativeWindowHandle() ?? null,}),entitlementEvaluator: new EntitlementEvaluator(),});registerSubscriptionHandlers({subscriptionService,getWindows: () ElectronBrowserWindow.getAllWindows(),});}windowHandle来自mainWindow.getNativeWindowHandle()这个 Buffer 会被解析成bigint传给原生 addonaddon 再拿它去调IInitializeWithWindow::Initialize。这是 Store API 在桌面应用非 UWP里弹出购买框的必要步骤否则购买窗口就没有所有者行为会异常——一个人若是没了归属做事总归是飘的窗口也是。渲染进程通过preload暴露的 bridge 去调主进程const subscriptionBridge: SubscriptionBridge {getSnapshot: (options) ipcRenderer.invoke(subscriptionChannels.getSnapshot, options),verifyStartup: () ipcRenderer.invoke(subscriptionChannels.verifyStartup),refresh: () ipcRenderer.invoke(subscriptionChannels.refresh),purchase: () ipcRenderer.invoke(subscriptionChannels.purchase),onDidChange: (callback) {const listener (_event, snapshot) callback(snapshot);ipcRenderer.on(subscriptionChannels.changed, listener);return () ipcRenderer.removeListener(subscriptionChannels.changed, listener);},};状态变化通过broadcastSnapshotChanged推送给所有窗口。购买完成之后completePurchase会触发一次refresh(purchase)新状态便自动广播出去渲染进程的订阅 UI 也就实时更新了。另外main.ts里还有一个setInterval在后台默默同步subscriptionService?.refresh(scheduled)。这让应用开着的时候能捕捉到用户在 Store 客户端里悄悄做的续费、退订。频率自然不能太高Store 是有限流的代码里用的是分钟级的间隔——远不远近不近刚刚好。几个容易踩的坑第一原生 addon 的线程安全。WinRT 的异步操作完成之后回调并不在 JavaScript 线程上。若直接在回调里去调Napi的 API是会崩的。addon 用Napi::ThreadSafeFunction::BlockingCall把结果投递回 JS 线程auto const status threadsafeFunction_.BlockingCall(payload,[self](Napi::Env env, Napi::Function, PurchaseCompletion* data) {std::unique_ptrPurchaseCompletion ownedData{ data };self-ResolveOnJs(env, *ownedData);});BlockingCall会阻塞 WinRT 的回调线程直到 JS 线程处理完。这个模式下回调线程不能是 JS 线程本身否则就是一场死锁。好在 WinRT 的Completed回调通常在 STA 或线程池上是满足这个条件的。第二COM 初始化。Electron 主线程可能早就初始化过 COM 了。addon 里winrt::init_apartment外面套了一层 try-catch失败就忽略try {winrt::init_apartment(winrt::apartment_type::single_threaded);} catch (...) {// Electron 可能已经为这个线程初始化过 COM忽略即可}不处理这个重复初始化便会抛异常addon 加载也就失败了。有些错忽略一下反而是对的。第三窗口句柄精度。getNativeWindowHandle()返回的是一个Buffer长度可能是 432 位或 864 位。然后在 addon 里被格式化成0x开头的十六进制字符串C 端再用std::stoull解析回HWND。为什么用字符串而不直接传数字因为 JS 的 number 精度只有 53 位64 位的指针会丢精度。这个坑不踩一次是很难发现的——就像有些事不经历一次是说不清的。第四状态隔离。订阅和永久许可证的状态是要分开存储的。HagiCode 的 spec 明确要求 TurboEngine 的持久化不能覆盖 sponsor 的状态。两套快照用不同的productKeysubscription与turboengine隔开避免一个产品的刷新把另一个产品的缓存给覆盖掉。各人管各人的事世界才太平。第五购买后必须刷新。购买完成之后必须再刷新一次才能广播。completePurchase里对succeeded和already-purchased两种情况都触发refresh(purchase)因为 Store 的购买结果只告诉你交易状态并不告诉你当前的许可证详情。许可证状态必须重新查询一遍——承诺和现实之间总还隔着一次确认。总结这套实现跑了一段时间整体还算稳。最值得借鉴的其实不是某个具体的小技巧而是这种分层的方式把和 Store 打交道这件脏活彻底隔离在 broker 和 addon 里上层只处理纯粹的业务语义。几条核心的经验记在这里WinRT 只在 C addon 里碰addon 只做异步转 JSON业务语义一概不沾。标准化和状态机分两层原始数据和业务状态别混在一起。网络失败时保留上次的好状态并标 stale别把付费用户的权限搞掉。权益和状态解耦功能代码只看entitlements数组。非 Store 环境走降级 broker永远不让不支持变成一次崩溃。如果你也在做 Electron 应用的 Store 商业化希望这套分层能让你少踩几个坑。本文分享的这套方案正是我们在开发 HagiCode 的过程里实际踩过坑、也实际优化出来的。如果你觉得它