Skip to content
← Tous les articles
· Delta1 Labs KeyrightLicences.NETAnalyse approfondie

Licencier une application .NET empaquetée en MSIX : la virtualisation d'AppData, la désinstallation qui supprime votre cache de bail, et où une licence Keyright devrait vraiment résider

MSIX est désormais la façon dont les applications de bureau Windows s'installent — via winget, via le déploiement en entreprise, via le Store — et il change discrètement trois choses dont dépend un SDK de licences. Les écritures dans %LocalAppData% sont redirigées vers un dossier propre au paquet que l'application ne voit jamais dans un chemin. Désinstaller le paquet supprime ce dossier, de sorte que le bail d'activation en cache, l'horloge d'essai locale et l'état de détection de manipulation de l'horloge disparaissent tandis que le poste reste lié sur le service de licences. Et le répertoire d'installation est en lecture seule, ce qui est exactement là où une licence de site embarquée devrait se trouver. Ce billet passe en revue ce que le SDK Keyright écrit et où cela atterrit sous une application empaquetée, pourquoi l'empreinte de la machine survit quand le cache ne survit pas, ce qu'un cycle de désinstallation/réinstallation fait au décompte des postes (rien, grâce à l'activation idempotente — sauf si le client avait l'intention de changer de machine), comment détecter l'identité du paquet et donner au SDK des chemins explicites, comment livrer une licence à l'intérieur du paquet, quand désactiver la virtualisation des écritures dans le manifeste, et la boucle de test qui le prouve.

Demandez à un développeur .NET de bureau où son application conserve ses paramètres et il répondra %LocalAppData%. Demandez ce qu’il advient de ces fichiers quand l’application est désinstallée et il répondra « rien, ce sont les données de l’utilisateur ». Les deux réponses sont justes pour un MSI. Les deux sont fausses pour un paquet MSIX — et MSIX est désormais la façon dont les applications de bureau arrivent sur les machines Windows, que ce soit via winget install, un déploiement Intune ou le Store.

Cela compte pour les licences plus que pour presque tout ce qu’une application écrit, parce qu’un SDK de licences conserve exactement le genre de petits fichiers importants dont vous devez comprendre l’emplacement et la durée de vie : un bail d’activation signé qui permet à l’application de s’exécuter hors ligne, un horodatage local qui empêche de redémarrer un essai, une trace de la dernière fois où l’horloge a été vue. Ce billet examine ce que le SDK Keyright écrit, où ces octets atterrissent réellement à l’intérieur d’une application empaquetée, ce qu’une désinstallation leur fait, et comment rendre tout cela explicite au lieu de laisser un filtre du système de fichiers décider.

Ce que le SDK écrit, et où il croit l’écrire

Un client Keyright est initialisé une fois avec le nom du produit et la clé publique embarquée, et à partir de là il conserve jusqu’à trois fichiers pour ce produit :

{LocalApplicationData}\Keyright\{product}\lease.json     signed activation lease (cached after ActivateAsync)
{LocalApplicationData}\Keyright\{product}\state.json     first-seen / last-seen timestamps (trial clock, clock-tamper)
{LocalApplicationData}\Keyright\{product}\license.json   default location for an offline license file

Il y en a un quatrième, {LocalApplicationData}\Keyright\machine.id, qui n’existe qu’en dernier recours : l’empreinte de la machine est ancrée sur le GUID de machine de l’OS, et l’identifiant aléatoire persistant n’est écrit que si ce GUID ne peut pas être lu du tout. Sur une machine Windows, il n’existe normalement jamais.

Chacun de ces chemins provient de Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), et cet appel renvoie la même chose à l’intérieur d’un paquet MSIX qu’à l’extérieur : C:\Users\dana\AppData\Local. Le SDK, votre application et tout ce qui journalise client.LeaseCachePath rapporteront tous ce chemin. Ce n’est simplement pas là que se trouve le fichier.

Où cela va réellement

