diff --git a/.gitignore b/.gitignore
index c68b193..953f377 100644
--- a/.gitignore
+++ b/.gitignore
@@ -2,3 +2,4 @@ bin
 obj
 .vscode
 Releases
+/.vs
diff --git a/Build/build-osx.sh b/Build/build-osx.sh
index 8838bfd..1c84995 100755
--- a/Build/build-osx.sh
+++ b/Build/build-osx.sh
@@ -1,25 +1,18 @@
 #!/bin/zsh
 
 PROJECT="Command/Command.csproj"
-TARGET="net6.0"
+TARGET="net7.0"
 ARCHES=("osx-x64" "osx-arm64")
 SIGN_IDENTITY="Developer ID Application: Feist GmbH (DHNHQKSSYT)"
-ASC_PROVIDER="DHNHQKSSYT"
 ENTITLEMENTS="Build/notarization.entitlements"
-BUNDLE_ID="ch.sttz.install-unity"
 
 # Mapping of arche names used by .Net to the ones used by lipo
 typeset -A LIPO_ARCHES=()
 LIPO_ARCHES[osx-x64]=x86_64
 LIPO_ARCHES[osx-arm64]=arm64
 
-if [[ -z "$ASC_USER" ]]; then
-    echo "ASC user not set in ASC_USER"
-    exit 1
-fi
-
-if [[ -z "$ASC_KEYCHAIN" ]]; then
-    echo "ASC keychain item not set in ASC_KEYCHAIN"
+if [[ -z "$NOTARY_PROFILE" ]]; then
+    echo "notarytool keychain profile not set in NOTARY_PROFILE"
     exit 1
 fi
 
@@ -42,9 +35,7 @@ for arch in $ARCHES; do
         -r "$arch" \
         -c release \
         -f "$TARGET" \
-        -p:PublishSingleFile=true \
-        -p:PublishReadyToRun=true \
-        -p:PublishTrimmed=true \
+        --self-contained \
         "$PROJECT" \
         || exit 1
 
@@ -79,7 +70,7 @@ pushd "$ARCHIVE"
 zip "../install-unity-$VERSION.zip" "install-unity" || exit 1
 popd
 
-xcrun altool --notarize-app --primary-bundle-id "$BUNDLE_ID" --asc-provider "$ASC_PROVIDER" --username "$ASC_USER" --password "@keychain:$ASC_KEYCHAIN" --file "$ZIPARCHIVE" || exit 1
+xcrun notarytool submit --wait --keychain-profile "$NOTARY_PROFILE" --wait --progress "$ZIPARCHIVE" || exit 1
 
 # Shasum for Homebrew
 
diff --git a/Changelog.md b/Changelog.md
index ae90932..fdcd643 100644
--- a/Changelog.md
+++ b/Changelog.md
@@ -1,5 +1,29 @@
 # Changelog
 
+### 2.12.0 (2023-05-??)
+* Use Unity's official Release API to get release and package information
+  * Releases should appear quicker when Unity is slow to update their archive webpage
+  * Can directly request information of a specific Unity version from the API
+  * No need to load the whole archive, update can be stopped once the last known version is reached
+  * Reduces number of requests and amount of data transferred when updating cache
+  * Previously synthesized packages are now provided by Unity (Documentation, language packs and Android components)
+  * Legacy scraper and ini-based system can still be used for irregular Unity releases
+* Split platform and architecture options (e.g. `--platform macOSIntel` becomes `--platform mac_os --arch x68_64`)
+* Added `--clear-cache` to force clearing the versions and package cache
+* Added `--redownload` to force redownloading all files
+* Improve handling of already downloaded or partially downloaded files
+* Speed up detecting of current platform (.Net now reports Apple Silicon properly)
+* Speed up detecting installed Unity versions by keeping command line invocations to a minimum
+* Removed support for Unity patch releases
+* Update to .Net 7
+
+### 2.11.1 (2023-02-05)
+* Add warning when Spotlight is disabled and installations cannot be found
+* Update Android packages for Unity 2023.1
+* Fix discovery of beta and alpha releases
+* Fix Apple Silicon packages not saved in cache
+* Fix exception when cleaning up after installing additional packages to an installation at `/Applications/Unity`
+
 ### 2.11.0 (2022-09-03)
 * Add "--upgrade <version>" to `run` command to upgrade a project to a specific Unity version
 * Fix --allow-newer attempting to downgrade project if project Unity version is newer than installed versions
diff --git a/Command/Command.csproj b/Command/Command.csproj
index 1f090f8..6db3a55 100644
--- a/Command/Command.csproj
+++ b/Command/Command.csproj
@@ -2,12 +2,16 @@
 
   <PropertyGroup>
     <OutputType>Exe</OutputType>
-    <TargetFrameworks>net6.0</TargetFrameworks>
+    <TargetFrameworks>net7.0</TargetFrameworks>
+    <RuntimeIdentifiers>win-x64;osx-x64</RuntimeIdentifiers>
     <LangVersion>latest</LangVersion>
+    <PublishSingleFile>true</PublishSingleFile>
+    <PublishReadyToRun>true</PublishReadyToRun>
+    <PublishTrimmed>true</PublishTrimmed>
   </PropertyGroup>
 
   <PropertyGroup Label="Package">
-    <Version>2.11.0</Version>
+    <Version>2.12.0</Version>
     <Authors>Adrian Stutz (sttz.ch)</Authors>
     <Product>install-unity CLI</Product>
     <Description>CLI for install-unity unofficial Unity installer library</Description>
@@ -16,10 +20,11 @@
     <RepositoryUrl>https://github.com/sttz/install-unity</RepositoryUrl>
     <RepositoryType>git</RepositoryType>
     <PackageTags>CLI;Unity;Installer</PackageTags>
+    <ApplicationManifest>app.manifest</ApplicationManifest>
   </PropertyGroup>
 
   <ItemGroup>
-    <RdXmlFile Include="rd.xml" />
+    <TrimmerRootAssembly Include="sttz.InstallUnity" />
   </ItemGroup>
 
   <ItemGroup>
diff --git a/Command/Program.cs b/Command/Program.cs
index af6b162..18452e9 100644
--- a/Command/Program.cs
+++ b/Command/Program.cs
@@ -2,13 +2,13 @@
 using System.Collections.Generic;
 using System.IO;
 using System.Linq;
-using System.Reflection;
-using System.Runtime.InteropServices;
 using System.Text.RegularExpressions;
 using System.Threading.Tasks;
 using Microsoft.Extensions.Logging;
 using sttz.NiceConsoleLogger;
 
