Electron 桌面应用如何接入 Microsoft Store 订阅与永久许可证

Electron 桌面应用如何接入 Microsoft Store 订阅与永久许可证

当你的 Electron 应用要进 Microsoft Store 卖订阅和永久授权,WinRT 那一套商业 API 到底怎么干净地接到业务里?这事儿说起来也算一桩旧梦,我们在 HagiCode Desktop 里踩过坑、也擦过汗,最后摸索出这么一套分层方案,写下来,算是给后来的人留个路标。

背景

HagiCode Desktop 是个 Electron 应用,通过 Microsoft Store 分发。商业化上其实也就两类产品:一类是 Sponsor Plan(赞助者订阅,Store ID 9N0BTGWV23M1),按月、按年续费,像一段需要不断浇水的感情;另一类是 TurboEngine(永久授权的 DLC,Store ID 9NSD809W18Z6),一次买断,倒像是那本放在书架上再没翻动过的旧书,但总归是你的。

问题在于,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-license(TurboEngine 永久许可证的购买、刷新、DLC 注入)。下面慢慢说。

关于 HagiCode

本文分享的方案,来自我们在 HagiCode 项目里的实践。HagiCode 是一个 AI 代码助手项目,涵盖 Web、Desktop、CLI 等多个端。HagiCode Desktop 这条桌面产品线,便是这篇文章讨论的对象,完整源码可以看 HagiCode-org/site

分层是关键

直接在 Electron 主进程里写 Store 调用,会很乱。WinRT 的异步对象、COM 线程模型、窗口句柄传递,这些东西和业务逻辑搅在一起,几乎没法维护。我们的做法是把整条链路切成四层,每一层只担一份责:

复制代码
渲染进程 (React)
   ↕  IPC bridge
Electron 主进程 (TypeScript)
   ↕  broker 接口
原生 Node addon (C++)
   ↕  WinRT
Windows.Services.Store

最底下是一层 C++ 原生 addon,名字叫 hagicode_store_purchase_addon.node。它其实只露两个方法:requestPurchase(storeId, windowHandle)queryStoreStatus(storeId, productName, productKinds)。这两个对应 WinRT 的 RequestPurchaseAsyncGetAssociatedStoreProductsAsync / GetUserCollectionAsync。addon 的全部工作,不过是把 WinRT 异步的结果转成 JSON,再通过 Napi::ThreadSafeFunction 送回 JavaScript 线程罢了。

中间一层是一个 TypeScript 的 StoreLicenseService。它不关心 WinRT,只关心业务语义:刷新、重试、缓存、权益派生、状态广播。它通过一个 StoreLicensePlatformBroker 接口与底层通信,这个接口也不过三个方法:queryStatus()purchase()dispose()

最上面一层是 SubscriptionServiceTurboEngineLicenseService,它们其实只是 StoreLicenseService 的薄薄一层封装,各自绑了具体产品的配置(Store ID、产品名、权益名称)而已。

这样的分层,带来的一个直接好处是:订阅和永久许可证可以共用同一套引擎。StoreLicenseService 是个泛型类,参数化的是快照类型和权益名称。加一个新产品,只需要再写一份 StoreLicenseProductConfig,不必把整个服务复制粘贴一遍。HagiCode 日后若要接入 macOS 的 StoreKit 或别的商业化渠道,理论上也只要换一个 broker 实现,业务层一行都不用动------这大概就是分层的温柔之处吧。

标准化:把 Store 的脏数据洗干净

WinRT 返回的数据,是很"原始"的。StoreProductQueryResult 里嵌着 IVectorViewIMap,SKU 的 CollectionData.EndDate 是 Windows DateTime ticks(以 1601 年为起点,100 纳秒为单位),错误码是 HRESULT。这些东西若直接丢给渲染进程,前端的代码大概是要垮的。

所以 broker 层做了一道标准化,把原始 WinRT 对象拍平成 RawStoreLicenseState

typescript 复制代码
export 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 日期的代码,值得留意:

typescript 复制代码
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 函数,做的就是这一层翻译:

typescript 复制代码
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
}

最终的业务状态有七个:activeinactiveexpiredcanceledgrace-periodpendingunknown。渲染进程只看这一个字段,不再去碰那些原始数据。

这里有个设计上的取舍:active 的判定,并不去看 expirationDate 是否存在。原因无他------永久许可证(TurboEngine)压根没有过期时间这一说,Store 返回的 license.isActive 为 true 便足够了。若硬要要求"有过期时间才算 active",反倒会把买断用户误判成未订阅,那就太伤人了。这个细节在 spec 里写得很明白:永久许可证在没有过期元数据时,仍保持 active。

容错:网络不好时别把订阅搞丢

Store API 在网络抖动的时候,会返回错误或超时。如果每次失败都把状态清空,付费用户的权限就会频繁地掉下去------这无异于废话,可它确实是会发生的。HagiCode 的策略是"失败时保留上次已知状态,标记为 stale"。

StoreLicenseService.refresh 内部有一个重试循环(默认 3 次,间隔 350ms),还会做"状态回归"检测:如果上一次是 active,这次查出来却不是 active,那就当成一次临时错误重试,而不是直接接受这个退化的结果。

typescript 复制代码
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 的订阅,可能对应好几个权益(赞助者徽章、高级功能开关),未来说不定还要按档位区分。

所以中间多了一层 EntitlementEvaluator

typescript 复制代码
evaluate(snapshot: TSnapshot): TEntitlement[] {
  if (snapshot.availability !== 'supported' || snapshot.status !== 'active') {
    return [];
  }
  return [...this.activeEntitlements];
}

订阅产品配置里,声明它在激活时会授予哪些权益:

typescript 复制代码
export const subscriptionEntitlementNames = [
  'sponsorBadge',
  'premiumFeatureGate',
] as const;

这样一来,功能代码只依赖 entitlements 这个数组,不再去直接读 status。以后想加档位、拆分权益,只改配置和 evaluator 就好,不必去动消费方。这种解耦,在 HagiCode 这种多产品线的项目里,尤其重要------订阅和永久许可证共享同一套权益模型,前端只需要查一个数组就够了,世界一下子清爽了许多。

运行时降级:没有 Store 怎么办

非 Store 分发的版本(便携版、开发环境)去调 addon,是会失败的。HagiCode 用 MicrosoftStoreSubscriptionBroker 做了延迟初始化和降级:

typescript 复制代码
private async initializeBroker(): Promise<StoreLicensePlatformBroker> {
  try {
    return this.setBroker(
      await this.adapterFactory(this.windowHandle, this.productConfig)
    );
  } catch (error) {
    // 找不到 Store 运行时就降级到一个"什么都不支持"的 broker
    return this.setBroker(new UnavailableSubscriptionPlatformBroker(error));
  }
}

UnavailableSubscriptionPlatformBroker 实现的是同一个接口,只是它的 queryStatus 永远返回 store-unavailablepurchase 永远返回 not-supported。上层代码完全无感知,只是状态变成了"不支持",渲染进程据此显示一句"请通过 Microsoft Store 获取"的引导而已。

这个设计,让整个商业化模块可以在任何分发渠道下安全运行,不会因为缺了 Store 运行时就崩溃。如果你也在做多渠道分发的 Electron 应用,这点特别值得抄一份------别让"环境不支持"变成一次崩溃,毕竟有些事,承认下来,反而体面。

启动流程与 IPC 通道

应用启动时,main.ts 会根据 --desktop-subscription-enabled=1 参数决定要不要初始化订阅服务。这个参数只在 Store 版本的启动命令里带,避免非 Store 版本白白加载------能省的力气,总是要省的。

typescript 复制代码
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 传给原生 addon,addon 再拿它去调 IInitializeWithWindow::Initialize。这是 Store API 在桌面应用(非 UWP)里弹出购买框的必要步骤,否则购买窗口就没有所有者,行为会异常------一个人若是没了归属,做事总归是飘的,窗口也是。