Une application de bureau empaquetée avec la capacité runFullTrust s’exécute dans un conteneur léger, et l’une des choses que fait ce conteneur est la virtualisation des écritures du système de fichiers pour les dossiers AppData de l’utilisateur. Quand le processus écrit dans %LocalAppData%\Keyright\acme-studio\lease.json, un pilote de filtre redirige l’écriture vers

%LocalAppData%\Packages\<PackageFamilyName>\LocalCache\Local\Keyright\acme-studio\lease.json

La redirection est invisible pour le processus. File.WriteAllText réussit, File.Exists renvoie true, Directory.EnumerateFiles liste le fichier — tout cela contre le chemin virtuel, que le conteneur présente comme une vue fusionnée : les lectures consultent d’abord le LocalCache du paquet et retombent sur le vrai %LocalAppData% si le fichier n’y est pas, et les écritures atterrissent toujours dans LocalCache (en copie sur écriture si le fichier réel existait). Ouvrez Explorer sur C:\Users\dana\AppData\Local\Keyright et il n’y a rien dedans ; les fichiers sont deux niveaux plus bas, dans Packages.

Une application empaquetée et ses fichiers de licenceAcme Studio (MSIX, runFullTrust)GetFolderPath(LocalApplicationData)→ C:\Users\dana\AppData\Localécrit Keyright\acme-studio\lease.jsonécrit …\state.json, lit …\license.jsonoù les octets atterrissent (virtualisé)%LocalAppData%\Packages\Acme.Studio_8wekyb3d8bbwe\ LocalCache\Local\Keyright\acme-studio\lease.json LocalCache\Local\Keyright\acme-studio\state.jsonsupprimé à la désinstallation du paquet · conservé aux mises à jourle dossier réel (où écrivent les installations non empaquetées)C:\Users\dana\AppData\Local\Keyright\acme-studio\repli deslecturesancre de l'empreinte — hors du paquet, non virtualiséeHKLM\SOFTWARE\Microsoft\Cryptography\MachineGuid → machineId (identique avant et après désinstallation)La virtualisation des écritures déplace le cache ; elle ne déplace pas la machine. Le poste est lié à la machine.Le cache peut donc disparaître, et le poste est toujours le même poste quand l'application revient.

Trois conséquences en découlent, et elles sont toute la raison d’être de ce billet.

Le bail survit aux mises à jour du paquet. LocalCache appartient à la famille du paquet, pas à sa version. Quand winget upgrade installe la 2.4 par-dessus la 2.3, le dossier reste intact, le bail en cache est toujours là, et l’application démarre sous licence. Appeler ActivateAsync au démarrage — le schéma que le SDK recommande de toute façon — le rafraîchit silencieusement quand la machine est en ligne.

Le bail ne survit pas à la désinstallation. Supprimer le paquet supprime %LocalAppData%\Packages\<family> avec tout ce qu’il contient. lease.json, state.json, un license.json que l’utilisateur avait collé à l’emplacement par défaut : disparus. C’est le comportement qu’un MSI n’a jamais eu, et c’est celui qui surprend.

L’empreinte de la machine n’est affectée ni par l’un ni par l’autre. L’ancre sur laquelle l’empreinte est construite est HKLM\SOFTWARE\Microsoft\Cryptography\MachineGuid, qui appartient à l’installation de Windows, se trouve en dehors du paquet, et est lue — pas écrite — par le SDK. Les lectures de HKLM ne sont pas virtualisées ; le registre virtuel du paquet ne superpose que les clés que le paquet transporte lui-même. Le nom de la machine et la version de l’OS, les deux signaux plus faibles, ne sont pas non plus un état du paquet. L’empreinte que l’application présente au service de licences est donc octet pour octet la même avant une désinstallation et après une réinstallation.

Ce que la désinstallation et la réinstallation font au poste

Mettez les deux dernières conséquences ensemble et le décompte des postes en découle.

