Comment intégrer les abonnements et licences perpétuelles du Microsoft Store dans une application de bureau Electron
Comment intégrer les abonnements et licences perpétuelles du Microsoft Store dans une application de bureau Electron
Lorsque votre application Electron doit être publiée sur le Microsoft Store pour vendre des abonnements et des licences perpétuelles, comment intégrer proprement ces API commerciales de WinRT dans votre métier ? C’est une histoire qui nous tient à cœur, nous avons rencontré des obstacles et versé de la sueur dans HagiCode Desktop, et finalement nous avons élaboré cette solution en couches. Nous l’écrivons ici, comme une borne pour ceux qui viendront après.
Contexte
HagiCode Desktop est une application Electron, distribuée via le Microsoft Store. En termes de commercialisation, il n’y a en réalité que deux types de produits : l’un est le Sponsor Plan (abonnement de sponsor, Store ID 9N0BTGWV23M1), renouvelable mensuellement ou annuellement, comme une relation qui a besoin d’être constamment arrosée ; l’autre est TurboEngine (DLC à licence perpétuelle, Store ID 9NSD809W18Z6), achat unique, comme ce vieux livre sur l’étagère qu’on n’a plus jamais ouvert, mais qui reste à nous.
Le problème est que le runtime Electron lui-même n’a pas la capacité d’appeler directement les API commerciales du Microsoft Store. L’achat dans le Store, l’interrogation des licences, tout dépend de l’espace de noms Windows.Services.Store de WinRT, ces API ne peuvent être utilisées que dans du code natif. Or le processus principal d’Electron est justement un environnement Node.js, vous ne pouvez pas y import un type WinRT — comme si vous vouliez saisir la lumière de la lune, mais votre main reste toujours vide.
Plus embêtant encore, l’état de commercialisation n’est pas quelque chose dont on peut se rassurer après une seule requête. L’utilisateur peut se désabonner, renouveler, changer d’appareil dans le client Store, et les commutateurs de fonctionnalités de l’application doivent suivre. Si à chaque fois il faut attendre que l’utilisateur clique lui-même sur “actualiser”, l’expérience est naturellement moche ; mais si on interroge trop fréquemment, on va se heurter au rate limiting du Store, et un simple saut de réseau peut faire qu’un abonnement parfaitement valide soit détecté comme “non abonné”, coupant ainsi les fonctionnalités des utilisateurs payants — ce genre de chose, une fois fait, fait envie de rire pour masquer les larmes qui tombent.
Il y a aussi un coin facilement négligé : les comportements des différents canaux de distribution ne sont pas les mêmes. Les versions non-Store (comme la version portable) n’ont tout simplement pas le runtime Store, appeler StoreContext échouera directement. Dans ce cas, on ne peut pas laisser l’application planter, ni faire semblant que l’utilisateur a un abonnement, il faut donner un état clair de “non supporté”. Après tout, faire semblant de posséder est finalement plus triste que d’admettre honnêtement.
Pour toutes ces raisons, nous avons conçu une architecture en couches. Par la suite, cette solution s’est concrétisée en deux propositions OpenSpec de HagiCode : desktop-subscription-entitlements (persistance, standardisation, dérivation des droits pour les abonnements) et desktop-turboengine-msstore-license (achat, actualisation, injection de DLC pour la licence perpétuelle TurboEngine). Nous allons l’expliquer progressivement.
À propos de HagiCode
La solution partagée dans cet article provient de notre pratique dans le projet HagiCode. HagiCode est un projet d’assistant de code IA, couvrant plusieurs terminaux comme Web, Desktop, CLI. La ligne de produits de bureau HagiCode Desktop est l’objet de discussion de cet article, le code source complet peut être consulté sur HagiCode-org/site.
La stratification est la clé
Écrire directement les appels Store dans le processus principal d’Electron serait très désordonné. Les objets asynchrones de WinRT, le modèle de threads COM, la transmission des handles de fenêtre, toutes ces choses mélangées à la logique métier, rendent presque impossible la maintenance. Notre approche consiste à découper toute la chaîne en quatre couches, chaque couche ne portant qu’une seule responsabilité :
Processus de rendu (React) ↕ bridge IPCProcessus principal Electron (TypeScript) ↕ interface brokerAddon Node natif (C++) ↕ WinRTWindows.Services.StoreTout en bas se trouve un addon natif C++, nommé hagicode_store_purchase_addon.node. Il n’expose en réalité que deux méthodes : requestPurchase(storeId, windowHandle) et queryStoreStatus(storeId, productName, productKinds). Elles correspondent respectivement à RequestPurchaseAsync et GetAssociatedStoreProductsAsync / GetUserCollectionAsync de WinRT. Tout le travail de l’addon consiste simplement à convertir le résultat asynchrone WinRT en JSON, puis à le renvoyer au thread JavaScript via Napi::ThreadSafeFunction.
Au milieu se trouve un StoreLicenseService en TypeScript. Il ne se soucie pas de WinRT, seulement de la sémantique métier : actualisation, réessai, cache, dérivation des droits, diffusion d’état. Il communique avec la couche inférieure via une interface StoreLicensePlatformBroker, cette interface n’a que trois méthodes : queryStatus(), purchase(), dispose().
Tout en haut se trouvent SubscriptionService et TurboEngineLicenseService, qui ne sont en fait qu’une fine couche d’encapsulation autour de StoreLicenseService, chacun lié à la configuration d’un produit spécifique (Store ID, nom du produit, noms des droits).
Cette stratification apporte un avantage direct : les abonnements et les licences perpétuelles peuvent partager le même moteur. StoreLicenseService est une classe générique, paramétrée par le type de snapshot et les noms des droits. Ajouter un nouveau produit ne nécessite que d’écrire un StoreLicenseProductConfig, sans avoir à copier-coller tout le service. Si HagiCode devait à l’avenir intégrer StoreKit de macOS ou d’autres canaux de commercialisation, théoriquement il suffirait de changer l’implémentation du broker, sans toucher une seule ligne de la couche métier — c’est probablement là la douceur de la stratification.
Standardisation : nettoyer les données sales du Store
Les données renvoyées par WinRT sont très “brutes”. StoreProductQueryResult contient des IVectorView, des IMap, le CollectionData.EndDate des SKU est en ticks DateTime Windows (point de départ en 1601, unité de 100 nanosecondes), les codes d’erreur sont des HRESULT. Si on balance directement ça au processus de rendu, le code frontend va probablement s’effondrer.
C’est pourquoi la couche broker fait une standardisation, aplatissant les objets WinRT bruts en 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;}Il y a un détail qui vaut la peine d’être mentionné : l’interrogation utilise en fait deux appels Store. L’un est GetAssociatedStoreProductsAsync (produits associés à l’application actuelle), l’autre est GetUserCollectionAsync (produits que l’utilisateur possède déjà). La raison est simple : les produits d’abonnement peuvent apparaître dans la liste associée mais l’utilisateur ne les a pas encore achetés, ou ils peuvent déjà être dans la collection de l’utilisateur. C’est en croisant les deux résultats qu’on peut juger précisément “possède ou non” — c’est comme regarder une personne à distance, sous deux angles, pour éviter de se tromper.
Le code de conversion des ticks en date ISO mérite attention :
const WINDOWS_EPOCH_OFFSET_MILLISECONDS = 11644473600000n;const HUNDRED_NANOSECONDS_PER_MILLISECOND = 10000n;
// ticks sont en unités de 100 nanosecondes depuis 1601, d'abord convertir en millisecondes, puis soustraire l'écart d'époque Windows/Unixconst unixMilliseconds = ticks / HUNDRED_NANOSECONDS_PER_MILLISECOND - WINDOWS_EPOCH_OFFSET_MILLISECONDS;11644473600000 est le nombre de millisecondes entre 1601-01-01 et 1970-01-01. Cette conversion est aussi faite dans l’addon C++ (avec FileTimeToSystemTime), les résultats des deux côtés doivent être cohérents, sinon on aura des décalages bizarres comme “le processus principal voit aujourd’hui, l’addon voit hier” — le temps et les sentiments, une fois décalés, rien n’est plus clair.
Machine à états : des “données brutes” à “l’état métier”
Après la standardisation, il faut encore une couche d’abstraction. Le code métier n’a pas vraiment besoin de savoir ce qu’est purchaseEligibility, il se soucie seulement de “l’abonnement est-il valide ou non”. La fonction deriveStatus dans normalize.ts fait justement cette traduction :
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'; } // ...autres branches : inactive / canceled / grace-period / pending}L’état métier final a sept valeurs : active, inactive, expired, canceled, grace-period, pending, unknown. Le processus de rendu ne regarde que ce champ, sans toucher aux données brutes.
Il y a un compromis de conception ici : le jugement de active ne regarde pas si expirationDate existe. La raison est simple — les licences perpétuelles (TurboEngine) n’ont tout simplement pas de date d’expiration, ce que le Store renvoie license.isActive à true suffit. Si on exigeait “avoir une date d’expiration pour être actif”, on jugerait par erreur les utilisateurs à achat unique comme non abonnés, ce qui serait blessant. Ce détail est clairement écrit dans la spec : les licences perpétuelles restent actives même sans métadonnées d’expiration.
Tolérance aux pannes : ne pas perdre l’abonnement quand le réseau est mauvais
L’API Store renvoie des erreurs ou des timeouts quand le réseau tremble. Si chaque effacement efface l’état, les autorisations des utilisateurs payants vont fréquemment tomber — c’est une évidence, mais ça arrive vraiment. La stratégie de HagiCode est “garder le dernier état connu en cas d’échec, le marquer comme stale”.
Dans StoreLicenseService.refresh, il y a une boucle de réessai (par défaut 3 fois, intervalle 350ms), et aussi une détection de “régression d’état” : si la dernière fois c’était active, et que cette fois ce n’est pas active, on considère ça comme une erreur temporaire à réessayer, plutôt que d’accepter directement ce résultat dégradé.
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;}Ce n’est que lorsque tous les réessais échouent qu’on utilise createStaleSnapshot pour marquer le dernier bon état comme stale et le renvoyer, en attachant un diagnostic store-refresh-failed. Le processus de rendu peut décider lui-même s’il faut désactiver les fonctionnalités en état stale — la pratique habituelle est de continuer à autoriser, donnant à l’utilisateur une marge de manœuvre, après tout personne ne veut se retrouver sans utiliser ce qu’il a acheté le jour où le réseau est mauvais.
Un autre détail est la déduplication de refreshInFlight. Si une actualisation est déjà en cours, le nouvel appel de refresh réutilisera le même Promise, évitant que les requêtes concurrentes ne fassent exploser le Store — c’est la même logique que faire la queue, s’entasser empêche tout le monde de passer.
Dérivation des droits : découplage de l’état et des commutateurs de fonctionnalités
L’état d’abonnement répond à “l’abonnement est-il valide”, mais les commutateurs de fonctionnalités se soucient de “l’utilisateur peut-il utiliser une fonctionnalité”. Ces deux ne sont pas en correspondance un-à-un. Un abonnement actif peut correspondre à plusieurs droits (badge de sponsor, commutateur de fonctionnalités premium), à l’avenir on pourrait même distinguer par paliers.
C’est pourquoi il y a une couche intermédiaire EntitlementEvaluator :
evaluate(snapshot: TSnapshot): TEntitlement[] { if (snapshot.availability !== 'supported' || snapshot.status !== 'active') { return []; } return [...this.activeEntitlements];}Dans la configuration du produit d’abonnement, on déclare quels droits il accorde lorsqu’il est activé :
export const subscriptionEntitlementNames = [ 'sponsorBadge', 'premiumFeatureGate',] as const;Ainsi, le code des fonctionnalités ne dépend que du tableau entitlements, sans lire directement status. À l’avenir, pour ajouter des paliers ou diviser les droits, il suffit de modifier la configuration et l’évaluateur, sans toucher aux consommateurs. Ce découplage est particulièrement important dans des projets comme HagiCode avec plusieurs lignes de produits — les abonnements et les licences perpétuelles partagent le même modèle de droits, le frontend n’a qu’à interroger un tableau, le monde devient tout de suite plus clair.
Dégradation à l’exécution : que faire sans Store
Les versions distribuées hors Store (version portable, environnement de développement) qui appellent l’addon vont échouer. HagiCode utilise MicrosoftStoreSubscriptionBroker pour l’initialisation différée et la dégradation :
private async initializeBroker(): Promise<StoreLicensePlatformBroker> { try { return this.setBroker( await this.adapterFactory(this.windowHandle, this.productConfig) ); } catch (error) { // Si le runtime Store n'est pas trouvé, on dégrade vers un broker qui "ne supporte rien" return this.setBroker(new UnavailableSubscriptionPlatformBroker(error)); }}UnavailableSubscriptionPlatformBroker implémente la même interface, seulement son queryStatus renvoie toujours store-unavailable, et purchase renvoie toujours not-supported. Le code de la couche supérieure n’est aucunement affecté, seulement l’état devient “non supporté”, le processus de rendu affiche en conséquence un guide “veuillez obtenir via le Microsoft Store”.
Cette conception permet à l’ensemble du module de commercialisation de fonctionner en toute sécurité dans n’importe quel canal de distribution, sans planter faute de runtime Store. Si vous développez aussi une application Electron à canaux multiples, ce point vaut particulièrement la peine d’être copié — ne laissez pas “environnement non supporté” devenir un crash, après tout, admettre certaines choses est plus digne.
Flux de démarrage et canaux IPC
Au démarrage de l’application, main.ts décide s’il faut initialiser le service d’abonnement en fonction du paramètre --desktop-subscription-enabled=1. Ce paramètre n’est inclus que dans la commande de démarrage de la version Store, évitant que la version non Store ne charge inutilement — l’énergie qu’on peut économiser, il faut l’économiser.
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 vient de mainWindow.getNativeWindowHandle(), ce Buffer sera analysé en bigint et passé à l’addon natif, l’addon l’utilisera ensuite pour appeler IInitializeWithWindow::Initialize. C’est une étape nécessaire pour que l’API Store puisse faire apparaître la fenêtre d’achat dans les applications de bureau (non UWP), sinon la fenêtre d’achat n’a pas de propriétaire, le comportement sera anormal — une personne sans appartenance, ses actes sont toujours flottants, les fenêtres aussi.
Le processus de rendu appelle le processus principal via le bridge exposé par 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); },};Les changements d’état sont diffusés à toutes les fenêtres via broadcastSnapshotChanged. Après l’achat, completePurchase déclenche un refresh('purchase'), le nouvel état est automatiquement diffusé, et l’UI d’abonnement du processus de rendu se met à jour en temps réel.
De plus, il y a un setInterval dans main.ts qui synchronise silencieusement en arrière-plan (subscriptionService?.refresh('scheduled')). Cela permet à l’application, pendant qu’elle est ouverte, de détecter les renouvellements et désabonnements que l’utilisateur fait discrètement dans le client Store. La fréquence ne peut bien sûr pas être trop élevée (le Store a un rate limiting), le code utilise un intervalle de l’ordre de la minute — ni trop loin, ni trop près, juste au bon moment.
Quelques pièges faciles à éviter
Premièrement, la sécurité des threads de l’addon natif. Une fois l’opération asynchrone WinRT terminée, le callback n’est pas sur le thread JavaScript. Si on appelle directement l’API Napi dans le callback, ça va planter. L’addon utilise Napi::ThreadSafeFunction::BlockingCall pour envoyer le résultat au thread 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 bloque le thread de callback WinRT jusqu’à ce que le thread JS ait fini de traiter. Dans ce mode, le thread de callback ne peut pas être le thread JS lui-même, sinon c’est un interblocage. Heureusement, le callback Completed de WinRT est généralement sur STA ou sur le pool de threads, ce qui satisfait cette condition.
Deuxièmement, l’initialisation COM. Le thread principal d’Electron a peut-être déjà initialisé COM. L’addon entoure winrt::init_apartment d’un try-catch, en cas d’échec on ignore :
try { winrt::init_apartment(winrt::apartment_type::single_threaded);} catch (...) { // Electron a peut-être déjà initialisé COM pour ce thread, on ignore}Si on ne gère pas ça, l’initialisation répétée va lancer une exception, et le chargement de l’addon échouera. Certaines erreurs, les ignorer est la bonne chose à faire.
Troisièmement, la précision du handle de fenêtre. getNativeWindowHandle() renvoie un Buffer, dont la longueur peut être 4 (32 bits) ou 8 (64 bits). Puis dans l’addon il est formaté en une chaîne hexadécimale commençant par 0x, et le côté C++ utilise std::stoull pour l’analyser en HWND. Pourquoi une chaîne au lieu de passer directement un nombre ? Parce que la précision du nombre JS n’est que de 53 bits, les pointeurs 64 bits vont perdre de la précision. Ce piège, sans y tomber une fois, est difficile à découvrir — comme certaines choses, sans les vivre une fois, on ne peut pas les expliquer clairement.
Quatrièmement, l’isolement des états. Les états des abonnements et des licences perpétuelles doivent être stockés séparément. La spec de HagiCode exige explicitement que la persistance de TurboEngine ne doive pas écraser l’état du sponsor. Les deux snapshots sont séparés par différents productKey (subscription et turboengine), évitant que l’actualisation d’un produit n’écrase le cache de l’autre. Chacun gère ses affaires, le monde est paisible.
Cinquièmement, il faut actualiser après l’achat. Après l’achat terminé, il faut encore actualiser une fois pour diffuser. Dans completePurchase, on déclenche refresh('purchase') pour les deux cas succeeded et already-purchased, parce que le résultat d’achat du Store ne vous dit que l’état de la transaction, pas les détails de la licence actuelle. L’état de la licence doit être interrogé à nouveau — entre la promesse et la réalité, il y a toujours une confirmation.
Conclusion
Cette implémentation tourne depuis un moment, dans l’ensemble elle est assez stable. Ce qui vaut vraiment la peine d’être emprunté, ce n’est pas une petite technique spécifique, mais cette façon de stratifier : isoler complètement le “sale travail de parler au Store” dans le broker et l’addon, la couche supérieure ne traitant que la sémantique métier pure.
Quelques expériences clés, notées ici :
- WinRT n’est touché que dans l’addon C++, l’addon ne fait que “asynchrone vers JSON”, ne touche pas à la sémantique métier.
- Standardisation et machine à états sont deux couches, ne mélangez pas les données brutes et l’état métier.
- En cas d’échec réseau, gardez le dernier bon état et marquez-le stale, ne faites pas tomber les autorisations des utilisateurs payants.
- Découplez les droits et l’état, le code des fonctionnalités ne regarde que le tableau
entitlements. - Environnement non Store utilise un broker de dégradation, ne laissez jamais “non supporté” devenir un crash.
Si vous développez aussi la commercialisation Store d’une application Electron, j’espère que cette stratification vous fera éviter quelques pièges.
La solution partagée dans cet article est précisément celle que nous avons rencontrée et optimisée réellement dans le développement de HagiCode. Si vous pensez qu’elle a encore de la valeur, cela montre que nos capacités d’ingénierie sont passables — ainsi, HagiCode lui-même mérite aussi que vous y jetiez un coup d’œil…
Références
- Site officiel de HagiCode
- Dépôt GitHub HagiCode-org/site
- Espace de noms Windows.Services.Store - Documentation WinRT
- Documentation Electron getNativeWindowHandle
- Documentation Node-API ThreadSafeFunction
Résumé
Autour de “Comment intégrer les abonnements et licences perpétuelles du Microsoft Store dans une application de bureau Electron”, la façon de progresser plus sûrement est d’abord faire fonctionner progressivement les configurations clés, les limites de dépendance et le chemin de déploiement, puis compléter les détails d’optimisation.
Une fois les objectifs, les étapes et les points de validation clarifiés, ce type de solution peut généralement entrer plus facilement dans la livraison réelle.
开始使用 HagiCode
一次安装,几分钟上手
HagiCode for Windows 在 Microsoft Store 免费提供。打开商店即可安装并保持更新;也可以先对比各版本与定价,再决定从哪个渠道开始。