惯性聚合 高效追踪和阅读你感兴趣的博客、新闻、科技资讯
阅读原文 在惯性聚合中打开

推荐订阅源

P
Proofpoint News Feed
V
V2EX
博客园_首页
让小产品的独立变现更简单 - ezindie.com
让小产品的独立变现更简单 - ezindie.com
Recent Announcements
Recent Announcements
博客园 - 司徒正美
Microsoft Security Blog
Microsoft Security Blog
K
KPMG report finds enterprise disconnect between AI and its ROI | CIO
Latest news
Latest news
Vercel News
Vercel News
The Register - Security
The Register - Security
T
The Exploit Database - CXSecurity.com
S
Schneier on Security
N
Netflix TechBlog - Medium
WordPress大学
WordPress大学
小众软件
小众软件
L
Lohrmann on Cybersecurity
GbyAI
GbyAI
P
Privacy & Cybersecurity Law Blog
T
Tor Project blog
AWS News Blog
AWS News Blog
美团技术团队
钛媒体:引领未来商业与生活新知
钛媒体:引领未来商业与生活新知
K
Kaspersky official blog
B
Blog RSS Feed
G
Google Developers Blog
量子位
大猫的无限游戏
大猫的无限游戏
Google DeepMind News
Google DeepMind News
Scott Helme
Scott Helme
奇客Solidot–传递最新科技情报
奇客Solidot–传递最新科技情报
I
Intezer
雷峰网
雷峰网
Martin Fowler
Martin Fowler
freeCodeCamp Programming Tutorials: Python, JavaScript, Git & More
Blog — PlanetScale
Blog — PlanetScale
IT之家
IT之家
F
Full Disclosure
Apple Machine Learning Research
Apple Machine Learning Research
博客园 - 【当耐特】
The Hacker News
The Hacker News
U
Unit 42
S
SegmentFault 最新的问题
I
InfoQ
aimingoo的专栏
aimingoo的专栏
Y
Y Combinator Blog
宝玉的分享
宝玉的分享
罗磊的独立博客
Spread Privacy
Spread Privacy
C
CERT Recently Published Vulnerability Notes

博客园_首页