Le poste n’est pas un fichier sur la machine. C’est un enregistrement sur le service de licences qui dit cette licence, ce machineId. Quand le client désinstalle le paquet, rien ne dit quoi que ce soit au service — le cache du bail est supprimé localement, le poste reste lié. Quand il réinstalle et saisit à nouveau sa clé, l’application prend l’empreinte de la même machine, envoie le même machineId, et le service fait ce qu’il fait pour toute machine qu’il a déjà vue : il rafraîchit l’activation existante et renvoie un nouveau bail. La réactivation sur la même machine est idempotente. Aucun second poste n’est consommé, le tableau de bord affiche toujours un seul appareil, et le client n’a jamais su qu’il y avait une question.

Une machine, un poste — quoi que fasse le paquet1 · installer + activermachineId KRM1:abc…lease.json en cachepostes 1 / 32 · winget upgradeLocalCache conservébail toujours validepostes 1 / 33 · désinstallationLocalCache supprimébail disparu en localpostes 1 / 3 (encore)4a · réinstallé, même PCmachineId KRM1:abc…idempotent → rafraîchipostes 1 / 34b · nouveau portablemachineId KRM1:9f0…nouveau poste → 2 / 3ancien poste encore liéLa désinstallation n'a rien dit au service, donc l'ancienposte n'est réutilisé que si la même machine revient.Pour un déménagement : DeactivateAsync dans l'app avant de désinstaller,ou libérez la machine morte depuis le panneau Devices de la licence.

Le cas qui tourne bel et bien mal est celui où le client a désinstallé parce qu’il quittait la machine. Il met un portable au rebut, en configure un nouveau, installe le paquet, saisit la clé — et le nouveau portable est une nouvelle empreinte. Si la licence n’avait qu’un poste, le service répond LicenseStatus.SeatLimit : tous les postes sont utilisés, libérez-en un d’abord. L’ancien portable ne peut pas le libérer ; l’application n’y est plus.

Deux choses gèrent cela, et aucune n’est propre à MSIX, mais MSIX leur donne plus d’importance parce que désinstaller est tellement plus propre qu’avant :

  • Donnez à l’utilisateur une désactivation dans l’application. DeactivateAsync(key) libère le poste de cette machine sur le service et efface le bail local. Placez-la là où un utilisateur sur le point de mettre une machine au rebut la trouvera — une entrée « Déplacer cette licence vers un autre ordinateur » à côté de « Saisir la clé de licence » — et dites ce qu’elle fait. Une application empaquetée n’a aucun crochet de désinstallation depuis lequel exécuter du code, donc la désactivation doit se faire avant la désinstallation, ce qui veut dire que l’interface doit y inviter.
  • Libérez les machines mortes côté éditeur. Le panneau Devices de la licence dans le tableau de bord Keyright liste chaque machine liée avec une action Free seat, et POST /admin/licenses/{id}/free-seat fait la même chose depuis un script de support. La prochaine activation du client prend le poste libéré.

Ce qu’il ne faut pas faire, c’est tenter de reconstituer une désactivation à partir de la désinstallation. Il n’y a pas de script de désinstallation au niveau du paquet dans MSIX, par conception, et un appel de licence exécuté pendant la désinstallation serait la requête réseau la moins fiable de votre produit.

Sachez que vous êtes empaqueté, et dites où vont les fichiers

S’appuyer sur la redirection fonctionne jusqu’au jour où un ingénieur support demande lease.json et passe vingt minutes dans le mauvais dossier. L’approche plus propre consiste à détecter l’identité du paquet au démarrage et à donner au SDK des chemins explicites à l’intérieur du dossier de données propre au paquet, pour que le chemin que l’application journalise soit celui où se trouvent les octets.

L’identité du paquet vient de GetCurrentPackageFullName dans kernel32. En dehors d’un paquet, elle renvoie APPMODEL_ERROR_NO_PACKAGE (15700) ; à l’intérieur, elle renvoie le nom complet du paquet.

using System.Runtime.InteropServices;
using System.Text;

public static class PackageContext
{
    private const int APPMODEL_ERROR_NO_PACKAGE = 15700;

    [DllImport("kernel32.dll", CharSet = CharSet.Unicode, ExactSpelling = true)]
    private static extern int GetCurrentPackageFullName(ref int length, StringBuilder? fullName);

