Lizenzierung einer MSIX-paketierten .NET-App: AppData-Virtualisierung, die Deinstallation, die Ihren Lease-Cache löscht, und wo eine Keyright-Lizenz tatsächlich liegen sollte
MSIX ist heute der Weg, auf dem Windows-Desktop-Apps installiert werden — über winget, über Unternehmensbereitstellung, über den Store — und es ändert stillschweigend drei Dinge, auf die ein Lizenzierungs-SDK angewiesen ist. Schreibzugriffe auf %LocalAppData% werden in einen paketspezifischen Ordner umgeleitet, den die App nie in einem Pfad zu sehen bekommt. Das Deinstallieren des Pakets löscht diesen Ordner, sodass die zwischengespeicherte Aktivierungs-Lease, die lokale Testphasen-Uhr und der Uhrmanipulations-Zustand verschwinden, während der Platz auf dem Lizenzierungsdienst gebunden bleibt. Und das Installationsverzeichnis ist schreibgeschützt — genau dort, wo eine mitgelieferte Standortlizenz liegen sollte. Dieser Beitrag geht durch, was das Keyright-SDK schreibt und wo es unter einer paketierten App landet, warum der Maschinen-Fingerabdruck überlebt, wenn der Cache es nicht tut, was ein Deinstallations-/Neuinstallationszyklus mit der Platzzählung macht (nichts, dank idempotenter Aktivierung — es sei denn, der Kunde wollte umziehen), wie Sie die Paketidentität erkennen und dem SDK explizite Pfade geben, wie Sie eine Lizenz im Paket ausliefern, wann Sie die Schreibvirtualisierung im Manifest deaktivieren, und die Testschleife, die das alles belegt.
Fragen Sie einen .NET-Desktop-Entwickler, wo seine App ihre Einstellungen ablegt, und er wird %LocalAppData% sagen. Fragen Sie, was mit diesen Dateien passiert, wenn die App deinstalliert wird, und er wird sagen: „nichts, das sind die Daten des Benutzers”. Beide Antworten sind für ein MSI richtig. Beide sind für ein MSIX-Paket falsch — und MSIX ist heute der Weg, auf dem Desktop-Apps auf Windows-Maschinen ankommen, ob über winget install, eine Intune-Bereitstellung oder den Store.
Für die Lizenzierung zählt das mehr als für fast alles andere, was eine App schreibt, denn ein Lizenzierungs-SDK hält genau die Art kleiner, wichtiger Dateien, deren Speicherort und Lebensdauer Sie verstehen müssen: eine signierte Aktivierungs-Lease, die die App offline laufen lässt, einen lokalen Zeitstempel, der verhindert, dass eine Testphase neu gestartet wird, einen Eintrag, wann die Uhr zuletzt gesehen wurde. Dieser Beitrag sieht sich an, was das Keyright-SDK schreibt, wo diese Bytes innerhalb einer paketierten App tatsächlich landen, was eine Deinstallation mit ihnen macht, und wie Sie das Ganze explizit machen, statt einen Dateisystemfilter entscheiden zu lassen.
Was das SDK schreibt und wo es glaubt, es zu schreiben
Ein Keyright-Client wird einmal mit dem Produktnamen und dem eingebetteten öffentlichen Schlüssel initialisiert und hält von da an bis zu drei Dateien für dieses Produkt:
{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
Es gibt eine vierte, {LocalApplicationData}\Keyright\machine.id, die nur als letzter Ausweg existiert: der Maschinen-Fingerabdruck ist an der Maschinen-GUID des Betriebssystems verankert, und die zufällige, persistierte ID wird nur geschrieben, wenn diese GUID überhaupt nicht gelesen werden kann. Auf einer Windows-Maschine existiert sie normalerweise nie.
Jeder dieser Pfade stammt aus Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), und dieser Aufruf liefert innerhalb eines MSIX-Pakets dasselbe wie außerhalb: C:\Users\dana\AppData\Local. Das SDK, Ihre App und alles, was client.LeaseCachePath protokolliert, werden diesen Pfad melden. Es ist nur nicht dort, wo die Datei liegt.
Wo es tatsächlich landet
Eine Desktop-App, die mit der Capability runFullTrust paketiert ist, läuft in einem leichtgewichtigen Container, und eines der Dinge, die dieser Container tut, ist Dateisystem-Schreibvirtualisierung für die AppData-Ordner des Benutzers. Wenn der Prozess nach %LocalAppData%\Keyright\acme-studio\lease.json schreibt, leitet ein Filtertreiber den Schreibzugriff um nach
%LocalAppData%\Packages\<PackageFamilyName>\LocalCache\Local\Keyright\acme-studio\lease.json
Die Umleitung ist für den Prozess unsichtbar. File.WriteAllText gelingt, File.Exists liefert true, Directory.EnumerateFiles listet die Datei — alles gegen den virtuellen Pfad, den der Container als zusammengeführte Sicht präsentiert: Lesezugriffe prüfen zuerst den LocalCache des Pakets und fallen auf das reale %LocalAppData% zurück, wenn die Datei dort nicht liegt, und Schreibzugriffe landen immer in LocalCache (Copy-on-Write, falls die reale Datei existierte). Öffnen Sie den Explorer unter C:\Users\dana\AppData\Local\Keyright, und dort ist nichts; die Dateien liegen zwei Ebenen tiefer in Packages.
Daraus ergeben sich drei Konsequenzen, und sie sind der ganze Grund, warum es diesen Beitrag gibt.
Die Lease überlebt Paketupdates. LocalCache gehört zur Paket-Familie, nicht zur Paket-Version. Wenn winget upgrade 2.4 über 2.3 installiert, bleibt der Ordner unangetastet, die zwischengespeicherte Lease ist noch da, und die App startet lizenziert. Ein Aufruf von ActivateAsync beim Start — das Muster, das das SDK ohnehin empfiehlt — frischt sie stillschweigend auf, wenn die Maschine online ist.
Die Lease überlebt die Deinstallation nicht. Das Entfernen des Pakets entfernt %LocalAppData%\Packages\<family> mit allem darin. lease.json, state.json, eine license.json, die der Benutzer an den Standardspeicherort kopiert hat: weg. Das ist das Verhalten, das ein MSI nie hatte, und es ist das, das die Leute überrascht.
Der Maschinen-Fingerabdruck ist von beidem unberührt. Der Anker, auf dem der Fingerabdruck aufbaut, ist HKLM\SOFTWARE\Microsoft\Cryptography\MachineGuid, der zur Windows-Installation gehört, außerhalb des Pakets liegt und vom SDK gelesen — nicht geschrieben — wird. HKLM-Lesezugriffe werden nicht virtualisiert; die virtuelle Registrierung des Pakets überlagert nur die Schlüssel, die das Paket selbst mitbringt. Der Maschinenname und die Betriebssystemversion, die beiden weicheren Signale, sind ebenfalls kein Paketzustand. Der Fingerabdruck, den die App dem Lizenzierungsdienst präsentiert, ist also vor einer Deinstallation und nach einer Neuinstallation Byte für Byte derselbe.
Was Deinstallation und Neuinstallation mit dem Platz machen
Nimmt man die letzten beiden Konsequenzen zusammen, folgt daraus die Platzzählung.
Der Platz ist keine Datei auf der Maschine. Er ist ein Datensatz auf dem Lizenzierungsdienst, der besagt: diese Lizenz, diese machineId. Wenn der Kunde das Paket deinstalliert, teilt nichts dem Dienst irgendetwas mit — der Lease-Cache wird lokal gelöscht, der Platz bleibt gebunden. Wenn er neu installiert und seinen Schlüssel erneut eingibt, nimmt die App den Fingerabdruck derselben Maschine, sendet dieselbe machineId, und der Dienst tut, was er für jede Maschine tut, die er schon gesehen hat: er frischt die bestehende Aktivierung auf und liefert eine frische Lease zurück. Die erneute Aktivierung auf derselben Maschine ist idempotent. Kein zweiter Platz wird verbraucht, das Dashboard zeigt weiterhin ein Gerät, und der Kunde hat nie erfahren, dass es eine Frage gab.
Der Fall, der tatsächlich schiefgeht, ist der, in dem der Kunde deinstalliert hat, weil er die Maschine verlässt. Er mustert einen Laptop aus, richtet einen neuen ein, installiert das Paket, gibt den Schlüssel ein — und der neue Laptop ist ein neuer Fingerabdruck. Hatte die Lizenz einen Platz, antwortet der Dienst mit LicenseStatus.SeatLimit: alle Plätze belegt, erst einen freigeben. Der alte Laptop kann ihn nicht freigeben; die App ist nicht mehr darauf.
Zwei Dinge fangen das ab, und keines davon ist MSIX-spezifisch, aber MSIX lässt sie mehr zählen, weil Deinstallieren so viel sauberer ist als früher:
- Geben Sie dem Benutzer eine Deaktivierung in der App.
DeactivateAsync(key)gibt den Platz dieser Maschine auf dem Dienst frei und löscht die lokale Lease. Platzieren Sie sie dort, wo ein Benutzer, der gleich eine Maschine ausmustert, sie findet — ein Eintrag „Diese Lizenz auf einen anderen Computer übertragen” neben „Lizenzschlüssel eingeben” — und sagen Sie, was sie tut. Eine paketierte App hat keinen Deinstallations-Hook, aus dem Sie Code ausführen könnten, die Deaktivierung muss also vor der Deinstallation passieren, was bedeutet, dass die Oberfläche dazu einladen muss. - Geben Sie tote Maschinen anbieterseitig frei. Das Devices-Panel der Lizenz im Keyright-Dashboard listet jede gebundene Maschine mit einer Aktion Free seat, und
POST /admin/licenses/{id}/free-seattut dasselbe aus einem Support-Skript. Die nächste Aktivierung des Kunden nimmt den freigegebenen Platz.
Was Sie nicht tun sollten, ist zu versuchen, aus der Deinstallation eine Deaktivierung zu rekonstruieren. Es gibt in MSIX absichtlich kein Deinstallationsskript auf Paketebene, und ein Lizenzierungsaufruf, der während der Deinstallation läuft, wäre die unzuverlässigste Netzwerkanfrage in Ihrem Produkt.
Wissen, dass Sie paketiert sind, und sagen, wohin die Dateien gehen
Sich auf die Umleitung zu verlassen, ist in Ordnung, bis ein Support-Techniker nach lease.json fragt und zwanzig Minuten im falschen Ordner verbringt. Der sauberere Ansatz ist, beim Start die Paketidentität zu erkennen und dem SDK explizite Pfade im eigenen Datenordner des Pakets zu übergeben, sodass der Pfad, den die App protokolliert, der Pfad ist, in dem die Bytes liegen.
Die Paketidentität kommt von GetCurrentPackageFullName in kernel32. Außerhalb eines Pakets liefert es APPMODEL_ERROR_NO_PACKAGE (15700); innerhalb liefert es den vollständigen Paketnamen.
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;
}
}
Damit wird der Client in einer von zwei Formen gebaut. Nicht paketiert sind die SDK-Standardwerte richtig. Paketiert richten Sie jeden Pfad auf ApplicationData.Current.LocalFolder — den LocalState-Ordner des Pakets, der das dokumentierte Zuhause für die Daten einer paketierten App ist, nie virtualisiert wird, weil er ohnehin paketprivat ist, Updates überlebt und bei der Deinstallation genau wie LocalCache gelöscht wird:
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);
}
Das SDK legt das Verzeichnis beim ersten Schreibzugriff an, es gibt also nichts weiter einzurichten. Wenn Ihr Projekt schlichtes net8.0-windows ohne Windows-SDK-Version anvisiert, liefert Ihnen GetCurrentPackageFamilyName aus derselben DLL den Familiennamen, und LocalState liegt unter %LocalAppData%\Packages\<family>\LocalState.
Fügen Sie dann die Diagnose hinzu, die das Support-Ticket zum Einzeiler macht — dieselbe Idee wie der --site-info-Befehl im Beitrag zu Standortlizenzen: geben Sie aus, was das Gate sehen wird, aus dem Codepfad, den das Gate nutzt.
// 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()}");
Die Zeile mit der Maschinen-ID ist die, die ein Support-Techniker braucht, um ein Gerät im Dashboard zuzuordnen, und sie gibt paketiert wie nicht paketiert denselben Wert aus — und genau darum geht es.
Ein gemeinsam genutzter PC: zwei Benutzer, ein Platz
MSIX-Pakete werden pro Benutzer registriert. Auf einer gemeinsam genutzten Workstation, auf der Dana und Sam beide die App verwenden, hat jeder seinen eigenen Packages\<family>-Ordner, seinen eigenen LocalCache, seine eigene Lease. Jeder von ihnen muss einmal aktivieren. Das klingt nach zwei Plätzen, und das ist es nicht: beide Aktivierungen nehmen den Fingerabdruck derselben Maschine, senden dieselbe machineId, und der Dienst bindet einen Platz und frischt ihn zweimal auf. Der Cache pro Benutzer ist eine Bequemlichkeit für die Lease; der Platz war schon immer pro Maschine.
Das Einzige, was Sie vermeiden sollten, ist, das zu „reparieren”, indem Sie die Lease nach %ProgramData% verschieben, damit beide Benutzer eine Datei teilen. Schreibzugriffe außerhalb des AppData des Benutzers werden nicht virtualisiert, es würde also funktionieren, aber dann gehören Ihnen die ACLs — ein Ordner, den Danas Installer angelegt hat, ist für Sam nicht beschreibbar — ohne jeden Gewinn, weil die Platzzahl bereits stimmt.
Eine Lizenz im Paket ausliefern
Nicht jede Lizenz wird vom Benutzer aktiviert. Standortlizenzen, OEM-Builds und abgeschottete Bereitstellungen liefern eine signierte Lizenzdatei mit dem Produkt aus, und ein Paket ist das richtige Vehikel dafür: das Installationsverzeichnis unter C:\Program Files\WindowsApps\<PackageFullName> ist schreibgeschützt, als Teil des Pakets signiert, und AppContext.BaseDirectory löst zur Laufzeit dorthin auf.
<!-- 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 ist in den Vorrangregeln des SDK eine explizite Quelle: ist sie gesetzt, werden die umgebenden Quellen — die Umgebungsvariable KEYRIGHT_LICENSE, ein Konfigurationspfad, der Standard-AppData-Pfad — nicht konsultiert, und eine fehlende oder ungültige Datei ist ein NoLicense- oder SignatureInvalid-Ergebnis statt eines Durchfallens. Das ist, was Sie für einen Build wollen, dessen Lizenzierung eine Bereitstellungsentscheidung ist und keine des Benutzers. Die beiden Signaturen erledigen unterschiedliche Aufgaben: die Paketsignatur sagt, dass die Bytes von Ihnen stammen; die Lizenzsignatur, verifiziert gegen den von Ihnen eingebetteten öffentlichen Schlüssel, sagt, dass die Claims von Ihnen stammen. Das schreibgeschützte Installationsverzeichnis hält jeden davon ab, die Datei an Ort und Stelle zu bearbeiten, aber das ist eine Bequemlichkeit — es ist die Signatur, die die Bearbeitung sinnlos macht.
Für ein gemischtes Produkt — die meisten Kunden aktivieren mit einem Schlüssel, Unternehmenskunden bekommen eine Standortdatei — lassen Sie LicenseFilePath im Retail-Paket ungesetzt und lassen Sie die Standortkunden die Datei unter DefaultPath ablegen, was im obigen Setup mit expliziten Pfaden ein stabiler, dokumentierbarer Speicherort innerhalb von LocalState ist.
Die Testphase, die in einer Datei lebt
Ein Keyright-Verhalten verdient einen eigenen MSIX-Hinweis. Eine online aktivierte Testphase trägt ein Ablaufdatum, das der Dienst bei der Ausstellung geschrieben hat; die Uhr liegt auf dem Dienst, und eine Deinstallation ändert nichts. Eine Testphase, die als signierte Offline-Lizenzdatei mit trialDays und ohne eingebranntes Ablaufdatum ausgeliefert wird, wird ab der ersten Nutzung auf dieser Maschine gezählt, und der Zeitstempel der ersten Sichtung, der sie verankert, wird in state.json gehalten. Unter einer paketierten App liegt diese Datei im Datenordner des Pakets, und die Deinstallation löscht sie — ein Deinstallations-/Neuinstallationszyklus startet die lokale Uhr also neu.
Die Lösung ist, die Testphasen-Uhr nicht in paketgebundenem Speicher zu halten. Stellen Sie Testphasen entweder online aus, damit der Dienst das Ablaufdatum fixiert, oder richten Sie LocalStatePath auf einen benutzerbezogenen Speicherort, der nicht innerhalb von Packages\<family> liegt — was in einer virtualisierten App heißt, die Schreibvirtualisierung für diesen Schreibzugriff zu deaktivieren oder einen Pfad außerhalb von AppData zu wählen. Der Beitrag über Testphasen, die nicht lecken, behandelt die allgemeine Form; die MSIX-spezifische Lektion ist nur, dass „das AppData des Benutzers” nicht dort ist, wo die Datei liegt.
Wann man die Virtualisierung abschaltet
Sie können Windows anweisen, AppData-Schreibzugriffe für Ihr Paket nicht umzuleiten:
<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>
Damit ist %LocalAppData%\Keyright\acme-studio\lease.json eine reale Datei am realen Pfad, sie überlebt die Deinstallation, und ein nicht paketierter Build desselben Produkts liest denselben Cache. Dieser letzte Punkt ist der einzige starke Grund, es zu tun: wenn Sie sowohl eine MSI- als auch eine MSIX-Edition ausliefern, oder ein Support-Werkzeug, das die Lizenzierungsdateien direkt inspiziert, hält ein gemeinsamer, nicht virtualisierter Speicherort sie konsistent. Andernfalls ist der Standard besser — ein Cache, der mit dem Paket stirbt, ist genau die richtige Lebensdauer für einen Cache, der Platz überlebt ihn ohnehin, und unvirtualizedResources ist eine eingeschränkte Capability, die der Store Sie begründen lassen wird (per Sideloading und winget verteilte Pakete können sie ohne Zeremonie deklarieren).
Überprüfen Sie es
Die ganze obige Argumentation ist auf einer Maschine in wenigen Minuten testbar, und es lohnt sich, das einmal zu tun, bevor Sie den ersten paketierten Build ausliefern.
# 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.
Fügen Sie einen weiteren Schritt hinzu, wenn Sie eine Deaktivierung in der App anbieten: führen Sie sie vor Schritt 3 aus und bestätigen Sie, dass das Dashboard vor der Deinstallation 0 Geräte zeigt, nach Schritt 4 dann wieder 1. (Nur zum Debuggen: Remove-AppxPackage -PreserveApplicationData behält den Datenordner über eine Deinstallation hinweg, was praktisch ist, wenn Sie den Update-Pfad testen wollen, ohne erneut zu aktivieren.)
Und der Test, der den Testphasen-Fall fängt, falls Sie Offline-Testphasendateien ausliefern: aktivieren Sie eine trialDays-Testphase, notieren Sie das Ablaufdatum, das die App zeigt, deinstallieren, neu installieren, und bestätigen Sie, dass das Ablaufdatum dasselbe ist — was es nur sein wird, wenn state.json das Paket überlebt hat oder die Testphasen-Uhr auf dem Dienst liegt.
Was Sie mitnehmen sollten
MSIX verschiebt das AppData Ihrer App, ohne es Ihrer App zu sagen, löscht es bei der Deinstallation und lässt die Maschine selbst in Ruhe. Für die Lizenzierung ist das fast ausschließlich eine gute Nachricht: die zwischengespeicherte Lease ist ein Cache und verhält sich wie einer, der Anker des Fingerabdrucks ist kein Paketzustand, sodass eine Neuinstallation dieselbe Maschine ist, und idempotente Aktivierung bedeutet, dass der Kunde, der neu installiert, nie einen Platz verbrennt. Die zwei Dinge, für die Sie entwerfen müssen, sind der Kunde, der deinstalliert, um umzuziehen — geben Sie ihm eine Deaktivierung, die er findet — und die eine Datei, die kein Cache sein sollte, die lokale Testphasen-Uhr. Machen Sie die Pfade explizit, geben Sie sie in einer Diagnose aus, und an dem Tag, an dem ein Support-Techniker lease.json braucht, findet er sie auf Anhieb.
Nebula.NET testen
Härten Sie Ihren .NET-Code in wenigen Minuten — starten Sie mit der kostenlosen Edition.