Licenciar una app .NET empaquetada como MSIX: la virtualización de AppData, la desinstalación que borra tu caché de lease y dónde debería vivir realmente una licencia Keyright
MSIX es la forma en que se instalan hoy las apps de escritorio en Windows —a través de winget, del despliegue empresarial, de la Store— y cambia silenciosamente tres cosas de las que depende un SDK de licencias. Las escrituras en %LocalAppData% se redirigen a una carpeta por paquete que la app nunca ve en una ruta. Desinstalar el paquete borra esa carpeta, así que el lease de activación en caché, el reloj local de prueba y el estado de manipulación del reloj desaparecen mientras el puesto en el servicio de licencias sigue vinculado. Y el directorio de instalación es de solo lectura, que es exactamente donde debería estar una licencia de sitio incluida en el paquete. Esta entrada recorre qué escribe el SDK de Keyright y dónde acaba bajo una app empaquetada, por qué la huella de máquina sobrevive cuando la caché no, qué le hace un ciclo de desinstalación/reinstalación al recuento de puestos (nada, gracias a la activación idempotente — salvo que el cliente quisiera mudarse), cómo detectar la identidad de paquete y darle al SDK rutas explícitas, cómo incluir una licencia dentro del paquete, cuándo desactivar la virtualización de escritura en el manifiesto, y el bucle de pruebas que lo demuestra.
Pregúntale a un desarrollador de escritorio .NET dónde guarda su app la configuración y te dirá %LocalAppData%. Pregúntale qué pasa con esos archivos cuando se desinstala la app y te dirá «nada, son datos del usuario». Las dos respuestas son correctas para un MSI. Las dos son incorrectas para un paquete MSIX — y MSIX es ahora la forma en que las apps de escritorio llegan a las máquinas Windows, ya sea mediante winget install, un despliegue de Intune o la Store.
Eso importa al licenciamiento más que a casi cualquier otra cosa que escriba una app, porque un SDK de licencias guarda exactamente el tipo de archivos pequeños e importantes cuya ubicación y ciclo de vida necesitas entender: un lease de activación firmado que permite a la app ejecutarse sin conexión, una marca de tiempo local que impide reiniciar una prueba, un registro de la última vez que se vio el reloj. Esta entrada examina qué escribe el SDK de Keyright, dónde acaban realmente esos bytes dentro de una app empaquetada, qué les hace una desinstalación, y cómo hacer todo explícito en lugar de dejar que lo decida un filtro del sistema de archivos.
Qué escribe el SDK, y dónde cree que lo está escribiendo
Un cliente Keyright se inicializa una vez con el nombre del producto y la clave pública incrustada, y a partir de ahí mantiene hasta tres archivos para ese producto:
{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
Hay un cuarto, {LocalApplicationData}\Keyright\machine.id, que solo existe como último recurso: la huella de máquina está anclada en el GUID de máquina del sistema operativo, y el id aleatorio persistido solo se escribe si ese GUID no puede leerse en absoluto. En una máquina Windows normalmente nunca existe.
Cada una de esas rutas sale de Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), y esa llamada devuelve lo mismo dentro de un paquete MSIX que fuera de él: C:\Users\dana\AppData\Local. El SDK, tu app y cualquier cosa que registre client.LeaseCachePath informarán todos de esa ruta. Solo que no es donde está el archivo.
Adónde va realmente
Una app de escritorio empaquetada con la capacidad runFullTrust se ejecuta dentro de un contenedor ligero, y una de las cosas que hace ese contenedor es virtualización de escritura del sistema de archivos para las carpetas AppData del usuario. Cuando el proceso escribe en %LocalAppData%\Keyright\acme-studio\lease.json, un controlador de filtro redirige la escritura a
%LocalAppData%\Packages\<PackageFamilyName>\LocalCache\Local\Keyright\acme-studio\lease.json
La redirección es invisible para el proceso. File.WriteAllText tiene éxito, File.Exists devuelve true, Directory.EnumerateFiles lista el archivo — todo contra la ruta virtual, que el contenedor presenta como una vista combinada: las lecturas comprueban primero la LocalCache del paquete y caen al %LocalAppData% real si el archivo no está ahí, y las escrituras siempre acaban en LocalCache (copia en escritura si el archivo real existía). Abre el Explorador de archivos en C:\Users\dana\AppData\Local\Keyright y no hay nada dentro; los archivos están dos niveles más abajo, dentro de Packages.
De ahí se derivan tres consecuencias, y son toda la razón de ser de esta entrada.
El lease sobrevive a las actualizaciones del paquete. LocalCache pertenece a la familia del paquete, no a la versión del paquete. Cuando winget upgrade instala la 2.4 sobre la 2.3, la carpeta queda intacta, el lease en caché sigue ahí y la app arranca licenciada. Llamar a ActivateAsync al arrancar —el patrón que el SDK recomienda de todos modos— lo refresca silenciosamente cuando la máquina está en línea.
El lease no sobrevive a la desinstalación. Eliminar el paquete elimina %LocalAppData%\Packages\<family> con todo lo que contiene. lease.json, state.json, un license.json que el usuario pegó en la ubicación por defecto: desaparecidos. Este es el comportamiento que un MSI nunca tuvo, y es el que sorprende a la gente.
La huella de máquina no se ve afectada por ninguna de las dos cosas. El ancla sobre la que se construye la huella es HKLM\SOFTWARE\Microsoft\Cryptography\MachineGuid, que pertenece a la instalación de Windows, está fuera del paquete, y el SDK la lee — no la escribe. Las lecturas de HKLM no se virtualizan; el registro virtual del paquete solo superpone las claves que el propio paquete lleva. El nombre de máquina y la versión del sistema operativo, las dos señales más blandas, tampoco son estado del paquete. Así que la huella que la app presenta al servicio de licencias es byte a byte la misma antes de una desinstalación y después de una reinstalación.
Qué le hacen la desinstalación y la reinstalación al puesto
Junta las dos últimas consecuencias y el recuento de puestos se deduce solo.
El puesto no es un archivo en la máquina. Es un registro en el servicio de licencias que dice esta licencia, este machineId. Cuando el cliente desinstala el paquete, nada le dice nada al servicio — la caché del lease se borra en local, el puesto sigue vinculado. Cuando reinstala y vuelve a introducir su clave, la app toma la huella de la misma máquina, envía el mismo machineId, y el servicio hace lo que hace con cualquier máquina que ya ha visto: refresca la activación existente y devuelve un lease nuevo. La reactivación en la misma máquina es idempotente. No se consume un segundo puesto, el panel de control sigue mostrando un dispositivo, y el cliente nunca supo que había una duda.
El caso que sí sale mal es aquel en que el cliente desinstaló porque dejaba la máquina. Retira un portátil, prepara uno nuevo, instala el paquete, introduce la clave — y el portátil nuevo es una huella nueva. Si la licencia tenía un puesto, el servicio responde LicenseStatus.SeatLimit: todos los puestos en uso, libera uno primero. El portátil viejo no puede liberarlo; la app ya no está en él.
Dos cosas resuelven eso, y ninguna es específica de MSIX, pero MSIX hace que importen más porque desinstalar es mucho más limpio de lo que solía ser:
- Dale al usuario una desactivación en la app.
DeactivateAsync(key)libera el puesto de esta máquina en el servicio y limpia el lease local. Ponla donde la encuentre un usuario que está a punto de retirar una máquina —una opción «Mover esta licencia a otro equipo» junto a «Introducir clave de licencia»— y explica qué hace. Una app empaquetada no tiene ningún gancho de desinstalación desde el que puedas ejecutar código, así que la desactivación tiene que ocurrir antes de la desinstalación, lo que significa que la interfaz tiene que invitar a ello. - Libera las máquinas muertas desde el lado del proveedor. El panel Dispositivos de la licencia en el panel de control de Keyright lista cada máquina vinculada con una acción Liberar puesto, y
POST /admin/licenses/{id}/free-seathace lo mismo desde un script de soporte. La siguiente activación del cliente toma el puesto liberado.
Lo que no debes hacer es intentar reconstruir una desactivación a partir de la desinstalación. En MSIX no hay script de desinstalación a nivel de paquete por diseño, y una llamada de licencia que se ejecutara durante la desinstalación sería la petición de red menos fiable de tu producto.
Saber que estás empaquetado, y decir adónde van los archivos
Confiar en la redirección está bien hasta que un ingeniero de soporte pide lease.json y se pasa veinte minutos en la carpeta equivocada. El enfoque más limpio es detectar la identidad de paquete al arrancar y entregar al SDK rutas explícitas dentro de la propia carpeta de datos del paquete, para que la ruta que la app registra sea la ruta donde están los bytes.
La identidad de paquete sale de GetCurrentPackageFullName en kernel32. Fuera de un paquete devuelve APPMODEL_ERROR_NO_PACKAGE (15700); dentro de uno devuelve el nombre completo del paquete.
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;
}
}
Con eso, el cliente se construye de una de dos formas. Sin empaquetar, los valores por defecto del SDK son correctos. Empaquetado, apunta cada ruta a ApplicationData.Current.LocalFolder — la carpeta LocalState del paquete, que es el hogar documentado para los datos de una app empaquetada, nunca se virtualiza porque ya es privada del paquete, sobrevive a las actualizaciones, y se borra al desinstalar exactamente igual que 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);
}
El SDK crea el directorio en la primera escritura, así que no hay nada más que configurar. Si tu proyecto apunta a net8.0-windows a secas, sin versión del SDK de Windows, GetCurrentPackageFamilyName de la misma DLL te da el nombre de familia, y LocalState vive en %LocalAppData%\Packages\<family>\LocalState.
Después añade el diagnóstico que convierte el ticket de soporte en una sola línea — la misma idea que el comando --site-info de la entrada sobre licencias de sitio: imprime lo que verá la puerta, desde el código que usa la puerta.
// 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 línea del id de máquina es la que un ingeniero de soporte necesita para casar un dispositivo en el panel de control, e imprime el mismo valor empaquetado o no — que es la gracia.
Un PC compartido: dos usuarios, un puesto
Los paquetes MSIX se registran por usuario. En una estación de trabajo compartida donde Dana y Sam usan ambos la app, cada uno tiene su propia carpeta Packages\<family>, su propia LocalCache, su propio lease. Cada uno tiene que activar una vez. Eso suena a dos puestos y no lo es: ambas activaciones toman la huella de la misma máquina, envían el mismo machineId, y el servicio vincula un puesto y lo refresca dos veces. La caché por usuario es una comodidad para el lease; el puesto siempre fue por máquina.
Lo único que hay que evitar es «arreglar» esto moviendo el lease a %ProgramData% para que ambos usuarios compartan un archivo. Las escrituras fuera del AppData del usuario no se virtualizan, así que funcionaría, pero entonces las ACL son problema tuyo —una carpeta que creó el instalador de Dana no es escribible por Sam— sin ganancia alguna, porque el recuento de puestos ya es correcto.
Incluir una licencia dentro del paquete
No toda licencia la activa el usuario. Las licencias de sitio, las compilaciones OEM y los despliegues aislados de la red distribuyen un archivo de licencia firmado con el producto, y un paquete es el vehículo adecuado para ello: el directorio de instalación bajo C:\Program Files\WindowsApps\<PackageFullName> es de solo lectura, está firmado como parte del paquete, y AppContext.BaseDirectory resuelve ahí en tiempo de ejecución.
<!-- 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 es una fuente explícita en las reglas de precedencia del SDK: cuando está establecida, las fuentes ambientales —la variable de entorno KEYRIGHT_LICENSE, una ruta de configuración, la ruta por defecto en AppData— no se consultan, y un archivo ausente o inválido es un resultado NoLicense o SignatureInvalid en lugar de una caída a la siguiente fuente. Eso es lo que quieres para una compilación cuyo licenciamiento es una decisión de despliegue y no del usuario. Las dos firmas hacen trabajos distintos: la firma del paquete dice que los bytes vinieron de ti; la firma de la licencia, verificada contra la clave pública que incrustaste, dice que las reclamaciones vinieron de ti. El directorio de instalación de solo lectura impide que alguien edite el archivo en su sitio, pero eso es una comodidad — es la firma la que hace inútil la edición.
Para un producto mixto —la mayoría de los clientes activan con una clave, los clientes empresariales reciben un archivo de sitio— deja LicenseFilePath sin establecer en el paquete comercial y deja que los clientes de sitio suelten el archivo en DefaultPath, que con la configuración de rutas explícitas de arriba es una ubicación estable y documentable dentro de LocalState.
La prueba que vive en un archivo
Un comportamiento de Keyright merece una nota específica para MSIX. Una prueba activada online lleva una caducidad que el servicio escribió al emitirla; el reloj está en el servicio y una desinstalación no cambia nada. Una prueba distribuida como archivo de licencia firmado offline con trialDays y sin caducidad incorporada se cronometra desde el primer uso en esta máquina, y la marca de tiempo de primera vez que la ancla se guarda en state.json. Bajo una app empaquetada ese archivo vive en la carpeta de datos del paquete, y la desinstalación lo borra — así que un ciclo de desinstalación/reinstalación reinicia el reloj local.
La solución es no guardar el reloj de prueba en almacenamiento con ámbito de paquete. O bien emite las pruebas online para que el servicio fije la caducidad, o bien apunta LocalStatePath a una ubicación por usuario que no esté dentro de Packages\<family> — lo que, en una app virtualizada, significa desactivar la virtualización de escritura para esa escritura o elegir una ruta fuera de AppData. La entrada sobre pruebas que no se filtran cubre la forma general; la lección específica de MSIX es solo que «el AppData del usuario» no es donde está el archivo.
Cuándo desactivar la virtualización
Puedes decirle a Windows que no redirija las escrituras en AppData de tu paquete:
<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>
Con eso en su sitio, %LocalAppData%\Keyright\acme-studio\lease.json es un archivo real en la ruta real, sobrevive a la desinstalación, y una compilación sin empaquetar del mismo producto lee la misma caché. Ese último punto es la única razón de peso para hacerlo: si distribuyes una edición MSI y otra MSIX, o una herramienta de soporte que inspecciona directamente los archivos de licencia, una ubicación compartida sin virtualizar los mantiene coherentes. En caso contrario el valor por defecto es mejor — una caché que muere con el paquete es exactamente el ciclo de vida correcto para una caché, el puesto le sobrevive de todos modos, y unvirtualizedResources es una capacidad restringida que la Store te pedirá justificar (los paquetes sideloaded y de winget pueden declararla sin ceremonia).
Verifícalo
Todo el argumento anterior se puede probar en una máquina en unos minutos, y merece la pena hacerlo una vez antes de distribuir la primera compilación empaquetada.
# 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.
Añade un paso más si ofreces una desactivación en la app: ejecútala antes del paso 3 y confirma que el panel de control muestra 0 dispositivos antes de la desinstalación, y de nuevo 1 después del paso 4. (Solo para depuración, Remove-AppxPackage -PreserveApplicationData conserva la carpeta de datos a través de una desinstalación, lo que resulta útil cuando quieres probar la ruta de actualización sin volver a activar.)
Y la prueba que detecta el caso de la prueba de evaluación, si distribuyes archivos de prueba offline: activa una prueba con trialDays, anota la caducidad que muestra la app, desinstala, reinstala, y confirma que la caducidad es la misma — lo que solo ocurrirá si state.json sobrevivió al paquete o el reloj de prueba está en el servicio.
Qué llevarse
MSIX mueve el AppData de tu app sin decírselo a tu app, lo borra al desinstalar, y deja la máquina en sí en paz. Para el licenciamiento eso es casi todo buenas noticias: el lease en caché es una caché y se comporta como tal, el ancla de la huella no es estado del paquete así que una reinstalación es la misma máquina, y la activación idempotente significa que el cliente que reinstala nunca quema un puesto. Las dos cosas para las que hay que diseñar son el cliente que desinstala para mudarse —dale una desactivación que pueda encontrar— y el único archivo que no debería ser una caché, el reloj local de prueba. Haz las rutas explícitas, imprímelas en un diagnóstico, y el día que un ingeniero de soporte necesite lease.json lo encontrará a la primera.
Prueba Nebula.NET
Endurece tu código .NET en minutos — empieza con la edición gratuita.