    /// <summary>The package full name when running packaged; null when unpackaged or not on Windows.</summary>
    public static string? FullName { get; } = Resolve();

    public static bool IsPackaged => FullName is not null;

    private static string? Resolve()
    {
        if (!OperatingSystem.IsWindowsVersionAtLeast(10)) return null;
        int length = 0;
        int rc = GetCurrentPackageFullName(ref length, null);     // ERROR_INSUFFICIENT_BUFFER when packaged
        if (rc == APPMODEL_ERROR_NO_PACKAGE) return null;
        var sb = new StringBuilder(length);
        rc = GetCurrentPackageFullName(ref length, sb);
        return rc == 0 ? sb.ToString() : null;
    }
}

Avec cela, le client se construit selon l’une de deux formes. Non empaqueté, les valeurs par défaut du SDK conviennent. Empaqueté, pointez chaque chemin vers ApplicationData.Current.LocalFolder — le dossier LocalState du paquet, qui est l’emplacement documenté pour les données d’une application empaquetée, n’est jamais virtualisé parce qu’il est déjà privé au paquet, survit aux mises à jour, et est supprimé à la désinstallation exactement comme LocalCache :

using Keyright;
using Windows.Storage;                       // needs a Windows TFM, e.g. net8.0-windows10.0.19041.0

static KeyrightClient CreateLicenseClient()
{
    var options = new KeyrightOptions
    {
        Product         = "acme-studio",
        PublicKeyBase64 = AcmeKeys.LicensePublicKey,       // public key only; safe to ship in the package
        ServiceUrl      = "https://licensing.acme.example",
    };

    if (PackageContext.IsPackaged)
    {
        // Explicit, package-private, visible in Explorer at the path we log.
        string root = Path.Combine(ApplicationData.Current.LocalFolder.Path, "keyright");
        options.LeaseCachePath     = Path.Combine(root, "lease.json");
        options.LocalStatePath     = Path.Combine(root, "state.json");
        options.DefaultLicensePath = Path.Combine(root, "license.json");
    }

    return KeyrightClient.Initialize(options);
}

Le SDK crée le répertoire à la première écriture, donc il n’y a rien d’autre à configurer. Si votre projet cible simplement net8.0-windows sans version du SDK Windows, GetCurrentPackageFamilyName de la même DLL vous donne le nom de famille, et LocalState se trouve dans %LocalAppData%\Packages\<family>\LocalState.

Ajoutez ensuite le diagnostic qui transforme le ticket de support en une seule ligne — la même idée que la commande --site-info du billet sur les licences de site : affichez ce que la porte verra, depuis le chemin de code que la porte utilise.

// acme-studio --license-info
Console.WriteLine($"Packaged     : {(PackageContext.IsPackaged ? PackageContext.FullName : "no")}");
Console.WriteLine($"Lease cache  : {License.LeaseCachePath}  (exists: {File.Exists(License.LeaseCachePath)})");
Console.WriteLine($"Local state  : {License.LocalStatePath}");
Console.WriteLine($"License file : {License.DefaultPath}");
Console.WriteLine($"Machine id   : {MachineFingerprint.Current().ToBoundString()}");

La ligne machine id est celle dont un ingénieur support a besoin pour retrouver un appareil dans le tableau de bord, et elle affiche la même valeur, empaqueté ou non — ce qui est tout l’intérêt.

Un PC partagé : deux utilisateurs, un poste

Les paquets MSIX sont enregistrés par utilisateur. Sur une station de travail partagée où Dana et Sam utilisent tous deux l’application, chacun a son propre dossier Packages\<family>, son propre LocalCache, son propre bail. Chacun doit activer une fois. Cela ressemble à deux postes, et ce n’en est pas : les deux activations prennent l’empreinte de la même machine, envoient le même machineId, et le service lie un seul poste et le rafraîchit deux fois. Le cache par utilisateur est une commodité pour le bail ; le poste a toujours été par machine.

