Как интегрировать подписки и постоянные лицензии Microsoft Store в настольное приложение Electron
Как интегрировать подписки и постоянные лицензии Microsoft Store в настольное приложение Electron
Когда ваше Electron приложение попадает в Microsoft Store для продажи подписок и постоянных лицензий, как чисто интегрировать этот набор коммерческих API WinRT в бизнес-логику? Это похоже на старую мечту — в HagiCode Desktop мы наступали на грабли, вытирали пот, и в итоге выработали эту многоуровневую схему, которую записали, чтобы оставить ориентир для тех, кто придёт позже.
Предпосылки
HagiCode Desktop — это Electron приложение, распространяемое через Microsoft Store. В коммерческом плане есть всего два типа продуктов: Sponsor Plan (подписка спонсора, Store ID 9N0BTGWV23M1) с ежемесячным или ежегодным продлением, как отношения, требующие постоянного внимания; и TurboEngine (DLC с постоянной лицензией, Store ID 9NSD809W18Z6), разовая покупка, как старая книга на полке, которую давно не открывали, но она всё равно ваша.
Проблема в том, что среда выполнения Electron сама по себе не может напрямую вызывать коммерческие API Microsoft Store. Покупки в Store и запросы лицензий зависят от пространства имён WinRT Windows.Services.Store, и эти API можно использовать только в нативном коде. Но основной процесс Electron — это среда Node.js, и вы не можете import тип WinRT в него — как будто хотите удержать лунный свет в руке, а ладонь всегда пуста.
Более того, состояние коммерческих лицензий нельзя проверить один раз и успокоиться. Пользователи могут отменять, продлевать подписки, менять устройства через клиент Store, и функциональные переключатели в приложении должны соответствующим образом меняться. Если каждый раз ждать, пока пользователь нажмёт “обновить”, это плохой опыт; но если проверять слишком часто, можно столкнуться с ограничениями Store, и при проблемах с сетью активная подписка может быть помечена как “неактивная”, отключая функции платных пользователей — такое заставляет хотеть смеяться, чтобы скрыть слёзы.
Есть ещё один легко упускаемый момент: поведение разных каналов распространения различается. Версии не из Store (например, портативная) вообще не имеют среды выполнения Store, вызов StoreContext сразу завершится ошибкой. В таких случаях нельзя позволить приложению упасть, но и нельзя притворяться, что у пользователя есть подписка — нужно предоставить чёткое состояние “не поддерживается”. Ведь притворное владение грустнее честного признания.
Поэтому мы создали многоуровневую архитектуру. Позже эта схема стала двумя предложениями OpenSpec в HagiCode: desktop-subscription-entitlements (персистентность, стандартизация и деривация прав для подписок) и desktop-turboengine-msstore-license (покупка, обновление и инъекция DLC для постоянных лицензий TurboEngine). Рассмотрим по порядку.
О HagiCode
Схема, описанная в этой статье, основана на нашем опыте в проекте HagiCode. HagiCode — это проект ИИ-помощника для кодирования, охватывающий Web, Desktop, CLI и другие платформы. HagiCode Desktop — это настольная линейка продуктов, обсуждаемая в этой статье, полный исходный код можно посмотреть в репозитории HagiCode-org/site.
Многоуровневая архитектура — ключевой момент
Писать вызовы Store напрямую в основном процессе Electron — очень запутанно. Асинхронные объекты WinRT, модель потоков COM, передача дескрипторов окон — всё это смешивается с бизнес-логикой, и практически невозможно поддерживать. Наш подход — разделить всю цепочку на четыре уровня, каждый несёт свою ответственность:
Процесс рендеринга (React) ↕ IPC bridgeОсновной процесс Electron (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 и отправить их обратно в поток JavaScript через Napi::ThreadSafeFunction.
Средний уровень — это TypeScript StoreLicenseService. Он не заботится о WinRT, только о бизнес-семантике: обновление, повторы, кэширование, деривация прав, широковещательная рассылка состояния. Он общается с нижним уровнем через интерфейс StoreLicensePlatformBroker, у которого всего три метода: queryStatus(), purchase(), dispose().
Самый верхний уровень — SubscriptionService и TurboEngineLicenseService, которые представляют собой тонкую обёртку вокруг StoreLicenseService, связанную с конфигурацией конкретных продуктов (Store ID, имя продукта, имена прав).
Такое многоуровневое разделение имеет одно прямое преимущество: подписки и постоянные лицензии могут использовать один и тот же движок. StoreLicenseService — это параметризованный по типу снимка и именам прав универсальный класс. Чтобы добавить новый продукт, нужно просто написать конфигурацию StoreLicenseProductConfig, не нужно копировать и вставлять весь сервис. Если в будущем HagiCode захочет интегрировать StoreKit macOS или другие коммерческие каналы, теоретически нужно только сменить реализацию broker, а слой бизнес-логики не изменится ни одной строчкой — это, пожалуй, и есть мягкость многоуровневой архитектуры.
Стандартизация: очистка грязных данных от Store
Данные, возвращаемые WinRT, очень “сырые”. В StoreProductQueryResult вложены IVectorView и IMap, CollectionData.EndDate SKU — это тики DateTime Windows (начиная с 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 (продукты, которые уже есть у пользователя). Причина в том, что подписочные продукты могут появиться в списке ассоциированных, но пользователь их ещё не купил, или они уже давно в коллекции пользователя. Нужно сопоставить оба результата, чтобы точно определить “имеется или нет” — как смотреть на человека с двух сторон, чтобы не ошибиться.
Код для преобразования тиков в дату ISO стоит отметить:
const WINDOWS_EPOCH_OFFSET_MILLISECONDS = 11644473600000n;const HUNDRED_NANOSECONDS_PER_MILLISECOND = 10000n;
// ticks — единицы 100 наносекунд начиная с 1601, сначала конвертируем в миллисекунды, затем вычитаем разницу между эпохами Windows и Unixconst unixMilliseconds = ticks / HUNDRED_NANOSECONDS_PER_MILLISECOND - WINDOWS_EPOCH_OFFSET_MILLISECONDS;11644473600000 — это количество миллисекунд между 1601-01-01 и 1970-01-01. Это преобразование также выполняется в C++ addon (с использованием FileTimeToSystemTime), и результаты должны совпадать, иначе возникнет странное несоответствие типа “основной процесс видит сегодня, addon видит вчера” — время и отношения, когда расходятся, ничего нельзя понять.
Машина состояний: от “сырых данных” к “бизнес-состоянию”
После стандартизации нужен ещё один уровень абстракции. Бизнес-код не должен знать, что такое purchaseEligibility, ему важно только “активна ли подписка”. Функция deriveStatus в normalize.ts выполняет именно этот перевод:
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”, пользователи с разовой покупкой будут ошибочно помечены как неактивные, что слишком обидно. Эта деталь чётко указана в спецификации: постоянные лицензии остаются активными, даже если нет метаданных о сроке действия.
Обработка ошибок: не теряйте подписки при плохой сети
Store API при проблемах с сетью может возвращать ошибки или тайм-ауты. Если при каждой ошибке очищать состояние, права платных пользователей будут часто отключаться — это банальность, но она действительно случается. Стратегия HagiCode — “при ошибке сохранять последнее известное состояние, помечая как stale”.
Внутри StoreLicenseService.refresh есть цикл повтора (по умолчанию 3 раза с интервалом 350 мс), а также проверка “регрессии состояния”: если в прошлый раз было 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 — это как очередь: толпа никто не пройдёт.
Деривация прав: развязка состояния и функциональных переключателей
Состояние подписки отвечает на вопрос “активна ли подписка”, а функциональные переключатели интересуются “может ли пользователь использовать определённую функцию”. Они не обязательно соответствуют один к одному. Активная подписка может соответствовать нескольким правам (бейдж спонсора, переключатель премиум-функций), в будущем может потребоваться разделение по уровням.
Поэтому добавлен промежуточный слой 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-unavailable, а purchase — 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, который использует его для вызова IInitializeWithWindow::Initialize. Это необходимый шаг для Store API, чтобы в настольном приложении (не UWP) появлялось окно покупки, иначе у окна покупки нет владельца и поведение будет ненормальным — если человек без принадлежности, действия всегда нестабильны, так же и окно.
Процесс рендеринга вызывает основной процесс через bridge, экспортируемый через preload:
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. Если напрямую вызвать API Napi в обратном вызове, это приведёт к падению. Addon использует Napi::ThreadSafeFunction::BlockingCall для отправки результатов обратно в поток JavaScript:
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, пока поток JavaScript не обработает. В этом режиме поток обратного вызова не может быть самим потоком JavaScript, иначе это приведёт к взаимной блокировке. К счастью, обратные вызовы 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. Почему строка, а не число напрямую? Потому что точность number в JS всего 53 бита, 64-битный указатель потеряет точность. Эта ловушка трудно обнаружима, если не попасть в неё один раз — как некоторые вещи, не пережив один раз, не объяснишь.
В-четвёртых, изоляция состояния. Состояния подписок и постоянных лицензий должны храниться отдельно. Спецификация HagiCode чётко требует, чтобы персистентность TurboEngine не перезаписывала состояние sponsor. Два набора снимков разделены разными productKey (subscription и turboengine), чтобы обновление одного продукта не перезаписывало кэш другого. Каждый занимается своим, мир спокойнее.
В-пятых, после покупки необходимо обновить. После завершения покупки нужно обновить ещё раз, чтобы транслировать. В completePurchase для случаев succeeded и already-purchased оба запускают refresh('purchase'), потому что результат покупки Store говорит только о состоянии транзакции, а не о деталях текущей лицензии. Состояние лицензии нужно запросить заново — между обещанием и реальностью всегда есть подтверждение.
Заключение
Эта реализация работает уже некоторое время, в целом стабильно. Стоит借鉴 не какой-то конкретный трюк, а именно такой подход к многоуровневой архитектуре: полностью изолировать “общение с Store” в broker и addon, а верхний уровень обрабатывает только чистую бизнес-семантику.
Несколько ключевых моментов:
- WinRT только в C++ addon, addon только делает “асинхронное в JSON”, бизнес-семантику не касается.
- Стандартизация и машина состояний — два уровня, не смешивайте сырые данные и бизнес-состояния.
- При ошибке сети сохраняйте последнее хорошее состояние и помечайте как stale, не отключайте права платных пользователей.
- Права и состояние развязаны, функциональный код смотрит только массив
entitlements. - В среде не из Store используйте понижающий broker, никогда не позволяйте “не поддерживается” стать падением.
Если вы тоже делаете коммерциализацию Store для Electron приложения, надеюсь, эта многоуровневая схема поможет вам избежать нескольких ловушек.
Схема, описанная в этой статье, — это то, что мы на практике прошли через ловушки и оптимизировали при разработке HagiCode. Если вы считаете, что она имеет ценность, это значит, что наши инженерные возможности ещё неплохи — и в таком случае, HagiCode сам по себе заслуживает того, чтобы вы на него взглянули…
Дополнительные материалы
- Официальный сайт HagiCode
- GitHub репозиторий HagiCode-org/site
- Пространство имён Windows.Services.Store - документация WinRT
- Документация Electron getNativeWindowHandle
- Документация Node-API ThreadSafeFunction
Заключение
Вокруг “как интегрировать подписки и постоянные лицензии Microsoft Store в настольное приложение Electron”, более надёжный способ продвижения — сначала пошагово проработать ключевые конфигурации, границы зависимостей и путь внедрения, затем дополнить оптимизационными деталями.
Когда цели, шаги и точки приёмки ясны, такие схемы обычно более плавно переходят в фактическую доставку.
开始使用 HagiCode
一次安装,几分钟上手
HagiCode for Windows 在 Microsoft Store 免费提供。打开商店即可安装并保持更新;也可以先对比各版本与定价,再决定从哪个渠道开始。