+using static sttz.InstallUnity.UnityReleaseAPIClient;
+
 namespace sttz.InstallUnity
 {
 
@@ -45,9 +45,17 @@ public class InstallUnityCLI
     /// </summary>
     public bool update;
     /// <summary>
-    /// Path to store all data at.
+    /// Clear the versions cache.
     /// </summary>
-    public CachePlatform platform;
+    public bool clearCache;
+    /// <summary>
+    /// Platform of the editor to download.
+    /// </summary>
+    public Platform platform;
+    /// <summary>
+    /// Architecture of the editor to download and/or install.
+    /// </summary>
+    public Architecture architecture;
     /// <summary>
     /// Path to store all data at.
     /// </summary>
@@ -88,6 +96,10 @@ public class InstallUnityCLI
     /// </summary>
     public bool upgrade;
     /// <summary>
+    /// Force redownloading all files.
+    /// </summary>
+    public bool redownload;
+    /// <summary>
     /// Skip size and hash checks for downloads.
     /// </summary>
     public bool yolo;
@@ -158,11 +170,13 @@ public override string ToString()
         var cmd = action ?? "";
         if (help) cmd += " --help";
         if (verbose > 0) cmd += string.Concat(Enumerable.Repeat(" --verbose", verbose));
+        if (clearCache) cmd += " --clear-cache";
         if (update) cmd += " --update";
         if (dataPath != null) cmd += " --data-path " + dataPath;
         if (options.Count > 0) cmd += " --opt " + string.Join(" ", options);
-        if (platform != CachePlatform.None) cmd += " --platform " + platform;
-        
+        if (platform != Platform.None) cmd += " --platform " + platform;
+        if (architecture != Architecture.None) cmd += " --arch " + platform;
+
         if (matchVersion != null) cmd += " " + matchVersion;
 
         if (installed) cmd += " --installed";
@@ -205,6 +219,8 @@ public static Arguments<InstallUnityCLI> ArgumentsDefinition {
                     .Description("Don't prompt for confirmation (use with care)")
                 .Option((InstallUnityCLI t, bool v) => t.update = v, "u", "update")
                     .Description("Force an update of the versions cache")
+                .Option((InstallUnityCLI t, bool v) => t.clearCache = v, "clear-cache")
+                    .Description("Clear the versions cache before running any commands")
                 .Option((InstallUnityCLI t, string v) => t.dataPath = v, "data-path", "datapath")
                     .ArgumentName("<path>")
                     .Description("Store all data at the given path, also don't delete packages after install")
@@ -221,18 +237,22 @@ public static Arguments<InstallUnityCLI> ArgumentsDefinition {
                     .Description("Pattern to match Unity version")
                 .Option((InstallUnityCLI t, bool v) => t.installed = v, "i", "installed")
                     .Description("List installed versions of Unity")
-                .Option((InstallUnityCLI t, CachePlatform v) => t.platform = v, "platform")
+                .Option((InstallUnityCLI t, Platform v) => t.platform = v, "platform")
                     .Description("Platform to list the versions for (default = current platform)")
-                
+                .Option((InstallUnityCLI t, Architecture v) => t.architecture = v, "arch")
+                    .Description("Architecture to list the versions for (default = current architecture)")
+
                 .Action("details", (t, a) => t.action = a)
                     .Description("Show version information and all its available packages")
-                
+
                 .Option((InstallUnityCLI t, string v) => t.matchVersion = v, 0)
                     .ArgumentName("<version>")
                     .Description("Pattern to match Unity version or release notes / unity hub url")
-                .Option((InstallUnityCLI t, CachePlatform v) => t.platform = v, "platform")
+                .Option((InstallUnityCLI t, Platform v) => t.platform = v, "platform")
                     .Description("Platform to show the details for (default = current platform)")
-                
+                .Option((InstallUnityCLI t, Architecture v) => t.architecture = v, "arch")
+                    .Description("Architecture to show the details for (default = current architecture)")
+
                 .Action("install", (t, a) => t.action = a)
                     .DefaultAction()
                     .Description("Download and install a version of Unity")
@@ -249,8 +269,12 @@ public static Arguments<InstallUnityCLI> ArgumentsDefinition {
                     .Description("Install previously downloaded packages (requires '--data-path')")
                 .Option((InstallUnityCLI t, bool v) => t.upgrade = v, "upgrade")
                     .Description("Replace existing matching Unity installation after successful install")
-                .Option((InstallUnityCLI t, CachePlatform v) => t.platform = v, "platform")
+                .Option((InstallUnityCLI t, Platform v) => t.platform = v, "platform")
                     .Description("Platform to download the packages for (only valid with '--download', default = current platform)")
+                .Option((InstallUnityCLI t, Architecture v) => t.architecture = v, "arch")
+                    .Description("Architecture to download the packages for (default = current architecture)")
+                .Option((InstallUnityCLI t, bool v) => t.redownload = v, "redownload")
+                    .Description("Force redownloading all files")
                 .Option((InstallUnityCLI t, bool v) => t.yolo = v, "yolo")
                     .Description("Skip size and hash checks of downloaded files")
                 
@@ -365,8 +389,8 @@ public void PrintHelp()
     /// </summary>
     public string GetVersion()
     {
-        var assembly = Assembly.GetExecutingAssembly();
-        return assembly.GetCustomAttribute<AssemblyInformationalVersionAttribute>().InformationalVersion;
+        var assembly = System.Reflection.Assembly.GetExecutingAssembly();
+        return System.Reflection.CustomAttributeExtensions.GetCustomAttribute<System.Reflection.AssemblyInformationalVersionAttribute>(assembly).InformationalVersion;
     }
 
     /// <summary>
@@ -411,13 +435,12 @@ public async Task<UnityVersion> Setup(bool avoidCacheUpate = false)
         if (!enableColors) Logger.LogInformation("Console colors disabled");
 
         // Set current platform
-        if (platform == CachePlatform.None) {
-            platform = await installer.Platform.GetCurrentPlatform();
+        if (platform == Platform.None || architecture == Architecture.None) {
+            var (defaultPlatform, defaultarch) = await installer.Platform.GetCurrentPlatform();
+            if (platform == Platform.None)         platform = defaultPlatform;
+            if (architecture == Architecture.None) architecture = defaultarch;
         }
-        Logger.LogDebug($"Selected platform {platform}");
-
-        // Enable generating virtual packages
-        VirtualPackages.Enable();
+        Logger.LogDebug($"Selected platform {platform}-{architecture}");
 
         // Parse version argument (positional argument)
         var version = new UnityVersion(matchVersion);
@@ -432,8 +455,8 @@ public async Task<UnityVersion> Setup(bool avoidCacheUpate = false)
                 Environment.Exit(0);
             } else if (options.Contains("save")) {
                 var configPath = installer.DataPath ?? installer.Platform.GetConfigurationDirectory();
-                configPath = Path.Combine(configPath, UnityInstaller.CONFIG_FILENAME);
-                if (File.Exists(configPath)) {
+                configPath = System.IO.Path.Combine(configPath, UnityInstaller.CONFIG_FILENAME);
+                if (System.IO.File.Exists(configPath)) {
                     Console.WriteLine($"Configuration file already exists:\n{configPath}");
                 } else {
                     installer.Configuration.Save(configPath);
@@ -452,6 +475,12 @@ public async Task<UnityVersion> Setup(bool avoidCacheUpate = false)
             }
         }
 
+        // Clear the versions cache (--clear-cache)
+        if (clearCache) {
+            installer.Versions.Clear();
+            installer.Versions.Save();
+        }
+
         // Update cache if needed or requested (--update)
         var updateType = version.type;
         if (updateType == UnityVersion.Type.Undefined) {
@@ -465,7 +494,7 @@ public async Task<UnityVersion> Setup(bool avoidCacheUpate = false)
         IEnumerable<VersionMetadata> newVersionsData;
         if (update || (!avoidCacheUpate && installer.IsCacheOutdated(updateType))) {
             WriteTitle("Updating Cache...");
-            newVersionsData = await installer.UpdateCache(platform, updateType);
+            newVersionsData = await installer.UpdateCache(platform, architecture, updateType);
 
             var total = newVersionsData.Count();
             var maxVersions = 10;
@@ -473,8 +502,9 @@ public async Task<UnityVersion> Setup(bool avoidCacheUpate = false)
                 Console.WriteLine("No new Unity versions");
             } else if (total > 0) {
                 Console.WriteLine($"New Unity version{(total > 1 ? "s" : "")}:");
-                foreach (var newVersion in newVersionsData.OrderByDescending(m => m.version).Take(maxVersions)) {
-                    Console.WriteLine($"- {newVersion.version} ({installer.Scraper.GetReleaseNotesUrl(newVersion)})");
+                foreach (var newVersion in newVersionsData.OrderByDescending(m => m.Version).Take(maxVersions)) {
+                    var url = Scraper.GetReleaseNotesUrl(newVersion.release.stream, newVersion.Version);
+                    Console.WriteLine($"- {newVersion.Version} ({url})");
                 }
                 if (total - maxVersions > 0) {
                     Console.WriteLine($"And {total - maxVersions} more...");
@@ -483,7 +513,7 @@ public async Task<UnityVersion> Setup(bool avoidCacheUpate = false)
 
             if (total <= maxVersions) {
                 newVersions = new HashSet<UnityVersion>();
-                foreach (var newVersion in newVersionsData.Select(d => d.version)) {
+                foreach (var newVersion in newVersionsData.Select(d => d.Version)) {
                     newVersions.Add(newVersion);
                 }
             }
@@ -517,52 +547,74 @@ public async Task<UnityVersion> Setup(bool avoidCacheUpate = false)
                 Logger.LogInformation($"Got url instead of version, trying to find version at url...");
                 metadata = await installer.Scraper.LoadUrl(matchVersion);
 
-                if (!metadata.version.IsValid) {
+                if (!metadata.Version.IsValid) {
                     throw new Exception("Could not find version at url: " + versionString);
                 }
             }
 
-            version = metadata.version;
+            version = metadata.Version;
 
         } else {
             // Locate version in cache or look it up
             metadata = installer.Versions.Find(version);
-            if (!metadata.version.IsValid) {
+            if (!metadata.Version.IsValid) {
                 if (installOnly) {
                     throw new Exception("Could not find version matching input: " + version);
                 }
 
                 try {
                     Logger.LogInformation($"Version {version} not found in cache, trying exact lookup");
-                    metadata = await installer.Scraper.LoadExact(version);
+                    metadata.release = await installer.Releases.FindRelease(version, platform, architecture);
                 } catch (Exception e) {
                     Logger.LogInformation("Failed exact lookup: " + e.Message);
                 }
 
-                if (!metadata.version.IsValid) {
+                if (!metadata.Version.IsValid) {
                     throw new Exception("Could not find version matching input: " + version);
                 }
 
                 installer.Versions.Add(metadata);
                 installer.Versions.Save();
-                Console.WriteLine($"Guessed release notes URL to discover {metadata.version}");
+                Console.WriteLine($"Guessed release notes URL to discover {metadata.Version}");
             }
         }
 
-        if (!metadata.version.MatchesVersionOrHash(version)) {
+        if (!metadata.Version.MatchesVersionOrHash(version)) {
             Console.WriteLine();
-            ConsoleLogger.WriteLine($"Selected <white bg=darkgray>{metadata.version}</white> for input {version}");
+            ConsoleLogger.WriteLine($"Selected <white bg=darkgray>{metadata.Version}</white> for input {version}");
         }
 
         // Load packages ini if needed
-        if (!metadata.HasPackagesMetadata(platform)) {
+        var editor = metadata.GetEditorDownload(platform, architecture);
+        if (editor == null) {
             if (installOnly) {
                 throw new Exception("Packages not found in versions cache (install only): " + version);
             }
-            Logger.LogInformation("Packages not yet loaded, loading ini now");
-            metadata = await installer.Scraper.LoadPackages(metadata, platform);
-            installer.Versions.Add(metadata);
-            installer.Versions.Save();
+
+            // Try to load version from API
+            // The API doesn't allow lookup by hash, so we have to check if after loading
+            Logger.LogInformation($"Missing packages for {platform}-{architecture} in cache, loading from Release API");
+            var release = await installer.Releases.FindRelease(metadata.Version, platform, architecture);
+            if (release != null && (metadata.Version.hash == null || release.version.hash == metadata.Version.hash)) {
+                editor = release.downloads.Where(d => d.platform == platform && d.architecture == architecture).FirstOrDefault();
+                if (editor != null) {
+                    metadata.SetEditorDownload(editor);
+                    installer.Versions.Add(metadata);
+                    installer.Versions.Save();
+                }
+            }
+
+            // Fall back to look up version using legacy INI system (for e.g. test releases of the editor)
+            if (editor == null && !string.IsNullOrEmpty(metadata.baseUrl)) {
+                Logger.LogInformation("Loading packages from legacy INI system");
+                metadata = await installer.Scraper.LoadPackages(metadata, platform, architecture);
+                installer.Versions.Add(metadata);
+                installer.Versions.Save();
+            }
+
+            if (editor == null) {
+                throw new Exception($"Could not load packages for {metadata.Version} on {platform}-{architecture}");
+            }
         }
 
         return (version, metadata);
@@ -619,15 +671,15 @@ public async Task ListUpdates()
 
         if (!installs.Any()) {
             var latest = installer.Versions
-                .Where(m => m.IsFinalRelease)
-                .OrderByDescending(m => m.version)
+                .Where(m => (m.release.stream & ReleaseStream.PrereleaseMask) == 0)
+                .OrderByDescending(m => m.Version)
                 .FirstOrDefault();
-            
+
             Console.WriteLine("No installed Unity versions found.");
 
-            if (latest.version.IsValid) {
+            if (latest.Version.IsValid) {
                 Console.WriteLine();
-                Console.WriteLine($"The latest Unity version available is {latest.version.ToString(verbose > 0)}.");
+                Console.WriteLine($"The latest Unity version available is {latest.Version.ToString(verbose > 0)}.");
             }
 
         } else {
@@ -635,9 +687,9 @@ public async Task ListUpdates()
             var updates = new List<(Installation install, VersionMetadata update)>();
             foreach (var install in installs.OrderByDescending(i => i.version)) {
                 var newerPatch = installer.Versions
-                    .Where(m => IsNewerPatch(m.version, install.version))
+                    .Where(m => IsNewerPatch(m.Version, install.version))
                     .FirstOrDefault();
-                if (newerPatch.version.IsValid) {
+                if (newerPatch.Version.IsValid) {
                     updates.Add((install, newerPatch));
                 }
             }
@@ -647,29 +699,29 @@ public async Task ListUpdates()
             } else {
                 WriteTitle("Minor updates to installed Unity versions:");
                 foreach (var update in updates) {
-                    Console.WriteLine($"- {update.install.version.ToString(verbose > 0)} ➤ {update.update.version.ToString(verbose > 0)}");
+                    Console.WriteLine($"- {update.install.version.ToString(verbose > 0)} ➤ {update.update.Version.ToString(verbose > 0)}");
                 }
             }
 
             // Compile latest version for each major.minor release
             var mms = installer.Versions
-                .OrderByDescending(m => m.version)
-                .GroupBy(m => (major: m.version.major, minor: m.version.minor))
+                .OrderByDescending(m => m.Version)
+                .GroupBy(m => (major: m.Version.major, minor: m.Version.minor))
                 .Select(g => g.First())
                 .Reverse();
 
             // Find major/minor new Unity versions not yet installed
             var latest = installs.OrderByDescending(i => i.version).First();
             var newer = mms
-                .Where(m => m.IsFinalRelease && m.version > latest.version);
+                .Where(m => (m.release.stream & ReleaseStream.PrereleaseMask) == 0 && m.Version > latest.version);
 
             if (newer.Any()) {
                 WriteTitle("New major Unity versions:");
                 foreach (var m in newer) {
                     if (verbose > 0) {
-                        Console.WriteLine($"- {m.version}");
+                        Console.WriteLine($"- {m.Version}");
                     } else {
-                        Console.WriteLine($"- {m.version.major}.{m.version.minor}");
+                        Console.WriteLine($"- {m.Version.major}.{m.Version.minor}");
                     }
                 }
             }
@@ -677,19 +729,19 @@ public async Task ListUpdates()
             // Find alpha/beta/RC versions not yet installed
             var abs = mms
                 .Where(m => 
-                    m.IsPrerelease 
-                    && m.version > latest.version 
-                    && (m.version.major != latest.version.major
-                    || m.version.minor != latest.version.minor)
+                    (m.release.stream & ReleaseStream.PrereleaseMask) != 0
+                    && m.Version > latest.version 
+                    && (m.Version.major != latest.version.major
+                    || m.Version.minor != latest.version.minor)
                 );
             
             if (abs.Any()) {
                 WriteTitle("New Unity release candidates, betas and alphas:");
                 foreach (var m in abs) {
                     if (verbose > 0) {
-                        Console.WriteLine($"- {m.version}");
+                        Console.WriteLine($"- {m.Version}");
                     } else {
-                        Console.WriteLine($"- {m.version.major}.{m.version.minor}{(char)m.version.type}");
+                        Console.WriteLine($"- {m.Version.major}.{m.Version.minor}{(char)m.Version.type}");
                     }
                 }
             }
@@ -739,8 +791,8 @@ public void VersionsTable(UnityInstaller installer, UnityVersion version, IEnume
         var currentList = new List<VersionMetadata>();
         int lastMajor = -1, lastMinor = -1;
         foreach (var metadata in installer.Versions) {
-            if (!metadata.IsFuzzyMatchedBy(version)) continue;
-            var other = metadata.version;
+            if (!version.FuzzyMatches(metadata.Version)) continue;
+            var other = metadata.Version;
 
             if (lastMinor < 0) lastMinor = other.minor;
             else if (lastMinor != other.minor) {
@@ -769,10 +821,9 @@ public void VersionsTable(UnityInstaller installer, UnityVersion version, IEnume
         // Write the generated columns line by line, wrapping to buffer size
         var colWidth = (verbose > 0 ? ListVersionsWithHashColumnWith : ListVersionsColumnWidth);
         var maxColumns = Math.Max(Console.BufferWidth / colWidth, 1);
-        var hasReleaseCandidate = false;
         foreach (var majorRow in majorRows) {
             // Major version separator / title
-            var major = majorRow[0][0].version.major;
+            var major = majorRow[0][0].Version.major;
             WriteBigTitle(major.ToString());
             
             var groupCount = (majorRow.Count - 1) / maxColumns + 1;
@@ -786,7 +837,7 @@ public void VersionsTable(UnityInstaller installer, UnityVersion version, IEnume
                             Console.SetCursorPosition((c - columnOffset) * colWidth, Console.CursorTop);
 
                             SetColors(ConsoleColor.White, ConsoleColor.DarkGray);
-                            var minorVersion = majorRow[c][0].version;
+                            var minorVersion = majorRow[c][0].Version;
                             var title = minorVersion.major + "." + minorVersion.minor;
                             Console.Write(title);
 
@@ -797,14 +848,10 @@ public void VersionsTable(UnityInstaller installer, UnityVersion version, IEnume
                         Console.SetCursorPosition((c - columnOffset) * colWidth, Console.CursorTop);
 
                         var m = majorRow[c][r];
-                        Console.Write(m.version.ToString(verbose > 0));
-                        if (m.IsReleaseCandidate) {
-                            hasReleaseCandidate = true;
-                            Console.Write("*");
-                        }
+                        Console.Write(m.Version.ToString(verbose > 0));
 
-                        var isNewVersion = (newVersions != null && newVersions.Contains(m.version));
-                        var isInstalled = (installed != null && installed.Contains(m.version));
+                        var isNewVersion = (newVersions != null && newVersions.Contains(m.Version));
+                        var isInstalled = (installed != null && installed.Contains(m.Version));
 
                         if (isNewVersion || isInstalled) {
                             SetColors(ConsoleColor.White, ConsoleColor.DarkGray);
@@ -819,10 +866,6 @@ public void VersionsTable(UnityInstaller installer, UnityVersion version, IEnume
                 Console.WriteLine();
             }
         }
-
-        if (hasReleaseCandidate) {
-            Console.WriteLine("* indicates a release candidate, it will only be selected by an exact match or as a beta version.");
-        }
     }
 
     // -------- Version Details --------
@@ -834,82 +877,129 @@ public async Task Details()
         ShowDetails(installer, metadata);
     }
 
-    void ShortPackagesList(VersionMetadata metadata)
+    void ShortModulesList(VersionMetadata metadata)
     {
-        var packageMetadata = metadata.GetPackages(platform);
-        var list = string.Join(", ", packageMetadata
+        var editor = metadata.GetEditorDownload(platform, architecture);
+
+        var list = editor.AllModules.Values
             .Where(p => !p.hidden)
-            .Select(p => p.name + (p.install ? "*" : ""))
-            .ToArray()
-        );
-        Console.WriteLine(packageMetadata.Count() + " Packages: " + list);
+            .Select(p => p.id + (p.preSelected ? "*" : ""))
+            .OrderBy(p => p)
+            .Prepend("unity");
+        if (list.Any()) {
+            Console.WriteLine(list.Count() + " Packages: " + string.Join(", ", list));
+            Console.WriteLine();
+        }
+
+        list = editor.AllModules.Values
+            .Where(p => p.hidden)
+            .Select(p => p.id + (p.preSelected ? "*" : ""))
+            .OrderBy(p => p);
+        if (list.Any()) {
+            Console.WriteLine(list.Count() + " Hidden Packages: " + string.Join(", ", list));
+            Console.WriteLine();
+        }
+
         Console.WriteLine("* = default package");
         Console.WriteLine();
     }
 
-    void DetailedPackagesList(IEnumerable<PackageMetadata> packages)
+    void DetailedModulesList(IEnumerable<Module> modules)
     {
         fieldWidth = 14;
-        foreach (var package in packages) {
+        foreach (var module in modules) {
             SetColors(ConsoleColor.DarkGray, ConsoleColor.DarkGray);
             Console.Write("--------------- ");
             SetForeground(ConsoleColor.White);
-            Console.Write(package.name + (package.install ? "* " : " "));
+            Console.Write($"{module.name} [{module.id}] " + (module.preSelected ? " *" : ""));
             ResetColor();
             Console.WriteLine();
 
-            WriteField("Title", package.title);
-            WriteField("Description", package.description);
-            WriteField("URL", package.url);
-            WriteField("Mandatory", (package.mandatory ? "yes" : null));
-            WriteField("Hidden", (package.hidden ? "yes" : null));
-            WriteField("Size", $"{Helpers.FormatSize(package.size)} ({Helpers.FormatSize(package.installedsize)} installed)");
-            WriteField("EULA", package.eulamessage);
-            if (package.eulalabel1 != null && package.eulaurl1 != null)
-                WriteField("", package.eulalabel1 + ": " + package.eulaurl1);
-            if (package.eulalabel2 != null && package.eulaurl2 != null)
-                WriteField("", package.eulalabel2 + ": " + package.eulaurl2);
-            WriteField("Install with", package.sync);
-            WriteField("MD5", package.md5);
+            WriteField("Description", module.description);
+            WriteField("URL", module.url);
+            WriteField("Required", (module.required ? "yes" : null));
+            WriteField("Hidden", (module.hidden ? "yes" : null));
+            WriteField("Size", $"{Helpers.FormatSize(module.downloadSize.GetBytes())} ({Helpers.FormatSize(module.installedSize.GetBytes())} installed)");
+
+            if (module.eula?.Length > 0) {
+                WriteField("EULA", module.eula[0].message);
+                foreach (var eula in module.eula) {
+                    WriteField("", eula.label + ": " + eula.url);
+                }
+            }
+
+            if (module.subModules?.Count > 0)
+                WriteField("Sub-Modules", string.Join(", ", module.subModules.Select(m => m.name)));
+            if (module.parentModuleId != null)
+                WriteField("Parent Module", module.parentModuleId);
+
+            WriteField("Hash", module.integrity);
 
             Console.WriteLine();
         }
     }
 
-    void ShowDetails(UnityInstaller installer, VersionMetadata metadata)
+    void EditorPackageDetails(EditorDownload module)
     {
-        var packageMetadata = metadata.GetPackages(platform);
+        fieldWidth = 14;
 
-        WriteBigTitle($"Details for Unity {metadata.version}");
+        SetColors(ConsoleColor.DarkGray, ConsoleColor.DarkGray);
+        Console.Write("--------------- ");
+        SetForeground(ConsoleColor.White);
+        Console.Write($"Unity Editor [{module.Id}]" + " *");
+        ResetColor();
+        Console.WriteLine();
 
-        if (metadata.IsReleaseCandidate) {
-            ConsoleLogger.WriteLine(
-                "<white bg=darkgray>This is a release candidate:</white> "
-                + "Even though it has an f version, it will only be selected " 
-                + "by an exact match or as a beta version.");
-            Console.WriteLine();
-        }
+        WriteField("Description", "Main Unity Editor package");
+        WriteField("URL", module.url);
+        WriteField("Size", $"{Helpers.FormatSize(module.downloadSize.GetBytes())} ({Helpers.FormatSize(module.installedSize.GetBytes())} installed)");
+
+        WriteField("Hash", module.integrity);
+
+        Console.WriteLine();
+    }
+
+    void ShowDetails(UnityInstaller installer, VersionMetadata metadata)
+    {
+        var editor = metadata.GetEditorDownload(platform, architecture);
+
+        WriteBigTitle($"Details for Unity {metadata.Version} {editor.platform}-{editor.architecture}");
 
         if (metadata.baseUrl != null) {
             Console.WriteLine("Base URL: " + metadata.baseUrl);
         }
 
-        var releaseNotes = installer.Scraper.GetReleaseNotesUrl(metadata);
+        var releaseNotes = Scraper.GetReleaseNotesUrl(metadata.release.stream, metadata.Version);
         if (releaseNotes != null) {
             Console.WriteLine("Release notes: " + releaseNotes);
         }
 
         Console.WriteLine();
 
-        ShortPackagesList(metadata);
+        ShortModulesList(metadata);
 
-        DetailedPackagesList(packageMetadata.Where(p => !p.hidden).OrderBy(p => p.name));
+        EditorPackageDetails(editor);
+        DetailedModulesList(IterateModulesRecursive(editor.modules, hidden: false));
 
-        var hidden = packageMetadata.Where(p => p.hidden).OrderBy(p => p.name);
+        var hidden = IterateModulesRecursive(editor.modules, hidden: true);
         if (hidden.Any()) {
             WriteTitle("Hidden Packages");
             Console.WriteLine();
-            DetailedPackagesList(hidden);
+            DetailedModulesList(hidden);
+        }
+    }
+
+    IEnumerable<Module> IterateModulesRecursive(IEnumerable<Module> modules, bool hidden)
+    {
+        foreach (var module in modules) {
+            if (module.hidden == hidden)
+                yield return module;
+
+            if (module.subModules != null) {
+                foreach (var subModule in IterateModulesRecursive(module.subModules, hidden)) {
+                    yield return subModule;
+                }
+            }
         }
     }
 
@@ -938,21 +1028,21 @@ public async Task Install()
         }
 
         if (op != UnityInstaller.InstallStep.Download) {
-            var installable = await installer.Platform.GetInstallablePlatforms();
-            if (!installable.Contains(platform)) {
-                throw new Exception($"Cannot install {platform} on the current platform.");
+            var installable = await installer.Platform.GetInstallableArchitectures();
+            if (!installable.HasFlag(architecture)) {
+                throw new Exception($"Cannot install {architecture} on the current platform ({platform}).");
             }
         }
 
         VersionMetadata metadata;
         (version, metadata) = await SelectAndLoad(version, matchVersion, op == UnityInstaller.InstallStep.Install);
-        var packageMetadata = metadata.GetPackages(platform);
+        var editor = metadata.GetEditorDownload(platform, architecture);
 
         // Determine packages to install (-p / --packages or defaultPackages option)
         IEnumerable<string> selection = packages;
         if (string.Equals(selection.FirstOrDefault(), "all", StringComparison.OrdinalIgnoreCase)) {
             Logger.LogInformation("Found 'all', selecting all available packages");
-            selection = packageMetadata.Select(p => p.name);
+            selection = editor.AllModules.Keys;
         } else if (!selection.Any()) {
             Console.WriteLine();
             if (installer.Configuration.defaultPackages != null) {
@@ -960,34 +1050,34 @@ public async Task Install()
                 selection = installer.Configuration.defaultPackages;
             } else {
                 Console.WriteLine("Selecting default packages (select packages with '--packages', see available packages with 'details')");
-                selection = installer.GetDefaultPackages(metadata, platform);
+                selection = installer.GetDefaultPackages(metadata, platform, architecture);
             }
         }
 
         var notFound = new List<string>();
-        var resolved = installer.ResolvePackages(metadata, platform, selection, notFound: notFound);
+        var resolved = installer.ResolvePackages(metadata, platform, architecture, selection, notFound: notFound);
 
         // Check version to be installed against already installed
         Installation uninstall = null;
         if (upgrade || (op & UnityInstaller.InstallStep.Install) > 0) {
-            var freshInstall = resolved.Any(p => p.name == PackageMetadata.EDITOR_PACKAGE_NAME);
+            var freshInstall = resolved.Any(p => p is EditorDownload);
             var installs = await installer.Platform.FindInstallations();
-            var existing = installs.FirstOrDefault(i => i.version == metadata.version);
+            var existing = installs.FirstOrDefault(i => i.version == metadata.Version);
             if (!freshInstall && existing == null) {
-                throw new Exception($"Installing additional packages but Unity {metadata.version} hasn't been installed yet (add the 'Unity' package to install it).");
+                throw new Exception($"Installing additional packages but Unity {metadata.Version} hasn't been installed yet (add the 'Unity' package to install it).");
             } else if (freshInstall && existing != null) {
                 if (upgrade) {
-                    Console.WriteLine($"Unity {metadata.version} already installed at '{existing.path}', nothing to upgrade.");
+                    Console.WriteLine($"Unity {metadata.Version} already installed at '{existing.path}', nothing to upgrade.");
                     Environment.Exit(0);
                 } else {
-                    throw new Exception($"Unity {metadata.version} already installed at '{existing.path}' (remove the 'Unity' package to install additional packages).");
+                    throw new Exception($"Unity {metadata.Version} already installed at '{existing.path}' (remove the 'Unity' package to install additional packages).");
                 }
             }
 
             // Find version to upgrade
             if (upgrade) {
                 uninstall = installs
-                    .Where(i => i.version <= metadata.version)
+                    .Where(i => i.version <= metadata.Version)
                     .OrderByDescending(i => i.version)
                     .FirstOrDefault();
                 Console.WriteLine();
@@ -1003,21 +1093,21 @@ public async Task Install()
 
         WriteTitle("Selected packages:");
         long totalSpace = 0, totalDownload = 0;
-        var packageList = resolved.OrderBy(p => p.name);
-        foreach (var package in packageList.Where(p => !p.addedAutomatically)) {
-            totalSpace += package.installedsize;
-            totalDownload += package.size;
-            Console.WriteLine($"- {package.name} ({Helpers.FormatSize(package.size)})");
+        var packageList = resolved.OrderBy(p => p.Id);
+        foreach (var package in packageList.Where(p => !(p is Module module) || !module.addedAutomatically)) {
+            totalSpace += package.installedSize.GetBytes();
+            totalDownload += package.downloadSize.GetBytes();
+            Console.WriteLine($"- {package.Id} ({Helpers.FormatSize(package.downloadSize.GetBytes())})");
         }
 
-        var deps = packageList.Where(p => p.addedAutomatically);
+        var deps = packageList.OfType<Module>().Where(m => m.addedAutomatically);
         if (deps.Any()) {
             WriteTitle("Additional dependencies:");
             Console.WriteLine("Dependencies are added automatically, prefix a package with = to install only that package.");
             foreach (var package in deps) {
-                totalSpace += package.installedsize;
-                totalDownload += package.size;
-                Console.WriteLine($"- {package.name} ({Helpers.FormatSize(package.size)}) [from {package.sync}]");
+                totalSpace += package.installedSize.GetBytes();
+                totalDownload += package.downloadSize.GetBytes();
+                Console.WriteLine($"- {package.name} ({Helpers.FormatSize(package.downloadSize.GetBytes())}) [from {package.parentModule.Id}]");
             }
         }
 
@@ -1038,16 +1128,15 @@ public async Task Install()
 
         // Make user accept additional EULAs
         var hasEula = false;
-        foreach (var package in resolved) {
-            if (package.eulamessage == null) continue;
+        foreach (var module in resolved.OfType<Module>()) {
+            if (module.eula == null || module.eula.Length == 0) continue;
             hasEula = true;
             Console.WriteLine();
             SetForeground(ConsoleColor.Yellow);
-            Console.WriteLine($"Installing '{package.name}' requires accepting following EULA(s).");
-            Console.WriteLine(package.eulamessage);
-            Console.WriteLine($"- {package.eulalabel1}: {package.eulaurl1}");
-            if (package.eulalabel2 != null) {
-                Console.WriteLine($"- {package.eulalabel2}: {package.eulaurl2}");
+            Console.WriteLine($"Installing '{module.name}' requires accepting following EULA(s).");
+            foreach (var eula in module.eula) {
+                Console.WriteLine(eula.message);
+                Console.WriteLine($"- {eula.label}: {eula.url}");
             }
             ResetColor();
         }
@@ -1077,10 +1166,14 @@ public async Task Install()
         var downloadPath = installer.GetDownloadDirectory(metadata);
         Logger.LogInformation($"Downloading packages to '{downloadPath}'");
 
+        var existingFile = Downloader.ExistingFile.Undefined;
+        if (redownload) existingFile = Downloader.ExistingFile.Redownload;
+        else if (yolo)  existingFile = Downloader.ExistingFile.Skip;
+
         Installation installed = null;
-        var queue = installer.CreateQueue(metadata, platform, downloadPath, resolved);
-        if (installer.Configuration.progressBar && !Console.IsOutputRedirected) {
-            var processTask = installer.Process(op, queue, yolo);
+        var queue = installer.CreateQueue(metadata, platform, architecture, downloadPath, resolved);
+        if (installer.Configuration.progressBar && !Console.IsOutputRedirected && verbose < 2) {
+            var processTask = installer.Process(op, queue, existingFile);
 
             try {
                 var refreshInterval = installer.Configuration.progressRefreshInterval;
@@ -1103,12 +1196,12 @@ public async Task Install()
             }
         } else {
             Logger.LogInformation("Progress bar is disabled");
-            installed = await installer.Process(op, queue, yolo);
+            installed = await installer.Process(op, queue, existingFile);
         }
 
         if (dataPath == null) {
             Logger.LogInformation("Cleaning up downloaded packages ('--data-path' not set)");
-            installer.CleanUpDownloads(metadata, downloadPath, resolved);
+            installer.CleanUpDownloads(queue);
         }
 
         if (uninstall != null) {
@@ -1128,7 +1221,7 @@ public async Task Install()
 
     void WriteQueueStatus(UnityInstaller.Queue queue, long updateCount, int statusInterval)
     {
-        var longestName = Math.Max(queue.items.Max(i => i.package.name.Length), 12);
+        var longestName = Math.Max(queue.items.Max(i => i.package.Id.Length), 12);
         var longestStatus = queue.items.Max(i => i.status?.Length ?? 37);
 
         Console.Write(new string(' ', Console.BufferWidth));
@@ -1164,7 +1257,7 @@ void WriteQueueStatus(UnityInstaller.Queue queue, long updateCount, int statusIn
             ResetColor();
 
             Console.Write(" ");
-            Console.Write(item.package.name);
+            Console.Write(item.package.Id);
             Console.Write(new string(' ', Math.Max(barStartCol - Console.CursorLeft, 0)));
 
             var progressWidth = Console.BufferWidth - longestName - 6; // 4 for status, 2 padding
@@ -1242,9 +1335,9 @@ public async Task Uninstall()
         Installation uninstall = null;
         var version = new UnityVersion(matchVersion);
         if (!version.IsValid) {
-            var fullPath = Path.GetFullPath(matchVersion);
+            var fullPath = System.IO.Path.GetFullPath(matchVersion);
             foreach (var install in installs) {
-                var fullInstallPath = Path.GetFullPath(install.path);
+                var fullInstallPath = System.IO.Path.GetFullPath(install.path);
                 if (fullPath == fullInstallPath) {
                     uninstall = install;
                     break;
@@ -1312,20 +1405,20 @@ public async Task Run()
             version = default;
 
             projectPath = matchVersion;
-            if (!Directory.Exists(projectPath)) {
+            if (!System.IO.Directory.Exists(projectPath)) {
                 throw new Exception($"Project path '{projectPath}' does not exist.");
             }
 
-            var versionPath = Path.Combine(projectPath, "ProjectSettings", "ProjectVersion.txt");
-            if (!File.Exists(versionPath)) {
+            var versionPath = System.IO.Path.Combine(projectPath, "ProjectSettings", "ProjectVersion.txt");
+            if (!System.IO.File.Exists(versionPath)) {
                 throw new Exception($"ProjectVersion.txt not found at expected path: {versionPath}");
             }
 
             // Use full path, Unity doesn't properly recognize short relative paths
             // (as of Unity 2019.3)
-            projectPath = Path.GetFullPath(projectPath);
+            projectPath = System.IO.Path.GetFullPath(projectPath);
 
-            var lines = File.ReadAllLines(versionPath);
+            var lines = System.IO.File.ReadAllLines(versionPath);
             foreach (var line in lines) {
                 if (line.StartsWith("m_EditorVersion:") || line.StartsWith("m_EditorVersionWithRevision:")) {
                     var colonIndex = line.IndexOf(':');
@@ -1405,7 +1498,7 @@ public async Task Run()
         }
 
         if (projectPath != null) {
-            var projectName = Path.GetFileName(projectPath);
+            var projectName = System.IO.Path.GetFileName(projectPath);
             if (installation == null) {
                 Logger.LogError($"Could not run project '{projectName}', Unity {version} not installed");
 
diff --git a/Command/app.manifest b/Command/app.manifest
new file mode 100644
index 0000000..438ee0d
--- /dev/null
+++ b/Command/app.manifest
@@ -0,0 +1,79 @@
+<?xml version="1.0" encoding="utf-8"?>
+<assembly manifestVersion="1.0" xmlns="urn:schemas-microsoft-com:asm.v1">
+  <assemblyIdentity version="1.0.0.0" name="MyApplication.app"/>
+  <trustInfo xmlns="urn:schemas-microsoft-com:asm.v2">
+    <security>
+      <requestedPrivileges xmlns="urn:schemas-microsoft-com:asm.v3">
+        <!-- UAC Manifest Options
+             If you want to change the Windows User Account Control level replace the 
+             requestedExecutionLevel node with one of the following.
+
+        <requestedExecutionLevel  level="asInvoker" uiAccess="false" />
+        <requestedExecutionLevel  level="requireAdministrator" uiAccess="false" />
+        <requestedExecutionLevel  level="highestAvailable" uiAccess="false" />
+
+            Specifying requestedExecutionLevel element will disable file and registry virtualization. 
+            Remove this element if your application requires this virtualization for backwards
+            compatibility.
+        -->
+        <requestedExecutionLevel level="requireAdministrator" uiAccess="false" />
+      </requestedPrivileges>
+    </security>
+  </trustInfo>
+
+  <compatibility xmlns="urn:schemas-microsoft-com:compatibility.v1">
+    <application>
+      <!-- A list of the Windows versions that this application has been tested on
+           and is designed to work with. Uncomment the appropriate elements
+           and Windows will automatically select the most compatible environment. -->
+
+      <!-- Windows Vista -->
+      <!--<supportedOS Id="{e2011457-1546-43c5-a5fe-008deee3d3f0}" />-->
+
+      <!-- Windows 7 -->
+      <!--<supportedOS Id="{35138b9a-5d96-4fbd-8e2d-a2440225f93a}" />-->
+
+      <!-- Windows 8 -->
+      <!--<supportedOS Id="{4a2f28e3-53b9-4441-ba9c-d69d4a4a6e38}" />-->
+
+      <!-- Windows 8.1 -->
+      <!--<supportedOS Id="{1f676c76-80e1-4239-95bb-83d0f6d0da78}" />-->
+
+      <!-- Windows 10 -->
+      <!--<supportedOS Id="{8e0f7a12-bfb3-4fe8-b9a5-48fd50a15a9a}" />-->
+
+    </application>
+  </compatibility>
+
+  <!-- Indicates that the application is DPI-aware and will not be automatically scaled by Windows at higher
+       DPIs. Windows Presentation Foundation (WPF) applications are automatically DPI-aware and do not need 
+       to opt in. Windows Forms applications targeting .NET Framework 4.6 that opt into this setting, should 
+       also set the 'EnableWindowsFormsHighDpiAutoResizing' setting to 'true' in their app.config. 
+       
+       Makes the application long-path aware. See https://docs.microsoft.com/windows/win32/fileio/maximum-file-path-limitation -->
+  <!--
+  <application xmlns="urn:schemas-microsoft-com:asm.v3">
+    <windowsSettings>
+      <dpiAware xmlns="http://schemas.microsoft.com/SMI/2005/WindowsSettings">true</dpiAware>
+      <longPathAware xmlns="http://schemas.microsoft.com/SMI/2016/WindowsSettings">true</longPathAware>
+    </windowsSettings>
+  </application>
+  -->
+
+  <!-- Enable themes for Windows common controls and dialogs (Windows XP and later) -->
+  <!--
+  <dependency>
+    <dependentAssembly>
+      <assemblyIdentity
+          type="win32"
+          name="Microsoft.Windows.Common-Controls"
+          version="6.0.0.0"
+          processorArchitecture="*"
+          publicKeyToken="6595b64144ccf1df"
+          language="*"
+        />
+    </dependentAssembly>
+  </dependency>
+  -->
+
+</assembly>
diff --git a/Command/rd.xml b/Command/rd.xml
deleted file mode 100644
index cd8be85..0000000
--- a/Command/rd.xml
+++ /dev/null
@@ -1,15 +0,0 @@
-<Directives>
-    <Application>
-        <Assembly Name="sttz.InstallUnity">
-            <Type Name="sttz.InstallUnity.Configuration" Dynamic="Required All" />
-        </Assembly>
-        <Assembly Name="mscorlib" />
-        <Assembly Name="System.Linq.Expressions">
-            <Type Name="System.Linq.Expressions.ExpressionCreator`1[[Newtonsoft.Json.Serialization.ObjectConstructor`1[[System.Object,System.Private.CoreLib]],Newtonsoft.Json]]" Dynamic="Required All" />
-            <Type Name="System.Linq.Expressions.ExpressionCreator`1[[System.Func`2[[System.Object,System.Private.CoreLib],[System.Object,System.Private.CoreLib]],System.Private.CoreLib]]" Dynamic="Required All" />
-        </Assembly>
-        <Assembly Name="Microsoft.Extensions.Logging">
-            <Type Name="Microsoft.Extensions.Logging.ProviderAliasAttribute" Dynamic="Required All" />
-        </Assembly>
-    </Application>
-</Directives>
\ No newline at end of file
diff --git a/Readme.md b/Readme.md
index db6521b..477d000 100644
--- a/Readme.md
+++ b/Readme.md
@@ -2,7 +2,7 @@
 
 A command-line utility to install any recent version of Unity.
 
-Currently only supports macOS (Intel & Apple Silicon) but support for Windows/Linux is possible, PRs welcome.
+Currently only supports macOS (Intel & Apple Silicon) and Windows, but support for Linux is possible, PRs welcome.
 
 ## Table of Contents
 
@@ -25,6 +25,12 @@ Installing the latest release version of Unity is as simple as:
 
     install-unity install f
 
+# How to build plugin on Windows
+
+```shell
+dotnet publish -r win-x64 -c Release --self-contained --framework net6.0
+```
+
 ## Versions
 
 Most commands take a version as input, either to select the version to install or to filter the output.
@@ -111,19 +117,14 @@ The project will use Unity's default setup, including packages. Alternatively, y
 
     install-unity create --type minimal 2020.1 ~/Desktop/my-project
 
-### Patch Releases
-
-With the switch to LTS versions, Unity has stopped creating patch releases for Unity 2017.3 and newer. install-unity no longer scans for patch releases but you can still install them by specifying the full version number.
-
-    install-unity install 2017.2.3p3
-
 ## CLI Help
 
 ````
-install-unity v2.11.0
+install-unity v2.12.0
 
 USAGE: install-unity [--help] [--version] [--verbose...] [--yes] [--update] 
-                     [--data-path <path>] [--opt <name>=<value>...] <action> 
+                     [--clear-cache] [--data-path <path>] 
+                     [--opt <name>=<value>...] <action> 
 
 GLOBAL OPTIONS:
  -h, --help       Show this help 
@@ -131,6 +132,7 @@ GLOBAL OPTIONS:
  -v, --verbose    Increase verbosity of output, can be repeated 
  -y, --yes        Don't prompt for confirmation (use with care) 
  -u, --update     Force an update of the versions cache 
+     --clear-cache  Clear the versions cache before running any commands 
      --data-path <path>  Store all data at the given path, also don't delete 
                   packages after install 
      --opt <name>=<value>  Set additional options. Use '--opt list' to show all 
@@ -145,8 +147,9 @@ ACTIONS:
 
 USAGE: install-unity [options] [install] [--packages <name,name>...] 
                      [--download] [--install] [--upgrade] 
-                     [--platform none|macosintel|macosarm|windows|linux] 
-                     [--yolo] [<version>] 
+                     [--platform none|mac_os|linux|windows|all] 
+                     [--arch none|x86_64|arm64|all] [--redownload] [--yolo] 
+                     [<version>] 
 
 OPTIONS:
  <version>        Pattern to match Unity version or release notes / unity hub 
@@ -158,9 +161,12 @@ OPTIONS:
                   '--data-path') 
      --upgrade    Replace existing matching Unity installation after successful 
                   install 
-     --platform none|macosintel|macosarm|windows|linux  Platform to download 
-                  the packages for (only valid with '--download', default = 
-                  current platform) 
+     --platform none|mac_os|linux|windows|all  Platform to download the 
+                  packages for (only valid with '--download', default = current 
+                  platform) 
+     --arch none|x86_64|arm64|all  Architecture to download the packages for 
+                  (default = current architecture) 
+     --redownload  Force redownloading all files 
      --yolo       Skip size and hash checks of downloaded files 
 
 
@@ -168,28 +174,32 @@ OPTIONS:
      Get an overview of available or installed Unity versions 
 
 USAGE: install-unity [options] list [--installed] 
-                     [--platform none|macosintel|macosarm|windows|linux] 
-                     [<version>] 
+                     [--platform none|mac_os|linux|windows|all] 
+                     [--arch none|x86_64|arm64|all] [<version>] 
 
 OPTIONS:
  <version>        Pattern to match Unity version 
  -i, --installed  List installed versions of Unity 
-     --platform none|macosintel|macosarm|windows|linux  Platform to list the 
-                  versions for (default = current platform) 
+     --platform none|mac_os|linux|windows|all  Platform to list the versions 
+                  for (default = current platform) 
+     --arch none|x86_64|arm64|all  Architecture to list the versions for 
+                  (default = current architecture) 
 
 
 ---- DETAILS:
      Show version information and all its available packages 
 
 USAGE: install-unity [options] details 
-                     [--platform none|macosintel|macosarm|windows|linux] 
-                     [<version>] 
+                     [--platform none|mac_os|linux|windows|all] 
+                     [--arch none|x86_64|arm64|all] [<version>] 
 
 OPTIONS:
  <version>        Pattern to match Unity version or release notes / unity hub 
                   url 
-     --platform none|macosintel|macosarm|windows|linux  Platform to show the 
-                  details for (default = current platform) 
+     --platform none|mac_os|linux|windows|all  Platform to show the details for 
+                  (default = current platform) 
+     --arch none|x86_64|arm64|all  Architecture to show the details for 
+                  (default = current architecture) 
 
 
 ---- UNINSTALL:
diff --git a/Tests/Tests.csproj b/Tests/Tests.csproj
index 63039f8..05cb0df 100644
--- a/Tests/Tests.csproj
+++ b/Tests/Tests.csproj
@@ -1,8 +1,9 @@
 <Project Sdk="Microsoft.NET.Sdk">
 
   <PropertyGroup>
-    <TargetFramework>net6.0</TargetFramework>
-    <LangVersion>7.1</LangVersion>
+    <TargetFramework>net7.0</TargetFramework>
+    <RuntimeIdentifiers>win-x64;osx-x64</RuntimeIdentifiers>
+    <LangVersion>latest</LangVersion>
     <IsPackable>false</IsPackable>
   </PropertyGroup>
 
diff --git a/sttz.InstallUnity/Installer/Configuration.cs b/sttz.InstallUnity/Installer/Configuration.cs
index aeb2a7a..ca3037a 100644
--- a/sttz.InstallUnity/Installer/Configuration.cs
+++ b/sttz.InstallUnity/Installer/Configuration.cs
@@ -15,7 +15,10 @@ namespace sttz.InstallUnity
 public class Configuration
 {
     [Description("After how many seconds the cache is considered to be outdated.")]
-    public int cacheLifetime = 60 * 60 * 24; // 24 hours
+    public int cacheLifetime = 60 * 60 * 16; // 16 hours
+
+    [Description("Maximum age of Unity releases to load when refreshing the cache (days).")]
+    public int latestMaxAge = 90; // 90 days
 
     [Description("Delay between requests when scraping.")]
     public int scrapeDelayMs = 50;
@@ -62,6 +65,17 @@ public class Configuration
         + "/Applications/Unity {major}.{minor}.{patch}{type}{build};"
         + "/Applications/Unity {major}.{minor}.{patch}{type}{build} ({hash})";
 
+    [Description("Windows installation paths, separted by ; (first non-existing will be used, variables: {major} {minor} {patch} {type} {build} {hash} {ProgramFiles}).")]
+    public string installPathWindows = 
+          "{ProgramFiles}\\Unity {major}.{minor}.{patch}{type}{build};"
+        + "{ProgramFiles}\\Unity {major}.{minor}.{patch}{type}{build} ({hash});";
+
+    [Description("Windows directories which are searched for Unity installations, separted by ; (variables: {ProgramFiles}).")]
+    public string searchPathWindows = 
+          "{ProgramFiles};"
+        + "{ProgramFiles}\\Unity\\Editor;"
+        + "{ProgramFiles}\\Unity\\Hub\\Editor;";
+
     // -------- Serialization --------
 
     /// <summary>
diff --git a/sttz.InstallUnity/Installer/Downloader.cs b/sttz.InstallUnity/Installer/Downloader.cs
index 6839035..97f3d6a 100644
--- a/sttz.InstallUnity/Installer/Downloader.cs
+++ b/sttz.InstallUnity/Installer/Downloader.cs
@@ -1,12 +1,9 @@
 using System;
 using System.Collections.Generic;
-using System.Diagnostics.CodeAnalysis;
 using System.IO;
-using System.Linq;
 using System.Net;
 using System.Net.Http;
 using System.Net.Http.Headers;
-using System.Reflection;
 using System.Security.Cryptography;
 using System.Text;
 using System.Threading;
@@ -23,6 +20,31 @@ public class Downloader
 {
     // -------- Settings --------
 
+    /// <summary>
+    /// How to handle existing files.
+    /// </summary>
+    public enum ExistingFile
+    {
+        /// <summary>
+        /// Undefined behaviour, will default to Resume.
+        /// </summary>
+        Undefined,
+
+        /// <summary>
+        /// Always redownload, overwriting existing files.
+        /// </summary>
+        Redownload,
+        /// <summary>
+        /// Try to hash and/or resume existing file,
+        /// will fall back to redownloading and overwriting.
+        /// </summary>
+        Resume,
+        /// <summary>
+        /// Do not hash or touch existing files and complete immediately.
+        /// </summary>
+        Skip
+    }
+
     /// <summary>
     /// Url of the file to download.
     /// </summary>
@@ -39,20 +61,14 @@ public class Downloader
     public long ExpectedSize { get; protected set; }
 
     /// <summary>
-    /// Expected hash of the file (computed with <see cref="HashAlgorithm"/>).
+    /// Expected hash of the file (in WRC SRI format).
     /// </summary>
     public string ExpectedHash { get; protected set; }
 
     /// <summary>
-    /// Try to resume download of partially downloaded files.
-    /// </summary>
-    public bool Resume = true;
-
-    /// <summary>
-    /// Hash algorithm used to compute hash (null = don't compute hash).
+    /// How to handle existing files.
     /// </summary>
-    [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicMethods)]
-    public Type HashAlgorithm = typeof(MD5);
+    public ExistingFile Existing = ExistingFile.Resume;
 
     /// <summary>
     /// Buffer size used when downloading.
@@ -132,7 +148,7 @@ public enum State
     /// <summary>
     /// The hash after the file has been downloaded.
     /// </summary>
-    public string Hash { get; protected set; }
+    public byte[] Hash { get; protected set; }
 
     /// <summary>
     /// Event called for every <see cref="BufferSize"/> of data processed.
@@ -184,6 +200,10 @@ public void Reset()
             blocks = null;
             watch = null;
         }
+
+        if (Existing == ExistingFile.Undefined) {
+            Existing = ExistingFile.Resume;
+        }
     }
 
     /// <summary>
@@ -192,8 +212,22 @@ public void Reset()
     public bool CheckHash()
     {
         if (Hash == null) throw new InvalidOperationException("No Hash set.");
-        if (ExpectedHash == null) throw new InvalidOperationException("No ExpectedHash set.");
-        return string.Equals(ExpectedHash, Hash, StringComparison.OrdinalIgnoreCase);
+        if (string.IsNullOrEmpty(ExpectedHash)) throw new InvalidOperationException("No ExpectedHash set.");
+
+        var hash = SplitSRIHash(ExpectedHash);
+
+        var base64Hash = Convert.ToBase64String(Hash);
+        if (string.Equals(hash.value, base64Hash, StringComparison.OrdinalIgnoreCase))
+            return true;
+
+        // Unity generates their hashes in a non-standard way
+        // W3C SRI specifies the hash to be base64 encoded form the raw hash bytes
+        // but Unity takes the hex-encoded string of the hash and base64-encodes that
+        var hexBase64Hash = Convert.ToBase64String(Encoding.UTF8.GetBytes(Helpers.ToHexString(Hash)));
+        if (string.Equals(hash.value, hexBase64Hash, StringComparison.OrdinalIgnoreCase))
+            return true;
+
+        return false;
     }
 
     /// <summary>
@@ -201,12 +235,13 @@ public bool CheckHash()
     /// </summary>
     public async Task AssertExistingFileHash(CancellationToken cancellation = default)
     {
-        if (ExpectedHash == null) throw new InvalidOperationException("No ExpectedHash set.");
+        if (string.IsNullOrEmpty(ExpectedHash)) throw new InvalidOperationException("No ExpectedHash set.");
         if (!File.Exists(TargetPath)) return;
 
+        var hash = SplitSRIHash(ExpectedHash);
         HashAlgorithm hasher = null;
-        if (HashAlgorithm != null) {
-            hasher = CreateHashAlgorithm(HashAlgorithm);
+        if (hash.algorithm != null) {
+            hasher = CreateHashAlgorithm(hash.algorithm);
         }
 
         using (var input = File.Open(TargetPath, FileMode.Open, FileAccess.Read)) {
@@ -214,10 +249,10 @@ public async Task AssertExistingFileHash(CancellationToken cancellation = defaul
             await CopyToAsync(input, Stream.Null, hasher, cancellation);
         }
         hasher.TransformFinalBlock(new byte[0], 0, 0);
-        Hash = Helpers.ToHexString(hasher.Hash);
+        Hash = hasher.Hash;
 
         if (!CheckHash()) {
-            throw new Exception($"Existing file '{TargetPath}' does not match expected hash (got {Hash}, expected {ExpectedHash}).");
+            throw new Exception($"Existing file '{TargetPath}' does not match expected hash (got {Convert.ToBase64String(Hash)}, expected {hash.value}).");
         }
     }
 
@@ -231,8 +266,11 @@ public async Task Start(CancellationToken cancellation = default)
 
         try {
             HashAlgorithm hasher = null;
-            if (HashAlgorithm != null) {
-                hasher = CreateHashAlgorithm(HashAlgorithm);
+            if (!string.IsNullOrEmpty(ExpectedHash)) {
+                var hash = SplitSRIHash(ExpectedHash);
+                if (hash.algorithm != null) {
+                    hasher = CreateHashAlgorithm(hash.algorithm);
+                }
             }
 
             var filename = Path.GetFileName(TargetPath);
@@ -240,42 +278,18 @@ public async Task Start(CancellationToken cancellation = default)
             // Check existing file
             var mode = FileMode.Create;
             var startOffset = 0L;
-            if (File.Exists(TargetPath) && Resume) {
-                // Try to resume existing file
-                var fileInfo = new FileInfo(TargetPath);
-                if (ExpectedSize > 0 && fileInfo.Length >= ExpectedSize) {
-                    if (hasher != null) {
-                        using (var input = File.Open(TargetPath, FileMode.Open, FileAccess.Read)) {
-                            CurrentState = State.Hashing;
-                            await CopyToAsync(input, Stream.Null, hasher, cancellation);
-                        }
-                        hasher.TransformFinalBlock(new byte[0], 0, 0);
-                        Hash = Helpers.ToHexString(hasher.Hash);
-                        if (ExpectedHash != null) {
-                            if (CheckHash()) {
-                                Logger.LogInformation($"Existing file '{filename}' has matching hash, skipping...");
-                                CurrentState = State.Complete;
-                                return;
-                            } else {
-                                // Hash mismatch, force redownload
-                                Logger.LogWarning($"Existing file '{filename}' has different hash: Got {Hash} but expected {ExpectedHash}. Will redownload...");
-                                startOffset = 0;
-                                mode = FileMode.Create;
-                            }
-                        } else {
-                            Logger.LogInformation($"Existing file '{filename}' has hash {Hash} but we have nothing to check against, assuming it's ok...");
-                        }
-                    } else {
-                        // Assume file is good
-                        Logger.LogInformation($"Existing file '{filename}' cannot be checked for integrity, assuming it's ok...");
-                        CurrentState = State.Complete;
-                        return;
-                    }
-                
-                } else {
-                    Logger.LogInformation($"Resuming partial download of '{filename}' ({Helpers.FormatSize(fileInfo.Length)} already downloaded)...");
-                    startOffset = fileInfo.Length;
+            if (File.Exists(TargetPath)) {
+                // Handle existing file from a previous download
+                var existing = await HandleExistingFile(hasher, cancellation);
+                if (existing.complete) {
+                    CurrentState = State.Complete;
+                    return;
+                } else if (existing.startOffset > 0) {
+                    startOffset = existing.startOffset;
                     mode = FileMode.Append;
+                } else {
+                    startOffset = 0;
+                    mode = FileMode.Create;
                 }
             }
 
@@ -288,6 +302,13 @@ public async Task Start(CancellationToken cancellation = default)
             if (client.Timeout != TimeSpan.FromSeconds(Timeout))
                 client.Timeout = TimeSpan.FromSeconds(Timeout);
             var response = await client.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, cancellation);
+
+            if (response.StatusCode == HttpStatusCode.RequestedRangeNotSatisfiable) {
+                // Disable resuming for next attempt
+                Existing = ExistingFile.Redownload;
+                throw new Exception($"Failed to resume, disabled resume for '{filename}' (HTTP Code 416)");
+            }
+
             response.EnsureSuccessStatusCode();
 
             // Redownload whole file if resuming fails
@@ -323,18 +344,18 @@ public async Task Start(CancellationToken cancellation = default)
 
             if (hasher != null) {
                 hasher.TransformFinalBlock(new byte[0], 0, 0);
-                Hash = Helpers.ToHexString(hasher.Hash);
+                Hash = hasher.Hash;
             }
 
-            if (Hash != null && ExpectedHash != null && !CheckHash()) {
-                if (ExpectedHash == null) {
-                    Logger.LogInformation($"Downloaded file '{filename}' with hash {Hash}");
+            if (Hash != null && !string.IsNullOrEmpty(ExpectedHash) && !CheckHash()) {
+                if (string.IsNullOrEmpty(ExpectedHash)) {
+                    Logger.LogInformation($"Downloaded file '{filename}' with hash {Convert.ToBase64String(Hash)}");
                     CurrentState = State.Complete;
                 } else if (CheckHash()) {
-                    Logger.LogInformation($"Downloaded file '{filename}' with expected hash {Hash}");
+                    Logger.LogInformation($"Downloaded file '{filename}' with expected hash {Convert.ToBase64String(Hash)}");
                     CurrentState = State.Complete;
                 } else {
-                    throw new Exception($"Downloaded file '{filename}' does not match expected hash (got {Hash} but expected {ExpectedHash})");
+                    throw new Exception($"Downloaded file '{filename}' does not match expected hash (got {Convert.ToBase64String(Hash)} but expected {ExpectedHash})");
                 }
             } else {
                 Logger.LogInformation($"Downloaded file '{filename}'");
@@ -346,6 +367,54 @@ public async Task Start(CancellationToken cancellation = default)
         }
     }
 
+    async Task<(bool complete, long startOffset)> HandleExistingFile(HashAlgorithm hasher, CancellationToken cancellation)
+    {
+        if (Existing == ExistingFile.Skip) {
+            // Complete without checking or resuming
+            return (true, -1);
+        }
+
+        var filename = Path.GetFileName(TargetPath);
+
+        if (Existing == ExistingFile.Resume) {
+            var hashChecked = false;
+            if (!string.IsNullOrEmpty(ExpectedHash) && hasher != null) {
+                // If we have a hash, always check against hash first
+                using (var input = File.Open(TargetPath, FileMode.Open, FileAccess.Read)) {
+                    CurrentState = State.Hashing;
+                    await CopyToAsync(input, Stream.Null, hasher, cancellation);
+                }
+                hasher.TransformFinalBlock(new byte[0], 0, 0);
+                Hash = hasher.Hash;
+
+                if (CheckHash()) {
+                    Logger.LogInformation($"Existing file '{filename}' has matching hash, skipping...");
+                    return (true, -1);
+                } else {
+                    hashChecked = true;
+                }
+            }
+
+            if (ExpectedSize > 0) {
+                var fileInfo = new FileInfo(TargetPath);
+                if (fileInfo.Length >= ExpectedSize && !hashChecked) {
+                    // No hash and big enough, Assume file is good
+                    Logger.LogInformation($"Existing file '{filename}' cannot be checked for integrity, assuming it's ok...");
+                    return (true, -1);
+
+                } else {
+                    // File smaller than it should be, try resuming
+                    Logger.LogInformation($"Resuming partial download of '{filename}' ({Helpers.FormatSize(fileInfo.Length)} already downloaded)...");
+                    return (false, fileInfo.Length);
+                }
+            }
+        }
+
+        // Force redownload from start
+        Logger.LogWarning($"Redownloading existing file '{filename}'");
+        return (false, 0);
+    }
+
     /// <summary>
     /// Helper method to copy the stream with a progress callback.
     /// </summary>
@@ -393,17 +462,36 @@ async Task CopyToAsync(Stream input, Stream output, HashAlgorithm hasher, Cancel
     }
 
     /// <summary>
-    /// Create a HashAlgorithm instance from the given type that is subclass of HashAlgorithm.
-    /// The type needs to implement a static Create method that takes no arguments and
-    /// return the HashAlgorithm instance.
+    /// Split a WRC SRI string into hash algorithm and hash value.
     /// </summary>
-    static HashAlgorithm CreateHashAlgorithm(Type type)
+    (string algorithm, string value) SplitSRIHash(string hash)
     {
-        var createMethod = type.GetMethod("Create", BindingFlags.Public | BindingFlags.Static, new Type[0]);
-        if (createMethod == null) {
-            throw new Exception($"Could not find static Create method on hash algorithm type '{type}'");
+        if (string.IsNullOrEmpty(hash))
+            return (null, null);
+
+        var firstDash = hash.IndexOf('-');
+        if (firstDash < 0) return (null, hash);
+
+        var hashName = hash.Substring(0, firstDash).ToLowerInvariant();
+        var hashValue = hash.Substring(firstDash + 1);
+
+        return (hashName, hashValue);
+    }
+
+    /// <summary>
+    /// Create a hash algorithm instance from a hash name.
+    /// </summary>
+    HashAlgorithm CreateHashAlgorithm(string hashName)
+    {
+        switch (hashName) {
+            case "md5": return MD5.Create();
+            case "sha256": return SHA256.Create();
+            case "sha512": return SHA512.Create();
+            case "sha384": return SHA384.Create();
         }
-        return (HashAlgorithm)createMethod.Invoke(null, null);
+
+        Logger.LogError($"Unsupported hash algorithm: '{hashName}'");
+        return null;
     }
 }
 
diff --git a/sttz.InstallUnity/Installer/IInstallerPlatform.cs b/sttz.InstallUnity/Installer/IInstallerPlatform.cs
index 19f1acd..dd60f81 100644
--- a/sttz.InstallUnity/Installer/IInstallerPlatform.cs
+++ b/sttz.InstallUnity/Installer/IInstallerPlatform.cs
@@ -2,6 +2,8 @@
 using System.Threading;
 using System.Threading.Tasks;
 
+using static sttz.InstallUnity.UnityReleaseAPIClient;
+
 namespace sttz.InstallUnity
 {
 
@@ -34,18 +36,27 @@ public interface IInstallerPlatform
     /// <summary>
     /// The platform that should be used by default.
     /// </summary>
-    Task<CachePlatform> GetCurrentPlatform();
+    Task<(Platform, Architecture)> GetCurrentPlatform();
 
     /// <summary>
-    /// Get platforms that can be installed on the current platform.
+    /// Get architectures that can be installed on the current platform.
     /// </summary>
-    Task<IEnumerable<CachePlatform>> GetInstallablePlatforms();
+    Task<Architecture> GetInstallableArchitectures();
 
     /// <summary>
     /// The path to the file where settings are stored.
     /// </summary>
     string GetConfigurationDirectory();
 
+    /// <summary>
+    /// Set the configuration instance to use.
+    /// </summary>
+    /// <remarks>
+    /// Note that other methods might be called before the configuraiton
+    /// is set, namely <see cref="GetConfigurationDirectory"/>.
+    /// </remarks>
+    void SetConfiguration(Configuration configuration);
+
     /// <summary>
     /// The directory where cache files are stored.
     /// </summary>
@@ -103,7 +114,7 @@ public interface IInstallerPlatform
     /// <summary>
     /// Uninstall a Unity installation.
     /// </summary>
-    Task Uninstall(Installation instalation, CancellationToken cancellation = default);
+    Task Uninstall(Installation installation, CancellationToken cancellation = default);
 
     /// <summary>
     /// Run a Unity installation with the given arguments.
diff --git a/sttz.InstallUnity/Installer/Platforms/MacPlatform.cs b/sttz.InstallUnity/Installer/Platforms/MacPlatform.cs
index 45af46f..ff477d4 100644
--- a/sttz.InstallUnity/Installer/Platforms/MacPlatform.cs
+++ b/sttz.InstallUnity/Installer/Platforms/MacPlatform.cs
@@ -2,12 +2,14 @@
 using System.Collections.Generic;
 using System.IO;
 using System.Linq;
-using System.Runtime.InteropServices;
 using System.Text.RegularExpressions;
 using System.Threading;
 using System.Threading.Tasks;
+using Claunia.PropertyList;
 using Microsoft.Extensions.Logging;
 
+using static sttz.InstallUnity.UnityReleaseAPIClient;
+
 namespace sttz.InstallUnity
 {
 
@@ -44,34 +46,30 @@ public class MacPlatform : IInstallerPlatform
 
     // -------- IInstallerPlatform --------
 
-    public async Task<CachePlatform> GetCurrentPlatform()
+    public Task<(Platform, Architecture)> GetCurrentPlatform()
     {
-        var result = await Command.Run("uname", "-a");
-        if (result.exitCode != 0) {
-            throw new Exception($"ERROR: {result.error}");
-        }
-
-        if (result.output.Contains("_ARM64_")) {
-            return CachePlatform.macOSArm;
-        } else if (result.output.Contains("x86_64")) {
-            return CachePlatform.macOSIntel;
+        #if !NET7_0_OR_GREATER
+            #error MacPlatform requires .Net 7 or newer to reliably detect Arm64 Macs
+        #endif
+
+        var arch = System.Runtime.InteropServices.RuntimeInformation.OSArchitecture;
+        switch (arch) {
+            case System.Runtime.InteropServices.Architecture.Arm64:
+                return Task.FromResult((Platform.Mac_OS, Architecture.ARM64));
+            case System.Runtime.InteropServices.Architecture.X64:
+                return Task.FromResult((Platform.Mac_OS, Architecture.X86_64));
+            default:
+                throw new Exception($"Unexpected macOS architecture: {arch}");
         }
-
-        throw new Exception($"Unknown runtime architecture: '{result.output.Trim()}'");
     }
 
-    public async Task<IEnumerable<CachePlatform>> GetInstallablePlatforms()
+    public async Task<Architecture> GetInstallableArchitectures()
     {
-        var platform = await GetCurrentPlatform();
-        if (platform == CachePlatform.macOSIntel) {
-            return new CachePlatform[] {
-                CachePlatform.macOSIntel
-            };
+        var (_, arch) = await GetCurrentPlatform();
+        if (arch == Architecture.X86_64) {
+            return Architecture.X86_64;
         } else {
-            return new CachePlatform[] {
-                CachePlatform.macOSIntel,
-                CachePlatform.macOSArm
-            };
+            return Architecture.ARM64 | Architecture.X86_64;
         }
     }
 
@@ -91,6 +89,11 @@ public string GetConfigurationDirectory()
         return GetUserApplicationSupportDirectory();
     }
 
+    public void SetConfiguration(Configuration configuration)
+    {
+        // Not used
+    }
+
     public string GetCacheDirectory()
     {
         return GetUserApplicationSupportDirectory();
@@ -155,23 +158,20 @@ public async Task<IEnumerable<Installation>> FindInstallations(CancellationToken
                 continue;
             }
 
-            var versionResult = await Command.Run("/usr/bin/defaults", $"read \"{appPath}/Contents/Info\" CFBundleVersion", null, cancellation);
-            if (versionResult.exitCode != 0) {
-                throw new Exception($"ERROR: {versionResult.error}");
-            }
+            // Extract version and build hash from Info.plist
+            var plistPath = Path.Combine(appPath, "Contents/Info.plist");
+            var rootDict = (NSDictionary)PropertyListParser.Parse(plistPath);
 
-            var version = new UnityVersion(versionResult.output.Trim());
+            var versionString = rootDict.ObjectForKey("CFBundleVersion")?.ToString() ?? "";
+
+            var version = new UnityVersion(versionString);
             if (!version.IsFullVersion) {
-                Logger.LogWarning($"Could not determine Unity version at path '{appPath}': {versionResult.output.Trim()}");
+                Logger.LogWarning($"Could not determine Unity version at path '{appPath}': {versionString}");
                 continue;
             }
 
-            var hashResult = await Command.Run("/usr/bin/defaults", $"read \"{appPath}/Contents/Info\" UnityBuildNumber", null, cancellation);
-            if (hashResult.exitCode != 0) {
-                throw new Exception($"ERROR: {hashResult.error}");
-            }
-
-            version.hash = hashResult.output.Trim();
+            var hashString = rootDict.ObjectForKey("UnityBuildNumber")?.ToString();
+            version.hash = hashString;
 
             var executable = ExecutableFromAppPath(appPath);
             if (executable == null) continue;
@@ -184,83 +184,120 @@ public async Task<IEnumerable<Installation>> FindInstallations(CancellationToken
             });
         }
 
+        if (installations.Count == 0) {
+            // Check spotlight status if we couldn't find any installations
+            var spotlightResult = await Command.Run("/usr/bin/mdutil", "-s /Applications", null, cancellation);
+            if (spotlightResult.exitCode != 0) {
+                Logger.LogWarning($"Could not determine Spotlight status of '/Applications', finding Unity installations might not work.");
+            } else if (spotlightResult.output.Contains("disabled") || spotlightResult.output.Contains("No index")) {
+                Logger.LogWarning($"Spotlight is disabled for '/Applications', existing Unity installations might not be found.");
+            }
+        }
+
         return installations;
     }
 
     public async Task PrepareInstall(UnityInstaller.Queue queue, string installationPaths, CancellationToken cancellation = default)
     {
-        if (installing.version.IsValid)
-            throw new InvalidOperationException($"Already installing another version: {installing.version}");
+        if (installing.Version.IsValid)
+            throw new InvalidOperationException($"Already installing another version: {installing.Version}");
 
         installing = queue.metadata;
         this.installationPaths = installationPaths;
         installedEditor = false;
 
-        // Move existing installation out of the way
-        movedExisting = false;
-        if (Directory.Exists(INSTALL_PATH)) {
-            if (Directory.Exists(INSTALL_PATH_TMP)) {
-                throw new InvalidOperationException($"Fallback installation path '{INSTALL_PATH_TMP}' already exists.");
-            }
-            Logger.LogInformation("Temporarily moving existing installation at default install path: " + INSTALL_PATH);
-            await Move(INSTALL_PATH, INSTALL_PATH_TMP, cancellation);
-            movedExisting = true;
-        }
-
         // Check for upgrading installation
         upgradeOriginalPath = null;
-        if (!queue.items.Any(i => i.package.name == PackageMetadata.EDITOR_PACKAGE_NAME)) {
+        if (!queue.items.Any(i => i.package is EditorDownload)) {
             var installs = await FindInstallations(cancellation);
-            var existingInstall = installs.Where(i => i.version == queue.metadata.version).FirstOrDefault();
+            var existingInstall = installs.Where(i => i.version == queue.metadata.Version).FirstOrDefault();
             if (existingInstall == null) {
-                throw new InvalidOperationException($"Not installing editor but version {queue.metadata.version} not already installed.");
+                throw new InvalidOperationException($"Not installing editor but version {queue.metadata.Version} not already installed.");
             }
 
             upgradeOriginalPath = existingInstall.path;
+        }
 
-            Logger.LogInformation($"Temporarily moving installation to upgrade from '{existingInstall}' to default install path");
-            await Move(existingInstall.path, INSTALL_PATH, cancellation);
+        // Move existing installation out of the way
+        movedExisting = false;
+        if (upgradeOriginalPath != INSTALL_PATH) {
+            if (Directory.Exists(INSTALL_PATH)) {
+                if (Directory.Exists(INSTALL_PATH_TMP)) {
+                    throw new InvalidOperationException($"Fallback installation path '{INSTALL_PATH_TMP}' already exists.");
+                }
+                Logger.LogInformation("Temporarily moving existing installation at default install path: " + INSTALL_PATH);
+                await Move(INSTALL_PATH, INSTALL_PATH_TMP, cancellation);
+                movedExisting = true;
+            }
+
+            // Check for upgrading installation
+            if (upgradeOriginalPath != null) {
+                Logger.LogInformation($"Temporarily moving installation to upgrade from '{upgradeOriginalPath}' to default install path");
+                await Move(upgradeOriginalPath, INSTALL_PATH, cancellation);
+            }
         }
     }
 
     public async Task Install(UnityInstaller.Queue queue, UnityInstaller.QueueItem item, CancellationToken cancellation = default)
     {
-        if (item.package.name != PackageMetadata.EDITOR_PACKAGE_NAME && !installedEditor && upgradeOriginalPath == null) {
+        if (item.package is not EditorDownload && !installedEditor && upgradeOriginalPath == null) {
             throw new InvalidOperationException("Cannot install package without installing editor first.");
         }
 
-        var extentsion = Path.GetExtension(item.filePath).ToLower();
-        if (extentsion == ".pkg") {
+        var module = (item.package as Module);
+
+        if (item.package is EditorDownload editor) {
+            // Install main editor
+            if (Path.GetExtension(item.filePath).ToLowerInvariant() != ".pkg")
+                throw new Exception($"Unexpected file type for editor package (expected PKG but got '{Path.GetFileName(item.filePath)}')");
             await InstallPkg(item.filePath, cancellation);
-        } else if (extentsion == ".dmg") {
-            await InstallDmg(item.filePath, cancellation);
-        } else if (extentsion == ".zip") {
-            await InstallZip(item.filePath, item.package.destination, item.package.renameFrom, item.package.renameTo, cancellation);
-        } else if (extentsion == ".po") {
-            await InstallFile(item.filePath, item.package.destination, cancellation);
+
         } else {
-            throw new Exception("Cannot install package of type: " + extentsion);
+            // Install additional module
+            var extension = Path.GetExtension(item.filePath);
+            switch (extension.ToLowerInvariant()) {
+                case ".pkg":
+                    await InstallPkg(item.filePath, cancellation);
+                    break;
+                case ".dmg":
+                    await InstallDmg(item.filePath, module.destination, cancellation);
+                    break;
+                case ".zip":
+                    await InstallZip(item.filePath, module.destination, cancellation);
+                    break;
+                case ".po":
+                    await InstallFile(item.filePath, module.destination, cancellation);
+                    break;
+                default:
+                    throw new Exception("Cannot install package of type: " + module.type);
+            }
+        }
+
+        if (module?.extractedPathRename.IsSet == true) {
+            await Rename(item.filePath, module.extractedPathRename, cancellation);
         }
 
-        if (item.package.name == PackageMetadata.EDITOR_PACKAGE_NAME) {
+        if (item.package is EditorDownload) {
             installedEditor = true;
         }
     }
 
     public async Task<Installation> CompleteInstall(bool aborted, CancellationToken cancellation = default)
     {
-        if (!installing.version.IsValid)
+        if (!installing.Version.IsValid)
             throw new InvalidOperationException("Not installing any version to complete");
 
         string destination = null;
         if (upgradeOriginalPath != null) {
             // Move back installation
             destination = upgradeOriginalPath;
-            Logger.LogInformation("Moving back upgraded installation to: " + destination);
-            await Move(INSTALL_PATH, destination, cancellation);
+            if (upgradeOriginalPath != INSTALL_PATH) {
+                Logger.LogInformation("Moving back upgraded installation to: " + destination);
+                await Move(INSTALL_PATH, destination, cancellation);
+            }
         } else if (!aborted) {
             // Move new installations to "Unity VERSION"
-            destination = GetUniqueInstallationPath(installing.version, installationPaths);
+            destination = GetUniqueInstallationPath(installing.Version, installationPaths);
             Logger.LogInformation("Moving newly installed version to: " + destination);
             await Move(INSTALL_PATH, destination, cancellation);
         } else if (aborted) {
@@ -280,7 +317,7 @@ public async Task<Installation> CompleteInstall(bool aborted, CancellationToken
             if (executable == null) return default;
 
             var installation = new Installation() {
-                version = installing.version,
+                version = installing.Version,
                 executable = executable,
                 path = destination
             };
@@ -318,10 +355,7 @@ public async Task Run(Installation installation, IEnumerable<string> arguments,
             Logger.LogInformation($"$ {cmd.StartInfo.FileName} {cmd.StartInfo.Arguments}");
             
             cmd.Start();
-            
-            while (!cmd.HasExited) {
-                await Task.Delay(100);
-            }
+            await cmd.WaitForExitAsync();
 
         } else {
             if (!arguments.Contains("-logFile")) {
@@ -349,12 +383,7 @@ public async Task Run(Installation installation, IEnumerable<string> arguments,
             cmd.Start();
             cmd.BeginOutputReadLine();
             cmd.BeginErrorReadLine();
-
-            while (!cmd.HasExited) {
-                await Task.Delay(100);
-            }
-
-            cmd.WaitForExit(); // Let stdout and stderr flush
+            await cmd.WaitForExitAsync(); // Let stdout and stderr flush
             Logger.LogInformation($"Unity exited with code {cmd.ExitCode}");
             Environment.Exit(cmd.ExitCode);
         }
@@ -390,8 +419,8 @@ string ExecutableFromAppPath(string appPath)
     /// </summary>
     async Task InstallPkg(string filePath, CancellationToken cancellation = default)
     {
-        var platform = await GetCurrentPlatform();
-        if (platform == CachePlatform.macOSIntel) {
+        var (_, arch) = await GetCurrentPlatform();
+        if (arch == Architecture.X86_64) {
             var result = await Sudo("/usr/sbin/installer", $"-pkg \"{filePath}\" -target \"{INSTALL_VOLUME}\" -verbose", cancellation);
             if (result.exitCode != 0) {
                 throw new Exception($"ERROR: {result.error}");
@@ -410,7 +439,7 @@ async Task InstallPkg(string filePath, CancellationToken cancellation = default)
     /// <summary>
     /// Install a DMG package by mounting it and copying the app bundle.
     /// </summary>
-    async Task InstallDmg(string filePath, CancellationToken cancellation = default)
+    async Task InstallDmg(string filePath, string destination = null, CancellationToken cancellation = default)
     {
         // Mount DMG
         var result = await Command.Run("/usr/bin/hdiutil", $"attach -nobrowse -mountrandom /tmp \"{filePath}\"", cancellation: cancellation);
@@ -436,13 +465,26 @@ async Task InstallDmg(string filePath, CancellationToken cancellation = default)
             if (apps.Length == 0) {
                 throw new Exception("No app bundles found in DMG.");
             }
-            
-            var targetDir = Path.Combine(INSTALL_VOLUME, "Applications");
+
+            string targetDir;
+            if (!string.IsNullOrEmpty(destination)) {
+                targetDir = destination.Replace("{UNITY_PATH}", INSTALL_PATH);
+            } else {
+                targetDir = Path.Combine(INSTALL_VOLUME, "Applications");
+            }
+
             foreach (var app in apps) {
-                var dst = Path.Combine(targetDir, Path.GetFileName(app));
+                var dst = targetDir;
+                if (string.IsNullOrEmpty(destination)) {
+                    // If we have a destination, we only copy the contents
+                    // So only create an app bundle directory without destination
+                    dst = Path.Combine(dst, Path.GetFileName(app));
+                }
+
                 if (Directory.Exists(dst) || File.Exists(dst)) {
                     await Delete(dst, cancellation);
                 }
+
                 await Copy(app, dst, cancellation);
             }
         } finally {
@@ -471,7 +513,7 @@ async Task InstallFile(string filePath, string destination, CancellationToken ca
     /// <summary>
     /// Unpack a Zip file to the given destination.
     /// </summary>
-    async Task InstallZip(string filePath, string destination, string renameFrom, string renameTo, CancellationToken cancellation = default)
+    async Task InstallZip(string filePath, string destination, CancellationToken cancellation = default)
     {
         if (string.IsNullOrEmpty(destination)) {
             throw new Exception($"Cannot install {filePath}: Zip packages must have a destination set.");
@@ -508,15 +550,19 @@ async Task InstallZip(string filePath, string destination, string renameFrom, st
 
         // Fix permissions, some files are not readable if installed with root
         await Sudo("/bin/chmod", $"-R o+rX \"{target}\"", cancellation);
+    }
 
-        if (!string.IsNullOrEmpty(renameFrom) && !string.IsNullOrEmpty(renameTo)) {
-            var from = renameFrom.Replace("{UNITY_PATH}", INSTALL_PATH);
-            var to = renameTo.Replace("{UNITY_PATH}", INSTALL_PATH);
-            if (!Directory.Exists(from) && !File.Exists(from)) {
-                throw new Exception($"{filePath}: renameFrom path does not exist: {from}");
-            }
-            await Move(from, to, cancellation);
+    /// <summary>
+    /// Rename an installed filed or folder after inital installation.
+    /// </summary>
+    async Task Rename(string filePath, PathRename rename, CancellationToken cancellation = default)
+    {
+        var from = rename.from.Replace("{UNITY_PATH}", INSTALL_PATH);
+        var to = rename.to.Replace("{UNITY_PATH}", INSTALL_PATH);
+        if (!Directory.Exists(from) && !File.Exists(from)) {
+            throw new Exception($"{filePath}: renameFrom path does not exist: {from}");
         }
+        await Move(from, to, cancellation);
     }
 
     /// <summary>
@@ -560,6 +606,15 @@ string GetUniqueInstallationPath(UnityVersion version, string installationPaths)
     /// </summary>
     async Task Move(string sourcePath, string newPath, CancellationToken cancellation)
     {
+        if (sourcePath.StartsWith(newPath + "/")) {
+            // We're moving something to replace one of its parent folders,
+            // we need to move it out of the parent first and then delte the parent
+            var tmpSource = Path.Combine(Path.GetTempPath(), UnityInstaller.PRODUCT_NAME, Path.GetFileName(newPath));
+            await Move(sourcePath, tmpSource, cancellation);
+            await Delete(newPath, cancellation);
+            sourcePath = tmpSource;
+        }
+
         var baseDst = Path.GetDirectoryName(newPath);
 
         try {
@@ -598,7 +653,7 @@ async Task Copy(string sourcePath, string newPath, CancellationToken cancellatio
                 throw new Exception($"ERROR: {result.error}");
             }
 
-            result = await Command.Run("/bin/cp", $"-R \"{sourcePath}\" \"{newPath}\"", cancellation: cancellation);
+            result = await Command.Run("/bin/cp", $"-a \"{sourcePath}\" \"{newPath}\"", cancellation: cancellation);
             if (result.exitCode != 0) {
                 throw new Exception($"ERROR: {result.error}");
             }
@@ -614,7 +669,7 @@ async Task Copy(string sourcePath, string newPath, CancellationToken cancellatio
             throw new Exception($"ERROR: {result.error}");
         }
 
-        result = await Sudo("/bin/mv", $"\"{sourcePath}\" \"{newPath}\"", cancellation);
+        result = await Sudo("/bin/cp", $"-a \"{sourcePath}\" \"{newPath}\"", cancellation);
         if (result.exitCode != 0) {
             throw new Exception($"ERROR: {result.error}");
         }
@@ -697,5 +752,4 @@ async Task<bool> CheckIsRoot(bool withSudo, CancellationToken cancellation)
         }
     }
 }
-
 }
diff --git a/sttz.InstallUnity/Installer/Platforms/WindowsPlatform.cs b/sttz.InstallUnity/Installer/Platforms/WindowsPlatform.cs
new file mode 100755
index 0000000..7ca8e36
--- /dev/null
+++ b/sttz.InstallUnity/Installer/Platforms/WindowsPlatform.cs
@@ -0,0 +1,392 @@
+using Microsoft.Extensions.Logging;
+using System;
+using System.Collections.Generic;
+using System.Diagnostics;
+using System.IO;
+using System.Linq;
+using System.Security.Principal;
+using System.Threading;
+using System.Threading.Tasks;
+
+using static sttz.InstallUnity.UnityReleaseAPIClient;
+
+namespace sttz.InstallUnity
+{
+
+/// <summary>
+/// Platform-specific installer code for Windows.
+/// </summary>
+public class WindowsPlatform : IInstallerPlatform
+{
+    /// <summary>
+    /// Default installation path.
+    /// </summary>
+    static readonly string INSTALL_PATH = Path.Combine(ProgramFilesPath, "Unity");
+
+    /// <summary>
+    /// Paths where Unity installations are searched in.
+    /// </summary>
+    static readonly string[] INSTALL_LOCATIONS = new string[] {
+        ProgramFilesPath,
+        Path.Combine(ProgramFilesPath, "Unity", "Editor"),
+        Path.Combine(ProgramFilesPath, "Unity", "Hub", "Editor"),
+    };
+
+    /// <summary>
+    /// Path to the program files directory.
+    /// </summary>
+    static string ProgramFilesPath { get {
+        if (System.Runtime.InteropServices.RuntimeInformation.OSArchitecture != System.Runtime.InteropServices.Architecture.X86
+            && System.Runtime.InteropServices.RuntimeInformation.ProcessArchitecture == System.Runtime.InteropServices.Architecture.X86) {
+            // The unity editor since 2017.1 is 64bit
+            // If install-unity is run as X86 on a non-X86 system, GetFolderPath will return
+            // the "Program Files (x86)" directory instead of the main one where the editor
+            // is likely installed.
+            throw new Exception($"install-unity cannot run as X86 on a non-X86 Windows");
+        }
+        return Environment.GetFolderPath(Environment.SpecialFolder.ProgramFiles);
+    } }
+
+    string GetLocalApplicationDataDirectory()
+    {
+        return Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
+            UnityInstaller.PRODUCT_NAME);
+    }
+
+    public void SetConfiguration(Configuration configuration)
+    {
+        this.configuration = configuration;
+    }
+
+    public async Task<Architecture> GetInstallableArchitectures()
+    {
+        var (_, arch) = await GetCurrentPlatform();
+        if (arch == Architecture.X86_64) {
+            return Architecture.X86_64;
+        } else {
+            return Architecture.ARM64 | Architecture.X86_64;
+        }
+    }
+
+    public Task<IEnumerable<Platform>> GetInstallablePlatforms()
+    {
+        IEnumerable<Platform> platforms = new Platform[] { Platform.Windows };
+        return Task.FromResult(platforms);
+    }
+
+    public string GetCacheDirectory()
+    {
+        return GetLocalApplicationDataDirectory();
+    }
+
+    public Task<(Platform, Architecture)> GetCurrentPlatform()
+    {
+        return Task.FromResult((Platform.Windows, Architecture.X86_64));
+    }
+
+    public string GetConfigurationDirectory()
+    {
+        return GetLocalApplicationDataDirectory();
+    }
+
+    public string GetDownloadDirectory()
+    {
+        return Path.Combine(Path.GetTempPath(), UnityInstaller.PRODUCT_NAME);
+    }
+
+    public Task<bool> IsAdmin(CancellationToken cancellation = default)
+    {
+#pragma warning disable CA1416 // Validate platform compatibility
+        return Task.FromResult(new WindowsPrincipal(WindowsIdentity.GetCurrent()).IsInRole(WindowsBuiltInRole.Administrator));
+#pragma warning restore CA1416 // Validate platform compatibility
+    }
+
+    public Task<Installation> CompleteInstall(bool aborted, CancellationToken cancellation = default)
+    {
+        if (!installing.Version.IsValid)
+            throw new InvalidOperationException("Not installing any version to complete");
+
+        if (!aborted)
+        {
+            var executable = Path.Combine(installPath, "Editor", "Unity.exe");
+            if (!File.Exists(executable))
+                throw new Exception($"Unity exe not found at expected path after installation: {installPath}");
+
+            var installation = new Installation()
+            {
+                version = installing.Version,
+                executable = executable,
+                path = installPath
+            };
+
+            installing = default;
+
+            return Task.FromResult(installation);
+        }
+        else
+        {
+            return Task.FromResult<Installation>(null);
+        }
+    }
+
+    public async Task<IEnumerable<Installation>> FindInstallations(CancellationToken cancellation = default)
+    {
+        var locations = INSTALL_LOCATIONS;
+        if (configuration != null && !string.IsNullOrEmpty(configuration.searchPathWindows)) {
+            locations = configuration.searchPathWindows.Split(';', StringSplitOptions.RemoveEmptyEntries);
+            var comparison = StringComparison.OrdinalIgnoreCase;
+            for (int i = 0; i < locations.Length; i++) {
+                locations[i] = Helpers.Replace(locations[i], "{ProgramFiles}", ProgramFilesPath, comparison);
+            }
+        }
+
+        var unityInstallations = new List<Installation>();
+        foreach (var installPath in locations)
+        {
+            if (!Directory.Exists(installPath))
+                continue;
+
+            Logger.LogDebug($"Searching directory for Unity installations: {installPath}");
+
+            foreach (var unityCandidate in Directory.EnumerateDirectories(installPath))
+            {
+                var unityExePath = Path.Combine(unityCandidate, "Editor", "Unity.exe");
+                if (!File.Exists(unityExePath))
+                {
+                    Logger.LogDebug($"No Unity.exe in {unityCandidate}\\Editor");
+                    continue;
+                }
+
+                var versionInfo = FileVersionInfo.GetVersionInfo(unityExePath);
+                var splitCharacter = versionInfo.ProductVersion.Contains("_") ? '_' : '.'; // Versions are on format 2020.3.34f1_xxxx or 2020.3.34f1.xxxx
+
+                Logger.LogDebug($"Found version {versionInfo.ProductVersion} at path: {unityCandidate}");
+
+                unityInstallations.Add(new Installation {
+                    executable = unityExePath,
+                    path = unityCandidate,
+                    version = new UnityVersion(versionInfo.ProductVersion.Substring(0, versionInfo.ProductVersion.LastIndexOf(splitCharacter)))
+                });
+            }
+        }
+
+        return await Task.FromResult(unityInstallations);
+    }
+
+    public async Task Install(UnityInstaller.Queue queue, UnityInstaller.QueueItem item, CancellationToken cancellation = default)
+    {
+        if (item.package is not EditorDownload && !installedEditor && upgradeOriginalPath == null)
+        {
+            throw new InvalidOperationException("Cannot install package without installing editor first.");
+        }
+
+        var result = await RunAsAdmin(item.filePath, $"/S /D={installPath}");
+        if (result.exitCode != 0)
+        {
+            throw new Exception($"Failed to install {item.filePath} output: {result.output} / {result.error}");
+        }
+
+        if (item.package is EditorDownload)
+        {
+            installedEditor = true;
+        }
+    }
+
+    public Task MoveInstallation(Installation installation, string newPath, CancellationToken cancellation = default)
+    {
+        // Don't need to move installation on Windows, Unity is installed in the correct location automatically.
+        return Task.CompletedTask;
+    }
+
+    public async Task PrepareInstall(UnityInstaller.Queue queue, string installationPaths, CancellationToken cancellation = default)
+    {
+        if (installing.Version.IsValid)
+            throw new InvalidOperationException($"Already installing another version: {installing.Version}");
+
+        installing = queue.metadata;
+        installedEditor = false;
+
+        // Check for upgrading installation
+        if (!queue.items.Any(i => i.package is EditorDownload))
+        {
+            var installs = await FindInstallations(cancellation);
+            var existingInstall = installs.Where(i => i.version == queue.metadata.Version).FirstOrDefault();
+            if (existingInstall == null)
+            {
+                throw new InvalidOperationException($"Not installing editor but version {queue.metadata.Version} not already installed.");
+            }
+
+            installedEditor = true;
+        }
+
+        installPath = GetInstallationPath(installing.Version, installationPaths);
+    }
+
+    public Task<bool> PromptForPasswordIfNecessary(CancellationToken cancellation = default)
+    {
+        // Don't care about password. The system will ask for elevated priviliges automatically
+        return Task.FromResult(true);
+    }
+
+    public async Task Uninstall(Installation installation, CancellationToken cancellation = default)
+    {
+        var result = await RunAsAdmin(Path.Combine(installation.path, "Editor", "Uninstall.exe"), "/AllUsers /Q /S");
+        if (result.exitCode != 0)
+        {
+            throw new Exception($"Could not uninstall Unity. output: {result.output}, error: {result.error}.");
+        }
+
+        // Uninstall.exe captures the files within the folder and retains sole access to them for some time even after returning a result
+        // We wait for a period of time and then make sure that the folder and contents are deleted
+        const int msDelay = 5000;
+        bool deletedFolder = false;
+
+        try
+        {
+            Logger.LogDebug($"Deleting folder path {installation.path} recursively in {msDelay}ms.");
+            await Task.Delay(msDelay); // Wait for uninstallation to let go of files in folder
+            Directory.Delete(installation.path, true);
+
+            Logger.LogDebug($"Folder path {installation.path} deleted.");
+            deletedFolder = true;
+        }
+        catch (UnauthorizedAccessException)
+        {
+            try
+            {
+                // Sometimes access to folders and files are still in use by Uninstall.exe, so we wait some more
+                await Task.Delay(msDelay);
+                Directory.Delete(installation.path, true);
+
+                Logger.LogDebug($"Folder path {installation.path} deleted at second attempt.");
+                deletedFolder = true;
+            }
+            catch (DirectoryNotFoundException)
+            {
+                // Ignore, path already deleted
+            }
+            catch (Exception e)
+            {
+                Logger.LogError(e, $"Failed to delete folder path {installation.path} at second attempt. Ignoring excess files.");
+                // Continue even though errors occur deleting file path
+            }
+        }
+        catch (DirectoryNotFoundException)
+        {
+            // Ignore, path already deleted
+        }
+        catch (Exception e)
+        {
+            Logger.LogError(e, $"Failed to delete folder path {installation.path}.");
+            // Continue even though errors occur deleting file path
+        }
+
+        Logger.LogInformation($"Unity {installation.version} uninstalled successfully {(deletedFolder ? "and folder was deleted" : "but folder was not deleted")}.");
+    }
+
+    // -------- Helpers --------
+
+    ILogger Logger = UnityInstaller.CreateLogger<WindowsPlatform>();
+
+    Configuration configuration;
+
+    bool? isRoot;
+    string pwd;
+    VersionMetadata installing;
+    string installPath;
+    string upgradeOriginalPath;
+    bool movedExisting;
+    bool installedEditor;
+    
+    async Task<(int exitCode, string output, string error)> RunAsAdmin(string filename, string arguments)
+    {
+        var startInfo = new ProcessStartInfo();
+        startInfo.FileName = filename;
+        startInfo.Arguments = arguments;
+        startInfo.CreateNoWindow = true;
+        startInfo.WindowStyle = ProcessWindowStyle.Hidden;
+        startInfo.RedirectStandardError = true;
+        startInfo.RedirectStandardOutput = true;
+        startInfo.UseShellExecute = false;
+        startInfo.WorkingDirectory = Environment.CurrentDirectory;
+        startInfo.Verb = "runas";
+        try
+        {
+            var p = Process.Start(startInfo);
+            await p.WaitForExitAsync();
+            return (p.ExitCode, p.StandardOutput.ReadToEnd(), p.StandardError.ReadToEnd());
+        } catch (Exception e)
+        {
+            Logger.LogError(e, $"Execution of {filename} with {arguments} failed!");
+            throw;
+        }
+    }
+
+    string GetInstallationPath(UnityVersion version, string installationPaths)
+    {
+        string expanded = null;
+        if (!string.IsNullOrEmpty(installationPaths))
+        {
+            var comparison = StringComparison.OrdinalIgnoreCase;
+            var paths = installationPaths.Split(';', StringSplitOptions.RemoveEmptyEntries);
+            foreach (var path in paths)
+            {
+                expanded = path.Trim();
+                expanded = Helpers.Replace(expanded, "{major}", version.major.ToString(), comparison);
+                expanded = Helpers.Replace(expanded, "{minor}", version.minor.ToString(), comparison);
+                expanded = Helpers.Replace(expanded, "{patch}", version.patch.ToString(), comparison);
+                expanded = Helpers.Replace(expanded, "{type}", ((char)version.type).ToString(), comparison);
+                expanded = Helpers.Replace(expanded, "{build}", version.build.ToString(), comparison);
+                expanded = Helpers.Replace(expanded, "{hash}", version.hash, comparison);
+                expanded = Helpers.Replace(expanded, "{ProgramFiles}", ProgramFilesPath, comparison);
+
+                return expanded;
+            }
+        }
+
+        if (expanded != null)
+        {
+            return Helpers.GenerateUniqueFileName(expanded);
+        }
+        else
+        {
+            return Helpers.GenerateUniqueFileName(INSTALL_PATH);
+        }
+    }
+
+    public async Task Run(Installation installation, IEnumerable<string> arguments, bool child)
+    {
+        // child argument is ignored. We are always a child
+        if (!arguments.Contains("-logFile"))
+        {
+            arguments = arguments.Append("-logFile").Append("-");
+        }
+
+        var cmd = new System.Diagnostics.Process();
+        cmd.StartInfo.FileName = installation.executable;
+        cmd.StartInfo.Arguments = string.Join(" ", arguments);
+        cmd.StartInfo.UseShellExecute = false;
+
+        cmd.StartInfo.RedirectStandardOutput = true;
+        cmd.StartInfo.RedirectStandardError = true;
+        cmd.EnableRaisingEvents = true;
+
+        cmd.OutputDataReceived += (s, a) => {
+            if (a.Data == null) return;
+            Logger.LogInformation(a.Data);
+        };
+        cmd.ErrorDataReceived += (s, a) => {
+            if (a.Data == null) return;
+            Logger.LogError(a.Data);
+        };
+
+        cmd.Start();
+        cmd.BeginOutputReadLine();
+        cmd.BeginErrorReadLine();
+        await cmd.WaitForExitAsync(); // Let stdout and stderr flush
+
+        Logger.LogInformation($"Unity exited with code {cmd.ExitCode}");
+        Environment.Exit(cmd.ExitCode);
+    }
+}
+}
diff --git a/sttz.InstallUnity/Installer/Scraper.cs b/sttz.InstallUnity/Installer/Scraper.cs
index 9843b0c..9819ec0 100644
--- a/sttz.InstallUnity/Installer/Scraper.cs
+++ b/sttz.InstallUnity/Installer/Scraper.cs
@@ -1,16 +1,15 @@
 using IniParser.Parser;
 using Microsoft.Extensions.Logging;
-using Newtonsoft.Json;
 using System;
 using System.Collections.Generic;
-using System.IO;
 using System.Linq;
 using System.Net.Http;
-using System.Runtime.InteropServices;
 using System.Text.RegularExpressions;
 using System.Threading;
 using System.Threading.Tasks;
 
+using static sttz.InstallUnity.UnityReleaseAPIClient;
+
 namespace sttz.InstallUnity
 {
 
@@ -33,44 +32,39 @@ public class Scraper
     /// <summary>
     /// Base URL of Unity homepage.
     /// </summary>
-    const string UNITY_BASE_URL = "https://unity3d.com";
+    const string UNITY_BASE_URL = "https://unity.com";
 
     /// <summary>
-    /// Releases JSON used by Unity Hub ({0} should be either win32, darwin or linux).
+    /// HTML archive of Unity releases.
     /// </summary>
-    const string UNITY_HUB_RELEASES = "https://public-cdn.cloud.unity3d.com/hub/prod/releases-{0}.json";
+    const string UNITY_ARCHIVE = "https://unity.com/releases/editor/archive";
 
     /// <summary>
-    /// HTML archive of Unity releases.
+    /// Landing page for Unity beta releases.
     /// </summary>
-    const string UNITY_ARCHIVE = "https://unity3d.com/get-unity/download/archive";
+    const string UNITY_BETA = "https://unity.com/releases/editor/beta";
 
     /// <summary>
-    /// Landing page for Unity prereleases.
+    /// Landing page for Unity alpha releases.
     /// </summary>
-    const string UNITY_PRERELEASES = "https://unity3d.com/unity/beta";
+    const string UNITY_ALPHA = "https://unity.com/releases/editor/alpha";
 
     // -------- Release Notes --------
 
     /// <summary>
     /// HTML release notes of final Unity releases (append a version without type or build number, e.g. 2018.2.1)
     /// </summary>
-    const string UNITY_RELEASE_NOTES_FINAL = "https://unity3d.com/unity/whats-new/";
+    const string UNITY_RELEASE_NOTES_FINAL = "https://unity.com/releases/editor/whats-new/";
 
     /// <summary>
     /// HTML release notes of alpha Unity releases (append a full alpha version string)
     /// </summary>
-    const string UNITY_RELEASE_NOTES_ALPHA = "https://unity3d.com/unity/alpha/";
+    const string UNITY_RELEASE_NOTES_ALPHA = "https://unity.com/releases/editor/alpha/";
 
     /// <summary>
     /// HTML release notes of beta Unity releases (append a full beta version string)
     /// </summary>
-    const string UNITY_RELEASE_NOTES_BETA = "https://unity3d.com/unity/beta/";
-
-    /// <summary>
-    /// HTML release notes of patch Unity releases (append a full beta version string)
-    /// </summary>
-    const string UNITY_RELEASE_NOTES_PATCH = "https://unity3d.com/unity/qa/patch-releases/";
+    const string UNITY_RELEASE_NOTES_BETA = "https://unity.com/releases/editor/beta/";
 
     // -------- INIs --------
 
@@ -102,14 +96,9 @@ public class Scraper
     static readonly Regex UNITY_DOWNLOAD_RE = new Regex(@"https?:\/\/[\w.-]+unity3d\.com\/[\w\/.-]+\/([0-9a-f]{12})\/(?:[^\/]+\/)[\w\/.-]+-(\d+\.\d+\.\d+\w\d+)[\w\/.-]+");
 
     /// <summary>
-    /// /// Regex to extract available prerelease major versions from landing page.
+    /// Regex to extract available prerelease versions from landing page.
     /// </summary>
-    static readonly Regex UNITY_PRERELEASE_MAJOR_RE = new Regex(@"(?<!unity)\/(alpha|beta)\/(\d{4}\.\d[a-f]?)");
-
-    /// <summary>
-    /// Regex to extract available prerelease major versions from landing page.
-    /// </summary>
-    static readonly Regex UNITY_PRERELEASE_RE = new Regex(@"\/unity\/(alpha|beta)\/(\d+\.\d+\.\d+\w\d+)");
+    static readonly Regex UNITY_PRERELEASE_RE = new Regex(@"\/releases\/editor\/(alpha|beta)\/(\d+\.\d+\.\d+\w\d+)");
 
     // -------- Scraper --------
 
@@ -117,101 +106,6 @@ public class Scraper
 
     ILogger Logger = UnityInstaller.CreateLogger<Scraper>();
 
-    /// <summary>
-    /// Load the latest Unity releases, using the same JSON as Unity Hub.
-    /// </summary>
-    /// <param name="cachePlatform">Name of platform to load the JSON for</param>
-    /// <param name="cancellation">Cancellation token</param>
-    /// <returns>Task returning the discovered versions</returns>
-    public async Task<IEnumerable<VersionMetadata>> LoadLatest(CachePlatform cachePlatform, CancellationToken cancellation = default)
-    {
-        string platformName;
-        switch (cachePlatform) {
-            case CachePlatform.macOSIntel:
-                platformName = "darwin";
-                break;
-            case CachePlatform.macOSArm:
-                platformName = "silicon";
-                break;
-            case CachePlatform.Windows:
-                platformName = "win32";
-                break;
-            case CachePlatform.Linux:
-                platformName = "linux";
-                break;
-            default:
-                throw new NotImplementedException("Invalid platform name: " + cachePlatform);
-        }
-
-        var url = string.Format(UNITY_HUB_RELEASES, platformName);
-        Logger.LogInformation($"Loading latest releases for {platformName} from '{url}'");
-        var response = await client.GetAsync(url, cancellation);
-        response.EnsureSuccessStatusCode();
-
-        var json = await response.Content.ReadAsStringAsync();
-        Logger.LogDebug("Received response: {json}", json);
-        var data = JsonConvert.DeserializeObject<Dictionary<string, HubUnityVersion[]>>(json);
-
-        var result = new List<VersionMetadata>();
-        if (!data.ContainsKey("official")) {
-            Logger.LogWarning("Unity Hub JSON does not contain expected 'official' array.");
-        } else {
-            ParseVersions(cachePlatform, data["official"], result);
-        }
-
-        if (data.ContainsKey("beta")) {
-            ParseVersions(cachePlatform, data["beta"], result);
-        }
-
-        return result;
-    }
-
-    void ParseVersions(CachePlatform cachePlatform, HubUnityVersion[] versions, List<VersionMetadata> results)
-    {
-        foreach (var version in versions) {
-                // releases-darwin.json only contains intel, releases-silicon.json both arm and intel
-                if (cachePlatform == CachePlatform.macOSArm && version.arch != "arm64")
-                    continue;
-
-                var metadata = new VersionMetadata();
-                metadata.version = new UnityVersion(version.version);
-
-                var packages = new PackageMetadata[version.modules.Length + 1];
-                packages[0] = new PackageMetadata() {
-                    name = "Unity ",
-                    title = "Unity " + version.version,
-                    description = "Unity Editor",
-                    url = version.downloadUrl,
-                    install = true,
-                    mandatory = false,
-                    size = long.Parse(version.downloadSize),
-                    installedsize = long.Parse(version.installedSize),
-                    version = version.version,
-                    md5 = version.checksum
-                };
-
-                var i = 1;
-                foreach (var module in version.modules) {
-                    packages[i++] = new PackageMetadata() {
-                        name = module.id,
-                        title = module.name,
-                        description = module.description,
-                        url = module.downloadUrl,
-                        install = module.selected,
-                        mandatory = false,
-                        size = long.Parse(module.downloadSize),
-                        installedsize = long.Parse(module.installedSize),
-                        version = version.version,
-                        md5 = module.checksum
-                    };
-                }
-
-                Logger.LogDebug($"Found version {metadata.version} with {packages.Length} packages");
-                metadata.SetPackages(cachePlatform, packages);
-                results.Add(metadata);
-            }
-    }
-
     /// <summary>
     /// Load the available final versions.
     /// </summary>
@@ -229,7 +123,7 @@ public async Task<IEnumerable<VersionMetadata>> LoadFinal(CancellationToken canc
         var html = await response.Content.ReadAsStringAsync();
         Logger.LogTrace($"Got response: {html}");
 
-        return ExtractFromHtml(html).Values;
+        return ExtractFromHtml(html, ReleaseStream.None).Values;
     }
 
     /// <summary>
@@ -239,63 +133,54 @@ public async Task<IEnumerable<VersionMetadata>> LoadFinal(CancellationToken canc
     /// <returns>Task returning the discovered versions</returns>
     public async Task<IEnumerable<VersionMetadata>> LoadPrerelease(bool includeAlpha, IEnumerable<UnityVersion> knownVersions = null, int scrapeDelay = 50, CancellationToken cancellation = default)
     {
-        // Load main prereleases page to discover which major versions are available as prerelease
-        Logger.LogInformation($"Scraping latest prereleases with includeAlpha={includeAlpha} from '{UNITY_PRERELEASES}'");
+        var results = new Dictionary<UnityVersion, VersionMetadata>();
+
+        if (includeAlpha) {
+            await LoadPrerelease(UNITY_ALPHA, ReleaseStream.Alpha, results, knownVersions, scrapeDelay, cancellation);
+        }
+
+        await LoadPrerelease(UNITY_BETA, ReleaseStream.Beta, results, knownVersions, scrapeDelay, cancellation);
+
+        return results.Values;
+    }
+
+    /// <summary>
+    /// Load the available prerelase versions from a alpha/beta landing page.
+    /// </summary>
+    async Task LoadPrerelease(string url, ReleaseStream stream, Dictionary<UnityVersion, VersionMetadata> results, IEnumerable<UnityVersion> knownVersions = null, int scrapeDelay = 50, CancellationToken cancellation = default)
+    {
+        // Load major version's individual prerelease page to get individual versions
+        Logger.LogInformation($"Scraping latest prereleases from '{url}'");
         await Task.Delay(scrapeDelay);
-        var response = await client.GetAsync(UNITY_PRERELEASES, cancellation);
+        var response = await client.GetAsync(url, cancellation);
         if (!response.IsSuccessStatusCode) {
-            Logger.LogWarning($"Failed to scrape url '{UNITY_PRERELEASES}' ({response.StatusCode})");
-            return Enumerable.Empty<VersionMetadata>();
+            Logger.LogWarning($"Failed to scrape url '{url}' ({response.StatusCode})");
+            return;
         }
 
         var html = await response.Content.ReadAsStringAsync();
         Logger.LogTrace($"Got response: {html}");
 
-        var majorMatches = UNITY_PRERELEASE_MAJOR_RE.Matches(html);
-        var visitedMajorVersions = new HashSet<string>();
-        var results = new Dictionary<UnityVersion, VersionMetadata>();
-        foreach (Match majorMatch in majorMatches) {
-            if (!visitedMajorVersions.Add(majorMatch.Groups[2].Value)) continue;
-
-            var isAlpha = majorMatch.Groups[1].Value == "alpha";
-            if (isAlpha && !includeAlpha) continue;
+        var versionMatches = UNITY_PRERELEASE_RE.Matches(html);
+        foreach (Match versionMatch in versionMatches) {
+            var version = new UnityVersion(versionMatch.Groups[2].Value);
+            if (results.ContainsKey(version)) continue;
+            if (knownVersions != null && knownVersions.Contains(version)) continue;
 
-            // Load major version's individual prerelease page to get individual versions
-            var archiveUrl = UNITY_BASE_URL + majorMatch.Value;
-            Logger.LogInformation($"Scraping latest releases for {majorMatch.Groups[2].Value} from '{archiveUrl}'");
+            // Load version's release notes to get download links
+            var prereleaseUrl = UNITY_BASE_URL + versionMatch.Value;
+            Logger.LogInformation($"Scraping {versionMatch.Groups[1].Value} {version} from '{prereleaseUrl}'");
             await Task.Delay(scrapeDelay);
-            response = await client.GetAsync(archiveUrl, cancellation);
+            response = await client.GetAsync(prereleaseUrl, cancellation);
             if (!response.IsSuccessStatusCode) {
-                Logger.LogWarning($"Failed to scrape url '{archiveUrl}' ({response.StatusCode})");
-                return Enumerable.Empty<VersionMetadata>();
+                Logger.LogWarning($"Could not load release notes at url '{prereleaseUrl}' ({response.StatusCode})");
+                continue;
             }
 
             html = await response.Content.ReadAsStringAsync();
             Logger.LogTrace($"Got response: {html}");
-
-            var versionMatches = UNITY_PRERELEASE_RE.Matches(html);
-            foreach (Match versionMatch in versionMatches) {
-                var version = new UnityVersion(versionMatch.Groups[2].Value);
-                if (results.ContainsKey(version)) continue;
-                if (version.type == UnityVersion.Type.Alpha && !includeAlpha) continue;
-                if (knownVersions != null && knownVersions.Contains(version)) continue;
-
-                // Load version's release notes to get download links
-                var prereleaseUrl = UNITY_BASE_URL + versionMatch.Value;
-                Logger.LogInformation($"Scraping {versionMatch.Groups[1].Value} {version} from '{prereleaseUrl}'");
-                await Task.Delay(scrapeDelay);
-                response = await client.GetAsync(prereleaseUrl, cancellation);
-                if (!response.IsSuccessStatusCode) {
-                    Logger.LogWarning($"Could not load release notes at url '{prereleaseUrl}' ({response.StatusCode})");
-                    continue;
-                }
-
-                html = await response.Content.ReadAsStringAsync();
-                Logger.LogTrace($"Got response: {html}");
-                ExtractFromHtml(html, true, results);
-            }
+            ExtractFromHtml(html, stream, results);
         }
-        return results.Values;
     }
 
     /// <summary>
@@ -313,7 +198,7 @@ string GetIniBaseUrl(UnityVersion.Type type)
     /// <summary>
     /// Extract the versions and the base URLs from the html string.
     /// </summary>
-    Dictionary<UnityVersion, VersionMetadata> ExtractFromHtml(string html, bool prerelease = false, Dictionary<UnityVersion, VersionMetadata> results = null)
+    Dictionary<UnityVersion, VersionMetadata> ExtractFromHtml(string html, ReleaseStream stream, Dictionary<UnityVersion, VersionMetadata> results = null)
     {
         var matches = UNITYHUB_RE.Matches(html);
         results = results ?? new Dictionary<UnityVersion, VersionMetadata>();
@@ -323,11 +208,11 @@ Dictionary<UnityVersion, VersionMetadata> ExtractFromHtml(string html, bool prer
 
             VersionMetadata metadata = default;
             if (!results.TryGetValue(version, out metadata)) {
-                metadata.version = version;
+                if (stream == ReleaseStream.None)
+                metadata = CreateEmptyVersion(version, stream);
             }
 
             metadata.baseUrl = GetIniBaseUrl(version.type) + version.hash + "/";
-            metadata.prerelease = prerelease;
             results[version] = metadata;
         }
 
@@ -338,11 +223,10 @@ Dictionary<UnityVersion, VersionMetadata> ExtractFromHtml(string html, bool prer
 
             VersionMetadata metadata = default;
             if (!results.TryGetValue(version, out metadata)) {
-                metadata.version = version;
+                metadata = CreateEmptyVersion(version, stream);
             }
 
             metadata.baseUrl = GetIniBaseUrl(version.type) + version.hash + "/";
-            metadata.prerelease = prerelease;
             results[version] = metadata;
         }
 
@@ -363,8 +247,7 @@ public VersionMetadata UnityHubUrlToVersion(string url)
         var version = new UnityVersion(match.Groups[1].Value);
         version.hash = match.Groups[2].Value;
 
-        var metadata = new VersionMetadata();
-        metadata.version = version;
+        var metadata = CreateEmptyVersion(version, ReleaseStream.None);
         metadata.baseUrl = GetIniBaseUrl(version.type) + version.hash + "/";
 
         return metadata;
@@ -375,8 +258,8 @@ public VersionMetadata UnityHubUrlToVersion(string url)
     /// </summary>
     /// <remarks>
     /// The version must include major, minor and patch components.
-    /// For patch and beta releases, it must also contain the build component.
-    /// If no type is set, final is assumes.
+    /// For beta and alpha releases, it must also contain the build component.
+    /// If no type is set, final is assumed.
     /// </remarks>
     /// <param name="version">The version</param>
     /// <returns>The metadata or the default value if the version couldn't be found.</returns>
@@ -388,8 +271,9 @@ public async Task<VersionMetadata> LoadExact(UnityVersion version, CancellationT
         if (version.type != UnityVersion.Type.Final && version.type != UnityVersion.Type.Undefined && version.build < 0) {
             throw new ArgumentException("The Unity version is incomplete (build missing)", nameof(version));
         }
-        
-        var url = GetReleaseNotesUrl(version);
+
+        var stream = GuessStreamFromVersion(version);
+        var url = GetReleaseNotesUrl(stream, version);
         if (url == null) {
             throw new ArgumentException("The Unity version type is not supported: " + version.type, nameof(version));
         }
@@ -414,7 +298,7 @@ public async Task<VersionMetadata> LoadUrl(string url, CancellationToken cancell
 
         var html = await response.Content.ReadAsStringAsync();
         Logger.LogTrace($"Got response: {html}");
-        return ExtractFromHtml(html).Values.FirstOrDefault();
+        return ExtractFromHtml(html, ReleaseStream.None).Values.FirstOrDefault();
     }
 
     /// <summary>
@@ -422,40 +306,31 @@ public async Task<VersionMetadata> LoadUrl(string url, CancellationToken cancell
     /// The VersionMetadata must have iniUrl set.
     /// </summary>
     /// <param name="metadata">Version metadata with iniUrl.</param>
-    /// <param name="cachePlatform">Name of platform to load the packages for</param>
+    /// <param name="platform">Name of platform to load the packages for</param>
     /// <returns>A Task returning the metadata with packages filled in.</returns>
-    public async Task<VersionMetadata> LoadPackages(VersionMetadata metadata, CachePlatform cachePlatform, CancellationToken cancellation = default)
+    public async Task<VersionMetadata> LoadPackages(VersionMetadata metadata, Platform platform, Architecture architecture, CancellationToken cancellation = default)
     {
-        if (!metadata.version.IsFullVersion) {
+        if (!metadata.Version.IsFullVersion) {
             throw new ArgumentException("Unity version needs to be a full version", nameof(metadata));
         }
 
-        if (cachePlatform == CachePlatform.macOSArm && metadata.version < new UnityVersion(2021, 2)) {
+        if (platform == Platform.Mac_OS && architecture == Architecture.ARM64 && metadata.Version < new UnityVersion(2021, 2)) {
             throw new ArgumentException("Apple Silicon builds are only available from Unity 2021.2", nameof(metadata));
         }
 
-        string platformName;
-        switch (cachePlatform) {
-            case CachePlatform.macOSIntel:
-            case CachePlatform.macOSArm:
-                platformName = "osx";
-                break;
-            case CachePlatform.Windows:
-                platformName = "win";
-                break;
-            case CachePlatform.Linux:
-                platformName = "linux";
-                break;
-            default:
-                throw new NotImplementedException("Invalid platform name: " + cachePlatform);
-        }
+        string platformName = platform switch {
+            Platform.Mac_OS  => "osx",
+            Platform.Windows => "win",
+            Platform.Linux   => "linux",
+            _ => throw new NotImplementedException("Invalid platform name: " + platform)
+        };
 
         if (string.IsNullOrEmpty(metadata.baseUrl)) {
-            throw new ArgumentException("VersionMetadata.baseUrl is not set for " + metadata.version, nameof(metadata));
+            throw new ArgumentException("VersionMetadata.baseUrl is not set for " + metadata.Version, nameof(metadata));
         }
 
-        var url = metadata.baseUrl + string.Format(UNITY_INI_FILENAME, metadata.version.ToString(false), platformName);
-        Logger.LogInformation($"Loading packages for {metadata.version} and {platformName} from '{url}'");
+        var url = metadata.baseUrl + string.Format(UNITY_INI_FILENAME, metadata.Version.ToString(false), platformName);
+        Logger.LogInformation($"Loading packages for {metadata.Version} and {platformName} from '{url}'");
         var response = await client.GetAsync(url, cancellation);
         response.EnsureSuccessStatusCode();
 
@@ -472,176 +347,210 @@ public async Task<VersionMetadata> LoadPackages(VersionMetadata metadata, CacheP
             data = parser.Parse(ini);
         }
 
-        var packages = new PackageMetadata[data.Sections.Count];
-        var i = 0;
+        var editorDownload = new EditorDownload();
+        editorDownload.platform = platform;
+        editorDownload.architecture = architecture;
+        editorDownload.modules = new List<Module>();
+
+        // Create modules from all entries
+        var allModules = new Dictionary<string, Module>(StringComparer.OrdinalIgnoreCase);
         foreach (var section in data.Sections) {
-            var meta = new PackageMetadata();
-            meta.name = section.SectionName;
-
-            foreach (var pair in section.Keys) {
-                switch (pair.KeyName) {
-                    case "title":
-                        meta.title = pair.Value;
-                        break;
-                    case "description":
-                        meta.description = pair.Value;
-                        break;
-                    case "url":
-                        meta.url = pair.Value;
-                        break;
-                    case "install":
-                        meta.install = bool.Parse(pair.Value);
-                        break;
-                    case "mandatory":
-                        meta.mandatory = bool.Parse(pair.Value);
-                        break;
-                    case "size":
-                        meta.size = long.Parse(pair.Value);
-                        break;
-                    case "installedsize":
-                        meta.installedsize = long.Parse(pair.Value);
-                        break;
-                    case "version":
-                        meta.version = pair.Value;
-                        break;
-                    case "hidden":
-                        meta.hidden = bool.Parse(pair.Value);
-                        break;
-                    case "extension":
-                        meta.extension = pair.Value;
-                        break;
-                    case "sync":
-                        meta.sync = pair.Value;
-                        break;
-                    case "md5":
-                        meta.md5 = pair.Value;
-                        break;
-                    case "requires_unity":
-                        meta.requires_unity = bool.Parse(pair.Value);
-                        break;
-                    case "appidentifier":
-                        meta.appidentifier = pair.Value;
-                        break;
-                    case "eulamessage":
-                        meta.eulamessage = pair.Value;
-                        break;
-                    case "eulalabel1":
-                        meta.eulalabel1 = pair.Value;
-                        break;
-                    case "eulaurl1":
-                        meta.eulaurl1 = pair.Value;
-                        break;
-                    case "eulalabel2":
-                        meta.eulalabel2 = pair.Value;
-                        break;
-                    case "eulaurl2":
-                        meta.eulaurl2 = pair.Value;
-                        break;
-                    default:
-                        Logger.LogDebug($"Unknown ini field {pair.KeyName}: {pair.Value}");
-                        break;
-                }
+            if (section.SectionName.Equals(EditorDownload.ModuleId, StringComparison.OrdinalIgnoreCase)) {
+                SetDownloadKeys(editorDownload, section);
+                continue;
             }
 
-            packages[i++] = meta;
+            var module = new Module();
+            module.id = section.SectionName;
+
+            SetDownloadKeys(module, section);
+            SetModuleKeys(module, section);
+
+            allModules.Add(module.id, module);
+        }
+
+        // Add virtual packages
+        foreach (var virutal in VirtualPackages.GeneratePackages(metadata.Version, editorDownload)) {
+            allModules.Add(virutal.id, virutal);
+        }
+
+        // Register sub-modules with their parents
+        foreach (var module in allModules.Values) {
+            if (module.parentModuleId == null) continue;
+
+            if (!allModules.TryGetValue(module.parentModuleId, out var parentModule))
+                throw new Exception($"Missing parent module '{module.parentModuleId}' for modules '{module.id}'");
+
+            if (parentModule.subModules == null)
+                parentModule.subModules = new List<Module>();
+            
+            parentModule.subModules.Add(module);
+            module.parentModule = parentModule;
+        }
+
+        // Register remaining root modules with main editor download
+        foreach (var possibleRoot in allModules.Values) {
+            if (possibleRoot.parentModule != null)
+                continue;
+            
+            editorDownload.modules.Add(possibleRoot);
         }
 
+        Logger.LogInformation($"Found {allModules.Count} packages");
+
         // Patch editor URL to point to Apple Silicon editor
         // The old ini system probably won't be updated to include Apple Silicon variants
-        if (cachePlatform == CachePlatform.macOSArm) {
-            for (i = 0; i < packages.Length; i++) {
-                if (packages[i].name == "Unity") {
-                    // Change e.g.
-                    // https://download.unity3d.com/download_unity/e50cafbb4399/MacEditorInstaller/Unity.pkg
-                    // to
-                    // https://download.unity3d.com/download_unity/e50cafbb4399/MacEditorInstallerArm64/Unity.pkg
-                    var editorUrl = packages[i].url;
-                    if (!editorUrl.StartsWith("MacEditorInstaller/")) {
-                        throw new Exception($"Cannot convert to Apple Silicon editor URL: Does not start with 'MacEditorInstaller' (got '{editorUrl}')");
-                    }
-                    editorUrl = editorUrl.Replace("MacEditorInstaller/", "MacEditorInstallerArm64/");
-                    packages[i].url = editorUrl;
-                    packages[i].description += " (Apple Silicon)";
-                    // Clear fields that are now invalid
-                    packages[i].md5 = null;
-                }
+        if (platform == Platform.Mac_OS && architecture == Architecture.ARM64) {
+            // Change e.g.
+            // https://download.unity3d.com/download_unity/e50cafbb4399/MacEditorInstaller/Unity.pkg
+            // to
+            // https://download.unity3d.com/download_unity/e50cafbb4399/MacEditorInstallerArm64/Unity.pkg
+            var editorUrl = editorDownload.url;
+            if (!editorUrl.StartsWith("MacEditorInstaller/")) {
+                throw new Exception($"Cannot convert to Apple Silicon editor URL: Does not start with 'MacEditorInstaller' (got '{editorUrl}')");
             }
-        }
+            editorUrl = editorUrl.Replace("MacEditorInstaller/", "MacEditorInstallerArm64/");
+            editorDownload.url = editorUrl;
 
-        Logger.LogInformation($"Found {packages.Length} packages");
-        metadata.SetPackages(cachePlatform, packages);
+            // Clear fields that are now invalid
+            editorDownload.integrity = null;
+        }
 
+        metadata.SetEditorDownload(editorDownload);
         return metadata;
     }
 
-    /// <summary>
-    /// Guess the release notes URL for a version metadata.
-    /// </summary>
-    public string GetReleaseNotesUrl(VersionMetadata metadata)
+    void SetDownloadKeys(Download download, IniParser.Model.SectionData section)
     {
-        // Release candidates have a final version but are still on the beta page
-        if (metadata.IsReleaseCandidate) {
-            return UNITY_RELEASE_NOTES_BETA + metadata.version.ToString(false);
+        foreach (var pair in section.Keys) {
+            switch (pair.KeyName) {
+                case "url":
+                    download.url = pair.Value;
+                    break;
+                case "extension":
+                    download.type = pair.Value switch {
+                        "txt" => FileType.TEXT,
+                        "zip" => FileType.ZIP,
+                        "pkg" => FileType.PKG,
+                        "exe" => FileType.EXE,
+                        "po"  => FileType.PO,
+                        "dmg" => FileType.DMG,
+                        _     => FileType.Undefined,
+                    };
+                    break;
+                case "size":
+                    download.downloadSize.value = long.Parse(pair.Value);
+                    download.downloadSize.unit = "BYTE";
+                    break;
+                case "installedsize":
+                    download.installedSize.value = long.Parse(pair.Value);
+                    download.installedSize.unit = "BYTE";
+                    break;
+                case "md5":
+                    download.integrity = $"md5-{pair.Value}";
+                    break;
+            }
         }
+    }
 
-        return GetReleaseNotesUrl(metadata.version);
+    void SetModuleKeys(Module download, IniParser.Model.SectionData section)
+    {
+        var eulaUrl1 = section.Keys["eulaurl1"];
+        if (eulaUrl1 != null) {
+            var eulaMessage = section.Keys["eulamessage"];
+            var eulaUrl2 = section.Keys["eulaurl2"];
+
+            var eulaCount = (eulaUrl2 != null ? 2 : 1);
+            download.eula = new Eula[eulaCount];
+
+            download.eula[0] = new Eula() {
+                message = eulaMessage,
+                label = section.Keys["eulalabel1"],
+                url = eulaUrl1
+            };
+
+            if (eulaCount > 1) {
+                download.eula[1] = new Eula() {
+                    message = eulaMessage,
+                    label = section.Keys["eulalabel2"],
+                    url = eulaUrl2
+                };
+            }
+        }
+
+        foreach (var pair in section.Keys) {
+            switch (pair.KeyName) {
+                case "title":
+                    download.name = pair.Value;
+                    break;
+                case "description":
+                    download.description = pair.Value;
+                    break;
+                case "install":
+                    download.preSelected = bool.Parse(pair.Value);
+                    break;
+                case "mandatory":
+                    download.required = bool.Parse(pair.Value);
+                    break;
+                case "hidden":
+                    download.hidden = bool.Parse(pair.Value);
+                    break;
+                case "sync":
+                    download.parentModuleId = pair.Value;
+                    break;
+            }
+        }
     }
 
     /// <summary>
-    /// Guess the release notes URL for a version.
+    /// Create a new empty version.
     /// </summary>
-    public string GetReleaseNotesUrl(UnityVersion version)
+    static VersionMetadata CreateEmptyVersion(UnityVersion version, ReleaseStream stream)
     {
-        switch (version.type) {
-            case UnityVersion.Type.Undefined:
-            case UnityVersion.Type.Final:
-                return UNITY_RELEASE_NOTES_FINAL + version.major + "." + version.minor + "." + version.patch;
-            case UnityVersion.Type.Patch:
-                return UNITY_RELEASE_NOTES_PATCH + version.ToString(false);
-            case UnityVersion.Type.Beta:
-                return UNITY_RELEASE_NOTES_BETA + version.ToString(false);
-            case UnityVersion.Type.Alpha:
-                return UNITY_RELEASE_NOTES_ALPHA + version.ToString(false);
-            default:
-                return null;
-        }
-    }
+        var meta = new VersionMetadata();
+        meta.release = new Release();
+        meta.release.version = version;
+        meta.release.shortRevision = version.hash;
 
-    // -------- Types --------
+        if (stream == ReleaseStream.None)
+            stream = GuessStreamFromVersion(version);
+        meta.release.stream = stream;
 
-    // Disable never assigned warning, as the fields are 
-    // set dynamically in the JSON deserializer
-    #pragma warning disable CS0649
+        return meta;
+    }
 
-    struct HubUnityVersion
+    /// <summary>
+    /// Guess the release stream based on the Unity version.
+    /// </summary>
+    public static ReleaseStream GuessStreamFromVersion(UnityVersion version)
     {
-        public string version;
-        public bool lts;
-        public string downloadUrl;
-        public string downloadSize;
-        public string installedSize;
-        public string checksum;
-        public HubUnityModule[] modules;
-        public string arch;
+        if (version.type == UnityVersion.Type.Alpha) {
+            return ReleaseStream.Alpha;
+        } else if (version.type == UnityVersion.Type.Beta) {
+            return ReleaseStream.Beta;
+        } else if (version.major >= 2017 && version.major <= 2019 && version.minor == 4) {
+            return ReleaseStream.LTS;
+        } else if (version.major >= 2020 && version.minor == 3) {
+            return ReleaseStream.LTS;
+        } else {
+            return ReleaseStream.Tech;
+        }
     }
 
-    struct HubUnityModule
+    /// <summary>
+    /// Guess the release notes URL for a version.
+    /// </summary>
+    public static string GetReleaseNotesUrl(ReleaseStream stream, UnityVersion version)
     {
-        public string id;
-        public string name;
-        public string description;
-        public string downloadUrl;
-        public string destination;
-        public string category;
-        public string installedSize;
-        public string downloadSize;
-        public bool visible;
-        public bool selected;
-        public string checksum;
+        switch (stream) {
+            case ReleaseStream.Alpha:
+                return UNITY_RELEASE_NOTES_ALPHA + version.ToString(false);
+            case ReleaseStream.Beta:
+                return UNITY_RELEASE_NOTES_BETA + version.ToString(false);
+            default:
+                return UNITY_RELEASE_NOTES_FINAL + $"{version.major}.{version.minor}.{version.patch}";
+        }
     }
-
-    #pragma warning restore CS0649
-
 }
 
 }
diff --git a/sttz.InstallUnity/Installer/UnityInstaller.cs b/sttz.InstallUnity/Installer/UnityInstaller.cs
index 9aa00e8..3699780 100644
--- a/sttz.InstallUnity/Installer/UnityInstaller.cs
+++ b/sttz.InstallUnity/Installer/UnityInstaller.cs
@@ -3,12 +3,12 @@
 using System.IO;
 using System.Linq;
 using System.Net.Http;
-using System.Runtime.InteropServices;
-using System.Security.Cryptography;
 using System.Threading;
 using System.Threading.Tasks;
 using Microsoft.Extensions.Logging;
 
+using static sttz.InstallUnity.UnityReleaseAPIClient;
+
 namespace sttz.InstallUnity
 {
 
@@ -67,6 +67,11 @@ public static ILogger CreateLogger(string categoryName)
     /// </summary>
     public Scraper Scraper { get; protected set; }
 
+    /// <summary>
+    /// Client for the Unity Release API.
+    /// </summary>
+    public UnityReleaseAPIClient Releases { get; protected set; }
+
     // -------- API --------
 
     /// <summary>
@@ -107,6 +112,7 @@ public enum InstallStep
     public class Queue
     {
         public VersionMetadata metadata;
+        public string downloadPath;
         public IList<QueueItem> items;
     }
 
@@ -118,7 +124,8 @@ public class QueueItem
         /// <summary>
         /// Description of the item's current state.
         /// </summary>
-        public enum State {
+        public enum State
+        {
             /// <summary>
             /// Waiting for the download to start.
             /// </summary>
@@ -148,7 +155,7 @@ public enum State {
         /// <summary>
         /// The package metadata of this item.
         /// </summary>
-        public PackageMetadata package;
+        public Download package;
         /// <summary>
         /// The item's current state.
         /// </summary>
@@ -212,11 +219,14 @@ public UnityInstaller(Configuration config = null, string dataPath = null, ILogg
         GlobalLogger = CreateLogger("Global");
 
         // Initialize platform-specific classes
-        if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX)) {
+        if (System.Runtime.InteropServices.RuntimeInformation.IsOSPlatform(System.Runtime.InteropServices.OSPlatform.OSX)) {
             Logger.LogDebug("Loading platform integration for macOS");
             Platform = new MacPlatform();
+        } else if (System.Runtime.InteropServices.RuntimeInformation.IsOSPlatform(System.Runtime.InteropServices.OSPlatform.Windows)) {
+            Logger.LogDebug("Loading platform integration for Windows");
+            Platform = new WindowsPlatform();
         } else {
-            throw new NotImplementedException("Installer does not currently support the platform: " + RuntimeInformation.OSDescription);
+            throw new NotImplementedException("Installer does not currently support the platform: " + System.Runtime.InteropServices.RuntimeInformation.OSDescription);
         }
 
         DataPath = dataPath;
@@ -244,6 +254,7 @@ public UnityInstaller(Configuration config = null, string dataPath = null, ILogg
         // Initialize components
         Versions = new VersionsCache(GetCacheFilePath());
         Scraper = new Scraper();
+        Releases = new UnityReleaseAPIClient();
     }
 
     /// <summary>
@@ -270,7 +281,7 @@ public string GetCacheFilePath()
     public string GetDownloadDirectory(VersionMetadata metadata)
     {
         var downloadPath = DataPath ?? Platform.GetDownloadDirectory();
-        return Path.Combine(downloadPath, string.Format(Configuration.downloadSubdirectory, metadata.version));
+        return Path.Combine(downloadPath, string.Format(Configuration.downloadSubdirectory, metadata.Version));
     }
 
     /// <summary>
@@ -289,57 +300,46 @@ public bool IsCacheOutdated(UnityVersion.Type type = UnityVersion.Type.Undefined
     /// <summary>
     /// Update the Unity versions cache.
     /// </summary>
-    /// <param name="cachePlatform">Name of platform to update (only used for loading hub JSON)</param>
+    /// <param name="platform">Name of platform to update (only used for loading hub JSON)</param>
     /// <param name="type">Undefined = update latest, others = update archive of type and higher types</param>
     /// <returns>Task returning the newly discovered versions</returns>
-    public async Task<IEnumerable<VersionMetadata>> UpdateCache(CachePlatform cachePlatform, UnityVersion.Type type = UnityVersion.Type.Undefined, CancellationToken cancellation = default)
+    public async Task<IEnumerable<VersionMetadata>> UpdateCache(Platform platform, UnityReleaseAPIClient.Architecture architecture, UnityVersion.Type type = UnityVersion.Type.Undefined, CancellationToken cancellation = default)
     {
         var added = new List<VersionMetadata>();
-        if (type == UnityVersion.Type.Undefined) {
-            Logger.LogDebug("Loading UnityHub JSON with latest Unity versions...");
-            var newVersions = await Scraper.LoadLatest(cachePlatform, cancellation);
-            Logger.LogInformation($"Loaded {newVersions.Count()} versions from UnityHub JSON");
-            
-            Versions.Add(newVersions, added);
-            Versions.SetLastUpdate(type, DateTime.Now);
-        } else {
-            switch (type) {
-                case UnityVersion.Type.Final:
-                case UnityVersion.Type.Patch:
-                case UnityVersion.Type.Beta:
-                case UnityVersion.Type.Alpha:
-                    Logger.LogDebug($"Updating Final Unity Versions...");
-                    var newVersions = await Scraper.LoadFinal(cancellation);
-                    Logger.LogInformation($"Scraped {newVersions.Count()} versions of type Final");
-                    Versions.Add(newVersions, added);
-                    
-                    Versions.SetLastUpdate(UnityVersion.Type.Final, DateTime.Now);
-                    break;
-            }
 
-            switch (type) {
-                case UnityVersion.Type.Beta:
-                case UnityVersion.Type.Alpha:
-                    Logger.LogDebug($"Updating Prerelease Unity Versions...");
-                    var newVersions = await Scraper.LoadPrerelease(
-                        type == UnityVersion.Type.Alpha, 
-                        Versions.Select(m => m.version), 
-                        Configuration.scrapeDelayMs, 
-                        cancellation
-                    );
-                    Logger.LogInformation($"Scraped {newVersions.Count()} versions of type Beta/Alpha");
-                    Versions.Add(newVersions, added);
-                    
-                    Versions.SetLastUpdate(UnityVersion.Type.Beta, DateTime.Now);
-                    if (type == UnityVersion.Type.Alpha) {
-                        Versions.SetLastUpdate(UnityVersion.Type.Alpha, DateTime.Now);
-                    }
-                    break;
-            }
+        var req = new UnityReleaseAPIClient.RequestParams();
+        req.platform = platform;
+        req.architecture = architecture;
+
+        req.stream = ReleaseStream.Tech | ReleaseStream.LTS;
+        if (type == UnityVersion.Type.Beta) req.stream |= ReleaseStream.Beta;
+        if (type == UnityVersion.Type.Alpha) req.stream |= ReleaseStream.Beta | ReleaseStream.Alpha;
+
+        var lastUpdate = Versions.GetLastUpdate(type);
+        var updatePeriod = DateTime.Now - lastUpdate;
+
+        var maxAge = TimeSpan.FromDays(Configuration.latestMaxAge);
+        if (updatePeriod > maxAge) updatePeriod = maxAge;
+
+        Logger.LogDebug($"Loading the latest Unity releases from the last {updatePeriod} days");
+
+        var releases = await Releases.LoadLatest(req, updatePeriod, cancellation);
+        Logger.LogInformation($"Loaded {releases.Count()} releases from the Unity Release API");
+
+        var metaReleases = releases.Select(r => VersionMetadata.FromRelease(r));
+        Versions.Add(metaReleases, added);
+
+        Versions.SetLastUpdate(UnityVersion.Type.Final, DateTime.Now);
+        if (type == UnityVersion.Type.Beta) {
+            Versions.SetLastUpdate(UnityVersion.Type.Beta, DateTime.Now);
         }
+        if (type == UnityVersion.Type.Beta || type == UnityVersion.Type.Alpha) {
+            Versions.SetLastUpdate(UnityVersion.Type.Alpha, DateTime.Now);
+        }
+
         Versions.Save();
 
-        added.Sort((m1, m2) => m2.version.CompareTo(m1.version));
+        added.Sort((m1, m2) => m2.Version.CompareTo(m1.Version));
         return added;
     }
 
@@ -348,27 +348,32 @@ public async Task<IEnumerable<VersionMetadata>> UpdateCache(CachePlatform cacheP
     /// </summary>
     /// <param name="metadata">Unity version</param>
     /// <param name="cachePlatform">Name of platform</param>
-    public IEnumerable<string> GetDefaultPackages(VersionMetadata metadata, CachePlatform cachePlatform)
+    public IEnumerable<string> GetDefaultPackages(VersionMetadata metadata, Platform platform, UnityReleaseAPIClient.Architecture architecture)
     {
-        var packages = metadata.GetPackages(cachePlatform);
-        if (packages == null) throw new ArgumentException($"Unity version contains no packages: {metadata.version}");
-        return packages.Where(p => p.install).Select(p => p.name);
+        var editor = metadata.GetEditorDownload(platform, architecture);
+        if (editor == null) throw new ArgumentException($"No Unity version in cache for {platform}-{architecture}: {metadata.Version}");
+        return editor.modules.Where(p => p.preSelected).Select(p => p.id);
     }
 
     /// <summary>
     /// Resolve package patterns to package metadata.
     /// This method also adds package dependencies.
     /// </summary>
-    public IEnumerable<PackageMetadata> ResolvePackages(
+    public IEnumerable<Download> ResolvePackages(
         VersionMetadata metadata, 
-        CachePlatform cachePlatform,
+        Platform platform, UnityReleaseAPIClient.Architecture architecture,
         IEnumerable<string> packages, 
         IList<string> notFound = null
     ) {
-        var packageMetadata = metadata.GetPackages(cachePlatform);
-        var metas = new List<PackageMetadata>();
+        var editor = metadata.GetEditorDownload(platform, architecture);
+        var metas = new List<Download>();
         foreach (var pattern in packages) {
             var id = pattern;
+            if (id.Equals(EditorDownload.ModuleId, StringComparison.OrdinalIgnoreCase)) {
+                metas.Add(editor);
+                continue;
+            }
+
             bool fuzzy = false, addDependencies = true;
             while (id.StartsWith("~") || id.StartsWith("=")) {
                 if (id.StartsWith("~")) {
@@ -380,26 +385,26 @@ public IEnumerable<PackageMetadata> ResolvePackages(
                 }
             }
 
-            PackageMetadata resolved = default;
+            Module resolved = null;
             if (fuzzy) {
                 // Contains lookup
-                foreach (var package in packageMetadata) {
-                    if (package.name.IndexOf(id, StringComparison.OrdinalIgnoreCase) >= 0) {
-                        if (resolved.name == null) {
-                            Logger.LogDebug($"Fuzzy lookup '{pattern}' matched package '{resolved.name}'");
+                foreach (var package in editor.AllModules.Values) {
+                    if (package.id.IndexOf(id, StringComparison.OrdinalIgnoreCase) >= 0) {
+                        if (resolved == null) {
+                            Logger.LogDebug($"Fuzzy lookup '{pattern}' matched package '{package.id}'");
                             resolved = package;
                         } else {
-                            throw new Exception($"Fuzzy package match '{pattern}' is ambiguous between '{package.name}' and '{resolved.name}'");
+                            throw new Exception($"Fuzzy package match '{pattern}' is ambiguous between '{package.id}' and '{resolved.id}'");
                         }
                     }
                 }
             } else {
                 // Exact lookup
-                resolved = metadata.GetPackage(cachePlatform, id);
+                editor.AllModules.TryGetValue(id, out resolved);
             }
 
-            if (resolved.name != null) {
-                AddPackageWithDependencies(packageMetadata, metas, resolved, addDependencies);
+            if (resolved != null) {
+                AddPackageWithDependencies(editor, metas, resolved, addDependencies);
             } else if (notFound != null) {
                 notFound.Add(id);
             }
@@ -411,9 +416,9 @@ public IEnumerable<PackageMetadata> ResolvePackages(
     /// Recursive method to add package and dependencies.
     /// </summary>
     void AddPackageWithDependencies(
-        IEnumerable<PackageMetadata> packages, 
-        List<PackageMetadata> selected, 
-        PackageMetadata package, 
+        EditorDownload editor, 
+        List<Download> selected, 
+        Module package, 
         bool addDependencies,
         bool isDependency = false
     ) {
@@ -424,32 +429,76 @@ void AddPackageWithDependencies(
 
         if (!addDependencies) return;
 
-        foreach (var dep in packages) {
-            if (dep.sync == package.name && !selected.Contains(dep)) {
-                Logger.LogInformation($"Adding '{dep.name}' which '{package.name}' is synced with");
-                AddPackageWithDependencies(packages, selected, dep, addDependencies, true);
-            }
+        foreach (var subModule in package.subModules) {
+            if (selected.Contains(subModule))
+                continue;
+
+            Logger.LogInformation($"Adding '{subModule.id}' which '{package.id}' depends on");
+            AddPackageWithDependencies(editor, selected, subModule, addDependencies, true);
+        }
+    }
+
+    /// <summary>
+    /// Get the file name to use for the package.
+    /// </summary>
+    public string GetFileName(Download download)
+    {
+        string fileName;
+
+        // Try to get file name from URL
+        var uri = new Uri(download.url, UriKind.RelativeOrAbsolute);
+        if (uri.IsAbsoluteUri) {
+            fileName = uri.Segments.Last();
+        } else {
+            fileName = Path.GetFileName(download.url);
+        }
+
+        // Fallback to type-based extension
+        if (Path.GetExtension(fileName) == "" && download.type != FileType.Undefined) {
+            var typeExtension = download.type switch {
+                FileType.TEXT   => ".txt",
+                FileType.TAR_GZ => ".tar.gz",
+                FileType.TAR_XZ => ".tar.xz",
+                FileType.ZIP    => ".zip",
+                FileType.PKG    => ".pkg",
+                FileType.EXE    => ".exe",
+                FileType.PO     => ".po",
+                FileType.DMG    => ".dmg",
+                FileType.LZMA   => ".lzma",
+                FileType.LZ4    => ".lz4",
+                FileType.PDF    => ".pdf",
+                _ => throw new Exception($"Unhandled download type: {download.type}")
+            };
+
+            fileName = download.Id + typeExtension;
         }
+
+        // Force an extension for older versions that have neither extension nor type
+        if (Path.GetExtension(fileName) == "") {
+            fileName = download.Id + ".pkg";
+        }
+
+        return fileName;
     }
 
     /// <summary>
     /// Create a download and install queue from the given version and packages.
     /// </summary>
     /// <param name="metadata">The Unity version</param>
-    /// <param name="cachePlatform">Name of platform</param>
+    /// <param name="platform">Name of platform</param>
     /// <param name="downloadPath">Location of the downloaded the packages</param>
     /// <param name="packageIds">Packages to download and/or install</param>
     /// <returns>The queue list with the created queue items</returns>
-    public Queue CreateQueue(VersionMetadata metadata, CachePlatform cachePlatform, string downloadPath, IEnumerable<PackageMetadata> packages)
+    public Queue CreateQueue(VersionMetadata metadata, Platform platform, UnityReleaseAPIClient.Architecture architecture, string downloadPath, IEnumerable<Download> packages)
     {
-        if (!metadata.version.IsFullVersion)
+        if (!metadata.Version.IsFullVersion)
             throw new ArgumentException("VersionMetadata.version needs to contain a full Unity version", nameof(metadata));
         
-        var packageMetadata = metadata.GetPackages(cachePlatform);
+        var editor = metadata.GetEditorDownload(platform, architecture);
+
+        if (editor == null || !editor.modules.Any())
+            throw new ArgumentException("VersionMetadata.release cannot be null or empty", nameof(metadata));
 
-        if (packageMetadata == null || !packageMetadata.Any())
-            throw new ArgumentException("VersionMetadata.packages cannot be null or empty", nameof(metadata));
-        
         var items = new List<QueueItem>();
         foreach (var package in packages) {
             var fullUrl = package.url;
@@ -457,8 +506,8 @@ public Queue CreateQueue(VersionMetadata metadata, CachePlatform cachePlatform,
                 fullUrl = metadata.baseUrl + package.url;
             }
 
-            var fileName = package.GetFileName();
-            Logger.LogDebug($"{package.name}: Using file name '{fileName}' for url '{fullUrl}'");
+            var fileName = GetFileName(package);
+            Logger.LogDebug($"{package.Id}: Using file name '{fileName}' for url '{fullUrl}'");
             var outputPath = Path.Combine(downloadPath, fileName);
 
             items.Add(new QueueItem() {
@@ -471,6 +520,7 @@ public Queue CreateQueue(VersionMetadata metadata, CachePlatform cachePlatform,
 
         return new Queue() {
             metadata = metadata,
+            downloadPath = downloadPath,
             items = items
         };
     }
@@ -481,7 +531,7 @@ public Queue CreateQueue(VersionMetadata metadata, CachePlatform cachePlatform,
     /// <param name="steps">Which steps to perform.</param>
     /// <param name="queue">The queue to process</param>
     /// <param name="cancellation">Cancellation token</param>
-    public async Task<Installation> Process(InstallStep steps, Queue queue, bool skipChecks = false, CancellationToken cancellation = default)
+    public async Task<Installation> Process(InstallStep steps, Queue queue, Downloader.ExistingFile existingFile = Downloader.ExistingFile.Undefined, CancellationToken cancellation = default)
     {
         if (queue == null) throw new ArgumentNullException(nameof(queue));
 
@@ -493,12 +543,12 @@ public async Task<Installation> Process(InstallStep steps, Queue queue, bool ski
         Logger.LogDebug($"download = {download}, install = {install}");
 
         foreach (var item in queue.items) {
-            var size = skipChecks ? -1 : item.package.size;
-            var hash = skipChecks ? null : item.package.md5;
+            var size = item.package.downloadSize.GetBytes();
+            var hash = item.package.integrity;
 
             if (!download) {
                 if (!File.Exists(item.filePath))
-                    throw new InvalidOperationException($"File for package {item.package.name} not found at path: {item.filePath}");
+                    throw new InvalidOperationException($"File for package {item.package.Id} not found at path: {item.filePath}");
 
                 if (hash == null) {
                     Logger.LogWarning($"File exists but cannot be checked for completeness: {item.filePath}");
@@ -516,7 +566,13 @@ public async Task<Installation> Process(InstallStep steps, Queue queue, bool ski
                     item.currentState = install ? QueueItem.State.WaitingForInstall : QueueItem.State.Complete;
                 } else {
                     item.downloader = new Downloader();
-                    item.downloader.Resume = Configuration.resumeDownloads;
+                    if (existingFile != Downloader.ExistingFile.Undefined) {
+                        item.downloader.Existing = existingFile;
+                    } else {
+                        item.downloader.Existing = Configuration.resumeDownloads
+                            ? Downloader.ExistingFile.Resume
+                            : Downloader.ExistingFile.Redownload;
+                    }
                     item.downloader.Timeout = Configuration.requestTimeout;
                     item.downloader.Prepare(item.downloadUrl, item.filePath, size, hash);
                 }
@@ -525,17 +581,19 @@ public async Task<Installation> Process(InstallStep steps, Queue queue, bool ski
 
         if (install) {
             string installationPaths = null;
-            if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX)) {
+            if (System.Runtime.InteropServices.RuntimeInformation.IsOSPlatform(System.Runtime.InteropServices.OSPlatform.OSX)) {
                 installationPaths = Configuration.installPathMac;
+            } else if (System.Runtime.InteropServices.RuntimeInformation.IsOSPlatform(System.Runtime.InteropServices.OSPlatform.Windows)) {
+                installationPaths = Configuration.installPathWindows;
             } else {
-                throw new NotImplementedException("Installer does not currently support the platform: " + RuntimeInformation.OSDescription);
+                throw new NotImplementedException("Installer does not currently support the platform: " + System.Runtime.InteropServices.RuntimeInformation.OSDescription);
             }
 
             await Platform.PrepareInstall(queue, installationPaths, cancellation);
         }
 
         try {
-            var editorItem = queue.items.FirstOrDefault(i => i.package.name == PackageMetadata.EDITOR_PACKAGE_NAME);
+            var editorItem = queue.items.FirstOrDefault(i => i.package is EditorDownload);
             while (!cancellation.IsCancellationRequested) {
                 // Check completed and count active
                 int downloading = 0, installing = 0, complete = 0;
@@ -549,18 +607,19 @@ public async Task<Installation> Process(InstallStep steps, Queue queue, bool ski
                                     item.retries--;
                                     Logger.LogError(item.downloadTask.Exception.InnerException.Message 
                                         + $" (retrying in {Configuration.retryDelay}s, {item.retries} retries remaining)");
+                                    Logger.LogInformation(item.downloadTask.Exception.InnerException.StackTrace);
                                     item.waitUntil = DateTime.UtcNow + TimeSpan.FromSeconds(Configuration.retryDelay);
                                     item.downloader.Reset();
                                     item.currentState = QueueItem.State.WaitingForDownload;
                                 }
                             } else {
                                 item.currentState = install ? QueueItem.State.WaitingForInstall : QueueItem.State.Complete;
-                                Logger.LogDebug($"{item.package.name} download complete: now {item.currentState}");
+                                Logger.LogDebug($"{item.package.Id} download complete: now {item.currentState}");
                             }
                         } else {
                             if (item.currentState == QueueItem.State.Hashing && item.downloader.CurrentState == Downloader.State.Downloading) {
                                 item.currentState = QueueItem.State.Downloading;
-                                Logger.LogDebug($"{item.package.name} hashed: now {item.currentState}");
+                                Logger.LogDebug($"{item.package.Id} hashed: now {item.currentState}");
                             }
                             downloading++;
                         }
@@ -571,7 +630,7 @@ public async Task<Installation> Process(InstallStep steps, Queue queue, bool ski
                                 throw item.installTask.Exception;
                             }
                             item.currentState = QueueItem.State.Complete;
-                            Logger.LogDebug($"{item.package.name}: install complete");
+                            Logger.LogDebug($"{item.package.Id}: install complete");
                         } else {
                             installing++;
                         }
@@ -592,7 +651,7 @@ public async Task<Installation> Process(InstallStep steps, Queue queue, bool ski
                         if (item.waitUntil > DateTime.UtcNow) {
                             continue;
                         }
-                        Logger.LogDebug($"{item.package.name}: Starting download");
+                        Logger.LogDebug($"{item.package.Id}: Starting download");
                         if (download) {
                             item.downloadTask = item.downloader.Start(cancellation);
                         } else {
@@ -606,7 +665,7 @@ public async Task<Installation> Process(InstallStep steps, Queue queue, bool ski
                             // Wait for the editor to complete installation
                             continue;
                         }
-                        Logger.LogDebug($"{item.package.name}: Starting install");
+                        Logger.LogDebug($"{item.package.Id}: Starting install");
                         item.installTask = Platform.Install(queue, item, cancellation);
                         item.currentState = QueueItem.State.Installing;
                         installing++;
@@ -636,29 +695,34 @@ public async Task<Installation> Process(InstallStep steps, Queue queue, bool ski
     /// <param name="downloadPath">Where downloads were stored.</param>
     /// <param name="metadata">The Unity version downloaded</param>
     /// <param name="packageIds">Downloaded packages.</param>
-    public void CleanUpDownloads(VersionMetadata metadata, string downloadPath, IEnumerable<PackageMetadata> packages)
+    public void CleanUpDownloads(Queue queue)
     {
-        if (!Directory.Exists(downloadPath))
+        if (!Directory.Exists(queue.downloadPath))
             return;
         
-        foreach (var directory in Directory.GetDirectories(downloadPath)) {
+        foreach (var directory in Directory.GetDirectories(queue.downloadPath)) {
             throw new Exception("Unexpected directory in downloads folder: " + directory);
         }
 
-        var packageFileNames = packages
-            .Select(p => p.GetFileName())
+        var packageFilePaths = queue.items
+            .Select(p => Path.GetFullPath(p.filePath))
             .ToList();
-        foreach (var path in Directory.GetFiles(downloadPath)) {
+        foreach (var path in Directory.GetFiles(queue.downloadPath)) {
             var fileName = Path.GetFileName(path);
             if (fileName == ".DS_Store" || fileName == "thumbs.db" || fileName == "desktop.ini")
                 continue;
             
-            if (!packageFileNames.Contains(fileName)) {
-                throw new Exception("Unexpected file in downloads folder: " + path);
+            if (!packageFilePaths.Contains(path)) {
+                if (System.Runtime.InteropServices.RuntimeInformation.IsOSPlatform(System.Runtime.InteropServices.OSPlatform.Windows)) {
+                    // Don't throw on unexcpeted files in Windows Download folder
+                    Logger.LogWarning("Unexpected file in downloads folder: " + path);
+                } else {
+                    throw new Exception("Unexpected file in downloads folder: " + path);
+                }
             }
         }
 
-        Directory.Delete(downloadPath, true);
+        Directory.Delete(queue.downloadPath, true);
     }
 }
 
diff --git a/sttz.InstallUnity/Installer/UnityReleaseAPIClient.cs b/sttz.InstallUnity/Installer/UnityReleaseAPIClient.cs
new file mode 100644
index 0000000..7e77407
--- /dev/null
+++ b/sttz.InstallUnity/Installer/UnityReleaseAPIClient.cs
@@ -0,0 +1,837 @@
+using Microsoft.Extensions.Logging;
+using Newtonsoft.Json;
+using System;
+using System.Collections.Generic;
+using System.Net;
+using System.Net.Http;
+using System.Runtime.Serialization;
+using System.Threading;
+using System.Threading.Tasks;
+
+namespace sttz.InstallUnity
+{
+
+/// <summary>
+/// Client for the official Unity Release API.
+/// Providing the latest Unity editor releases and associated packages.
+/// https://services.docs.unity.com/release/v1/index.html#tag/Release/operation/getUnityReleases
+/// </summary>
+public class UnityReleaseAPIClient
+{
+    // -------- Types --------
+
+    /// <summary>
+    /// Different Unity release streams.
+    /// </summary>
+    [Flags]
+    public enum ReleaseStream
+    {
+        None = 0,
+
+        Alpha = 1<<0,
+        Beta  = 1<<1,
+        Tech  = 1<<2,
+        LTS   = 1<<3,
+
+        PrereleaseMask = (Alpha | Beta),
+
+        All = -1,
+    }
+
+    /// <summary>
+    /// Platforms the Unity editor runs on.
+    /// </summary>
+    [Flags]
+    public enum Platform
+    {
+        None,
+
+        Mac_OS = 1<<0,
+        Linux = 1<<1,
+        Windows = 1<<2,
+
+        All = -1,
+    }
+
+    /// <summary>
+    /// CPU architectures the Unity editor supports (on some platforms).
+    /// </summary>
+    [Flags]
+    public enum Architecture
+    {
+        None = 0,
+
+        X86_64 = 1<<10,
+        ARM64  = 1<<11,
+
+        All = -1,
+    }
+
+    /// <summary>
+    /// Different file types of downloads and links.
+    /// </summary>
+    public enum FileType
+    {
+        Undefined,
+
+        TEXT,
+        TAR_GZ,
+        TAR_XZ,
+        ZIP,
+        PKG,
+        EXE,
+        PO,
+        DMG,
+        LZMA,
+        LZ4,
+        MD,
+        PDF
+    }
+
+    /// <summary>
+    /// Response from the Releases API.
+    /// </summary>
+    [JsonObject(MemberSerialization.Fields)]
+    public class Response
+    {
+        /// <summary>
+        /// Return wether the request was successful.
+        /// </summary>
+        public bool IsSuccess => ((int)status >= 200 && (int)status <= 299);
+
+        // -------- Response Fields --------
+
+        /// <summary>
+        /// Start offset from the returned results.
+        /// </summary>
+        public int offset;
+        /// <summary>
+        /// Limit of results returned.
+        /// </summary>
+        public int limit;
+        /// <summary>
+        /// Total number of results.
+        /// </summary>
+        public int total;
+
+        /// <summary>
+        /// The release results.
+        /// </summary>
+        public Release[] results;
+
+        // -------- Error fields --------
+
+        /// <summary>
+        /// Error code.
+        /// </summary>
+        public HttpStatusCode status;
+        /// <summary>
+        /// Title of the error.
+        /// </summary>
+        public string title;
+        /// <summary>
+        /// Error detail description.
+        /// </summary>
+        public string detail;
+    }
+
+    /// <summary>
+    /// A specific release of the Unity editor.
+    /// </summary>
+    [JsonObject(MemberSerialization.Fields)]
+    public class Release
+    {
+        /// <summary>
+        /// Version of the editor.
+        /// </summary>
+        public UnityVersion version;
+        /// <summary>
+        /// The Git Short Revision of the Unity Release.
+        /// </summary>
+        public string shortRevision;
+
+        /// <summary>
+        /// Date and time of the release.
+        /// </summary>
+        public DateTime releaseDate;
+        /// <summary>
+        /// Link to the release notes.
+        /// </summary>
+        public ReleaseNotes releaseNotes;
+        /// <summary>
+        /// Stream this release is part of.
+        /// </summary>
+        public ReleaseStream stream;
+        /// <summary>
+        /// The SKU family of the Unity Release.
+        /// Possible values: CLASSIC or DOTS
+        /// </summary>
+        public string skuFamily;
+        /// <summary>
+        /// The indicator for whether the Unity Release is the recommended LTS
+        /// </summary>
+        public bool recommended;
+        /// <summary>
+        /// Deep link to open this release in Unity Hub.
+        /// </summary>
+        public string unityHubDeepLink;
+
+        /// <summary>
+        /// Editor downloads of this release.
+        /// </summary>
+        public List<EditorDownload> downloads;
+
+        /// <summary>
+        /// The Third Party Notices of the Unity Release.
+        /// </summary>
+        public ThirdPartyNotice[] thirdPartyNotices;
+
+        [OnDeserialized]
+        internal void OnDeserializedMethod(StreamingContext context)
+        {
+            // Copy the short revision to the UnityVersion struct
+            if (string.IsNullOrEmpty(version.hash) && !string.IsNullOrEmpty(shortRevision)) {
+                version.hash = shortRevision;
+            }
+        }
+    }
+
+    /// <summary>
+    /// Unity editor release notes.
+    /// </summary>
+    [JsonObject(MemberSerialization.Fields)]
+    public struct ReleaseNotes
+    {
+        /// <summary>
+        /// Url to the release notes.
+        /// </summary>
+        public string url;
+        /// <summary>
+        /// Type of the release notes.
+        /// (Only seen "MD" so far.)
+        /// </summary>
+        public FileType type;
+    }
+
+    /// <summary>
+    /// Third party notices associated with a Unity release.
+    /// </summary>
+    [JsonObject(MemberSerialization.Fields)]
+    public struct ThirdPartyNotice
+    {
+        /// <summary>
+        /// The original file name of the Unity Release Third Party Notice.
+        /// </summary>
+        public string originalFileName;
+        /// <summary>
+        /// The URL of the Unity Release Third Party Notice.
+        /// </summary>
+        public string url;
+        /// <summary>
+        /// Type of the release notes.
+        /// </summary>
+        public FileType type;
+    }
+
+    /// <summary>
+    /// An Unity editor download, including available modules.
+    /// </summary>
+    [JsonObject(MemberSerialization.Fields)]
+    public abstract class Download
+    {
+        /// <summary>
+        /// Url to download.
+        /// </summary>
+        public string url;
+        /// <summary>
+        /// Integrity hash (hash prefixed by hash type plus dash, seen md5 and sha384).
+        /// </summary>
+        public string integrity;
+        /// <summary>
+        /// Type of download.
+        /// (Only seen "DMG", "PKG", "ZIP" and "PO" so far)
+        /// </summary>
+        public FileType type;
+        /// <summary>
+        /// Size of the download.
+        /// </summary>
+        public FileSize downloadSize;
+        /// <summary>
+        /// Size required on disk.
+        /// </summary>
+        public FileSize installedSize;
+
+        /// <summary>
+        /// ID of the download.
+        /// </summary>
+        public abstract string Id { get; }
+    }
+
+    /// <summary>
+    /// Main editor download.
+    /// </summary>
+    [JsonObject(MemberSerialization.Fields)]
+    public class EditorDownload : Download
+    {
+        /// <summary>
+        /// The Id of the editor download pseudo-module.
+        /// </summary>
+        public const string ModuleId = "unity";
+
+        /// <summary>
+        /// Platform of the editor.
+        /// </summary>
+        public Platform platform;
+        /// <summary>
+        /// Architecture of the editor.
+        /// </summary>
+        public Architecture architecture;
+        /// <summary>
+        /// Available modules for this editor version.
+        /// </summary>
+        public List<Module> modules;
+
+        /// <summary>
+        /// Editor downloads all have the fixed "Unity" ID.
+        /// </summary>
+        public override string Id => ModuleId;
+
+        /// <summary>
+        /// Dictionary of all modules, including sub-modules.
+        /// </summary>
+        public Dictionary<string, Module> AllModules { get {
+            if (_allModules == null) {
+                _allModules = new Dictionary<string, Module>(StringComparer.OrdinalIgnoreCase);
+                if (modules != null) {
+                    foreach (var module in modules) {
+                        AddModulesRecursive(module);
+                    }
+                }
+            }
+            return _allModules;
+        } }
+        [NonSerialized] Dictionary<string, Module> _allModules;
+
+        void AddModulesRecursive(Module module)
+        {
+            if (string.IsNullOrEmpty(module.id)) {
+                throw new Exception($"EditorDownload.AllModules: Module is missing ID");
+            }
+
+            if (!_allModules.TryAdd(module.id, module)) {
+                throw new Exception($"EditorDownload.AllModules: Multiple modules with id '{module.id}'");
+            }
+
+            if (module.subModules != null) {
+                foreach (var subModule in module.subModules) {
+                    if (subModule == null) continue;
+                    AddModulesRecursive(subModule);
+                }
+            }
+        }
+    }
+
+    /// <summary>
+    /// Size description of a download or space required for install.
+    /// </summary>
+    [JsonObject(MemberSerialization.Fields)]
+    public struct FileSize
+    {
+        /// <summary>
+        /// Size value.
+        /// </summary>
+        public long value;
+        /// <summary>
+        /// Unit of the value.
+        /// Possible vaues: BYTE, KILOBYTE, MEGABYTE, GIGABYTE
+        /// (Only seen "BYTE" so far.)
+        /// </summary>
+        public string unit;
+
+        /// <summary>
+        /// Return the size in bytes, converting from the source unit when necessary.
+        /// </summary>
+        public long GetBytes()
+        {
+            switch (unit) {
+                case "BYTE":
+                    return value;
+                case "KILOBYTE":
+                    return value * 1024;
+                case "MEGABYTE":
+                    return value * 1024 * 1024;
+                case "GIGABYTE":
+                    return value * 1024 * 1024 * 1024;
+                default:
+                    throw new Exception($"FileSize: Unhandled size unit '{unit}'");
+            }
+        }
+
+        /// <summary>
+        /// Create a new instance with the given amount of bytes.
+        /// </summary>
+        public static FileSize FromBytes(long bytes)
+            => new FileSize() { value = bytes, unit = "BYTE" };
+
+        /// <summary>
+        /// Create a new instance with the given amount of bytes.
+        /// </summary>
+        public static FileSize FromMegaBytes(long megaBytes)
+            => new FileSize() { value = megaBytes, unit = "MEGABYTE" };
+    }
+
+    /// <summary>
+    /// A module of an editor.
+    /// </summary>
+    [JsonObject(MemberSerialization.Fields)]
+    public class Module : Download
+    {
+        /// <summary>
+        /// Identifier of the module.
+        /// </summary>
+        public string id;
+        /// <summary>
+        /// Slug identifier of the module.
+        /// </summary>
+        public string slug;
+        /// <summary>
+        /// Name of the module.
+        /// </summary>
+        public string name;
+        /// <summary>
+        /// Description of the module.
+        /// </summary>
+        public string description;
+        /// <summary>
+        /// Category type of the module.
+        /// </summary>
+        public string category;
+        /// <summary>
+        /// Wether this module is required for its parent module.
+        /// </summary>
+        public bool required;
+        /// <summary>
+        /// Wether this module is hidden from the user.
+        /// </summary>
+        public bool hidden;
+        /// <summary>
+        /// Wether this module is installed by default.
+        /// </summary>
+        public bool preSelected;
+        /// <summary>
+        /// Where to install the module to (can contain the {UNITY_PATH} variable).
+        /// </summary>
+        public string destination;
+        /// <summary>
+        /// How to rename the installed directory.
+        /// </summary>
+        public PathRename extractedPathRename;
+        /// <summary>
+        /// EULAs the user should accept before installing.
+        /// </summary>
+        public Eula[] eula;
+        /// <summary>
+        /// Sub-Modules of this module.
+        /// </summary>
+        public List<Module> subModules;
+
+        /// <summary>
+        /// Modules return their dynamic id.
+        /// </summary>
+        public override string Id => id;
+        /// <summary>
+        /// Id of the parent module.
+        /// </summary>
+        [NonSerialized] public string parentModuleId;
+        /// <summary>
+        /// The parent module that lists this sub-module (null = part of main editor module).
+        /// </summary>
+        [NonSerialized] public Module parentModule;
+        /// <summary>
+        /// Used to track automatically added dependencies.
+        /// </summary>
+        [NonSerialized] public bool addedAutomatically;
+
+        [OnDeserialized]
+        internal void OnDeserializedMethod(StreamingContext context)
+        {
+            if (subModules != null) {
+                // Set ourself as parent module on sub-modules
+                foreach (var sub in subModules) {
+                    if (sub == null) continue;
+                    sub.parentModule = this;
+                    sub.parentModuleId = id;
+                }
+            }
+        }
+    }
+
+    /// <summary>
+    /// EULA of a module.
+    /// </summary>
+    [JsonObject(MemberSerialization.Fields)]
+    public struct Eula
+    {
+        /// <summary>
+        /// URL to the EULA.
+        /// </summary>
+        public string url;
+        /// <summary>
+        /// Type of content at the url.
+        /// (Only seen "TEXT" so far.)
+        /// </summary>
+        public FileType type;
+        /// <summary>
+        /// Label for this EULA.
+        /// </summary>
+        public string label;
+        /// <summary>
+        /// Explanation message for the user.
+        /// </summary>
+        public string message;
+    }
+
+    /// <summary>
+    /// Path rename instruction.
+    /// </summary>
+    [JsonObject(MemberSerialization.Fields)]
+    public struct PathRename
+    {
+        /// <summary>
+        /// Path to rename from (can contain the {UNITY_PATH} variable).
+        /// </summary>
+        public string from;
+        /// <summary>
+        /// Path to rename to (can contain the {UNITY_PATH} variable).
+        /// </summary>
+        public string to;
+
+        /// <summary>
+        /// Wether both a from and to path are set.
+        /// </summary>
+        public bool IsSet => (!string.IsNullOrEmpty(from) && !string.IsNullOrEmpty(to));
+    }
+
+    // -------- API --------
+
+    /// <summary>
+    /// Order of the results returned by the API.
+    /// </summary>
+    [Flags]
+    public enum ResultOrder
+    {
+        /// <summary>
+        /// Default order (release date descending).
+        /// </summary>
+        Default = 0,
+
+        // -------- Sorting Cireteria --------
+
+        /// <summary>
+        /// Order by release date.
+        /// </summary>
+        ReleaseDate = 1<<0,
+
+        // -------- Sorting Order --------
+
+        /// <summary>
+        /// Return results in ascending order.
+        /// </summary>
+        Ascending = 1<<30,
+        /// <summary>
+        /// Return results in descending order.
+        /// </summary>
+        Descending = 1<<31,
+    }
+
+    /// <summary>
+    /// Request parameters of the Unity releases API.
+    /// </summary>
+    public class RequestParams
+    {
+        /// <summary>
+        /// Version filter, applied as full-text search on the version string.
+        /// </summary>
+        public string version = null;
+        /// <summary>
+        /// Unity release streams to load (can set multiple flags in bitmask).
+        /// </summary>
+        public ReleaseStream stream = ReleaseStream.All;
+        /// <summary>
+        /// Platforms to load (can set multiple flags in bitmask).
+        /// </summary>
+        public Platform platform = Platform.All;
+        /// <summary>
+        /// Architectures to load (can set multiple flags in bitmask).
+        /// </summary>
+        public Architecture architecture = Architecture.All;
+
+        /// <summary>
+        /// How many results to return (1-25).
+        /// </summary>
+        public int limit = 10;
+        /// <summary>
+        /// Offset of the first result returned
+        /// </summary>
+        public int offset = 0;
+        /// <summary>
+        /// Order of returned results.
+        /// </summary>
+        public ResultOrder order;
+    }
+
+    /// <summary>
+    /// Maximum number of requests that can be made per second.
+    /// </summary>
+    public const int MaxRequestsPerSecond = 10;
+    /// <summary>
+    /// Maximum number of requests that can be made per 30 minutes.
+    /// (Not currently tracked by the client.)
+    /// </summary>
+    public const int MaxRequestsPerHalfHour = 1000;
+
+    /// <summary>
+    /// Send a basic request to the Release API.
+    /// </summary>
+    public async Task<Response> Send(RequestParams request, CancellationToken cancellation = default)
+    {
+        var parameters = new List<KeyValuePair<string, string>>();
+        parameters.Add(new (nameof(RequestParams.limit), request.limit.ToString("R")));
+        parameters.Add(new (nameof(RequestParams.offset), request.offset.ToString("R")));
+
+        if (!string.IsNullOrEmpty(request.version)) {
+            parameters.Add(new (nameof(RequestParams.version), request.version));
+        }
+        if (request.stream != ReleaseStream.All) {
+            AddArrayParameters(parameters, nameof(RequestParams.stream), StreamValues, request.stream);
+        }
+        if (request.platform != Platform.All) {
+            AddArrayParameters(parameters, nameof(RequestParams.platform), PlatformValues, request.platform);
+        }
+        if (request.architecture != Architecture.All) {
+            AddArrayParameters(parameters, nameof(RequestParams.architecture), ArchitectureValues, request.architecture);
+        }
+        if (request.order != ResultOrder.Default) {
+            if (request.order.HasFlag(ResultOrder.ReleaseDate)) {
+                var dir = (request.order.HasFlag(ResultOrder.Descending) ? "_DESC" : "_ASC");
+                parameters.Add(new (nameof(RequestParams.order), "RELEASE_DATE" + dir));
+            }
+        }
+
+        var query = await new FormUrlEncodedContent(parameters).ReadAsStringAsync(cancellation);
+        Logger.LogDebug($"Sending request to Unity Releases API with query '{Endpoint + query}'");
+
+        var timeSinceLastRequest = DateTime.Now - lastRequestTime;
+        var minRequestInterval = TimeSpan.FromSeconds(1) / MaxRequestsPerSecond;
+        if (timeSinceLastRequest < minRequestInterval) {
+            // Delay request to not exceed max requests per second
+            await Task.Delay(minRequestInterval - timeSinceLastRequest);
+        }
+
+        lastRequestTime = DateTime.Now;
+        var response = await client.GetAsync(Endpoint + query, cancellation);
+
+        var json = await response.Content.ReadAsStringAsync(cancellation);
+        Logger.LogTrace($"Received response from Unity Releases API ({response.StatusCode}): {json}");
+        if (string.IsNullOrEmpty(json)) {
+            throw new Exception($"Got empty response from Unity Releases API (code {response.StatusCode})");
+        }
+
+        var parsedResponse = JsonConvert.DeserializeObject<Response>(json);
+        if (parsedResponse.status == 0) {
+            parsedResponse.status = response.StatusCode;
+        }
+
+        return parsedResponse;
+    }
+
+    /// <summary>
+    /// Load all releases for the given request, making multiple
+    /// paginated requests to the API.
+    /// </summary>
+    /// <param name="request">The request to send, the limit and offset fields will be modified</param>
+    /// <param name="maxResults">Limit returned results to not make too many requests</param>
+    /// <param name="cancellation">Cancellation token</param>
+    /// <returns>The results returned from the API</returns>
+    public async Task<Release[]> LoadAll(RequestParams request, int maxResults = 200, CancellationToken cancellation = default)
+    {
+        request.limit = 25;
+
+        int maxTotal = 0, currentOffset = 0;
+        Release[] releases = null;
+        Response response = null;
+        do {
+            response = await Send(request, cancellation);
+            if (!response.IsSuccess) {
+                throw new Exception($"Unity Release API request failed: {response.title} - {response.detail}");
+            }
+
+            maxTotal = Math.Min(response.total, maxResults);
+            if (releases == null) {
+                releases = new Release[maxTotal];
+            }
+
+            Array.Copy(response.results, 0, releases, currentOffset, response.results.Length);
+            currentOffset += response.results.Length;
+
+            request.offset += response.results.Length;
+
+        } while (currentOffset < maxTotal && response.results.Length > 0);
+
+        return releases;
+    }
+
+    /// <summary>
+    /// Load all latest releases from the given time period,
+    /// making multiple paginated requests to the API.
+    /// </summary>
+    /// <param name="request">The request to send, the limit, offset and order fields will be modified</param>
+    /// <param name="period">The period to load releases from</param>
+    /// <param name="cancellation">Cancellation token</param>
+    /// <returns>The results returned from the API, can contain releases older than the given period</returns>
+    public async Task<IEnumerable<Release>> LoadLatest(RequestParams request, TimeSpan period, CancellationToken cancellation = default)
+    {
+        request.limit = 25;
+        request.order = ResultOrder.ReleaseDate | ResultOrder.Descending;
+
+        var releases = new List<Release>();
+        var now = DateTime.Now;
+        Response response = null;
+        do {
+            response = await Send(request, cancellation);
+            if (!response.IsSuccess) {
+                throw new Exception($"Unity Release API request failed: {response.title} - {response.detail}");
+            } else if (response.results.Length == 0) {
+                break;
+            }
+
+            releases.AddRange(response.results);
+            request.offset += response.results.Length;
+
+            var oldestReleaseDate = response.results[^1].releaseDate;
+            var releasedSince = now - oldestReleaseDate;
+            if (releasedSince > period) {
+                break;
+            }
+
+        } while (true);
+
+        return releases;
+    }
+
+    /// <summary>
+    /// Try to find a release based on version string search.
+    /// </summary>
+    public async Task<Release> FindRelease(UnityVersion version, Platform platform, Architecture architecture, CancellationToken cancellation = default)
+    {
+        var req = new RequestParams();
+        req.limit = 1;
+        req.order = ResultOrder.ReleaseDate | ResultOrder.Descending;
+
+        req.platform = platform;
+        req.architecture = architecture;
+
+        // Set release stream based on input version
+        req.stream = ReleaseStream.Tech | ReleaseStream.LTS;
+        if (version.type == UnityVersion.Type.Beta) req.stream |= ReleaseStream.Beta;
+        if (version.type == UnityVersion.Type.Alpha) req.stream |= ReleaseStream.Beta | ReleaseStream.Alpha;
+
+        // Only add version if not just release type
+        if (version.major >= 0) {
+            // Build up version for a sub-string search (e.g. 2022b won't return any results)
+            var searchString = version.major.ToString();
+            if (version.minor >= 0) {
+                searchString += "." + version.minor;
+                if (version.patch >= 0) {
+                    searchString += "." + version.patch;
+                    if (version.type != UnityVersion.Type.Undefined) {
+                        searchString += (char)version.type;
+                        if (version.build >= 0) {
+                            searchString += version.build;
+                        }
+                    }
+                }
+            }
+            req.version = searchString;
+        }
+
+        var result = await Send(req, cancellation);
+        if (!result.IsSuccess) {
+            throw new Exception($"Unity Release API request failed: {result.title} - {result.detail}");
+        } else if (result.results.Length == 0) {
+            return null;
+        }
+
+        return result.results[0];
+    }
+
+    // -------- Implementation --------
+
+    ILogger Logger = UnityInstaller.CreateLogger<UnityReleaseAPIClient>();
+
+    static HttpClient client = new HttpClient();
+    static DateTime lastRequestTime = DateTime.MinValue;
+
+    /// <summary>
+    /// Endpoint of the releases API.
+    /// </summary>
+    const string Endpoint = "https://services.api.unity.com/unity/editor/release/v1/releases?";
+
+    /// <summary>
+    /// Query string values for streams.
+    /// </summary>
+    static readonly Dictionary<ReleaseStream, string> StreamValues = new() {
+        { ReleaseStream.Alpha, "ALPHA" },
+        { ReleaseStream.Beta,  "BETA" },
+        { ReleaseStream.Tech,  "TECH" },
+        { ReleaseStream.LTS,   "LTS" },
+    };
+    /// <summary>
+    /// Query string values for platforms.
+    /// </summary>
+    static readonly Dictionary<Platform, string> PlatformValues = new() {
+        { Platform.Mac_OS,   "MAC_OS" },
+        { Platform.Linux,   "LINUX" },
+        { Platform.Windows, "WINDOWS" },
+    };
+    /// <summary>
+    /// Query string values for architectures.
+    /// </summary>
+    static readonly Dictionary<Architecture, string> ArchitectureValues = new() {
+        { Architecture.X86_64, "X86_64" },
+        { Architecture.ARM64,  "ARM64" },
+    };
+
+    /// <summary>
+    /// Iterate all the single bits set in the given enum value.
+    /// (This does not check if the set bit is defined in the enum.)
+    /// </summary>
+    static IEnumerable<T> IterateBits<T>(T value)
+        where T : struct, System.Enum
+    {
+        var number = (int)(object)value;
+        for (int i = 0; i < 32; i++) {
+            var flag = 1 << i;
+            if ((number & flag) != 0)
+                yield return (T)(object)flag;
+        }
+    }
+
+    /// <summary>
+    /// Check the given bitmask enum for single set bits, look up those values
+    /// in the given dictionary and then add them to the query.
+    /// </summary>
+    static void AddArrayParameters<T>(List<KeyValuePair<string, string>> query, string name, Dictionary<T, string> values, T bitmask)
+        where T : struct, System.Enum
+    {
+        foreach (var flag in IterateBits(bitmask)) {
+            if (!values.TryGetValue(flag, out var value)) {
+                // ERROR: Value not found
+                continue;
+            }
+            query.Add(new (name, value));
+        }
+    }
+}
+
+}
diff --git a/sttz.InstallUnity/Installer/UnityVersion.cs b/sttz.InstallUnity/Installer/UnityVersion.cs
index 72b1bbd..0b8c1b5 100644
--- a/sttz.InstallUnity/Installer/UnityVersion.cs
+++ b/sttz.InstallUnity/Installer/UnityVersion.cs
@@ -21,10 +21,6 @@ public enum Type: ushort {
         /// </summary>
         Final = 'f',
         /// <summary>
-        /// Unity patch release.
-        /// </summary>
-        Patch = 'p',
-        /// <summary>
         /// Unity beta release.
         /// </summary>
         Beta  = 'b',
@@ -86,8 +82,6 @@ public static int GetSortingForType(Type type)
     {
         switch (type) {
             case Type.Final:
-                return 4;
-            case Type.Patch:
                 return 3;
             case Type.Beta:
                 return 2;
@@ -102,7 +96,7 @@ public static int GetSortingForType(Type type)
     /// Types sorted from unstable to stable.
     /// </summary>
     public static readonly Type[] SortedTypes = new Type[] {
-        Type.Alpha, Type.Beta, Type.Patch, Type.Final, Type.Undefined
+        Type.Alpha, Type.Beta, Type.Final, Type.Undefined
     };
 
     /// <summary>
@@ -396,6 +390,11 @@ public bool Equals(UnityVersion other)
     {
         return lhs.CompareTo(rhs) >= 0;
     }
+
+    public static explicit operator UnityVersion(string versionString)
+    {
+        return new UnityVersion(versionString);
+    }
 }
 
 }
diff --git a/sttz.InstallUnity/Installer/VersionsCache.cs b/sttz.InstallUnity/Installer/VersionsCache.cs
index 6fa8391..6f637ee 100644
--- a/sttz.InstallUnity/Installer/VersionsCache.cs
+++ b/sttz.InstallUnity/Installer/VersionsCache.cs
@@ -6,20 +6,10 @@
 using System.IO;
 using System.Linq;
 
-namespace sttz.InstallUnity
-{
+using static sttz.InstallUnity.UnityReleaseAPIClient;
 
-/// <summary>
-/// Platforms supported by the cache.
-/// </summary>
-public enum CachePlatform
+namespace sttz.InstallUnity
 {
-    None,
-    macOSIntel,
-    macOSArm,
-    Windows,
-    Linux,
-}
 
 /// <summary>
 /// Information about a Unity version available to install.
@@ -27,392 +17,87 @@ public enum CachePlatform
 public struct VersionMetadata
 {
     /// <summary>
-    /// Unity version.
+    /// Create a new version from a release.
     /// </summary>
-    public UnityVersion version;
-
-    /// <summary>
-    /// Wether version was scraped from a beta/alpha page.
-    /// </summary>
-    /// <remarks>
-    /// Release candidates appear on the beta page but have versions that
-    /// are indistinguishable from regular releases, we mark them here to
-    /// distinguish between them.
-    /// </remarks>
-    public bool prerelease;
-
-    /// <summary>
-    /// Returns wether the metadata represents a release candidate.
-    /// (A final version published on the prerelease pages.)
-    /// </summary>
-    public bool IsReleaseCandidate {
-        get {
-            return prerelease && version.type == UnityVersion.Type.Final;
-        }
+    public static VersionMetadata FromRelease(Release release)
+    {
+        return new VersionMetadata() { release = release };
     }
 
     /// <summary>
-    /// Returns wether the metadata represents a regular Unity release,
-    /// excluding release candidates.
+    /// The release metadata, in the format of the Unity Release API.
     /// </summary>
-    public bool IsFinalRelease {
-        get {
-            return version.type == UnityVersion.Type.Final && !prerelease;
-        }
-    }
+    public Release release;
 
     /// <summary>
-    /// Returns wether the metadata represents a Unity prerelease,
-    /// including alpha, beta and release candidates.
+    /// Shortcut to the Unity version of this release.
     /// </summary>
-    public bool IsPrerelease {
-        get {
-            return version.type == UnityVersion.Type.Alpha 
-                || version.type == UnityVersion.Type.Beta
-                || prerelease;
-        }
-    }
+    public UnityVersion Version => release?.version ?? default;
 
     /// <summary>
     /// Base URL of where INIs are stored.
     /// </summary>
     public string baseUrl;
 
-    /// <summary>
-    /// macOS packages.
-    /// </summary>
-    public PackageMetadata[] macPackages;
-
-    /// <summary>
-    /// macOS packages.
-    /// </summary>
-    public PackageMetadata[] macArmPackages;
-
-    /// <summary>
-    /// Windows packages.
-    /// </summary>
-    public PackageMetadata[] winPackages;
-
-    /// <summary>
-    /// Linux packages.
-    /// </summary>
-    public PackageMetadata[] linuxPackages;
-
-    /// <summary>
-    /// Virtual packages, generated dynamically for the current platform.
-    /// </summary>
-    [NonSerialized]
-    public IEnumerable<PackageMetadata> virtualPackages;
-
-    /// <summary>
-    /// Callback to add virtual packages.
-    /// </summary>
-    /// <remarks>
-    /// Don't call <see cref="GetPackages"/> in the callback or you'll end up in
-    /// an infinite recursion. Use <see cref="GetRawPackages"/> instead.
-    /// </remarks>
-    public static Func<VersionMetadata, CachePlatform, IEnumerable<PackageMetadata>> OnGenerateVirtualPackages;
-
-    /// <summary>
-    /// Wrapper of <see cref="UnityVersion.FuzzyMatches"/> that also checks that
-    /// final versions don't match release candidates.
-    /// </summary>
-    public bool IsFuzzyMatchedBy(UnityVersion query)
-    {
-        if (query.type == UnityVersion.Type.Final && prerelease) {
-            return false;
-        }
-
-        return query.FuzzyMatches(version);
-    }
-
     /// <summary>
     /// Determine wether the packages metadata has been loaded.
     /// </summary>
-    public bool HasPackagesMetadata(CachePlatform platform)
+    public bool HasDownload(Platform platform, Architecture architecture)
     {
-        return GetRawPackages(platform) != null;
+        return GetEditorDownload(platform, architecture) != null;
     }
 
     /// <summary>
     /// Get platform specific packages without adding virtual packages.
     /// </summary>
-    /// <param name="platform">Platform to get.</param>
-    public IEnumerable<PackageMetadata> GetRawPackages(CachePlatform platform)
+    public EditorDownload GetEditorDownload(Platform platform, Architecture architecture)
     {
-        switch (platform) {
-            case CachePlatform.macOSIntel:
-                return macPackages;
-            case CachePlatform.macOSArm:
-                return macArmPackages;
-            case CachePlatform.Windows:
-                return winPackages;
-            case CachePlatform.Linux:
-                return linuxPackages;
-            default:
-                throw new Exception("Invalid platform name: " + platform);
-        }
-    }
+        if (release.downloads == null)
+            return null;
 
-    /// <summary>
-    /// Get platform specific packages.
-    /// </summary>
-    /// <param name="platform">Platform to get.</param>
-    public IEnumerable<PackageMetadata> GetPackages(CachePlatform platform)
-    {
-        // Generate virtual packages
-        if (virtualPackages == null) {
-            if (OnGenerateVirtualPackages != null) {
-                foreach (Func<VersionMetadata, CachePlatform, IEnumerable<PackageMetadata>> func in OnGenerateVirtualPackages.GetInvocationList()) {
-                    var result = func(this, platform);
-                    if (result != null) {
-                        if (virtualPackages == null) {
-                            virtualPackages = result;
-                        } else {
-                            virtualPackages = virtualPackages.Concat(result);
-                        }
-                    }
-                }
-            }
-            if (virtualPackages == null) {
-                virtualPackages = Enumerable.Empty<PackageMetadata>();
-            }
+        foreach (var editor in release.downloads) {
+            if (editor.platform == platform && editor.architecture == architecture)
+                return editor;
         }
 
-        switch (platform) {
-            case CachePlatform.macOSIntel:
-                return macPackages.Concat(virtualPackages);
-            case CachePlatform.macOSArm:
-                return macArmPackages.Concat(virtualPackages);
-            case CachePlatform.Windows:
-                return winPackages.Concat(virtualPackages);
-            case CachePlatform.Linux:
-                return linuxPackages.Concat(virtualPackages);
-            default:
-                throw new Exception("Invalid platform name: " + platform);
-        }
+        return null;
     }
 
     /// <summary>
     /// Set platform specific packages.
     /// </summary>
-    /// <param name="platform">Platform to set.</param>
-    public void SetPackages(CachePlatform platform, PackageMetadata[] packages)
+    public void SetEditorDownload(EditorDownload download)
     {
-        switch (platform) {
-            case CachePlatform.macOSIntel:
-                macPackages = packages;
-                break;
-            case CachePlatform.macOSArm:
-                macArmPackages = packages;
-                break;
-            case CachePlatform.Windows:
-                winPackages = packages;
-                break;
-            case CachePlatform.Linux:
-                linuxPackages = packages;
-                break;
-            default:
-                throw new Exception("Invalid platform name: " + platform);
-        }
-    }
-
-    /// <summary>
-    /// Find a package by name, ignoring case and excluding virtual packages.
-    /// </summary>
-    public PackageMetadata GetRawPackage(CachePlatform platform, string name)
-    {
-        var packages = GetRawPackages(platform);
-        foreach (var package in packages) {
-            if (package.name.Equals(name, StringComparison.OrdinalIgnoreCase)) {
-                return package;
+        if (release.downloads == null)
+            release.downloads = new List<EditorDownload>();
+
+        for (int i = 0; i < release.downloads.Count; i++) {
+            var editor = release.downloads[i];
+            if (editor.platform == download.platform && editor.architecture == download.architecture) {
+                // Replace existing download
+                release.downloads[i] = download;
+                return;
             }
         }
-        return default;
-    }
 
-    /// <summary>
-    /// Find a package by name, ignoring case.
-    /// </summary>
-    public PackageMetadata GetPackage(CachePlatform platform, string name)
-    {
-        var packages = GetPackages(platform);
-        foreach (var package in packages) {
-            if (package.name.Equals(name, StringComparison.OrdinalIgnoreCase)) {
-                return package;
-            }
-        }
-        return default;
+        // Add new download
+        release.downloads.Add(download);
     }
-}
-
-/// <summary>
-/// Information about an version's individual package.
-/// </summary>
-public struct PackageMetadata
-{
-    /// <summary>
-    /// Name of the main editor package.
-    /// </summary>
-    public const string EDITOR_PACKAGE_NAME = "Unity";
-
-    /// <summary>
-    /// Identifier of the package.
-    /// </summary>
-    public string name;
-
-    /// <summary>
-    /// Title of the package.
-    /// </summary>
-    public string title;
-
-    /// <summary>
-    /// Description of the package.
-    /// </summary>
-    public string description;
-
-    /// <summary>
-    /// Relative or absolute url to the package download.
-    /// </summary>
-    public string url;
-
-    /// <summary>
-    /// Wether the package is installed by default.
-    /// </summary>
-    public bool install;
-
-    /// <summary>
-    /// Wether the package is mandatory.
-    /// </summary>
-    public bool mandatory;
-
-    /// <summary>
-    /// The download size in bytes.
-    /// </summary>
-    public long size;
-
-    /// <summary>
-    /// The installed size in bytes.
-    /// </summary>
-    public long installedsize;
-
-    /// <summary>
-    /// The version of the package.
-    /// </summary>
-    public string version;
-
-    /// <summary>
-    /// File extension to use.
-    /// </summary>
-    public string extension;
-
-    /// <summary>
-    /// Wether the package is hidden.
-    /// </summary>
-    public bool hidden;
-
-    /// <summary>
-    /// Install this package together with another one.
-    /// </summary>
-    public string sync;
-
-    /// <summary>
-    /// The md5 hash of the package download.
-    /// </summary>
-    public string md5;
-
-    /// <summary>
-    /// Wether the package can be installed without the editor.
-    /// </summary>
-    public bool requires_unity;
-
-    /// <summary>
-    /// Bundle Identifier of app in package.
-    /// </summary>
-    public string appidentifier;
 
     /// <summary>
-    /// Message for extra EULA terms.
+    /// Find a package by identifier, ignoring case.
     /// </summary>
-    public string eulamessage;
-
-    /// <summary>
-    /// Label of first extra EULA.
-    /// </summary>
-    public string eulalabel1;
-
-    /// <summary>
-    /// URL of first extra EULA.
-    /// </summary>
-    public string eulaurl1;
-
-    /// <summary>
-    /// Label of second extra EULA.
-    /// </summary>
-    public string eulalabel2;
-
-    /// <summary>
-    /// URL of second extra EULA.
-    /// </summary>
-    public string eulaurl2;
-
-    // -------- Fields used by virtual packages --------
-
-    /// <summary>
-    /// Where the archive should be extracted to (does not apply to installers).
-    /// </summary>
-    public string destination;
-
-    /// <summary>
-    /// Rename the extracted archive from this path.
-    /// </summary>
-    public string renameFrom;
-
-    /// <summary>
-    ///  Rename the extracted archive to this path.
-    /// </summary>
-    public string renameTo;
-
-    /// <summary>
-    /// Target file name.
-    /// </summary>
-    public string fileName;
-
-    /// <summary>
-    /// Used to track automatically added dependencies.
-    /// </summary>
-    [NonSerialized] public bool addedAutomatically;
-
-    /// <summary>
-    /// Get the file name to use for the package.
-    /// </summary>
-    public string GetFileName()
+    public Module GetModule(Platform platform, Architecture architecture, string id)
     {
-        if (!string.IsNullOrEmpty(fileName)) {
-            return fileName;
-        }
-
-        string guessedName;
-
-        // Try to get file name from URL
-        var uri = new Uri(url, UriKind.RelativeOrAbsolute);
-        if (uri.IsAbsoluteUri) {
-            guessedName = uri.Segments.Last();
-        } else {
-            guessedName = Path.GetFileName(url);
-        }
+        var editor = GetEditorDownload(platform, architecture);
+        if (editor == null) return null;
 
-        // Fallback to given extension if the url doesn't match
-        if (extension != null 
-                && !string.Equals(Path.GetExtension(guessedName), "." + extension, StringComparison.OrdinalIgnoreCase)) {
-            guessedName = name + "." + extension;
-        
-        // Force an extension for older versions that don't provide one
-        } else if (Path.GetExtension(guessedName) == "") {
-            guessedName = name + ".pkg";
+        foreach (var module in editor.modules) {
+            if (module.id.Equals(id, StringComparison.OrdinalIgnoreCase))
+                return module;
         }
 
-        return guessedName;
+        return null;
     }
 }
 
@@ -429,7 +114,7 @@ public class VersionsCache : IEnumerable<VersionMetadata>
     /// <summary>
     /// Version of cache format.
     /// </summary>
-    const int CACHE_FORMAT = 2;
+    const int CACHE_FORMAT = 3;
 
     /// <summary>
     /// Data written out to JSON file.
@@ -482,7 +167,7 @@ public VersionsCache(string dataFilePath)
     /// </summary>
     void SortVersions()
     {
-        cache.versions.Sort((m1, m2) => m2.version.CompareTo(m1.version));
+        cache.versions.Sort((m1, m2) => m2.release.version.CompareTo(m1.release.version));
     }
 
     /// <summary>
@@ -520,16 +205,16 @@ public void Clear()
     public bool Add(VersionMetadata metadata)
     {
         for (int i = 0; i < cache.versions.Count; i++) {
-            if (cache.versions[i].version == metadata.version) {
+            if (cache.versions[i].Version == metadata.Version) {
                 UpdateVersion(i, metadata);
-                Logger.LogDebug($"Updated version in cache: {metadata.version}");
+                Logger.LogDebug($"Updated version in cache: {metadata.Version}");
                 return false;
             }
         }
 
         cache.versions.Add(metadata);
         SortVersions();
-        Logger.LogDebug($"Added version to cache: {metadata.version}");
+        Logger.LogDebug($"Added version to cache: {metadata.Version}");
         return true;
     }
 
@@ -541,15 +226,15 @@ public void Add(IEnumerable<VersionMetadata> metadatas, IList<VersionMetadata> n
     {
         foreach (var metadata in metadatas) {
             for (int i = 0; i < cache.versions.Count; i++) {
-                if (cache.versions[i].version == metadata.version) {
+                if (cache.versions[i].Version == metadata.Version) {
                     UpdateVersion(i, metadata);
-                    Logger.LogDebug($"Updated version in cache: {metadata.version}");
+                    Logger.LogDebug($"Updated version in cache: {metadata.Version}");
                     goto continueOuter;
                 }
             }
             cache.versions.Add(metadata);
             if (newVersions != null) newVersions.Add(metadata);
-            Logger.LogDebug($"Added version to cache: {metadata.version}");
+            Logger.LogDebug($"Added version to cache: {metadata.Version}");
             continueOuter:;
         }
 
@@ -562,11 +247,18 @@ public void Add(IEnumerable<VersionMetadata> metadatas, IList<VersionMetadata> n
     void UpdateVersion(int index, VersionMetadata with)
     {
         var existing = cache.versions[index];
-        existing.prerelease = with.prerelease;
-        if (with.baseUrl != null) existing.baseUrl = with.baseUrl;
-        if (with.macPackages != null) existing.macPackages = with.macPackages;
-        if (with.winPackages != null) existing.macPackages = with.winPackages;
-        if (with.linuxPackages != null) existing.macPackages = with.linuxPackages;
+
+        // Same release instance, nothing to update
+        if (existing.release == with.release)
+            return;
+
+        if (with.baseUrl != null) {
+            existing.baseUrl = with.baseUrl;
+        }
+        foreach (var editor in with.release.downloads) {
+            existing.SetEditorDownload(editor);
+        }
+
         cache.versions[index] = existing;
     }
 
@@ -581,7 +273,7 @@ public VersionMetadata Find(UnityVersion version)
         if (version.IsFullVersion) {
             // Do exact match
             foreach (var metadata in cache.versions) {
-                if (version.MatchesVersionOrHash(metadata.version)) {
+                if (version.MatchesVersionOrHash(metadata.Version)) {
                     return metadata;
                 }
             }
@@ -590,7 +282,7 @@ public VersionMetadata Find(UnityVersion version)
 
         // Do fuzzy match
         foreach (var metadata in cache.versions) {
-            if (metadata.IsFuzzyMatchedBy(version)) {
+            if (version.FuzzyMatches(metadata.Version)) {
                 return metadata;
             }
         }
diff --git a/sttz.InstallUnity/Installer/VirtualPackages.cs b/sttz.InstallUnity/Installer/VirtualPackages.cs
index 2180594..c1cfab7 100644
--- a/sttz.InstallUnity/Installer/VirtualPackages.cs
+++ b/sttz.InstallUnity/Installer/VirtualPackages.cs
@@ -2,6 +2,8 @@
 using System.Linq;
 using System;
 
+using static sttz.InstallUnity.UnityReleaseAPIClient;
+
 namespace sttz.InstallUnity
 {
 
@@ -10,37 +12,16 @@ namespace sttz.InstallUnity
 /// </summary>
 public static class VirtualPackages
 {
-    /// <summary>
-    /// Enable virtual packages.
-    /// </summary>
-    /// <remarks>
-    /// Packages will be injected into existing <see cref="VersionMetadata"/> in
-    /// their <see cref="VersionMetadata.GetPackages"/> method.
-    /// </remarks>
-    public static void Enable()
+    public static IEnumerable<Module> GeneratePackages(UnityVersion version, EditorDownload editor)
     {
-        VersionMetadata.OnGenerateVirtualPackages -= GeneratePackages;
-        VersionMetadata.OnGenerateVirtualPackages += GeneratePackages;
-    }
-
-    /// <summary>
-    /// Disable virtual packages. Virtual packages already generated will not be removed.
-    /// </summary>
-    public static void Disable()
-    {
-        VersionMetadata.OnGenerateVirtualPackages -= GeneratePackages;
-    }
-
-    static IEnumerable<PackageMetadata> GeneratePackages(VersionMetadata version, CachePlatform platform)
-    {
-        return Generator(version, platform).ToList();
+        return Generator(version, editor).ToList();
     }
 
     static string[] Localizations_2018_1 = new string[] { "ja", "ko" };
     static string[] Localizations_2018_2 = new string[] { "ja", "ko", "zh-cn" };
     static string[] Localizations_2019_1 = new string[] { "ja", "ko", "zh-hans", "zh-hant" };
 
-    static Dictionary<string, string> LanguageNames = new Dictionary<string, string>() {
+    static Dictionary<string, string> LanguageNames = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase) {
         { "ja", "日本語" },
         { "ko", "한국어" },
         { "zh-cn", "简体中文" },
@@ -48,22 +29,25 @@ static IEnumerable<PackageMetadata> GeneratePackages(VersionMetadata version, Ca
         { "zh-hans", "简体中文" },
     };
 
-    static IEnumerable<PackageMetadata> Generator(VersionMetadata version, CachePlatform platform)
+    static IEnumerable<Module> Generator(UnityVersion version, EditorDownload editor)
     {
-        var v = version.version;
+        var v = version;
+        var allPackages = editor.AllModules;
 
         // Documentation
         if (v.major >= 2018 
-                && version.GetRawPackage(platform, "Documentation").name == null
-                && version.version.type != UnityVersion.Type.Alpha) {
-            yield return new PackageMetadata() {
+                && !allPackages.ContainsKey("Documentation")
+                && v.type != UnityVersion.Type.Alpha) {
+            yield return new Module() {
+                id = "Documentation",
                 name = "Documentation",
                 description = "Offline Documentation",
                 url = $"https://storage.googleapis.com/docscloudstorage/{v.major}.{v.minor}/UnityDocumentation.zip",
-                install = true,
+                type = FileType.ZIP,
+                preSelected = true,
                 destination = "{UNITY_PATH}",
-                size = 350 * 1024 * 1024, // Conservative estimate based on 2019.2
-                installedsize = 650 * 1024 * 1024, // "
+                downloadSize = FileSize.FromMegaBytes(350), // Conservative estimate based on 2019.2
+                installedSize = FileSize.FromMegaBytes(650), // "
             };
         }
 
@@ -79,164 +63,294 @@ static IEnumerable<PackageMetadata> Generator(VersionMetadata version, CachePlat
             }
 
             foreach (var loc in localizations) {
-                yield return new PackageMetadata() {
+                yield return new Module() {
+                    id = LanguageNames[loc],
                     name = LanguageNames[loc],
                     description = $"{LanguageNames[loc]} Language Pack",
                     url = $"https://new-translate.unity3d.jp/v1/live/54/{v.major}.{v.minor}/{loc}",
-                    fileName = $"{loc}.po",
+                    type = FileType.PO,
                     destination = "{UNITY_PATH}/Unity.app/Contents/Localization",
-                    size = 2 * 1024 * 1024, // Conservative estimate based on 2019.2
-                    installedsize = 2 * 1024 * 1024, // "
+                    downloadSize = FileSize.FromMegaBytes(2), // Conservative estimate based on 2019.2
+                    installedSize = FileSize.FromMegaBytes(2), // "
                 };
             }
         }
 
         // Android dependencies
-        if (v.major >= 2019 && version.GetRawPackage(platform, "Android").name != null) {
+        if (v.major >= 2019 && allPackages.ContainsKey("Android")) {
             // Android SDK & NDK & stuff
-            yield return new PackageMetadata() {
+            yield return new Module() {
+                id = "Android SDK & NDK Tools",
                 name = "Android SDK & NDK Tools",
                 description = "Android SDK & NDK Tools 26.1.1",
                 url = $"https://dl.google.com/android/repository/sdk-tools-darwin-4333796.zip",
                 destination = "{UNITY_PATH}/PlaybackEngines/AndroidPlayer/SDK",
-                size = 148 * 1024 * 1024,
-                installedsize = 174 * 1024 * 1024,
-                sync = "Android",
-                eulaurl1 = "https://dl.google.com/dl/android/repository/repository2-1.xml",
-                eulalabel1 = "Android SDK and NDK License Terms from Google",
-                eulamessage = "Please review and accept the license terms before downloading and installing Android\'s SDK and NDK.",
+                downloadSize = FileSize.FromMegaBytes(148),
+                installedSize = FileSize.FromMegaBytes(174),
+                parentModuleId = "Android",
+                eula = new Eula[] {
+                    new Eula() {
+                        url = "https://dl.google.com/dl/android/repository/repository2-1.xml",
+                        label = "Android SDK and NDK License Terms from Google",
+                        message = "Please review and accept the license terms before downloading and installing Android\'s SDK and NDK.",
+                    }
+                },
             };
 
             // Android platform tools
             if (v.major < 2021) {
-                yield return new PackageMetadata() {
+                yield return new Module() {
+                    id = "Android SDK Platform Tools",
                     name = "Android SDK Platform Tools",
                     description = "Android SDK Platform Tools 28.0.1",
                     url = $"https://dl.google.com/android/repository/platform-tools_r28.0.1-darwin.zip",
                     destination = "{UNITY_PATH}/PlaybackEngines/AndroidPlayer/SDK",
-                    size = 5 * 1024 * 1024,
-                    installedsize = 16 * 1024 * 1024,
+                    downloadSize = FileSize.FromMegaBytes(5),
+                    installedSize = FileSize.FromMegaBytes(16),
                     hidden = true,
-                    sync = "Android SDK & NDK Tools",
+                    parentModuleId = "Android SDK & NDK Tools",
                 };
-            } else {
-                yield return new PackageMetadata() {
+            } else if (v.major <= 2022) {
+                yield return new Module() {
+                    id = "Android SDK Platform Tools",
                     name = "Android SDK Platform Tools",
                     description = "Android SDK Platform Tools 30.0.4",
                     url = $"https://dl.google.com/android/repository/fbad467867e935dce68a0296b00e6d1e76f15b15.platform-tools_r30.0.4-darwin.zip",
                     destination = "{UNITY_PATH}/PlaybackEngines/AndroidPlayer/SDK",
-                    size = 10 * 1024 * 1024,
-                    installedsize = 30 * 1024 * 1024,
+                    downloadSize = FileSize.FromMegaBytes(10),
+                    installedSize = FileSize.FromMegaBytes(30),
+                    hidden = true,
+                    parentModuleId = "Android SDK & NDK Tools",
+                };
+            } else {
+                yield return new Module() {
+                    id = "Android SDK Platform Tools",
+                    name = "Android SDK Platform Tools",
+                    description = "Android SDK Platform Tools 32.0.0",
+                    url = $"https://dl.google.com/android/repository/platform-tools_r32.0.0-darwin.zip",
+                    destination = "{UNITY_PATH}/PlaybackEngines/AndroidPlayer/SDK",
+                    downloadSize = FileSize.FromBytes(18500000),
+                    installedSize = FileSize.FromBytes(48684075),
                     hidden = true,
-                    sync = "Android SDK & NDK Tools",
+                    parentModuleId = "Android SDK & NDK Tools"
                 };
             }
 
             // Android SDK platform & build tools
             if (v.major == 2019 && v.minor <= 3) {
-                yield return new PackageMetadata() {
+                yield return new Module() {
+                    id = "Android SDK Build Tools",
                     name = "Android SDK Build Tools",
                     description = "Android SDK Build Tools 28.0.3",
                     url = $"https://dl.google.com/android/repository/build-tools_r28.0.3-macosx.zip",
                     destination = "{UNITY_PATH}/PlaybackEngines/AndroidPlayer/SDK/build-tools",
-                    size = 53 * 1024 * 1024,
-                    installedsize = 120 * 1024 * 1024,
+                    downloadSize = FileSize.FromMegaBytes(53),
+                    installedSize = FileSize.FromMegaBytes(120),
                     hidden = true,
-                    sync = "Android SDK & NDK Tools",
-                    renameFrom = "{UNITY_PATH}/PlaybackEngines/AndroidPlayer/SDK/build-tools/android-9",
-                    renameTo = "{UNITY_PATH}/PlaybackEngines/AndroidPlayer/SDK/build-tools/28.0.3"
+                    parentModuleId = "Android SDK & NDK Tools",
+                    extractedPathRename = new PathRename() {
+                        from = "{UNITY_PATH}/PlaybackEngines/AndroidPlayer/SDK/build-tools/android-9",
+                        to = "{UNITY_PATH}/PlaybackEngines/AndroidPlayer/SDK/build-tools/28.0.3"
+                    },
                 };
-                yield return new PackageMetadata() {
+                yield return new Module() {
+                    id = "Android SDK Platforms",
                     name = "Android SDK Platforms",
                     description = "Android SDK Platforms 28 r06",
                     url = $"https://dl.google.com/android/repository/platform-28_r06.zip",
                     destination = "{UNITY_PATH}/PlaybackEngines/AndroidPlayer/SDK/platforms",
-                    size = 61 * 1024 * 1024,
-                    installedsize = 121 * 1024 * 1024,
+                    downloadSize = FileSize.FromMegaBytes(61),
+                    installedSize = FileSize.FromMegaBytes(121),
                     hidden = true,
-                    sync = "Android SDK & NDK Tools",
-                    renameFrom = "{UNITY_PATH}/PlaybackEngines/AndroidPlayer/SDK/platforms/android-9",
-                    renameTo = "{UNITY_PATH}/PlaybackEngines/AndroidPlayer/SDK/platforms/android-28"
+                    parentModuleId = "Android SDK & NDK Tools",
+                    extractedPathRename = new PathRename() {
+                        from = "{UNITY_PATH}/PlaybackEngines/AndroidPlayer/SDK/platforms/android-9",
+                        to = "{UNITY_PATH}/PlaybackEngines/AndroidPlayer/SDK/platforms/android-28"
+                    }
                 };
-            } else {
-                yield return new PackageMetadata() {
+            } else if (v.major <= 2022) {
+                yield return new Module() {
+                    id = "Android SDK Build Tools",
                     name = "Android SDK Build Tools",
                     description = "Android SDK Build Tools 30.0.2",
                     url = $"https://dl.google.com/android/repository/5a6ceea22103d8dec989aefcef309949c0c42f1d.build-tools_r30.0.2-macosx.zip",
                     destination = "{UNITY_PATH}/PlaybackEngines/AndroidPlayer/SDK/build-tools",
-                    size = 49 * 1024 * 1024,
-                    installedsize = 129 * 1024 * 1024,
+                    downloadSize = FileSize.FromMegaBytes(49),
+                    installedSize = FileSize.FromMegaBytes(129),
                     hidden = true,
-                    sync = "Android SDK & NDK Tools",
-                    renameFrom = "{UNITY_PATH}/PlaybackEngines/AndroidPlayer/SDK/build-tools/android-11",
-                    renameTo = "{UNITY_PATH}/PlaybackEngines/AndroidPlayer/SDK/build-tools/30.0.2"
+                    parentModuleId = "Android SDK & NDK Tools",
+                    extractedPathRename = new PathRename() {
+                        from = "{UNITY_PATH}/PlaybackEngines/AndroidPlayer/SDK/build-tools/android-11",
+                        to = "{UNITY_PATH}/PlaybackEngines/AndroidPlayer/SDK/build-tools/30.0.2"
+                    }
                 };
-                yield return new PackageMetadata() {
+                yield return new Module() {
+                    id = "Android SDK Platforms",
                     name = "Android SDK Platforms",
                     description = "Android SDK Platforms 30 r03",
                     url = $"https://dl.google.com/android/repository/platform-30_r03.zip",
                     destination = "{UNITY_PATH}/PlaybackEngines/AndroidPlayer/SDK/platforms",
-                    size = 52 * 1024 * 1024,
-                    installedsize = 116 * 1024 * 1024,
+                    downloadSize = FileSize.FromMegaBytes(52),
+                    installedSize = FileSize.FromMegaBytes(116),
+                    hidden = true,
+                    parentModuleId = "Android SDK & NDK Tools",
+                    extractedPathRename = new PathRename() {
+                        from = "{UNITY_PATH}/PlaybackEngines/AndroidPlayer/SDK/platforms/android-11",
+                        to = "{UNITY_PATH}/PlaybackEngines/AndroidPlayer/SDK/platforms/android-30"
+                    }
+                };
+            } else {
+                yield return new Module() {
+                    id = "Android SDK Build Tools",
+                    name = "Android SDK Build Tools",
+                    description = "Android SDK Build Tools 32.0.0",
+                    url = $"https://dl.google.com/android/repository/5219cc671e844de73762e969ace287c29d2e14cd.build-tools_r32-macosx.zip",
+                    destination = "{UNITY_PATH}/PlaybackEngines/AndroidPlayer/SDK/build-tools",
+                    downloadSize = FileSize.FromBytes(50400000),
+                    installedSize = FileSize.FromBytes(138655842),
+                    hidden = true,
+                    parentModuleId = "Android SDK & NDK Tools",
+                    extractedPathRename = new PathRename() {
+                        from = "{UNITY_PATH}/PlaybackEngines/AndroidPlayer/SDK/build-tools/android-12",
+                        to = "{UNITY_PATH}/PlaybackEngines/AndroidPlayer/SDK/build-tools/32.0.0"
+                    }
+                };
+                yield return new Module() {
+                    id = "Android SDK Platforms",
+                    name = "Android SDK Platforms",
+                    description = "Android SDK Platforms 31",
+                    url = $"https://dl.google.com/android/repository/platform-31_r01.zip",
+                    destination = "{UNITY_PATH}/PlaybackEngines/AndroidPlayer/SDK/platforms",
+                    downloadSize = FileSize.FromBytes(53900000),
+                    installedSize = FileSize.FromBytes(91868884),
+                    hidden = true,
+                    parentModuleId = "Android SDK & NDK Tools",
+                    extractedPathRename = new PathRename() {
+                        from = "{UNITY_PATH}/PlaybackEngines/AndroidPlayer/SDK/platforms/android-12",
+                        to = "{UNITY_PATH}/PlaybackEngines/AndroidPlayer/SDK/platforms/android-31"
+                    }
+                };
+                yield return new Module() {
+                    id = "Android SDK Platforms",
+                    name = "Android SDK Platforms",
+                    description = "Android SDK Platforms 32",
+                    url = $"https://dl.google.com/android/repository/platform-32_r01.zip",
+                    destination = "{UNITY_PATH}/PlaybackEngines/AndroidPlayer/SDK/platforms",
+                    downloadSize = FileSize.FromBytes(63000000),
+                    installedSize = FileSize.FromBytes(101630444),
                     hidden = true,
-                    sync = "Android SDK & NDK Tools",
-                    renameFrom = "{UNITY_PATH}/PlaybackEngines/AndroidPlayer/SDK/platforms/android-11",
-                    renameTo = "{UNITY_PATH}/PlaybackEngines/AndroidPlayer/SDK/platforms/android-30"
+                    parentModuleId = "Android SDK & NDK Tools",
+                    extractedPathRename = new PathRename() {
+                        from = "{UNITY_PATH}/PlaybackEngines/AndroidPlayer/SDK/platforms/android-12",
+                        to = "{UNITY_PATH}/PlaybackEngines/AndroidPlayer/SDK/platforms/android-32"
+                    }
+                };
+                yield return new Module() {
+                    id = "Android SDK Command Line Tools",
+                    name = "Android SDK Command Line Tools",
+                    description = "Android SDK Command Line Tools 6.0",
+                    url = $"https://dl.google.com/android/repository/commandlinetools-mac-8092744_latest.zip",
+                    destination = "{UNITY_PATH}/PlaybackEngines/AndroidPlayer/SDK/cmdline-tools",
+                    downloadSize = FileSize.FromBytes(119650616),
+                    installedSize = FileSize.FromBytes(119651596),
+                    hidden = true,
+                    parentModuleId = "Android SDK & NDK Tools",
+                    extractedPathRename = new PathRename() {
+                        from = "{UNITY_PATH}/PlaybackEngines/AndroidPlayer/SDK/cmdline-tools/cmdline-tools",
+                        to = "{UNITY_PATH}/PlaybackEngines/AndroidPlayer/SDK/cmdline-tools/6.0"
+                    }
                 };
             }
 
             // Android NDK
             if (v.major == 2019 && v.minor <= 2) {
-                yield return new PackageMetadata() {
+                yield return new Module() {
+                    id = "Android NDK 16b",
                     name = "Android NDK 16b",
                     description = "Android NDK r16b",
                     url = $"https://dl.google.com/android/repository/android-ndk-r16b-darwin-x86_64.zip",
                     destination = "{UNITY_PATH}/PlaybackEngines/AndroidPlayer",
-                    size = 770 * 1024 * 1024,
-                    installedsize = 2700L * 1024 * 1024,
+                    downloadSize = FileSize.FromMegaBytes(770),
+                    installedSize = FileSize.FromMegaBytes(2700),
                     hidden = true,
-                    sync = "Android SDK & NDK Tools",
-                    renameFrom = "{UNITY_PATH}/PlaybackEngines/AndroidPlayer/android-ndk-r16b",
-                    renameTo = "{UNITY_PATH}/PlaybackEngines/AndroidPlayer/NDK"
+                    parentModuleId = "Android SDK & NDK Tools",
+                    extractedPathRename = new PathRename() {
+                        from = "{UNITY_PATH}/PlaybackEngines/AndroidPlayer/android-ndk-r16b",
+                        to = "{UNITY_PATH}/PlaybackEngines/AndroidPlayer/NDK"
+                    }
                 };
             } else if (v.major <= 2020) {
-                yield return new PackageMetadata() {
+                yield return new Module() {
+                    id = "Android NDK 19",
                     name = "Android NDK 19",
                     description = "Android NDK r19",
                     url = $"https://dl.google.com/android/repository/android-ndk-r19-darwin-x86_64.zip",
                     destination = "{UNITY_PATH}/PlaybackEngines/AndroidPlayer",
-                    size = 770 * 1024 * 1024,
-                    installedsize = 2700L * 1024 * 1024,
+                    downloadSize = FileSize.FromMegaBytes(770),
+                    installedSize = FileSize.FromMegaBytes(2700),
                     hidden = true,
-                    sync = "Android SDK & NDK Tools",
-                    renameFrom = "{UNITY_PATH}/PlaybackEngines/AndroidPlayer/android-ndk-r19",
-                    renameTo = "{UNITY_PATH}/PlaybackEngines/AndroidPlayer/NDK"
+                    parentModuleId = "Android SDK & NDK Tools",
+                    extractedPathRename = new PathRename() {
+                        from = "{UNITY_PATH}/PlaybackEngines/AndroidPlayer/android-ndk-r19",
+                        to = "{UNITY_PATH}/PlaybackEngines/AndroidPlayer/NDK"
+                    }
                 };
-            } else {
-                yield return new PackageMetadata() {
+            } else if (v.major <= 2022) {
+                yield return new Module() {
+                    id = "Android NDK 21d",
                     name = "Android NDK 21d",
                     description = "Android NDK r21d",
                     url = $"https://dl.google.com/android/repository/android-ndk-r21d-darwin-x86_64.zip",
                     destination = "{UNITY_PATH}/PlaybackEngines/AndroidPlayer",
-                    size = 1065 * 1024 * 1024,
-                    installedsize = 3922L * 1024 * 1024,
+                    downloadSize = FileSize.FromMegaBytes(1065),
+                    installedSize = FileSize.FromMegaBytes(3922),
                     hidden = true,
-                    sync = "Android SDK & NDK Tools",
-                    renameFrom = "{UNITY_PATH}/PlaybackEngines/AndroidPlayer/android-ndk-r21d",
-                    renameTo = "{UNITY_PATH}/PlaybackEngines/AndroidPlayer/NDK"
+                    parentModuleId = "Android SDK & NDK Tools",
+                    extractedPathRename = new PathRename() {
+                        from = "{UNITY_PATH}/PlaybackEngines/AndroidPlayer/android-ndk-r21d",
+                        to = "{UNITY_PATH}/PlaybackEngines/AndroidPlayer/NDK"
+                    }
+                };
+            } else {
+                yield return new Module() {
+                    id = "Android NDK 23b",
+                    name = "Android NDK 23b",
+                    description = "Android NDK r23b",
+                    url = $"https://dl.google.com/android/repository/android-ndk-r23b-darwin.dmg",
+                    destination = "{UNITY_PATH}/PlaybackEngines/AndroidPlayer/NDK",
+                    downloadSize = FileSize.FromBytes(1400000000),
+                    installedSize = FileSize.FromBytes(4254572698),
+                    hidden = true,
+                    parentModuleId = "Android SDK & NDK Tools",
+                    extractedPathRename = new PathRename() {
+                        from = "{UNITY_PATH}/PlaybackEngines/AndroidPlayer/NDK/Contents/NDK",
+                        to = "{UNITY_PATH}/PlaybackEngines/AndroidPlayer/NDK"
+                    }
                 };
             }
 
             // Android JDK
-            if (v.major > 2019 || v.minor >= 2) {
-                yield return new PackageMetadata() {
+            if (v.major >= 2023) {
+                yield return new Module() {
+                    id = "OpenJDK",
+                    name = "OpenJDK",
+                    description = "Android Open JDK 11.0.14.1+1",
+                    url = $"https://download.unity3d.com/download_unity/open-jdk/open-jdk-mac-x64/jdk11.0.14.1-1_236fc2e31a8b6da32fbcf8624815f509c51605580cb2c6285e55510362f272f8.zip",
+                    destination = "{UNITY_PATH}/PlaybackEngines/AndroidPlayer/OpenJDK",
+                    downloadSize = FileSize.FromBytes(118453231),
+                    installedSize = FileSize.FromBytes(230230237),
+                    parentModuleId = "Android",
+                };
+            } else if (v.major > 2019 || v.minor >= 2) {
+                yield return new Module() {
+                    id = "OpenJDK",
                     name = "OpenJDK",
                     description = "Android Open JDK 8u172-b11",
                     url = $"http://download.unity3d.com/download_unity/open-jdk/open-jdk-mac-x64/jdk8u172-b11_4be8440cc514099cfe1b50cbc74128f6955cd90fd5afe15ea7be60f832de67b4.zip",
                     destination = "{UNITY_PATH}/PlaybackEngines/AndroidPlayer/OpenJDK",
-                    size = 73 * 1024 * 1024,
-                    installedsize = 165 * 1024 * 1024,
-                    sync = "Android",
+                    downloadSize = FileSize.FromMegaBytes(73),
+                    installedSize = FileSize.FromMegaBytes(165),
+                    parentModuleId = "Android",
                 };
             }
         }
diff --git a/sttz.InstallUnity/sttz.InstallUnity.csproj b/sttz.InstallUnity/sttz.InstallUnity.csproj
index 959ac2b..57f618a 100644
--- a/sttz.InstallUnity/sttz.InstallUnity.csproj
+++ b/sttz.InstallUnity/sttz.InstallUnity.csproj
@@ -1,13 +1,14 @@
 <Project Sdk="Microsoft.NET.Sdk">
 
   <PropertyGroup>
-    <TargetFrameworks>net6.0</TargetFrameworks>
+    <TargetFrameworks>net7.0</TargetFrameworks>
+    <RuntimeIdentifierss>win-x64;osx-x64</RuntimeIdentifierss>
     <LangVersion>latest</LangVersion>
     <RootNamespace>sttz.InstallUnity</RootNamespace>
   </PropertyGroup>
 
   <PropertyGroup Label="Package">
-    <Version>2.11.0</Version>
+    <Version>2.12.0</Version>
     <Authors>Adrian Stutz (sttz.ch)</Authors>
     <Product>install-unity</Product>
     <Description>install-unity unofficial Unity installer library</Description>
@@ -22,7 +23,8 @@
     <PackageReference Include="ini-parser" Version="2.5.2" NoWarn="NU1701" />
     <PackageReference Include="Microsoft.Extensions.Logging" Version="6.0.0" />
     <PackageReference Include="Microsoft.Extensions.Logging.Console" Version="6.0.0" />
-    <PackageReference Include="Newtonsoft.Json" Version="13.0.1" />
+    <PackageReference Include="Newtonsoft.Json" Version="13.0.3" />
+    <PackageReference Include="plist-cil" Version="2.2.0" />
   </ItemGroup>
 
 </Project>