La seule chose à éviter est de « corriger » cela en déplaçant le bail vers %ProgramData% pour que les deux utilisateurs partagent un seul fichier. Les écritures en dehors de l’AppData de l’utilisateur ne sont pas virtualisées, donc cela fonctionnerait, mais vous devenez alors responsable des ACL — un dossier créé par l’installateur de Dana n’est pas accessible en écriture pour Sam — sans aucun gain, parce que le décompte des postes est déjà correct.

Livrer une licence à l’intérieur du paquet

Toutes les licences ne sont pas activées par l’utilisateur. Les licences de site, les builds OEM et les déploiements isolés du réseau livrent un fichier de licence signé avec le produit, et un paquet est le bon véhicule pour cela : le répertoire d’installation sous C:\Program Files\WindowsApps\<PackageFullName> est en lecture seule, signé en tant que partie du paquet, et AppContext.BaseDirectory s’y résout à l’exécution.

<!-- Acme.Studio.csproj: bundle the signed site license as content -->
<ItemGroup>
  <Content Include="licenses\license.json" CopyToOutputDirectory="PreserveNewest" />
</ItemGroup>
// Site/OEM build: the license is part of the package. Explicit file path = authoritative.
options.LicenseFilePath = Path.Combine(AppContext.BaseDirectory, "licenses", "license.json");

LicenseFilePath est une source explicite dans les règles de précédence du SDK : quand elle est définie, les sources ambiantes — la variable d’environnement KEYRIGHT_LICENSE, un chemin de configuration, le chemin AppData par défaut — ne sont pas consultées, et un fichier manquant ou invalide donne un résultat NoLicense ou SignatureInvalid plutôt qu’un repli. C’est ce que vous voulez pour un build dont la licence est une décision de déploiement plutôt qu’une décision de l’utilisateur. Les deux signatures font des travaux différents : la signature du paquet dit que les octets viennent de vous ; la signature de la licence, vérifiée contre la clé publique que vous avez embarquée, dit que les revendications viennent de vous. Le répertoire d’installation en lecture seule empêche quiconque de modifier le fichier sur place, mais c’est une commodité — c’est la signature qui rend la modification inutile.

Pour un produit mixte — la plupart des clients activent avec une clé, les clients entreprise reçoivent un fichier de site — laissez LicenseFilePath non défini dans le paquet grand public et laissez les clients de site déposer le fichier à DefaultPath, qui, avec la configuration de chemins explicites ci-dessus, est un emplacement stable et documentable à l’intérieur de LocalState.

L’essai qui vit dans un fichier

Un comportement de Keyright mérite une note spécifique à MSIX. Un essai activé en ligne porte une expiration que le service a écrite à l’émission ; l’horloge est sur le service et une désinstallation ne change rien. Un essai livré sous forme de fichier de licence signé hors ligne avec trialDays et sans expiration intégrée est chronométré à partir de la première utilisation sur cette machine, et l’horodatage de première vue qui l’ancre est conservé dans state.json. Sous une application empaquetée, ce fichier vit dans le dossier de données du paquet, et la désinstallation le supprime — donc un cycle de désinstallation/réinstallation redémarre l’horloge locale.

La solution est de ne pas conserver l’horloge d’essai dans un stockage à la portée du paquet. Soit émettez les essais en ligne pour que l’expiration soit fixée par le service, soit pointez LocalStatePath vers un emplacement par utilisateur qui n’est pas à l’intérieur de Packages\<family> — ce qui, dans une application virtualisée, signifie désactiver la virtualisation des écritures pour cette écriture ou choisir un chemin hors AppData. Le billet sur les essais qui ne fuient pas couvre la forme générale ; la leçon propre à MSIX est simplement que « l’AppData de l’utilisateur » n’est pas là où se trouve le fichier.

Quand désactiver la virtualisation

Vous pouvez dire à Windows de ne pas rediriger les écritures AppData pour votre paquet :