Plist 二进制格式 Milvus 和 PGVector,哪个更好? OpenClaw 已过时?在 VS Code 中运行 Hermes Agent! 第30篇文章:一个大三计科生的自白 Manim如何在数学公式中完美显示中文? Docker 部署 RocketMQ 5 并发编程核心概念辨析 C#事务处理最佳实践:别再让“主表存了、明细丢了”的破事发生 CLI 是什么?为什么大厂突然集体卷命令行? 【从0到1构建一个ClaudeAgent】协作-自主Agent UIImageView 设置图片不生效的原因排查 最小二乘问题详解20:无先验约束下的增量式SFM自由网平差 痞子衡嵌入式:大话双核i.MXRT1180之XIP应用里借助MU实现可靠Flash IAP的方法 AI Chat 封装, SemanticKerne.AiProvider.Unified 已发布 Windows下右键编辑js文件无法打开记事本——在注册表中使用环境变量 在后台服务中使用 Scoped 服务,为什么总是报错? H200 安装驱动并使用sglang启动模型 wireshark 抓包Trap上报告警内容 我用 AI 辅助开发了一系列小工具(2):图片压缩工具 [A Primer On MC and CC] 2.1 Memory Consistency 1 - 指令重排序和 SC 模型 Oracle数据库SCN推进技术详解与实践指南 玩转控件:封装个带图片的Label控件 Claude Code 4.7 真正该升级的不是模型,而是你的工作流 前端小白一句话,AI 帮我做了个颜值拉满的桌面媒体播放器。当代码不再是门槛,一句话编程就是现实。 5. WorkBuddy: 小龙虾的灵魂三件套,让你的小龙虾不只是工具 SQLite 分片方案实战:三种分片策略的深度对比 告别简陋 UI!一款基于 Fluent Design 和基于 WinUI 的开源免费、现代化的 Avalonia UI 控件库 关于二进制排列组合枚举的总结 AI开发-python-LangGraph框架(3-27-LangGraph从零实现大模型智能决策工作流) ElasticSearch主分片和副本分片概念详解 【002】HTTPS 粗解:证书、TLS 握手与对后端配置的影响 Hermes Agent 一周暴涨五万 Star,但我劝你别急着追 明明连接的是Redis的DB0,为什么能查到DB3的数据? 【从0到1构建一个ClaudeAgent】协作-Agent团队 熟悉电子元器件之后,电子小白下一步该怎么走? MAF快速入门(23)通过C#类定义Skills .NET 高级开发 | 手写一个对象映射框架 FastAPI数据库ORM怎么选?我肝了三个Demo后,终于不再纠结了 mysqldump 参数拾遗:在遗忘与铭记之间 C# .NET 周刊|2026年3月5期 Claude code入门 - 陈彦斌 一文学习入门 ThingsBoard 开源物联网平台 GitHub 热门项目 | 2026年04月16日 如何为GIT设置全局勾子,为每次提交追加信息 Number.isFinite和isFinite与isNaN()和Number.isNaN的区别 PortSwigger SQL注入LAB2 推荐一个测试人必备的Skills,从功能到性能全搞定(附详细实操和安装下载方式) 筑基期:掌握Odoo基础核心知识点02(Odoo XML 开发方式详解) GLM模型这么火,咱们用vllm也咧一个呗! 深入理解 AbortController:从底层原理到跨语言设计哲学 字符串学习笔记 多租户系统框架的基础模块设计和分析设计 Apache SeaTunnel Zeta 为什么能做到“又快又稳”? AI开发-python-LangGraph框架(3-26-LangGraph基本概念及第一个简单样例) Vue 3 组件通信,别只会用 Props 和 Emits 了,这几个狠活儿你得看看 ElasticSearch7.X版本配置密码 用Manim实现动态交点计算--从一个动点问题说起 团结引擎+Addressable+Instant Game打包抖音小游戏 function call 实战:让 LLM 自动判断 pod 异常、调用日志工具并完成故障分析 bubseek —— 让 Agent 的足迹,变成团队的洞察 通过 C# 读取并导出 PDF 书签 如何用 GitHub Actions 实现 Steam 自动化发布 【从0到1构建一个ClaudeAgent】并发-后台任务 .NET 高级开发 | 定制 ASP.NET Core 框架 电子小白:什么是运算放大器(运放) zero2Agent:面向大厂面试的 Agent 工程教程,从概念到生产的完整学习路线 堆上的ORW HC32F460 USB CDC通信异常:非对齐访问异常排查 20260413-Hyperbridge 攻击事件:发生在默克尔山上的验证绕过 那些喊着AI 要淘汰你的人,正在靠你的焦虑赚大钱! 深度学习进阶(八)Swin Transformer 最小二乘问题详解19:带先验约束的增量式SFM优化与实现 SnapTranslate 3.0 正式发布:全局划词翻译 + 完整英语学习闭环,一站式搞定查词、记词、复习 工作的意义、工作的困难认知再思考 .NET + AI 进阶实战:基于类的技能开发 - 打造可治理的 Agent 能力模块 【从0到1构建一个ClaudeAgent】规划与协调-技能 上周热点回顾(4.6-4.12) 电子小白的工具三件套:面包板、杜邦线、万能板 单表五亿数据的查询优化 | Mysql、StarRocks 2. WorkBuddy:从“我是谁”到“帮我干活” C# 如何减少代码运行时间:7 个实战技巧 基于HelixToolkit.SharpDX 渲染3D模型 - 笺上知微 从零开始的双臂具身VLA起源及现阶段发展综述 - SkyXZ 记对 xonsh shell 的使用, 脚本编写, 迁移及调优 - pluvium27 受够了Vibe Coding的失控?换个起点,让AI事半功倍 从开始配置漏洞环境到漏洞复现流程 - 難しい 关于10年工作经验的程序员对OpenClaw的实战经验分享以及看法 - 虚无境 Any metadata 的内存布局 C# .NET 周刊|2026年3月2期 - InCerry 我帮你测过了,测试圈排名第二的 Skill 依然很牛逼 Skill Discovery | 无监督技能发现的经典工作总结 - MoonOut 上下文工程是什么?过时了么?一文讲明白! - 一枫说码 开了 TUN 模式还是直连?90% 的人都踩过这个坑 AScript扩展多种脚本语言 - rockey627 AI 学习笔记:Agent 的记忆机制 你能被装进一个文件里吗?——7 万人把同事"蒸馏"成了 AI - 我没有三颗心脏 Claude Code 通关手册(七):给 AI 装上技能包——Skills 完全指南 - 暮色之狐 在浏览器中快速编辑代码:VSCode Web 集成实践 - Newbe36524 蒸馏自己 skill?基于 Deepseek 的蒸馏器,丐版蒸馏方式,简单便捷 - To_Carpe_Diem Spring AI Aliababa和AgentScope,哪个更好? - 苏三说技术
Electron 桌面应用如何接入 Microsoft Store 订阅与永久许可证
Newbe36524 · 2026-06-16 · via 博客园_首页

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

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

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
}

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

这里有个设计上的取舍: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 的订阅,可能对应好几个权益(赞助者徽章、高级功能开关),未来说不定还要按档位区分。

所以中间多了一层 EntitlementEvaluator

evaluate(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(): 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 版本白白加载——能省的力气,总是要省的。

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 去调主进程:

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_ptr<PurchaseCompletion> 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,长度可能是 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 订阅与永久许可证”,更稳妥的推进方式是先把关键配置、依赖边界和落地路径逐步跑通,再补齐优化细节。

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

原文与版权说明

感谢您的阅读,如果您觉得本文有用,欢迎点赞、收藏和分享支持。
本内容采用人工智能辅助协作,最终内容由作者审核并确认。