渲染进程通过 preload 暴露的 bridge 去调主进程:

typescript 复制代码
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 线程:

cpp 复制代码
auto const status = threadsafeFunction_.BlockingCall(
    payload,
    [self](Napi::Env env, Napi::Function, PurchaseCompletion* data) {
        std::unique_ptr<PurchaseCompletion> ownedData{ data };
        self->ResolveOnJs(env, *ownedData);
    });

BlockingCall 会阻塞 WinRT 的回调线程,直到 JS 线程处理完。这个模式下,回调线程不能是 JS 线程本身,否则就是一场死锁。好在 WinRT 的 Completed 回调通常在 STA 或线程池上,是满足这个条件的。

第二,COM 初始化。 Electron 主线程可能早就初始化过 COM 了。addon 里 winrt::init_apartment 外面套了一层 try-catch,失败就忽略:

cpp 复制代码
try {
    winrt::init_apartment(winrt::apartment_type::single_threaded);
} catch (...) {
    // Electron 可能已经为这个线程初始化过 COM,忽略即可
}

不处理这个,重复初始化便会抛异常,addon 加载也就失败了。有些错,忽略一下,反而是对的。

第三,窗口句柄精度。 getNativeWindowHandle() 返回的是一个 Buffer,长度可能是 4(32 位)或 8(64 位)。然后在 addon 里被格式化成 0x 开头的十六进制字符串,C++ 端再用 std::stoull 解析回 HWND。为什么用字符串而不直接传数字?因为 JS 的 number 精度只有 53 位,64 位的指针会丢精度。这个坑,不踩一次是很难发现的------就像有些事,不经历一次,是说不清的。

第四,状态隔离。 订阅和永久许可证的状态,是要分开存储的。HagiCode 的 spec 明确要求 TurboEngine 的持久化不能覆盖 sponsor 的状态。两套快照用不同的 productKeysubscriptionturboengine)隔开,避免一个产品的刷新把另一个产品的缓存给覆盖掉。各人管各人的事,世界才太平。

第五,购买后必须刷新。 购买完成之后,必须再刷新一次才能广播。completePurchase 里对 succeededalready-purchased 两种情况都触发 refresh('purchase'),因为 Store 的购买结果只告诉你交易状态,并不告诉你当前的许可证详情。许可证状态,必须重新查询一遍------承诺和现实之间,总还隔着一次确认。

总结

这套实现跑了一段时间,整体还算稳。最值得借鉴的,其实不是某个具体的小技巧,而是这种分层的方式:把"和 Store 打交道"这件脏活,彻底隔离在 broker 和 addon 里,上层只处理纯粹的业务语义。

几条核心的经验,记在这里:

  • WinRT 只在 C++ addon 里碰,addon 只做"异步转 JSON",业务语义一概不沾。
  • 标准化和状态机分两层,原始数据和业务状态别混在一起。
  • 网络失败时保留上次的好状态并标 stale,别把付费用户的权限搞掉。
  • 权益和状态解耦,功能代码只看 entitlements 数组。
  • 非 Store 环境走降级 broker,永远不让"不支持"变成一次崩溃。

如果你也在做 Electron 应用的 Store 商业化,希望这套分层能让你少踩几个坑。

本文分享的这套方案,正是我们在开发 HagiCode 的过程里,实际踩过坑、也实际优化出来的。如果你觉得它还有点价值,那说明我们的工程实力也还过得去------这么一来,HagiCode 本身,也值得你回头看一眼......

参考资料

总结

围绕"Electron 桌面应用如何接入 Microsoft Store 订阅与永久许可证",更稳妥的推进方式是先把关键配置、依赖边界和落地路径逐步跑通,再补齐优化细节。

当目标、步骤和验收点都明确之后,这类方案通常就能更顺畅地进入实际交付。

原文与版权说明

感谢您的阅读,如果您觉得本文有用,欢迎点赞、收藏和分享支持。

本内容采用人工智能辅助协作,最终内容由作者审核并确认。