<Package
  xmlns="http://schemas.microsoft.com/appx/manifest/foundation/windows10"
  xmlns:desktop6="http://schemas.microsoft.com/appx/manifest/desktop/windows10/6"
  xmlns:rescap="http://schemas.microsoft.com/appx/manifest/foundation/windows10/restrictedcapabilities"
  IgnorableNamespaces="desktop6 rescap">

  <Properties>
    <desktop6:FileSystemWriteVirtualization>disabled</desktop6:FileSystemWriteVirtualization>
    <desktop6:RegistryWriteVirtualization>disabled</desktop6:RegistryWriteVirtualization>
  </Properties>

  <Capabilities>
    <rescap:Capability Name="runFullTrust" />
    <rescap:Capability Name="unvirtualizedResources" />
  </Capabilities>
</Package>

Avec cela en place, %LocalAppData%\Keyright\acme-studio\lease.json est un vrai fichier au vrai chemin, il survit à la désinstallation, et un build non empaqueté du même produit lit le même cache. Ce dernier point est la seule bonne raison de le faire : si vous livrez à la fois une édition MSI et une édition MSIX, ou un outil de support qui inspecte directement les fichiers de licence, un emplacement partagé non virtualisé les garde cohérents. Sinon, la valeur par défaut est meilleure — un cache qui meurt avec le paquet a exactement la bonne durée de vie pour un cache, le poste y survit de toute façon, et unvirtualizedResources est une capacité restreinte que le Store vous demandera de justifier (les paquets chargés localement et winget peuvent la déclarer sans cérémonie).

Vérifiez-le

Tout l’argument ci-dessus est testable sur une seule machine en quelques minutes, et cela vaut la peine de le faire une fois avant de livrer le premier build empaqueté.

# 1. Install the package and activate from inside the app. Then find the cache.
$pkg = Get-AppxPackage -Name Acme.Studio
Get-ChildItem "$env:LOCALAPPDATA\Packages\$($pkg.PackageFamilyName)\LocalState\keyright"
#    → lease.json, state.json. Dashboard: the license shows 1 device, your machine id.

# 2. Upgrade in place; the files and the activation must survive.
winget upgrade Acme.Studio
Get-ChildItem "$env:LOCALAPPDATA\Packages\$($pkg.PackageFamilyName)\LocalState\keyright"

# 3. Uninstall; the folder is gone. Dashboard: still 1 device — nothing told it.
Remove-AppxPackage -Package $pkg.PackageFullName
Test-Path "$env:LOCALAPPDATA\Packages\$($pkg.PackageFamilyName)"      # False

# 4. Reinstall and activate again. Dashboard: still 1 device, same machine id, lease refreshed.

Ajoutez une étape de plus si vous proposez une désactivation dans l’application : exécutez-la avant l’étape 3 et confirmez que le tableau de bord affiche 0 appareil avant la désinstallation, puis 1 à nouveau après l’étape 4. (Pour le débogage uniquement, Remove-AppxPackage -PreserveApplicationData conserve le dossier de données à travers une désinstallation, ce qui est pratique quand vous voulez tester le chemin de mise à jour sans réactiver.)

Et le test qui attrape le cas de l’essai, si vous livrez des fichiers d’essai hors ligne : activez un essai trialDays, notez l’expiration que l’application affiche, désinstallez, réinstallez, et confirmez que l’expiration est la même — ce qui ne sera le cas que si state.json a survécu au paquet ou si l’horloge d’essai est sur le service.

Ce qu’il faut retenir

MSIX déplace l’AppData de votre application sans le lui dire, le supprime à la désinstallation, et laisse la machine elle-même tranquille. Pour les licences, c’est presque entièrement une bonne nouvelle : le bail en cache est un cache et se comporte comme tel, l’ancre de l’empreinte n’est pas un état du paquet, donc une réinstallation est la même machine, et l’activation idempotente signifie que le client qui réinstalle ne brûle jamais un poste. Les deux choses à concevoir sont le client qui désinstalle pour déménager — donnez-lui une désactivation qu’il peut trouver — et le seul fichier qui ne devrait pas être un cache, l’horloge d’essai locale. Rendez les chemins explicites, affichez-les dans un diagnostic, et le jour où un ingénieur support aura besoin de lease.json, il le trouvera du premier coup.

Essayez Nebula.NET

Renforcez votre code .NET en quelques minutes — commencez avec l'édition gratuite.