From 9bc9122ec3f754286a0bc184409dff094285a8eb Mon Sep 17 00:00:00 2001 From: Adrian Stutz Date: Thu, 27 Oct 2022 12:07:02 +0200 Subject: [PATCH 01/36] [Mac] Add warning when Spotlight is disabled and installations might not be found --- sttz.InstallUnity/Installer/Platforms/MacPlatform.cs | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/sttz.InstallUnity/Installer/Platforms/MacPlatform.cs b/sttz.InstallUnity/Installer/Platforms/MacPlatform.cs index 45af46f..483cbcc 100644 --- a/sttz.InstallUnity/Installer/Platforms/MacPlatform.cs +++ b/sttz.InstallUnity/Installer/Platforms/MacPlatform.cs @@ -133,6 +133,13 @@ public async Task PromptForPasswordIfNecessary(CancellationToken cancellat public async Task> FindInstallations(CancellationToken cancellation = default) { + 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."); + } + var findResult = await Command.Run("/usr/bin/mdfind", $"kMDItemCFBundleIdentifier = '{BUNDLE_ID}'", null, cancellation); if (findResult.exitCode != 0) { throw new Exception($"ERROR: {findResult.error}"); From 04c14512e64f27eed9ecf72cf1fbc6f51f2e8a6f Mon Sep 17 00:00:00 2001 From: Adrian Stutz Date: Sat, 26 Nov 2022 12:59:54 +0100 Subject: [PATCH 02/36] Fix packages besides Mac Intel not properly saved in cache --- sttz.InstallUnity/Installer/VersionsCache.cs | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/sttz.InstallUnity/Installer/VersionsCache.cs b/sttz.InstallUnity/Installer/VersionsCache.cs index 6fa8391..52da990 100644 --- a/sttz.InstallUnity/Installer/VersionsCache.cs +++ b/sttz.InstallUnity/Installer/VersionsCache.cs @@ -563,10 +563,11 @@ 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; + if (with.baseUrl != null) existing.baseUrl = with.baseUrl; + if (with.macPackages != null) existing.macPackages = with.macPackages; + if (with.macArmPackages != null) existing.macArmPackages = with.macArmPackages; + if (with.winPackages != null) existing.winPackages = with.winPackages; + if (with.linuxPackages != null) existing.linuxPackages = with.linuxPackages; cache.versions[index] = existing; } From 2239d783c45b611090bde4b5db629bd8c2095a8f Mon Sep 17 00:00:00 2001 From: Adrian Stutz Date: Sat, 26 Nov 2022 13:01:13 +0100 Subject: [PATCH 03/36] Use TargetFramework instead of TargetFrameworks so the "dotnet --framework" option can be omitted --- Command/Command.csproj | 2 +- sttz.InstallUnity/sttz.InstallUnity.csproj | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Command/Command.csproj b/Command/Command.csproj index 1f090f8..272fd4f 100644 --- a/Command/Command.csproj +++ b/Command/Command.csproj @@ -2,7 +2,7 @@ Exe - net6.0 + net6.0 latest diff --git a/sttz.InstallUnity/sttz.InstallUnity.csproj b/sttz.InstallUnity/sttz.InstallUnity.csproj index 959ac2b..75802d6 100644 --- a/sttz.InstallUnity/sttz.InstallUnity.csproj +++ b/sttz.InstallUnity/sttz.InstallUnity.csproj @@ -1,7 +1,7 @@ - net6.0 + net6.0 latest sttz.InstallUnity From c4be72e5789fc364a3e0d5cff6fe7dbf3bcf8915 Mon Sep 17 00:00:00 2001 From: Adrian Stutz Date: Sun, 5 Feb 2023 16:57:27 +0100 Subject: [PATCH 04/36] Update scraping of beta and alpha releases, update URLs that now redirect to unity.com --- sttz.InstallUnity/Installer/Scraper.cs | 103 +++++++++++-------------- 1 file changed, 47 insertions(+), 56 deletions(-) diff --git a/sttz.InstallUnity/Installer/Scraper.cs b/sttz.InstallUnity/Installer/Scraper.cs index 9843b0c..a9274e1 100644 --- a/sttz.InstallUnity/Installer/Scraper.cs +++ b/sttz.InstallUnity/Installer/Scraper.cs @@ -33,7 +33,7 @@ public class Scraper /// /// Base URL of Unity homepage. /// - const string UNITY_BASE_URL = "https://unity3d.com"; + const string UNITY_BASE_URL = "https://unity.com"; /// /// Releases JSON used by Unity Hub ({0} should be either win32, darwin or linux). @@ -43,29 +43,34 @@ public class Scraper /// /// HTML archive of Unity releases. /// - const string UNITY_ARCHIVE = "https://unity3d.com/get-unity/download/archive"; + const string UNITY_ARCHIVE = "https://unity.com/releases/editor/archive"; /// - /// Landing page for Unity prereleases. + /// Landing page for Unity beta releases. /// - const string UNITY_PRERELEASES = "https://unity3d.com/unity/beta"; + const string UNITY_BETA = "https://unity.com/releases/editor/beta"; + + /// + /// Landing page for Unity alpha releases. + /// + const string UNITY_ALPHA = "https://unity.com/releases/editor/alpha"; // -------- Release Notes -------- /// /// HTML release notes of final Unity releases (append a version without type or build number, e.g. 2018.2.1) /// - 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/"; /// /// HTML release notes of alpha Unity releases (append a full alpha version string) /// - const string UNITY_RELEASE_NOTES_ALPHA = "https://unity3d.com/unity/alpha/"; + const string UNITY_RELEASE_NOTES_ALPHA = "https://unity.com/releases/editor/alpha/"; /// /// HTML release notes of beta Unity releases (append a full beta version string) /// - const string UNITY_RELEASE_NOTES_BETA = "https://unity3d.com/unity/beta/"; + const string UNITY_RELEASE_NOTES_BETA = "https://unity.com/releases/editor/beta/"; /// /// HTML release notes of patch Unity releases (append a full beta version string) @@ -102,14 +107,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\/.-]+"); /// - /// /// Regex to extract available prerelease major versions from landing page. + /// Regex to extract available prerelease versions from landing page. /// - static readonly Regex UNITY_PRERELEASE_MAJOR_RE = new Regex(@"(? - /// Regex to extract available prerelease major versions from landing page. - /// - 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 -------- @@ -239,63 +239,54 @@ public async Task> LoadFinal(CancellationToken canc /// Task returning the discovered versions public async Task> LoadPrerelease(bool includeAlpha, IEnumerable 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(); + + if (includeAlpha) { + await LoadPrerelease(UNITY_ALPHA, results, knownVersions, scrapeDelay, cancellation); + } + + await LoadPrerelease(UNITY_BETA, results, knownVersions, scrapeDelay, cancellation); + + return results.Values; + } + + /// + /// Load the available prerelase versions from a alpha/beta landing page. + /// + async Task LoadPrerelease(string url, Dictionary results, IEnumerable 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(); + 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(); - var results = new Dictionary(); - 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(); + 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, true, results); } - return results.Values; } /// From 3168dfb91b796bff036c8805bc1faedef692f27b Mon Sep 17 00:00:00 2001 From: Adrian Stutz Date: Sun, 5 Feb 2023 19:09:21 +0100 Subject: [PATCH 05/36] [Mac] Fix information log message not showing proper installation path --- sttz.InstallUnity/Installer/Platforms/MacPlatform.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/sttz.InstallUnity/Installer/Platforms/MacPlatform.cs b/sttz.InstallUnity/Installer/Platforms/MacPlatform.cs index 483cbcc..f061004 100644 --- a/sttz.InstallUnity/Installer/Platforms/MacPlatform.cs +++ b/sttz.InstallUnity/Installer/Platforms/MacPlatform.cs @@ -225,7 +225,7 @@ public async Task PrepareInstall(UnityInstaller.Queue queue, string installation upgradeOriginalPath = existingInstall.path; - Logger.LogInformation($"Temporarily moving installation to upgrade from '{existingInstall}' to default install path"); + Logger.LogInformation($"Temporarily moving installation to upgrade from '{existingInstall.path}' to default install path"); await Move(existingInstall.path, INSTALL_PATH, cancellation); } } From fc66031084c1f25d08b7cd531c4718595c3cee33 Mon Sep 17 00:00:00 2001 From: Adrian Stutz Date: Sun, 5 Feb 2023 19:13:38 +0100 Subject: [PATCH 06/36] [Mac] Update Android packages for 2023.1 - Updated across the board, including JDK and newly adding Android SDK Command Line Tools - NDK is now installed from a DMG but needs some additional processing: -- Added destination support to dmg packages, copies only app bundle content from dmg -- Made reanmeFrom/To generic instead of only supported for zip -- Support moving a directory in place of one of its parents (e.g. /path/to/source to /path/to) --- .../Installer/Platforms/MacPlatform.cs | 58 +++++++++--- .../Installer/VirtualPackages.cs | 91 ++++++++++++++++++- 2 files changed, 131 insertions(+), 18 deletions(-) diff --git a/sttz.InstallUnity/Installer/Platforms/MacPlatform.cs b/sttz.InstallUnity/Installer/Platforms/MacPlatform.cs index f061004..0054faf 100644 --- a/sttz.InstallUnity/Installer/Platforms/MacPlatform.cs +++ b/sttz.InstallUnity/Installer/Platforms/MacPlatform.cs @@ -240,15 +240,19 @@ public async Task Install(UnityInstaller.Queue queue, UnityInstaller.QueueItem i if (extentsion == ".pkg") { await InstallPkg(item.filePath, cancellation); } else if (extentsion == ".dmg") { - await InstallDmg(item.filePath, cancellation); + await InstallDmg(item.filePath, item.package.destination, cancellation); } else if (extentsion == ".zip") { - await InstallZip(item.filePath, item.package.destination, item.package.renameFrom, item.package.renameTo, cancellation); + await InstallZip(item.filePath, item.package.destination, cancellation); } else if (extentsion == ".po") { await InstallFile(item.filePath, item.package.destination, cancellation); } else { throw new Exception("Cannot install package of type: " + extentsion); } + if (!string.IsNullOrEmpty(item.package.renameFrom) && !string.IsNullOrEmpty(item.package.renameTo)) { + await Rename(item.filePath, item.package.renameFrom, item.package.renameTo, cancellation); + } + if (item.package.name == PackageMetadata.EDITOR_PACKAGE_NAME) { installedEditor = true; } @@ -417,7 +421,7 @@ async Task InstallPkg(string filePath, CancellationToken cancellation = default) /// /// Install a DMG package by mounting it and copying the app bundle. /// - 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); @@ -443,13 +447,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 { @@ -478,7 +495,7 @@ async Task InstallFile(string filePath, string destination, CancellationToken ca /// /// Unpack a Zip file to the given destination. /// - 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."); @@ -515,15 +532,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); + /// + /// Rename an installed filed or folder after inital installation. + /// + async Task Rename(string filePath, string renameFrom, string renameTo, CancellationToken cancellation = default) + { + 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); } /// @@ -567,6 +588,15 @@ string GetUniqueInstallationPath(UnityVersion version, string installationPaths) /// 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 { diff --git a/sttz.InstallUnity/Installer/VirtualPackages.cs b/sttz.InstallUnity/Installer/VirtualPackages.cs index 2180594..5f116b9 100644 --- a/sttz.InstallUnity/Installer/VirtualPackages.cs +++ b/sttz.InstallUnity/Installer/VirtualPackages.cs @@ -119,7 +119,7 @@ static IEnumerable Generator(VersionMetadata version, CachePlat hidden = true, sync = "Android SDK & NDK Tools", }; - } else { + } else if (v.major <= 2022) { yield return new PackageMetadata() { name = "Android SDK Platform Tools", description = "Android SDK Platform Tools 30.0.4", @@ -130,6 +130,17 @@ static IEnumerable Generator(VersionMetadata version, CachePlat hidden = true, sync = "Android SDK & NDK Tools", }; + } else { + yield return new PackageMetadata() { + 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", + size = 18500000, + installedsize = 48684075, + hidden = true, + sync = "Android SDK & NDK Tools" + }; } // Android SDK platform & build tools @@ -158,7 +169,7 @@ static IEnumerable Generator(VersionMetadata version, CachePlat renameFrom = "{UNITY_PATH}/PlaybackEngines/AndroidPlayer/SDK/platforms/android-9", renameTo = "{UNITY_PATH}/PlaybackEngines/AndroidPlayer/SDK/platforms/android-28" }; - } else { + } else if (v.major <= 2022) { yield return new PackageMetadata() { name = "Android SDK Build Tools", description = "Android SDK Build Tools 30.0.2", @@ -183,6 +194,55 @@ static IEnumerable Generator(VersionMetadata version, CachePlat renameFrom = "{UNITY_PATH}/PlaybackEngines/AndroidPlayer/SDK/platforms/android-11", renameTo = "{UNITY_PATH}/PlaybackEngines/AndroidPlayer/SDK/platforms/android-30" }; + } else { + yield return new PackageMetadata() { + 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", + size = 50400000, + installedsize = 138655842, + hidden = true, + sync = "Android SDK & NDK Tools", + renameFrom = "{UNITY_PATH}/PlaybackEngines/AndroidPlayer/SDK/build-tools/android-12", + renameTo = "{UNITY_PATH}/PlaybackEngines/AndroidPlayer/SDK/build-tools/32.0.0" + }; + yield return new PackageMetadata() { + 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", + size = 53900000, + installedsize = 91868884, + hidden = true, + sync = "Android SDK & NDK Tools", + renameFrom = "{UNITY_PATH}/PlaybackEngines/AndroidPlayer/SDK/platforms/android-12", + renameTo = "{UNITY_PATH}/PlaybackEngines/AndroidPlayer/SDK/platforms/android-31" + }; + yield return new PackageMetadata() { + 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", + size = 63000000, + installedsize = 101630444, + hidden = true, + sync = "Android SDK & NDK Tools", + renameFrom = "{UNITY_PATH}/PlaybackEngines/AndroidPlayer/SDK/platforms/android-12", + renameTo = "{UNITY_PATH}/PlaybackEngines/AndroidPlayer/SDK/platforms/android-32" + }; + yield return new PackageMetadata() { + 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", + size = 119650616, + installedsize = 119651596, + hidden = true, + sync = "Android SDK & NDK Tools", + renameFrom = "{UNITY_PATH}/PlaybackEngines/AndroidPlayer/SDK/cmdline-tools/cmdline-tools", + renameTo = "{UNITY_PATH}/PlaybackEngines/AndroidPlayer/SDK/cmdline-tools/6.0" + }; } // Android NDK @@ -212,7 +272,7 @@ static IEnumerable Generator(VersionMetadata version, CachePlat renameFrom = "{UNITY_PATH}/PlaybackEngines/AndroidPlayer/android-ndk-r19", renameTo = "{UNITY_PATH}/PlaybackEngines/AndroidPlayer/NDK" }; - } else { + } else if (v.major <= 2022) { yield return new PackageMetadata() { name = "Android NDK 21d", description = "Android NDK r21d", @@ -225,10 +285,33 @@ static IEnumerable Generator(VersionMetadata version, CachePlat renameFrom = "{UNITY_PATH}/PlaybackEngines/AndroidPlayer/android-ndk-r21d", renameTo = "{UNITY_PATH}/PlaybackEngines/AndroidPlayer/NDK" }; + } else { + yield return new PackageMetadata() { + 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", + size = 1400000000, + installedsize = 4254572698, + hidden = true, + sync = "Android SDK & NDK Tools", + renameFrom = "{UNITY_PATH}/PlaybackEngines/AndroidPlayer/NDK/Contents/NDK", + renameTo = "{UNITY_PATH}/PlaybackEngines/AndroidPlayer/NDK" + }; } // Android JDK - if (v.major > 2019 || v.minor >= 2) { + if (v.major >= 2023) { + yield return new PackageMetadata() { + 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", + size = 118453231, + installedsize = 230230237, + sync = "Android", + }; + } else if (v.major > 2019 || v.minor >= 2) { yield return new PackageMetadata() { name = "OpenJDK", description = "Android Open JDK 8u172-b11", From ae89567525c7dba35cf850929a7ea533a9a1a27c Mon Sep 17 00:00:00 2001 From: Adrian Stutz Date: Sun, 5 Feb 2023 19:14:27 +0100 Subject: [PATCH 07/36] [Mac] Fix Copy sudo fallback moving instead of copying, use -a instead of -R (now same as Hub is using) --- sttz.InstallUnity/Installer/Platforms/MacPlatform.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/sttz.InstallUnity/Installer/Platforms/MacPlatform.cs b/sttz.InstallUnity/Installer/Platforms/MacPlatform.cs index 0054faf..b6284e1 100644 --- a/sttz.InstallUnity/Installer/Platforms/MacPlatform.cs +++ b/sttz.InstallUnity/Installer/Platforms/MacPlatform.cs @@ -635,7 +635,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}"); } @@ -651,7 +651,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}"); } From e7f9c4338c7e08e9563137321166433167f98e35 Mon Sep 17 00:00:00 2001 From: Adrian Stutz Date: Sun, 5 Feb 2023 19:54:20 +0100 Subject: [PATCH 08/36] [Mac] Fix exception when cleaning up after upgrading an installation at "/Applications/Unity" --- .../Installer/Platforms/MacPlatform.cs | 37 +++++++++++-------- 1 file changed, 22 insertions(+), 15 deletions(-) diff --git a/sttz.InstallUnity/Installer/Platforms/MacPlatform.cs b/sttz.InstallUnity/Installer/Platforms/MacPlatform.cs index b6284e1..fb1f15f 100644 --- a/sttz.InstallUnity/Installer/Platforms/MacPlatform.cs +++ b/sttz.InstallUnity/Installer/Platforms/MacPlatform.cs @@ -203,17 +203,6 @@ public async Task PrepareInstall(UnityInstaller.Queue queue, string installation 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)) { @@ -224,9 +213,25 @@ public async Task PrepareInstall(UnityInstaller.Queue queue, string installation } upgradeOriginalPath = existingInstall.path; + } + + // 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; + } - Logger.LogInformation($"Temporarily moving installation to upgrade from '{existingInstall.path}' to default install path"); - await Move(existingInstall.path, INSTALL_PATH, cancellation); + // 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); + } } } @@ -267,8 +272,10 @@ public async Task CompleteInstall(bool aborted, CancellationToken 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); From 9937d9f06307b7daa7a9b4ee8c307f2a3dd135d4 Mon Sep 17 00:00:00 2001 From: Adrian Stutz Date: Sun, 5 Feb 2023 20:06:55 +0100 Subject: [PATCH 09/36] Bump to 2.11.1, update changelog --- Changelog.md | 7 +++++++ Command/Command.csproj | 2 +- Readme.md | 2 +- sttz.InstallUnity/sttz.InstallUnity.csproj | 2 +- 4 files changed, 10 insertions(+), 3 deletions(-) diff --git a/Changelog.md b/Changelog.md index ae90932..ffd6dc2 100644 --- a/Changelog.md +++ b/Changelog.md @@ -1,5 +1,12 @@ # Changelog +### 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 272fd4f..10729ac 100644 --- a/Command/Command.csproj +++ b/Command/Command.csproj @@ -7,7 +7,7 @@ - 2.11.0 + 2.11.1 Adrian Stutz (sttz.ch) install-unity CLI CLI for install-unity unofficial Unity installer library diff --git a/Readme.md b/Readme.md index db6521b..cbd72ed 100644 --- a/Readme.md +++ b/Readme.md @@ -120,7 +120,7 @@ With the switch to LTS versions, Unity has stopped creating patch releases for U ## CLI Help ```` -install-unity v2.11.0 +install-unity v2.11.1 USAGE: install-unity [--help] [--version] [--verbose...] [--yes] [--update] [--data-path ] [--opt =...] diff --git a/sttz.InstallUnity/sttz.InstallUnity.csproj b/sttz.InstallUnity/sttz.InstallUnity.csproj index 75802d6..677ef9f 100644 --- a/sttz.InstallUnity/sttz.InstallUnity.csproj +++ b/sttz.InstallUnity/sttz.InstallUnity.csproj @@ -7,7 +7,7 @@ - 2.11.0 + 2.11.1 Adrian Stutz (sttz.ch) install-unity install-unity unofficial Unity installer library From 8688aa4fef480ee921b3d5d8f2788744c924084c Mon Sep 17 00:00:00 2001 From: Adrian Stutz Date: Sun, 12 Feb 2023 18:59:42 +0100 Subject: [PATCH 10/36] Remove support for patch releases --- Readme.md | 6 ------ sttz.InstallUnity/Installer/Scraper.cs | 11 ++--------- sttz.InstallUnity/Installer/UnityInstaller.cs | 1 - sttz.InstallUnity/Installer/UnityVersion.cs | 8 +------- 4 files changed, 3 insertions(+), 23 deletions(-) diff --git a/Readme.md b/Readme.md index cbd72ed..02e5332 100644 --- a/Readme.md +++ b/Readme.md @@ -111,12 +111,6 @@ 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 ```` diff --git a/sttz.InstallUnity/Installer/Scraper.cs b/sttz.InstallUnity/Installer/Scraper.cs index a9274e1..ccecfac 100644 --- a/sttz.InstallUnity/Installer/Scraper.cs +++ b/sttz.InstallUnity/Installer/Scraper.cs @@ -72,11 +72,6 @@ public class Scraper /// const string UNITY_RELEASE_NOTES_BETA = "https://unity.com/releases/editor/beta/"; - /// - /// HTML release notes of patch Unity releases (append a full beta version string) - /// - const string UNITY_RELEASE_NOTES_PATCH = "https://unity3d.com/unity/qa/patch-releases/"; - // -------- INIs -------- /// @@ -366,8 +361,8 @@ public VersionMetadata UnityHubUrlToVersion(string url) /// /// /// 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. /// /// The version /// The metadata or the default value if the version couldn't be found. @@ -587,8 +582,6 @@ public string GetReleaseNotesUrl(UnityVersion version) 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: diff --git a/sttz.InstallUnity/Installer/UnityInstaller.cs b/sttz.InstallUnity/Installer/UnityInstaller.cs index 9aa00e8..9be6544 100644 --- a/sttz.InstallUnity/Installer/UnityInstaller.cs +++ b/sttz.InstallUnity/Installer/UnityInstaller.cs @@ -305,7 +305,6 @@ public async Task> UpdateCache(CachePlatform cacheP } 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..."); diff --git a/sttz.InstallUnity/Installer/UnityVersion.cs b/sttz.InstallUnity/Installer/UnityVersion.cs index 72b1bbd..f7e10cd 100644 --- a/sttz.InstallUnity/Installer/UnityVersion.cs +++ b/sttz.InstallUnity/Installer/UnityVersion.cs @@ -21,10 +21,6 @@ public enum Type: ushort { /// Final = 'f', /// - /// Unity patch release. - /// - Patch = 'p', - /// /// Unity beta release. /// 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. /// 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 }; /// From 5d78a22c24273a77c2c0f8e8622b742cccb1838d Mon Sep 17 00:00:00 2001 From: Adrian Stutz Date: Sun, 12 Feb 2023 19:09:35 +0100 Subject: [PATCH 11/36] Remove (never used) support for loading UnityHub's limited releases json --- sttz.InstallUnity/Installer/Scraper.cs | 139 ------------------ sttz.InstallUnity/Installer/UnityInstaller.cs | 71 ++++----- 2 files changed, 32 insertions(+), 178 deletions(-) diff --git a/sttz.InstallUnity/Installer/Scraper.cs b/sttz.InstallUnity/Installer/Scraper.cs index ccecfac..df7a99a 100644 --- a/sttz.InstallUnity/Installer/Scraper.cs +++ b/sttz.InstallUnity/Installer/Scraper.cs @@ -1,12 +1,9 @@ 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; @@ -35,11 +32,6 @@ public class Scraper /// const string UNITY_BASE_URL = "https://unity.com"; - /// - /// Releases JSON used by Unity Hub ({0} should be either win32, darwin or linux). - /// - const string UNITY_HUB_RELEASES = "https://public-cdn.cloud.unity3d.com/hub/prod/releases-{0}.json"; - /// /// HTML archive of Unity releases. /// @@ -112,101 +104,6 @@ public class Scraper ILogger Logger = UnityInstaller.CreateLogger(); - /// - /// Load the latest Unity releases, using the same JSON as Unity Hub. - /// - /// Name of platform to load the JSON for - /// Cancellation token - /// Task returning the discovered versions - public async Task> 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>(json); - - var result = new List(); - 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 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); - } - } - /// /// Load the available final versions. /// @@ -590,42 +487,6 @@ public string GetReleaseNotesUrl(UnityVersion version) return null; } } - - // -------- Types -------- - - // Disable never assigned warning, as the fields are - // set dynamically in the JSON deserializer - #pragma warning disable CS0649 - - struct HubUnityVersion - { - public string version; - public bool lts; - public string downloadUrl; - public string downloadSize; - public string installedSize; - public string checksum; - public HubUnityModule[] modules; - public string arch; - } - - struct HubUnityModule - { - 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; - } - - #pragma warning restore CS0649 - } } diff --git a/sttz.InstallUnity/Installer/UnityInstaller.cs b/sttz.InstallUnity/Installer/UnityInstaller.cs index 9be6544..6b623c7 100644 --- a/sttz.InstallUnity/Installer/UnityInstaller.cs +++ b/sttz.InstallUnity/Installer/UnityInstaller.cs @@ -295,47 +295,40 @@ public bool IsCacheOutdated(UnityVersion.Type type = UnityVersion.Type.Undefined public async Task> UpdateCache(CachePlatform cachePlatform, UnityVersion.Type type = UnityVersion.Type.Undefined, CancellationToken cancellation = default) { var added = new List(); - 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.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; - } + switch (type) { + case UnityVersion.Type.Final: + 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; } + Versions.Save(); added.Sort((m1, m2) => m2.version.CompareTo(m1.version)); From aecb0f50465c4f0586278e9c71e078076f005b3e Mon Sep 17 00:00:00 2001 From: Adrian Stutz Date: Thu, 4 May 2023 18:13:44 +0200 Subject: [PATCH 12/36] Switch to .Net 7 --- Build/build-osx.sh | 2 +- Command/Command.csproj | 2 +- Tests/Tests.csproj | 4 ++-- sttz.InstallUnity/sttz.InstallUnity.csproj | 2 +- 4 files changed, 5 insertions(+), 5 deletions(-) diff --git a/Build/build-osx.sh b/Build/build-osx.sh index 8838bfd..3686746 100755 --- a/Build/build-osx.sh +++ b/Build/build-osx.sh @@ -1,7 +1,7 @@ #!/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" diff --git a/Command/Command.csproj b/Command/Command.csproj index 10729ac..d4822e4 100644 --- a/Command/Command.csproj +++ b/Command/Command.csproj @@ -2,7 +2,7 @@ Exe - net6.0 + net7.0 latest diff --git a/Tests/Tests.csproj b/Tests/Tests.csproj index 63039f8..4a59b27 100644 --- a/Tests/Tests.csproj +++ b/Tests/Tests.csproj @@ -1,8 +1,8 @@ - net6.0 - 7.1 + net7.0 + latest false diff --git a/sttz.InstallUnity/sttz.InstallUnity.csproj b/sttz.InstallUnity/sttz.InstallUnity.csproj index 677ef9f..7319370 100644 --- a/sttz.InstallUnity/sttz.InstallUnity.csproj +++ b/sttz.InstallUnity/sttz.InstallUnity.csproj @@ -1,7 +1,7 @@ - net6.0 + net7.0 latest sttz.InstallUnity From f4da8d677fa898b87790cb97831d2ae77f43f482 Mon Sep 17 00:00:00 2001 From: Adrian Stutz Date: Thu, 4 May 2023 18:29:12 +0200 Subject: [PATCH 13/36] Use Unity's official Release API instead of scraping the website, other improvements - Reduces the amounts of requests and data loaded, can only load new releases instead of everything - Releases should appear quicker, in cases where Unity is slow to update their archive - Previously missing packages metadata (Documentation, Android components, language packs) is now provided by Unity - Legacy scraper and ini system can still be used for unpublished Unity releases - Platform / architecture is now split, added --arch option - Added --clear-cache option to force clearing the cache at start - Added --redownload option to force redownloading all files - Optimize detecting current architecture, RuntimeInformation.OSArchitecture now works correctly on .Net 7 - Optimize finding Unity installations by using native Plist parser - Improve handling of already downloaded files --- Command/Program.cs | 405 +++++---- sttz.InstallUnity/Installer/Configuration.cs | 5 +- sttz.InstallUnity/Installer/Downloader.cs | 230 +++-- .../Installer/IInstallerPlatform.cs | 8 +- .../Installer/Platforms/MacPlatform.cs | 150 ++-- sttz.InstallUnity/Installer/Scraper.cs | 348 +++++--- sttz.InstallUnity/Installer/UnityInstaller.cs | 254 ++++-- .../Installer/UnityReleaseAPIClient.cs | 837 ++++++++++++++++++ sttz.InstallUnity/Installer/UnityVersion.cs | 5 + sttz.InstallUnity/Installer/VersionsCache.cs | 431 ++------- .../Installer/VirtualPackages.cs | 303 ++++--- sttz.InstallUnity/sttz.InstallUnity.csproj | 1 + 12 files changed, 1934 insertions(+), 1043 deletions(-) create mode 100644 sttz.InstallUnity/Installer/UnityReleaseAPIClient.cs 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 /// public bool update; /// - /// Path to store all data at. + /// Clear the versions cache. /// - public CachePlatform platform; + public bool clearCache; + /// + /// Platform of the editor to download. + /// + public Platform platform; + /// + /// Architecture of the editor to download and/or install. + /// + public Architecture architecture; /// /// Path to store all data at. /// @@ -88,6 +96,10 @@ public class InstallUnityCLI /// public bool upgrade; /// + /// Force redownloading all files. + /// + public bool redownload; + /// /// Skip size and hash checks for downloads. /// 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 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("") .Description("Store all data at the given path, also don't delete packages after install") @@ -221,18 +237,22 @@ public static Arguments 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("") .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 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() /// public string GetVersion() { - var assembly = Assembly.GetExecutingAssembly(); - return assembly.GetCustomAttribute().InformationalVersion; + var assembly = System.Reflection.Assembly.GetExecutingAssembly(); + return System.Reflection.CustomAttributeExtensions.GetCustomAttribute(assembly).InformationalVersion; } /// @@ -411,13 +435,12 @@ public async Task 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 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 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 Setup(bool avoidCacheUpate = false) IEnumerable 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 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 Setup(bool avoidCacheUpate = false) if (total <= maxVersions) { newVersions = new HashSet(); - 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 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 {metadata.version} for input {version}"); + ConsoleLogger.WriteLine($"Selected {metadata.Version} 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(); 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 packages) + void DetailedModulesList(IEnumerable 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( - "This is a release candidate: " - + "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 IterateModulesRecursive(IEnumerable 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 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(); - 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().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()) { + 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/sttz.InstallUnity/Installer/Configuration.cs b/sttz.InstallUnity/Installer/Configuration.cs index aeb2a7a..6a6e42d 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; 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 -------- + /// + /// How to handle existing files. + /// + public enum ExistingFile + { + /// + /// Undefined behaviour, will default to Resume. + /// + Undefined, + + /// + /// Always redownload, overwriting existing files. + /// + Redownload, + /// + /// Try to hash and/or resume existing file, + /// will fall back to redownloading and overwriting. + /// + Resume, + /// + /// Do not hash or touch existing files and complete immediately. + /// + Skip + } + /// /// Url of the file to download. /// @@ -39,20 +61,14 @@ public class Downloader public long ExpectedSize { get; protected set; } /// - /// Expected hash of the file (computed with ). + /// Expected hash of the file (in WRC SRI format). /// public string ExpectedHash { get; protected set; } /// - /// Try to resume download of partially downloaded files. - /// - public bool Resume = true; - - /// - /// Hash algorithm used to compute hash (null = don't compute hash). + /// How to handle existing files. /// - [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicMethods)] - public Type HashAlgorithm = typeof(MD5); + public ExistingFile Existing = ExistingFile.Resume; /// /// Buffer size used when downloading. @@ -132,7 +148,7 @@ public enum State /// /// The hash after the file has been downloaded. /// - public string Hash { get; protected set; } + public byte[] Hash { get; protected set; } /// /// Event called for every of data processed. @@ -184,6 +200,10 @@ public void Reset() blocks = null; watch = null; } + + if (Existing == ExistingFile.Undefined) { + Existing = ExistingFile.Resume; + } } /// @@ -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; } /// @@ -201,12 +235,13 @@ public bool CheckHash() /// 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); + } + /// /// Helper method to copy the stream with a progress callback. /// @@ -393,17 +462,36 @@ async Task CopyToAsync(Stream input, Stream output, HashAlgorithm hasher, Cancel } /// - /// 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. /// - 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); + } + + /// + /// Create a hash algorithm instance from a hash name. + /// + 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..805a8b8 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,12 +36,12 @@ public interface IInstallerPlatform /// /// The platform that should be used by default. /// - Task GetCurrentPlatform(); + Task<(Platform, Architecture)> GetCurrentPlatform(); /// - /// Get platforms that can be installed on the current platform. + /// Get architectures that can be installed on the current platform. /// - Task> GetInstallablePlatforms(); + Task GetInstallableArchitectures(); /// /// The path to the file where settings are stored. diff --git a/sttz.InstallUnity/Installer/Platforms/MacPlatform.cs b/sttz.InstallUnity/Installer/Platforms/MacPlatform.cs index fb1f15f..308790e 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 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> GetInstallablePlatforms() + public async Task 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; } } @@ -133,13 +131,6 @@ public async Task PromptForPasswordIfNecessary(CancellationToken cancellat public async Task> FindInstallations(CancellationToken cancellation = default) { - 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."); - } - var findResult = await Command.Run("/usr/bin/mdfind", $"kMDItemCFBundleIdentifier = '{BUNDLE_ID}'", null, cancellation); if (findResult.exitCode != 0) { throw new Exception($"ERROR: {findResult.error}"); @@ -162,23 +153,20 @@ public async Task> 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 versionString = rootDict.ObjectForKey("CFBundleVersion")?.ToString() ?? ""; - var version = new UnityVersion(versionResult.output.Trim()); + 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; @@ -191,13 +179,23 @@ public async Task> 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; @@ -205,11 +203,11 @@ public async Task PrepareInstall(UnityInstaller.Queue queue, string installation // 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; @@ -237,35 +235,51 @@ public async Task PrepareInstall(UnityInstaller.Queue queue, string installation 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, item.package.destination, cancellation); - } else if (extentsion == ".zip") { - await InstallZip(item.filePath, item.package.destination, 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 (!string.IsNullOrEmpty(item.package.renameFrom) && !string.IsNullOrEmpty(item.package.renameTo)) { - await Rename(item.filePath, item.package.renameFrom, item.package.renameTo, cancellation); + 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 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; @@ -278,7 +292,7 @@ public async Task CompleteInstall(bool aborted, CancellationToken } } 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) { @@ -298,7 +312,7 @@ public async Task CompleteInstall(bool aborted, CancellationToken if (executable == null) return default; var installation = new Installation() { - version = installing.version, + version = installing.Version, executable = executable, path = destination }; @@ -408,8 +422,8 @@ string ExecutableFromAppPath(string appPath) /// 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}"); @@ -544,10 +558,10 @@ async Task InstallZip(string filePath, string destination, CancellationToken can /// /// Rename an installed filed or folder after inital installation. /// - async Task Rename(string filePath, string renameFrom, string renameTo, CancellationToken cancellation = default) + async Task Rename(string filePath, PathRename rename, CancellationToken cancellation = default) { - var from = renameFrom.Replace("{UNITY_PATH}", INSTALL_PATH); - var to = renameTo.Replace("{UNITY_PATH}", INSTALL_PATH); + 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}"); } diff --git a/sttz.InstallUnity/Installer/Scraper.cs b/sttz.InstallUnity/Installer/Scraper.cs index df7a99a..9819ec0 100644 --- a/sttz.InstallUnity/Installer/Scraper.cs +++ b/sttz.InstallUnity/Installer/Scraper.cs @@ -8,6 +8,8 @@ using System.Threading; using System.Threading.Tasks; +using static sttz.InstallUnity.UnityReleaseAPIClient; + namespace sttz.InstallUnity { @@ -121,7 +123,7 @@ public async Task> LoadFinal(CancellationToken canc var html = await response.Content.ReadAsStringAsync(); Logger.LogTrace($"Got response: {html}"); - return ExtractFromHtml(html).Values; + return ExtractFromHtml(html, ReleaseStream.None).Values; } /// @@ -134,10 +136,10 @@ public async Task> LoadPrerelease(bool includeAlpha var results = new Dictionary(); if (includeAlpha) { - await LoadPrerelease(UNITY_ALPHA, results, knownVersions, scrapeDelay, cancellation); + await LoadPrerelease(UNITY_ALPHA, ReleaseStream.Alpha, results, knownVersions, scrapeDelay, cancellation); } - await LoadPrerelease(UNITY_BETA, results, knownVersions, scrapeDelay, cancellation); + await LoadPrerelease(UNITY_BETA, ReleaseStream.Beta, results, knownVersions, scrapeDelay, cancellation); return results.Values; } @@ -145,7 +147,7 @@ public async Task> LoadPrerelease(bool includeAlpha /// /// Load the available prerelase versions from a alpha/beta landing page. /// - async Task LoadPrerelease(string url, Dictionary results, IEnumerable knownVersions = null, int scrapeDelay = 50, CancellationToken cancellation = default) + async Task LoadPrerelease(string url, ReleaseStream stream, Dictionary results, IEnumerable 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}'"); @@ -177,7 +179,7 @@ async Task LoadPrerelease(string url, Dictionary html = await response.Content.ReadAsStringAsync(); Logger.LogTrace($"Got response: {html}"); - ExtractFromHtml(html, true, results); + ExtractFromHtml(html, stream, results); } } @@ -196,7 +198,7 @@ string GetIniBaseUrl(UnityVersion.Type type) /// /// Extract the versions and the base URLs from the html string. /// - Dictionary ExtractFromHtml(string html, bool prerelease = false, Dictionary results = null) + Dictionary ExtractFromHtml(string html, ReleaseStream stream, Dictionary results = null) { var matches = UNITYHUB_RE.Matches(html); results = results ?? new Dictionary(); @@ -206,11 +208,11 @@ Dictionary 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; } @@ -221,11 +223,10 @@ Dictionary 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; } @@ -246,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; @@ -271,8 +271,9 @@ public async Task 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)); } @@ -297,7 +298,7 @@ public async Task 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(); } /// @@ -305,40 +306,31 @@ public async Task LoadUrl(string url, CancellationToken cancell /// The VersionMetadata must have iniUrl set. /// /// Version metadata with iniUrl. - /// Name of platform to load the packages for + /// Name of platform to load the packages for /// A Task returning the metadata with packages filled in. - public async Task LoadPackages(VersionMetadata metadata, CachePlatform cachePlatform, CancellationToken cancellation = default) + public async Task 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(); @@ -355,136 +347,208 @@ public async Task 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(); + + // Create modules from all entries + var allModules = new Dictionary(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(); + + 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; } + void SetDownloadKeys(Download download, IniParser.Model.SectionData section) + { + 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; + } + } + } + + 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; + } + } + } + /// - /// Guess the release notes URL for a version metadata. + /// Create a new empty version. /// - public string GetReleaseNotesUrl(VersionMetadata metadata) + static VersionMetadata CreateEmptyVersion(UnityVersion version, ReleaseStream stream) { - // 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); - } + var meta = new VersionMetadata(); + meta.release = new Release(); + meta.release.version = version; + meta.release.shortRevision = version.hash; + + if (stream == ReleaseStream.None) + stream = GuessStreamFromVersion(version); + meta.release.stream = stream; - return GetReleaseNotesUrl(metadata.version); + return meta; + } + + /// + /// Guess the release stream based on the Unity version. + /// + public static ReleaseStream GuessStreamFromVersion(UnityVersion version) + { + 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; + } } /// /// Guess the release notes URL for a version. /// - public string GetReleaseNotesUrl(UnityVersion version) + public static string GetReleaseNotesUrl(ReleaseStream stream, UnityVersion version) { - 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.Beta: - return UNITY_RELEASE_NOTES_BETA + version.ToString(false); - case UnityVersion.Type.Alpha: + 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 null; + return UNITY_RELEASE_NOTES_FINAL + $"{version.major}.{version.minor}.{version.patch}"; } } } diff --git a/sttz.InstallUnity/Installer/UnityInstaller.cs b/sttz.InstallUnity/Installer/UnityInstaller.cs index 6b623c7..c1d8426 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) /// public Scraper Scraper { get; protected set; } + /// + /// Client for the Unity Release API. + /// + public UnityReleaseAPIClient Releases { get; protected set; } + // -------- API -------- /// @@ -107,6 +112,7 @@ public enum InstallStep public class Queue { public VersionMetadata metadata; + public string downloadPath; public IList items; } @@ -118,7 +124,8 @@ public class QueueItem /// /// Description of the item's current state. /// - public enum State { + public enum State + { /// /// Waiting for the download to start. /// @@ -148,7 +155,7 @@ public enum State { /// /// The package metadata of this item. /// - public PackageMetadata package; + public Download package; /// /// The item's current state. /// @@ -212,11 +219,11 @@ 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 { - 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 +251,7 @@ public UnityInstaller(Configuration config = null, string dataPath = null, ILogg // Initialize components Versions = new VersionsCache(GetCacheFilePath()); Scraper = new Scraper(); + Releases = new UnityReleaseAPIClient(); } /// @@ -270,7 +278,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)); } /// @@ -289,49 +297,46 @@ public bool IsCacheOutdated(UnityVersion.Type type = UnityVersion.Type.Undefined /// /// Update the Unity versions cache. /// - /// Name of platform to update (only used for loading hub JSON) + /// Name of platform to update (only used for loading hub JSON) /// Undefined = update latest, others = update archive of type and higher types /// Task returning the newly discovered versions - public async Task> UpdateCache(CachePlatform cachePlatform, UnityVersion.Type type = UnityVersion.Type.Undefined, CancellationToken cancellation = default) + public async Task> UpdateCache(Platform platform, Architecture architecture, UnityVersion.Type type = UnityVersion.Type.Undefined, CancellationToken cancellation = default) { var added = new List(); - switch (type) { - case UnityVersion.Type.Final: - 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; - } + var req = new UnityReleaseAPIClient.RequestParams(); + req.platform = platform; + req.architecture = architecture; - 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; + 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; } @@ -340,27 +345,32 @@ public async Task> UpdateCache(CachePlatform cacheP /// /// Unity version /// Name of platform - public IEnumerable GetDefaultPackages(VersionMetadata metadata, CachePlatform cachePlatform) + public IEnumerable GetDefaultPackages(VersionMetadata metadata, Platform platform, 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); } /// /// Resolve package patterns to package metadata. /// This method also adds package dependencies. /// - public IEnumerable ResolvePackages( + public IEnumerable ResolvePackages( VersionMetadata metadata, - CachePlatform cachePlatform, + Platform platform, Architecture architecture, IEnumerable packages, IList notFound = null ) { - var packageMetadata = metadata.GetPackages(cachePlatform); - var metas = new List(); + var editor = metadata.GetEditorDownload(platform, architecture); + var metas = new List(); 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("~")) { @@ -372,26 +382,26 @@ public IEnumerable 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); } @@ -403,9 +413,9 @@ public IEnumerable ResolvePackages( /// Recursive method to add package and dependencies. /// void AddPackageWithDependencies( - IEnumerable packages, - List selected, - PackageMetadata package, + EditorDownload editor, + List selected, + Module package, bool addDependencies, bool isDependency = false ) { @@ -416,32 +426,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); } } + /// + /// Get the file name to use for the package. + /// + 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; + } + /// /// Create a download and install queue from the given version and packages. /// /// The Unity version - /// Name of platform + /// Name of platform /// Location of the downloaded the packages /// Packages to download and/or install /// The queue list with the created queue items - public Queue CreateQueue(VersionMetadata metadata, CachePlatform cachePlatform, string downloadPath, IEnumerable packages) + public Queue CreateQueue(VersionMetadata metadata, Platform platform, Architecture architecture, string downloadPath, IEnumerable 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(); foreach (var package in packages) { var fullUrl = package.url; @@ -449,8 +503,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() { @@ -463,6 +517,7 @@ public Queue CreateQueue(VersionMetadata metadata, CachePlatform cachePlatform, return new Queue() { metadata = metadata, + downloadPath = downloadPath, items = items }; } @@ -473,7 +528,7 @@ public Queue CreateQueue(VersionMetadata metadata, CachePlatform cachePlatform, /// Which steps to perform. /// The queue to process /// Cancellation token - public async Task Process(InstallStep steps, Queue queue, bool skipChecks = false, CancellationToken cancellation = default) + public async Task Process(InstallStep steps, Queue queue, Downloader.ExistingFile existingFile = Downloader.ExistingFile.Undefined, CancellationToken cancellation = default) { if (queue == null) throw new ArgumentNullException(nameof(queue)); @@ -485,12 +540,12 @@ public async Task 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}"); @@ -508,7 +563,13 @@ public async Task 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); } @@ -517,17 +578,17 @@ public async Task 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 { - 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; @@ -541,18 +602,19 @@ public async Task 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++; } @@ -563,7 +625,7 @@ public async Task 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++; } @@ -584,7 +646,7 @@ public async Task 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 { @@ -598,7 +660,7 @@ public async Task 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++; @@ -628,29 +690,29 @@ public async Task Process(InstallStep steps, Queue queue, bool ski /// Where downloads were stored. /// The Unity version downloaded /// Downloaded packages. - public void CleanUpDownloads(VersionMetadata metadata, string downloadPath, IEnumerable 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)) { + + if (!packageFilePaths.Contains(path)) { 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 +{ + +/// +/// 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 +/// +public class UnityReleaseAPIClient +{ + // -------- Types -------- + + /// + /// Different Unity release streams. + /// + [Flags] + public enum ReleaseStream + { + None = 0, + + Alpha = 1<<0, + Beta = 1<<1, + Tech = 1<<2, + LTS = 1<<3, + + PrereleaseMask = (Alpha | Beta), + + All = -1, + } + + /// + /// Platforms the Unity editor runs on. + /// + [Flags] + public enum Platform + { + None, + + Mac_OS = 1<<0, + Linux = 1<<1, + Windows = 1<<2, + + All = -1, + } + + /// + /// CPU architectures the Unity editor supports (on some platforms). + /// + [Flags] + public enum Architecture + { + None = 0, + + X86_64 = 1<<10, + ARM64 = 1<<11, + + All = -1, + } + + /// + /// Different file types of downloads and links. + /// + public enum FileType + { + Undefined, + + TEXT, + TAR_GZ, + TAR_XZ, + ZIP, + PKG, + EXE, + PO, + DMG, + LZMA, + LZ4, + MD, + PDF + } + + /// + /// Response from the Releases API. + /// + [JsonObject(MemberSerialization.Fields)] + public class Response + { + /// + /// Return wether the request was successful. + /// + public bool IsSuccess => ((int)status >= 200 && (int)status <= 299); + + // -------- Response Fields -------- + + /// + /// Start offset from the returned results. + /// + public int offset; + /// + /// Limit of results returned. + /// + public int limit; + /// + /// Total number of results. + /// + public int total; + + /// + /// The release results. + /// + public Release[] results; + + // -------- Error fields -------- + + /// + /// Error code. + /// + public HttpStatusCode status; + /// + /// Title of the error. + /// + public string title; + /// + /// Error detail description. + /// + public string detail; + } + + /// + /// A specific release of the Unity editor. + /// + [JsonObject(MemberSerialization.Fields)] + public class Release + { + /// + /// Version of the editor. + /// + public UnityVersion version; + /// + /// The Git Short Revision of the Unity Release. + /// + public string shortRevision; + + /// + /// Date and time of the release. + /// + public DateTime releaseDate; + /// + /// Link to the release notes. + /// + public ReleaseNotes releaseNotes; + /// + /// Stream this release is part of. + /// + public ReleaseStream stream; + /// + /// The SKU family of the Unity Release. + /// Possible values: CLASSIC or DOTS + /// + public string skuFamily; + /// + /// The indicator for whether the Unity Release is the recommended LTS + /// + public bool recommended; + /// + /// Deep link to open this release in Unity Hub. + /// + public string unityHubDeepLink; + + /// + /// Editor downloads of this release. + /// + public List downloads; + + /// + /// The Third Party Notices of the Unity Release. + /// + 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; + } + } + } + + /// + /// Unity editor release notes. + /// + [JsonObject(MemberSerialization.Fields)] + public struct ReleaseNotes + { + /// + /// Url to the release notes. + /// + public string url; + /// + /// Type of the release notes. + /// (Only seen "MD" so far.) + /// + public FileType type; + } + + /// + /// Third party notices associated with a Unity release. + /// + [JsonObject(MemberSerialization.Fields)] + public struct ThirdPartyNotice + { + /// + /// The original file name of the Unity Release Third Party Notice. + /// + public string originalFileName; + /// + /// The URL of the Unity Release Third Party Notice. + /// + public string url; + /// + /// Type of the release notes. + /// + public FileType type; + } + + /// + /// An Unity editor download, including available modules. + /// + [JsonObject(MemberSerialization.Fields)] + public abstract class Download + { + /// + /// Url to download. + /// + public string url; + /// + /// Integrity hash (hash prefixed by hash type plus dash, seen md5 and sha384). + /// + public string integrity; + /// + /// Type of download. + /// (Only seen "DMG", "PKG", "ZIP" and "PO" so far) + /// + public FileType type; + /// + /// Size of the download. + /// + public FileSize downloadSize; + /// + /// Size required on disk. + /// + public FileSize installedSize; + + /// + /// ID of the download. + /// + public abstract string Id { get; } + } + + /// + /// Main editor download. + /// + [JsonObject(MemberSerialization.Fields)] + public class EditorDownload : Download + { + /// + /// The Id of the editor download pseudo-module. + /// + public const string ModuleId = "unity"; + + /// + /// Platform of the editor. + /// + public Platform platform; + /// + /// Architecture of the editor. + /// + public Architecture architecture; + /// + /// Available modules for this editor version. + /// + public List modules; + + /// + /// Editor downloads all have the fixed "Unity" ID. + /// + public override string Id => ModuleId; + + /// + /// Dictionary of all modules, including sub-modules. + /// + public Dictionary AllModules { get { + if (_allModules == null) { + _allModules = new Dictionary(StringComparer.OrdinalIgnoreCase); + if (modules != null) { + foreach (var module in modules) { + AddModulesRecursive(module); + } + } + } + return _allModules; + } } + [NonSerialized] Dictionary _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); + } + } + } + } + + /// + /// Size description of a download or space required for install. + /// + [JsonObject(MemberSerialization.Fields)] + public struct FileSize + { + /// + /// Size value. + /// + public long value; + /// + /// Unit of the value. + /// Possible vaues: BYTE, KILOBYTE, MEGABYTE, GIGABYTE + /// (Only seen "BYTE" so far.) + /// + public string unit; + + /// + /// Return the size in bytes, converting from the source unit when necessary. + /// + 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}'"); + } + } + + /// + /// Create a new instance with the given amount of bytes. + /// + public static FileSize FromBytes(long bytes) + => new FileSize() { value = bytes, unit = "BYTE" }; + + /// + /// Create a new instance with the given amount of bytes. + /// + public static FileSize FromMegaBytes(long megaBytes) + => new FileSize() { value = megaBytes, unit = "MEGABYTE" }; + } + + /// + /// A module of an editor. + /// + [JsonObject(MemberSerialization.Fields)] + public class Module : Download + { + /// + /// Identifier of the module. + /// + public string id; + /// + /// Slug identifier of the module. + /// + public string slug; + /// + /// Name of the module. + /// + public string name; + /// + /// Description of the module. + /// + public string description; + /// + /// Category type of the module. + /// + public string category; + /// + /// Wether this module is required for its parent module. + /// + public bool required; + /// + /// Wether this module is hidden from the user. + /// + public bool hidden; + /// + /// Wether this module is installed by default. + /// + public bool preSelected; + /// + /// Where to install the module to (can contain the {UNITY_PATH} variable). + /// + public string destination; + /// + /// How to rename the installed directory. + /// + public PathRename extractedPathRename; + /// + /// EULAs the user should accept before installing. + /// + public Eula[] eula; + /// + /// Sub-Modules of this module. + /// + public List subModules; + + /// + /// Modules return their dynamic id. + /// + public override string Id => id; + /// + /// Id of the parent module. + /// + [NonSerialized] public string parentModuleId; + /// + /// The parent module that lists this sub-module (null = part of main editor module). + /// + [NonSerialized] public Module parentModule; + /// + /// Used to track automatically added dependencies. + /// + [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; + } + } + } + } + + /// + /// EULA of a module. + /// + [JsonObject(MemberSerialization.Fields)] + public struct Eula + { + /// + /// URL to the EULA. + /// + public string url; + /// + /// Type of content at the url. + /// (Only seen "TEXT" so far.) + /// + public FileType type; + /// + /// Label for this EULA. + /// + public string label; + /// + /// Explanation message for the user. + /// + public string message; + } + + /// + /// Path rename instruction. + /// + [JsonObject(MemberSerialization.Fields)] + public struct PathRename + { + /// + /// Path to rename from (can contain the {UNITY_PATH} variable). + /// + public string from; + /// + /// Path to rename to (can contain the {UNITY_PATH} variable). + /// + public string to; + + /// + /// Wether both a from and to path are set. + /// + public bool IsSet => (!string.IsNullOrEmpty(from) && !string.IsNullOrEmpty(to)); + } + + // -------- API -------- + + /// + /// Order of the results returned by the API. + /// + [Flags] + public enum ResultOrder + { + /// + /// Default order (release date descending). + /// + Default = 0, + + // -------- Sorting Cireteria -------- + + /// + /// Order by release date. + /// + ReleaseDate = 1<<0, + + // -------- Sorting Order -------- + + /// + /// Return results in ascending order. + /// + Ascending = 1<<30, + /// + /// Return results in descending order. + /// + Descending = 1<<31, + } + + /// + /// Request parameters of the Unity releases API. + /// + public class RequestParams + { + /// + /// Version filter, applied as full-text search on the version string. + /// + public string version = null; + /// + /// Unity release streams to load (can set multiple flags in bitmask). + /// + public ReleaseStream stream = ReleaseStream.All; + /// + /// Platforms to load (can set multiple flags in bitmask). + /// + public Platform platform = Platform.All; + /// + /// Architectures to load (can set multiple flags in bitmask). + /// + public Architecture architecture = Architecture.All; + + /// + /// How many results to return (1-25). + /// + public int limit = 10; + /// + /// Offset of the first result returned + /// + public int offset = 0; + /// + /// Order of returned results. + /// + public ResultOrder order; + } + + /// + /// Maximum number of requests that can be made per second. + /// + public const int MaxRequestsPerSecond = 10; + /// + /// Maximum number of requests that can be made per 30 minutes. + /// (Not currently tracked by the client.) + /// + public const int MaxRequestsPerHalfHour = 1000; + + /// + /// Send a basic request to the Release API. + /// + public async Task Send(RequestParams request, CancellationToken cancellation = default) + { + var parameters = new List>(); + 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(json); + if (parsedResponse.status == 0) { + parsedResponse.status = response.StatusCode; + } + + return parsedResponse; + } + + /// + /// Load all releases for the given request, making multiple + /// paginated requests to the API. + /// + /// The request to send, the limit and offset fields will be modified + /// Limit returned results to not make too many requests + /// Cancellation token + /// The results returned from the API + public async Task 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; + } + + /// + /// Load all latest releases from the given time period, + /// making multiple paginated requests to the API. + /// + /// The request to send, the limit, offset and order fields will be modified + /// The period to load releases from + /// Cancellation token + /// The results returned from the API, can contain releases older than the given period + public async Task> LoadLatest(RequestParams request, TimeSpan period, CancellationToken cancellation = default) + { + request.limit = 25; + request.order = ResultOrder.ReleaseDate | ResultOrder.Descending; + + var releases = new List(); + 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; + } + + /// + /// Try to find a release based on version string search. + /// + public async Task 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(); + + static HttpClient client = new HttpClient(); + static DateTime lastRequestTime = DateTime.MinValue; + + /// + /// Endpoint of the releases API. + /// + const string Endpoint = "https://services.api.unity.com/unity/editor/release/v1/releases?"; + + /// + /// Query string values for streams. + /// + static readonly Dictionary StreamValues = new() { + { ReleaseStream.Alpha, "ALPHA" }, + { ReleaseStream.Beta, "BETA" }, + { ReleaseStream.Tech, "TECH" }, + { ReleaseStream.LTS, "LTS" }, + }; + /// + /// Query string values for platforms. + /// + static readonly Dictionary PlatformValues = new() { + { Platform.Mac_OS, "MAC_OS" }, + { Platform.Linux, "LINUX" }, + { Platform.Windows, "WINDOWS" }, + }; + /// + /// Query string values for architectures. + /// + static readonly Dictionary ArchitectureValues = new() { + { Architecture.X86_64, "X86_64" }, + { Architecture.ARM64, "ARM64" }, + }; + + /// + /// Iterate all the single bits set in the given enum value. + /// (This does not check if the set bit is defined in the enum.) + /// + static IEnumerable IterateBits(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; + } + } + + /// + /// Check the given bitmask enum for single set bits, look up those values + /// in the given dictionary and then add them to the query. + /// + static void AddArrayParameters(List> query, string name, Dictionary 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 f7e10cd..0b8c1b5 100644 --- a/sttz.InstallUnity/Installer/UnityVersion.cs +++ b/sttz.InstallUnity/Installer/UnityVersion.cs @@ -390,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 52da990..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; -/// -/// Platforms supported by the cache. -/// -public enum CachePlatform +namespace sttz.InstallUnity { - None, - macOSIntel, - macOSArm, - Windows, - Linux, -} /// /// Information about a Unity version available to install. @@ -27,392 +17,87 @@ public enum CachePlatform public struct VersionMetadata { /// - /// Unity version. + /// Create a new version from a release. /// - public UnityVersion version; - - /// - /// Wether version was scraped from a beta/alpha page. - /// - /// - /// Release candidates appear on the beta page but have versions that - /// are indistinguishable from regular releases, we mark them here to - /// distinguish between them. - /// - public bool prerelease; - - /// - /// Returns wether the metadata represents a release candidate. - /// (A final version published on the prerelease pages.) - /// - public bool IsReleaseCandidate { - get { - return prerelease && version.type == UnityVersion.Type.Final; - } + public static VersionMetadata FromRelease(Release release) + { + return new VersionMetadata() { release = release }; } /// - /// Returns wether the metadata represents a regular Unity release, - /// excluding release candidates. + /// The release metadata, in the format of the Unity Release API. /// - public bool IsFinalRelease { - get { - return version.type == UnityVersion.Type.Final && !prerelease; - } - } + public Release release; /// - /// Returns wether the metadata represents a Unity prerelease, - /// including alpha, beta and release candidates. + /// Shortcut to the Unity version of this release. /// - public bool IsPrerelease { - get { - return version.type == UnityVersion.Type.Alpha - || version.type == UnityVersion.Type.Beta - || prerelease; - } - } + public UnityVersion Version => release?.version ?? default; /// /// Base URL of where INIs are stored. /// public string baseUrl; - /// - /// macOS packages. - /// - public PackageMetadata[] macPackages; - - /// - /// macOS packages. - /// - public PackageMetadata[] macArmPackages; - - /// - /// Windows packages. - /// - public PackageMetadata[] winPackages; - - /// - /// Linux packages. - /// - public PackageMetadata[] linuxPackages; - - /// - /// Virtual packages, generated dynamically for the current platform. - /// - [NonSerialized] - public IEnumerable virtualPackages; - - /// - /// Callback to add virtual packages. - /// - /// - /// Don't call in the callback or you'll end up in - /// an infinite recursion. Use instead. - /// - public static Func> OnGenerateVirtualPackages; - - /// - /// Wrapper of that also checks that - /// final versions don't match release candidates. - /// - public bool IsFuzzyMatchedBy(UnityVersion query) - { - if (query.type == UnityVersion.Type.Final && prerelease) { - return false; - } - - return query.FuzzyMatches(version); - } - /// /// Determine wether the packages metadata has been loaded. /// - public bool HasPackagesMetadata(CachePlatform platform) + public bool HasDownload(Platform platform, Architecture architecture) { - return GetRawPackages(platform) != null; + return GetEditorDownload(platform, architecture) != null; } /// /// Get platform specific packages without adding virtual packages. /// - /// Platform to get. - public IEnumerable 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; - /// - /// Get platform specific packages. - /// - /// Platform to get. - public IEnumerable GetPackages(CachePlatform platform) - { - // Generate virtual packages - if (virtualPackages == null) { - if (OnGenerateVirtualPackages != null) { - foreach (Func> 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(); - } + 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; } /// /// Set platform specific packages. /// - /// Platform to set. - 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); - } - } - - /// - /// Find a package by name, ignoring case and excluding virtual packages. - /// - 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(); + + 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; - } - /// - /// Find a package by name, ignoring case. - /// - 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); } -} - -/// -/// Information about an version's individual package. -/// -public struct PackageMetadata -{ - /// - /// Name of the main editor package. - /// - public const string EDITOR_PACKAGE_NAME = "Unity"; - - /// - /// Identifier of the package. - /// - public string name; - - /// - /// Title of the package. - /// - public string title; - - /// - /// Description of the package. - /// - public string description; - - /// - /// Relative or absolute url to the package download. - /// - public string url; - - /// - /// Wether the package is installed by default. - /// - public bool install; - - /// - /// Wether the package is mandatory. - /// - public bool mandatory; - - /// - /// The download size in bytes. - /// - public long size; - - /// - /// The installed size in bytes. - /// - public long installedsize; - - /// - /// The version of the package. - /// - public string version; - - /// - /// File extension to use. - /// - public string extension; - - /// - /// Wether the package is hidden. - /// - public bool hidden; - - /// - /// Install this package together with another one. - /// - public string sync; - - /// - /// The md5 hash of the package download. - /// - public string md5; - - /// - /// Wether the package can be installed without the editor. - /// - public bool requires_unity; - - /// - /// Bundle Identifier of app in package. - /// - public string appidentifier; /// - /// Message for extra EULA terms. + /// Find a package by identifier, ignoring case. /// - public string eulamessage; - - /// - /// Label of first extra EULA. - /// - public string eulalabel1; - - /// - /// URL of first extra EULA. - /// - public string eulaurl1; - - /// - /// Label of second extra EULA. - /// - public string eulalabel2; - - /// - /// URL of second extra EULA. - /// - public string eulaurl2; - - // -------- Fields used by virtual packages -------- - - /// - /// Where the archive should be extracted to (does not apply to installers). - /// - public string destination; - - /// - /// Rename the extracted archive from this path. - /// - public string renameFrom; - - /// - /// Rename the extracted archive to this path. - /// - public string renameTo; - - /// - /// Target file name. - /// - public string fileName; - - /// - /// Used to track automatically added dependencies. - /// - [NonSerialized] public bool addedAutomatically; - - /// - /// Get the file name to use for the package. - /// - 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 /// /// Version of cache format. /// - const int CACHE_FORMAT = 2; + const int CACHE_FORMAT = 3; /// /// Data written out to JSON file. @@ -482,7 +167,7 @@ public VersionsCache(string dataFilePath) /// void SortVersions() { - cache.versions.Sort((m1, m2) => m2.version.CompareTo(m1.version)); + cache.versions.Sort((m1, m2) => m2.release.version.CompareTo(m1.release.version)); } /// @@ -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 metadatas, IList 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,12 +247,18 @@ public void Add(IEnumerable metadatas, IList 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.macArmPackages != null) existing.macArmPackages = with.macArmPackages; - if (with.winPackages != null) existing.winPackages = with.winPackages; - if (with.linuxPackages != null) existing.linuxPackages = 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; } @@ -582,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; } } @@ -591,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 5f116b9..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 /// public static class VirtualPackages { - /// - /// Enable virtual packages. - /// - /// - /// Packages will be injected into existing in - /// their method. - /// - public static void Enable() - { - VersionMetadata.OnGenerateVirtualPackages -= GeneratePackages; - VersionMetadata.OnGenerateVirtualPackages += GeneratePackages; - } - - /// - /// Disable virtual packages. Virtual packages already generated will not be removed. - /// - public static void Disable() - { - VersionMetadata.OnGenerateVirtualPackages -= GeneratePackages; - } - - static IEnumerable GeneratePackages(VersionMetadata version, CachePlatform platform) + public static IEnumerable GeneratePackages(UnityVersion version, EditorDownload editor) { - 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 LanguageNames = new Dictionary() { + static Dictionary LanguageNames = new Dictionary(StringComparer.OrdinalIgnoreCase) { { "ja", "日本語" }, { "ko", "한국어" }, { "zh-cn", "简体中文" }, @@ -48,22 +29,25 @@ static IEnumerable GeneratePackages(VersionMetadata version, Ca { "zh-hans", "简体中文" }, }; - static IEnumerable Generator(VersionMetadata version, CachePlatform platform) + static IEnumerable 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,247 +63,294 @@ static IEnumerable 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 if (v.major <= 2022) { - yield return new PackageMetadata() { + 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, - sync = "Android SDK & NDK Tools", + parentModuleId = "Android SDK & NDK Tools", }; } else { - yield return new PackageMetadata() { + 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", - size = 18500000, - installedsize = 48684075, + 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 if (v.major <= 2022) { - yield return new PackageMetadata() { + 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, - 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-11", + to = "{UNITY_PATH}/PlaybackEngines/AndroidPlayer/SDK/platforms/android-30" + } }; } else { - yield return new PackageMetadata() { + 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", - size = 50400000, - installedsize = 138655842, + downloadSize = FileSize.FromBytes(50400000), + installedSize = FileSize.FromBytes(138655842), hidden = true, - sync = "Android SDK & NDK Tools", - renameFrom = "{UNITY_PATH}/PlaybackEngines/AndroidPlayer/SDK/build-tools/android-12", - renameTo = "{UNITY_PATH}/PlaybackEngines/AndroidPlayer/SDK/build-tools/32.0.0" + 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 PackageMetadata() { + 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", - size = 53900000, - installedsize = 91868884, + downloadSize = FileSize.FromBytes(53900000), + installedSize = FileSize.FromBytes(91868884), hidden = true, - sync = "Android SDK & NDK Tools", - renameFrom = "{UNITY_PATH}/PlaybackEngines/AndroidPlayer/SDK/platforms/android-12", - renameTo = "{UNITY_PATH}/PlaybackEngines/AndroidPlayer/SDK/platforms/android-31" + 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 PackageMetadata() { + 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", - size = 63000000, - installedsize = 101630444, + downloadSize = FileSize.FromBytes(63000000), + installedSize = FileSize.FromBytes(101630444), hidden = true, - sync = "Android SDK & NDK Tools", - renameFrom = "{UNITY_PATH}/PlaybackEngines/AndroidPlayer/SDK/platforms/android-12", - renameTo = "{UNITY_PATH}/PlaybackEngines/AndroidPlayer/SDK/platforms/android-32" + 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 PackageMetadata() { + 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", - size = 119650616, - installedsize = 119651596, + downloadSize = FileSize.FromBytes(119650616), + installedSize = FileSize.FromBytes(119651596), hidden = true, - sync = "Android SDK & NDK Tools", - renameFrom = "{UNITY_PATH}/PlaybackEngines/AndroidPlayer/SDK/cmdline-tools/cmdline-tools", - renameTo = "{UNITY_PATH}/PlaybackEngines/AndroidPlayer/SDK/cmdline-tools/6.0" + 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 if (v.major <= 2022) { - yield return new PackageMetadata() { + 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 PackageMetadata() { + 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", - size = 1400000000, - installedsize = 4254572698, + downloadSize = FileSize.FromBytes(1400000000), + installedSize = FileSize.FromBytes(4254572698), hidden = true, - sync = "Android SDK & NDK Tools", - renameFrom = "{UNITY_PATH}/PlaybackEngines/AndroidPlayer/NDK/Contents/NDK", - renameTo = "{UNITY_PATH}/PlaybackEngines/AndroidPlayer/NDK" + 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 >= 2023) { - yield return new PackageMetadata() { + 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", - size = 118453231, - installedsize = 230230237, - sync = "Android", + downloadSize = FileSize.FromBytes(118453231), + installedSize = FileSize.FromBytes(230230237), + parentModuleId = "Android", }; } else if (v.major > 2019 || v.minor >= 2) { - yield return new PackageMetadata() { + 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 7319370..37cfe48 100644 --- a/sttz.InstallUnity/sttz.InstallUnity.csproj +++ b/sttz.InstallUnity/sttz.InstallUnity.csproj @@ -23,6 +23,7 @@ + From ce0530919cbf229460227a97737d2c0f85ac6d58 Mon Sep 17 00:00:00 2001 From: Adrian Stutz Date: Mon, 8 May 2023 20:28:20 +0200 Subject: [PATCH 14/36] Build configuration changes --- Build/build-osx.sh | 4 +--- Command/Command.csproj | 5 ++++- Command/rd.xml | 15 --------------- sttz.InstallUnity/sttz.InstallUnity.csproj | 2 +- 4 files changed, 6 insertions(+), 20 deletions(-) delete mode 100644 Command/rd.xml diff --git a/Build/build-osx.sh b/Build/build-osx.sh index 3686746..fcbdbba 100755 --- a/Build/build-osx.sh +++ b/Build/build-osx.sh @@ -42,9 +42,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 diff --git a/Command/Command.csproj b/Command/Command.csproj index d4822e4..77a9ac9 100644 --- a/Command/Command.csproj +++ b/Command/Command.csproj @@ -4,6 +4,9 @@ Exe net7.0 latest + true + true + true @@ -19,7 +22,7 @@ - + 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 @@ - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/sttz.InstallUnity/sttz.InstallUnity.csproj b/sttz.InstallUnity/sttz.InstallUnity.csproj index 37cfe48..15f6e47 100644 --- a/sttz.InstallUnity/sttz.InstallUnity.csproj +++ b/sttz.InstallUnity/sttz.InstallUnity.csproj @@ -22,7 +22,7 @@ - + From c9439a041cdb13d4ea626e0dd0694b4917858d6d Mon Sep 17 00:00:00 2001 From: Adrian Stutz Date: Mon, 8 May 2023 20:42:16 +0200 Subject: [PATCH 15/36] Fix build script, switch from altool to notarytool --- Build/build-osx.sh | 15 ++++----------- 1 file changed, 4 insertions(+), 11 deletions(-) diff --git a/Build/build-osx.sh b/Build/build-osx.sh index fcbdbba..1c84995 100755 --- a/Build/build-osx.sh +++ b/Build/build-osx.sh @@ -4,22 +4,15 @@ PROJECT="Command/Command.csproj" 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,7 +35,7 @@ for arch in $ARCHES; do -r "$arch" \ -c release \ -f "$TARGET" \ - --self-contained + --self-contained \ "$PROJECT" \ || exit 1 @@ -77,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 From d4e35fcdf2640f31cf28011393cd8b5e373e791e Mon Sep 17 00:00:00 2001 From: Adrian Stutz Date: Sat, 13 May 2023 20:11:49 +0200 Subject: [PATCH 16/36] Bump to 2.12.0, update changelog --- Changelog.md | 17 +++++++++ Command/Command.csproj | 2 +- Readme.md | 40 ++++++++++++++-------- sttz.InstallUnity/sttz.InstallUnity.csproj | 2 +- 4 files changed, 44 insertions(+), 17 deletions(-) diff --git a/Changelog.md b/Changelog.md index ffd6dc2..fdcd643 100644 --- a/Changelog.md +++ b/Changelog.md @@ -1,5 +1,22 @@ # 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 diff --git a/Command/Command.csproj b/Command/Command.csproj index 77a9ac9..0946436 100644 --- a/Command/Command.csproj +++ b/Command/Command.csproj @@ -10,7 +10,7 @@ - 2.11.1 + 2.12.0 Adrian Stutz (sttz.ch) install-unity CLI CLI for install-unity unofficial Unity installer library diff --git a/Readme.md b/Readme.md index 02e5332..98f68b1 100644 --- a/Readme.md +++ b/Readme.md @@ -114,10 +114,11 @@ The project will use Unity's default setup, including packages. Alternatively, y ## CLI Help ```` -install-unity v2.11.1 +install-unity v2.12.0 USAGE: install-unity [--help] [--version] [--verbose...] [--yes] [--update] - [--data-path ] [--opt =...] + [--clear-cache] [--data-path ] + [--opt =...] GLOBAL OPTIONS: -h, --help Show this help @@ -125,6 +126,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 Store all data at the given path, also don't delete packages after install --opt = Set additional options. Use '--opt list' to show all @@ -139,8 +141,9 @@ ACTIONS: USAGE: install-unity [options] [install] [--packages ...] [--download] [--install] [--upgrade] - [--platform none|macosintel|macosarm|windows|linux] - [--yolo] [] + [--platform none|mac_os|linux|windows|all] + [--arch none|x86_64|arm64|all] [--redownload] [--yolo] + [] OPTIONS: Pattern to match Unity version or release notes / unity hub @@ -152,9 +155,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 @@ -162,28 +168,32 @@ OPTIONS: Get an overview of available or installed Unity versions USAGE: install-unity [options] list [--installed] - [--platform none|macosintel|macosarm|windows|linux] - [] + [--platform none|mac_os|linux|windows|all] + [--arch none|x86_64|arm64|all] [] OPTIONS: 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] - [] + [--platform none|mac_os|linux|windows|all] + [--arch none|x86_64|arm64|all] [] OPTIONS: 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/sttz.InstallUnity/sttz.InstallUnity.csproj b/sttz.InstallUnity/sttz.InstallUnity.csproj index 15f6e47..2a4defd 100644 --- a/sttz.InstallUnity/sttz.InstallUnity.csproj +++ b/sttz.InstallUnity/sttz.InstallUnity.csproj @@ -7,7 +7,7 @@ - 2.11.1 + 2.12.0 Adrian Stutz (sttz.ch) install-unity install-unity unofficial Unity installer library From e913af69b720c709cd03d634129aed6d41ac0578 Mon Sep 17 00:00:00 2001 From: Eirik Wilhelmsen Date: Mon, 25 Jul 2022 14:33:48 +0200 Subject: [PATCH 17/36] Copied Windows support from minorai --- .gitignore | 1 + sttz.InstallUnity/Installer/Configuration.cs | 3 + sttz.InstallUnity/Installer/Helpers.cs | 6 +- .../Installer/IInstallerPlatform.cs | 2 +- .../Installer/Platforms/WIndowsPlatform.cs | 282 ++++++++++++++++++ sttz.InstallUnity/Installer/UnityInstaller.cs | 5 + 6 files changed, 295 insertions(+), 4 deletions(-) create mode 100644 sttz.InstallUnity/Installer/Platforms/WIndowsPlatform.cs 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/sttz.InstallUnity/Installer/Configuration.cs b/sttz.InstallUnity/Installer/Configuration.cs index 6a6e42d..a029776 100644 --- a/sttz.InstallUnity/Installer/Configuration.cs +++ b/sttz.InstallUnity/Installer/Configuration.cs @@ -64,6 +64,9 @@ public class Configuration "/Applications/Unity {major}.{minor};" + "/Applications/Unity {major}.{minor}.{patch}{type}{build};" + "/Applications/Unity {major}.{minor}.{patch}{type}{build} ({hash})"; + + [Description("Windwos installation paths, separted by ; (first non-existing will be used, variables: {major} {minor} {patch} {type} {build} {hash}).")] + public string installPathWindows = Environment.GetFolderPath(Environment.SpecialFolder.ProgramFiles) + "\\Unity\\Hub\\Editor\\{major}.{minor}.{patch}{type}{build};"; // -------- Serialization -------- diff --git a/sttz.InstallUnity/Installer/Helpers.cs b/sttz.InstallUnity/Installer/Helpers.cs index 14ce899..0e93a80 100644 --- a/sttz.InstallUnity/Installer/Helpers.cs +++ b/sttz.InstallUnity/Installer/Helpers.cs @@ -13,7 +13,7 @@ namespace sttz.InstallUnity public static class Helpers { static readonly string[] SizeNames = new string[] { - "KB", "MB", "GB", "TB", "PB", "EB", "ZB", "YB" + "MB", "GB", "TB", "PB", "EB", "ZB", "YB" }; /// @@ -24,8 +24,8 @@ public static class Helpers /// Size formatted with appropriate size suffix (B, KB, MB, etc) public static string FormatSize(long bytes, string format = "{0:0.00} {1}") { - if (bytes < 0) return "? B"; - else if (bytes < 1024) return bytes + " B"; + if (bytes < 0) return "? KB"; + else if (bytes < 1024) return bytes + " KB"; var size = bytes / 1024.0; var index = Math.Min((int)Math.Log(size, 1024), SizeNames.Length - 1); diff --git a/sttz.InstallUnity/Installer/IInstallerPlatform.cs b/sttz.InstallUnity/Installer/IInstallerPlatform.cs index 805a8b8..c6b260a 100644 --- a/sttz.InstallUnity/Installer/IInstallerPlatform.cs +++ b/sttz.InstallUnity/Installer/IInstallerPlatform.cs @@ -105,7 +105,7 @@ public interface IInstallerPlatform /// /// Uninstall a Unity installation. /// - Task Uninstall(Installation instalation, CancellationToken cancellation = default); + Task Uninstall(Installation installation, CancellationToken cancellation = default); /// /// Run a Unity installation with the given arguments. diff --git a/sttz.InstallUnity/Installer/Platforms/WIndowsPlatform.cs b/sttz.InstallUnity/Installer/Platforms/WIndowsPlatform.cs new file mode 100644 index 0000000..2af0dcd --- /dev/null +++ b/sttz.InstallUnity/Installer/Platforms/WIndowsPlatform.cs @@ -0,0 +1,282 @@ +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; + +namespace sttz.InstallUnity +{ + public class WIndowsPlatform : IInstallerPlatform + { + + private string INSTALL_PATH => Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.ProgramFiles), "Unity", "Hub", "Editor"); + + string GetUserApplicationSupportDirectory() + { + return Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), + UnityInstaller.PRODUCT_NAME); + } + + public Task GetCurrentPlatform() + { + return Task.FromResult(CachePlatform.Windows); + } + + public Task> GetInstallablePlatforms() + { + IEnumerable platforms = new CachePlatform[] { CachePlatform.Windows }; + return Task.FromResult(platforms); + } + + public string GetCacheDirectory() + { + return GetUserApplicationSupportDirectory(); + } + + public string GetConfigurationDirectory() + { + return GetUserApplicationSupportDirectory(); + } + + public string GetDownloadDirectory() + { + return Path.Combine(Path.GetTempPath(), UnityInstaller.PRODUCT_NAME); + } + + public async Task IsAdmin(CancellationToken cancellation = default) + { + return new WindowsPrincipal(WindowsIdentity.GetCurrent()).IsInRole(WindowsBuiltInRole.Administrator); + } + + public async Task 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(installationPaths, "Editor", "Unity.exe"); + if (executable == null) return default; + + var installation = new Installation() + { + version = installing.version, + executable = executable, + path = installationPaths + }; + + installing = default; + + return installation; + } + else + { + return default; + } + } + + public async Task> FindInstallations(CancellationToken cancellation = default) + { + var hubInstallations = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ProgramFiles), "Unity", "Hub", "Editor"); + var defaultUnityPath = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ProgramFiles), "Unity", "Editor"); + var installUnityPath = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ProgramFiles), "Unity", "install-unity"); + var unityCandidates = new List(); + if (Directory.Exists(hubInstallations)) + unityCandidates.AddRange(Directory.GetDirectories(hubInstallations)); + if (Directory.Exists(defaultUnityPath)) + unityCandidates.Add(defaultUnityPath); + if (Directory.Exists(installUnityPath)) + unityCandidates.AddRange(Directory.GetDirectories(installUnityPath)); + var unityInstallations = new List(); + foreach (var unityCandidate in unityCandidates) + { + var modulesJsonPath = Path.Combine(unityCandidate, "Editor", "Unity.exe"); + if (!File.Exists(modulesJsonPath)) + { + Logger.LogDebug($"No Unity.exe in {unityCandidate}\\Editor"); + continue; + } + var versionInfo = FileVersionInfo.GetVersionInfo(modulesJsonPath); + Logger.LogDebug($"Found version {versionInfo.ProductVersion}"); + unityInstallations.Add(new Installation { + executable = modulesJsonPath, + path = unityCandidate, + version = new UnityVersion(versionInfo.ProductVersion.Substring(0, versionInfo.ProductVersion.LastIndexOf("."))) + }); + } + return unityInstallations; + } + + public async Task Install(UnityInstaller.Queue queue, UnityInstaller.QueueItem item, CancellationToken cancellation = default) + { + if (item.package.name != PackageMetadata.EDITOR_PACKAGE_NAME && !installedEditor) + { + throw new InvalidOperationException("Cannot install package without installing editor first."); + } + + var installPath = GetInstallationPath(installing.version, installationPaths); + // TODO: start info runas + 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.name == PackageMetadata.EDITOR_PACKAGE_NAME) + { + installedEditor = true; + } + } + + public async Task MoveInstallation(Installation installation, string newPath, CancellationToken cancellation = default) + { + // do nothing + } + + 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; + this.installationPaths = installationPaths; + installedEditor = false; + + // Check for upgrading installation + if (!queue.items.Any(i => i.package.name == PackageMetadata.EDITOR_PACKAGE_NAME)) + { + 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; + } + } + + public async Task PromptForPasswordIfNecessary(CancellationToken cancellation = default) + { + // Don't care about password. The system will ask for elevated priviliges automatically + return 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}"); + } + } + + // -------- Helpers -------- + + ILogger Logger = UnityInstaller.CreateLogger(); + + VersionMetadata installing; + string installationPaths; + 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); + p.WaitForExit(); + return (p.ExitCode, p.StandardOutput.ReadToEnd(), p.StandardError.ReadToEnd()); + } catch (Exception) + { + Logger.LogError($"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(new char[] { ';' }, 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); + + return expanded; + } + } + + if (expanded != null) + { + return Helpers.GenerateUniqueFileName(expanded); + } + else + { + return Helpers.GenerateUniqueFileName(INSTALL_PATH); + } + } + + public async Task Run(Installation installation, IEnumerable 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(); + + while (!cmd.HasExited) + { + await Task.Delay(100); + } + + cmd.WaitForExit(); // Let stdout and stderr flush + Logger.LogInformation($"Unity exited with code {cmd.ExitCode}"); + Environment.Exit(cmd.ExitCode); + } + } +} diff --git a/sttz.InstallUnity/Installer/UnityInstaller.cs b/sttz.InstallUnity/Installer/UnityInstaller.cs index c1d8426..1d861a3 100644 --- a/sttz.InstallUnity/Installer/UnityInstaller.cs +++ b/sttz.InstallUnity/Installer/UnityInstaller.cs @@ -222,6 +222,9 @@ public UnityInstaller(Configuration config = null, string dataPath = null, ILogg if (System.Runtime.InteropServices.RuntimeInformation.IsOSPlatform(System.Runtime.InteropServices.OSPlatform.OSX)) { Logger.LogDebug("Loading platform integration for macOS"); Platform = new MacPlatform(); + } else if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) { + Logger.LogDebug("Loading platform integration for WIndows"); + Platform = new WIndowsPlatform(); } else { throw new NotImplementedException("Installer does not currently support the platform: " + System.Runtime.InteropServices.RuntimeInformation.OSDescription); } @@ -580,6 +583,8 @@ public async Task Process(InstallStep steps, Queue queue, Download string installationPaths = null; if (System.Runtime.InteropServices.RuntimeInformation.IsOSPlatform(System.Runtime.InteropServices.OSPlatform.OSX)) { installationPaths = Configuration.installPathMac; + } else if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) { + installationPaths = Configuration.installPathWindows; } else { throw new NotImplementedException("Installer does not currently support the platform: " + System.Runtime.InteropServices.RuntimeInformation.OSDescription); } From ac79827b2263ed0fd520bba56e9b26f59f1bd1df Mon Sep 17 00:00:00 2001 From: Eirik Wilhelmsen Date: Mon, 25 Jul 2022 15:11:48 +0200 Subject: [PATCH 18/36] Added Runtime Identifies to be able to build with .Net 6.0 --- Command/Command.csproj | 1 + Tests/Tests.csproj | 1 + sttz.InstallUnity/sttz.InstallUnity.csproj | 1 + 3 files changed, 3 insertions(+) diff --git a/Command/Command.csproj b/Command/Command.csproj index 0946436..3bf2c86 100644 --- a/Command/Command.csproj +++ b/Command/Command.csproj @@ -4,6 +4,7 @@ Exe net7.0 latest + win-x64 true true true diff --git a/Tests/Tests.csproj b/Tests/Tests.csproj index 4a59b27..8c34287 100644 --- a/Tests/Tests.csproj +++ b/Tests/Tests.csproj @@ -1,6 +1,7 @@ + win-x64 net7.0 latest false diff --git a/sttz.InstallUnity/sttz.InstallUnity.csproj b/sttz.InstallUnity/sttz.InstallUnity.csproj index 2a4defd..531423e 100644 --- a/sttz.InstallUnity/sttz.InstallUnity.csproj +++ b/sttz.InstallUnity/sttz.InstallUnity.csproj @@ -1,6 +1,7 @@ + win-x64 net7.0 latest sttz.InstallUnity From 4d85f4aa7708aaf936ea3fd868571dcd6ff8a556 Mon Sep 17 00:00:00 2001 From: Eirik Wilhelmsen Date: Mon, 25 Jul 2022 18:45:30 +0200 Subject: [PATCH 19/36] Fixed FindInstallations on Windows --- BUILD.md | 5 +++++ sttz.InstallUnity/Installer/Platforms/WIndowsPlatform.cs | 2 +- 2 files changed, 6 insertions(+), 1 deletion(-) create mode 100644 BUILD.md diff --git a/BUILD.md b/BUILD.md new file mode 100644 index 0000000..c09d0cc --- /dev/null +++ b/BUILD.md @@ -0,0 +1,5 @@ +# How to build + +```shell +dotnet publish -r win-x64 -c Release --self-contained --framework net6.0 +``` \ No newline at end of file diff --git a/sttz.InstallUnity/Installer/Platforms/WIndowsPlatform.cs b/sttz.InstallUnity/Installer/Platforms/WIndowsPlatform.cs index 2af0dcd..d0c9e9d 100644 --- a/sttz.InstallUnity/Installer/Platforms/WIndowsPlatform.cs +++ b/sttz.InstallUnity/Installer/Platforms/WIndowsPlatform.cs @@ -106,7 +106,7 @@ public async Task> FindInstallations(CancellationToken unityInstallations.Add(new Installation { executable = modulesJsonPath, path = unityCandidate, - version = new UnityVersion(versionInfo.ProductVersion.Substring(0, versionInfo.ProductVersion.LastIndexOf("."))) + version = new UnityVersion(versionInfo.ProductVersion.Substring(0, versionInfo.ProductVersion.LastIndexOf("_"))) // Versions are on format 2020.3.34f1_9a4c9c70452b }); } return unityInstallations; From 70918d342bc93487f02e20f3cb3ca7d992c14ad4 Mon Sep 17 00:00:00 2001 From: Eirik Wilhelmsen Date: Mon, 25 Jul 2022 19:25:08 +0200 Subject: [PATCH 20/36] Delete unity folder after uninstalling --- sttz.InstallUnity/Installer/Platforms/WIndowsPlatform.cs | 3 +++ 1 file changed, 3 insertions(+) diff --git a/sttz.InstallUnity/Installer/Platforms/WIndowsPlatform.cs b/sttz.InstallUnity/Installer/Platforms/WIndowsPlatform.cs index d0c9e9d..4252239 100644 --- a/sttz.InstallUnity/Installer/Platforms/WIndowsPlatform.cs +++ b/sttz.InstallUnity/Installer/Platforms/WIndowsPlatform.cs @@ -174,6 +174,9 @@ public async Task Uninstall(Installation installation, CancellationToken cancell { throw new Exception($"Could not uninstall Unity. output: {result.output}, error: {result.error}"); } + + // TODO: Should folder be deleted even when uninstall command returns with exitcode != 0? + Directory.Delete(installation.path, true); } // -------- Helpers -------- From 135d4923f43ef4409d6245014727d3ae051466d6 Mon Sep 17 00:00:00 2001 From: Eirik Wilhelmsen Date: Tue, 26 Jul 2022 08:09:23 +0200 Subject: [PATCH 21/36] Revert kb change --- sttz.InstallUnity/Installer/Helpers.cs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/sttz.InstallUnity/Installer/Helpers.cs b/sttz.InstallUnity/Installer/Helpers.cs index 0e93a80..14ce899 100644 --- a/sttz.InstallUnity/Installer/Helpers.cs +++ b/sttz.InstallUnity/Installer/Helpers.cs @@ -13,7 +13,7 @@ namespace sttz.InstallUnity public static class Helpers { static readonly string[] SizeNames = new string[] { - "MB", "GB", "TB", "PB", "EB", "ZB", "YB" + "KB", "MB", "GB", "TB", "PB", "EB", "ZB", "YB" }; /// @@ -24,8 +24,8 @@ public static class Helpers /// Size formatted with appropriate size suffix (B, KB, MB, etc) public static string FormatSize(long bytes, string format = "{0:0.00} {1}") { - if (bytes < 0) return "? KB"; - else if (bytes < 1024) return bytes + " KB"; + if (bytes < 0) return "? B"; + else if (bytes < 1024) return bytes + " B"; var size = bytes / 1024.0; var index = Math.Min((int)Math.Log(size, 1024), SizeNames.Length - 1); From c4a4bac1e22f886baa0493f173170e265b61f58c Mon Sep 17 00:00:00 2001 From: Eirik Wilhelmsen Date: Tue, 26 Jul 2022 08:22:29 +0200 Subject: [PATCH 22/36] Fix productversion split on older Unity versions --- sttz.InstallUnity/Installer/Platforms/WIndowsPlatform.cs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/sttz.InstallUnity/Installer/Platforms/WIndowsPlatform.cs b/sttz.InstallUnity/Installer/Platforms/WIndowsPlatform.cs index 4252239..d836952 100644 --- a/sttz.InstallUnity/Installer/Platforms/WIndowsPlatform.cs +++ b/sttz.InstallUnity/Installer/Platforms/WIndowsPlatform.cs @@ -102,11 +102,12 @@ public async Task> FindInstallations(CancellationToken continue; } var versionInfo = FileVersionInfo.GetVersionInfo(modulesJsonPath); + var splitCharacter = versionInfo.ProductVersion.Contains("_") ? '_' : '.'; // Versions are on format 2020.3.34f1_xxxx or 2020.3.34f1.xxxx Logger.LogDebug($"Found version {versionInfo.ProductVersion}"); unityInstallations.Add(new Installation { executable = modulesJsonPath, path = unityCandidate, - version = new UnityVersion(versionInfo.ProductVersion.Substring(0, versionInfo.ProductVersion.LastIndexOf("_"))) // Versions are on format 2020.3.34f1_9a4c9c70452b + version = new UnityVersion(versionInfo.ProductVersion.Substring(0, versionInfo.ProductVersion.LastIndexOf(splitCharacter))) }); } return unityInstallations; From 42d48235dd43016ee022124f361012a9ef9b6968 Mon Sep 17 00:00:00 2001 From: Eirik Wilhelmsen Date: Tue, 26 Jul 2022 09:00:13 +0200 Subject: [PATCH 23/36] Added try-catch to directory delete --- .../Installer/Platforms/WIndowsPlatform.cs | 34 +++++++++++++++++-- 1 file changed, 32 insertions(+), 2 deletions(-) diff --git a/sttz.InstallUnity/Installer/Platforms/WIndowsPlatform.cs b/sttz.InstallUnity/Installer/Platforms/WIndowsPlatform.cs index d836952..5d77370 100644 --- a/sttz.InstallUnity/Installer/Platforms/WIndowsPlatform.cs +++ b/sttz.InstallUnity/Installer/Platforms/WIndowsPlatform.cs @@ -176,8 +176,38 @@ public async Task Uninstall(Installation installation, CancellationToken cancell throw new Exception($"Could not uninstall Unity. output: {result.output}, error: {result.error}"); } - // TODO: Should folder be deleted even when uninstall command returns with exitcode != 0? - Directory.Delete(installation.path, true); + Logger.LogDebug($"Unity {installation.version} uninstalled successfully"); + + try + { + // TODO: Should folder be deleted even when uninstall command returns with exitcode != 0? + Logger.LogInformation($"Deleting folder path {installation.path} recursively"); + await Task.Delay(1000); // Wait for uninstallation + Directory.Delete(installation.path, true); + + Logger.LogDebug($"Folder path {installation.path} deleted"); + } + catch (UnauthorizedAccessException _) + { + try + { + // Sometimes access to folders and files are still in use by Unity uninstall, so we wait some more + await Task.Delay(3000); + Directory.Delete(installation.path, true); + + Logger.LogDebug($"Folder path {installation.path} deleted at second attempt"); + } + catch (Exception e) + { + Logger.LogError(e, $"Failed to delete folder path {installation.path} at second attempt"); + // Continue even though errors occur deleting file path + } + } + catch (Exception e) + { + Logger.LogError(e, $"Failed to delete folder path {installation.path}"); + // Continue even though errors occur deleting file path + } } // -------- Helpers -------- From 5a6bbdcd919c361d0eb9137462bb8b65a924be09 Mon Sep 17 00:00:00 2001 From: Eirik Wilhelmsen Date: Mon, 8 Aug 2022 12:48:23 +0200 Subject: [PATCH 24/36] Fix PR comments and cleanup code --- BUILD.md | 5 -- Readme.md | 8 ++- sttz.InstallUnity/Installer/Configuration.cs | 16 +++-- .../Installer/Platforms/WIndowsPlatform.cs | 60 ++++++++++--------- sttz.InstallUnity/Installer/UnityInstaller.cs | 13 ++-- 5 files changed, 57 insertions(+), 45 deletions(-) delete mode 100644 BUILD.md diff --git a/BUILD.md b/BUILD.md deleted file mode 100644 index c09d0cc..0000000 --- a/BUILD.md +++ /dev/null @@ -1,5 +0,0 @@ -# How to build - -```shell -dotnet publish -r win-x64 -c Release --self-contained --framework net6.0 -``` \ No newline at end of file diff --git a/Readme.md b/Readme.md index 98f68b1..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. diff --git a/sttz.InstallUnity/Installer/Configuration.cs b/sttz.InstallUnity/Installer/Configuration.cs index a029776..0751437 100644 --- a/sttz.InstallUnity/Installer/Configuration.cs +++ b/sttz.InstallUnity/Installer/Configuration.cs @@ -64,15 +64,19 @@ public class Configuration "/Applications/Unity {major}.{minor};" + "/Applications/Unity {major}.{minor}.{patch}{type}{build};" + "/Applications/Unity {major}.{minor}.{patch}{type}{build} ({hash})"; - - [Description("Windwos installation paths, separted by ; (first non-existing will be used, variables: {major} {minor} {patch} {type} {build} {hash}).")] - public string installPathWindows = Environment.GetFolderPath(Environment.SpecialFolder.ProgramFiles) + "\\Unity\\Hub\\Editor\\{major}.{minor}.{patch}{type}{build};"; + + + [Description("Windows installation paths, separted by ; (first non-existing will be used, variables: {major} {minor} {patch} {type} {build} {hash}).")] + public string installPathWindows = + Environment.GetFolderPath(Environment.SpecialFolder.ProgramFiles) + "\\Unity\\Hub\\Editor\\{major}.{minor}.{patch}{type}{build};" + + Environment.GetFolderPath(Environment.SpecialFolder.ProgramFiles) + "\\Unity\\Editor\\{major}.{minor}.{patch}{type}{build};" + + Environment.GetFolderPath(Environment.SpecialFolder.ProgramFiles) + "\\Unity\\{major}.{minor}.{patch}{type}{build};"; // -------- Serialization -------- - /// - /// Save the configuration as JSON to the given path. - /// + /// + /// Save the configuration as JSON to the given path. + /// public bool Save(string path) { try { diff --git a/sttz.InstallUnity/Installer/Platforms/WIndowsPlatform.cs b/sttz.InstallUnity/Installer/Platforms/WIndowsPlatform.cs index 5d77370..728988a 100644 --- a/sttz.InstallUnity/Installer/Platforms/WIndowsPlatform.cs +++ b/sttz.InstallUnity/Installer/Platforms/WIndowsPlatform.cs @@ -10,9 +10,8 @@ namespace sttz.InstallUnity { - public class WIndowsPlatform : IInstallerPlatform + public class WindowsPlatform : IInstallerPlatform { - private string INSTALL_PATH => Path.Combine( Environment.GetFolderPath(Environment.SpecialFolder.ProgramFiles), "Unity", "Hub", "Editor"); @@ -50,7 +49,9 @@ public string GetDownloadDirectory() public async Task IsAdmin(CancellationToken cancellation = default) { - return new WindowsPrincipal(WindowsIdentity.GetCurrent()).IsInRole(WindowsBuiltInRole.Administrator); +#pragma warning disable CA1416 // Validate platform compatibility + return await Task.FromResult(new WindowsPrincipal(WindowsIdentity.GetCurrent()).IsInRole(WindowsBuiltInRole.Administrator)); +#pragma warning restore CA1416 // Validate platform compatibility } public async Task CompleteInstall(bool aborted, CancellationToken cancellation = default) @@ -72,7 +73,7 @@ public async Task CompleteInstall(bool aborted, CancellationToken installing = default; - return installation; + return await Task.FromResult(installation); } else { @@ -110,7 +111,7 @@ public async Task> FindInstallations(CancellationToken version = new UnityVersion(versionInfo.ProductVersion.Substring(0, versionInfo.ProductVersion.LastIndexOf(splitCharacter))) }); } - return unityInstallations; + return await Task.FromResult(unityInstallations); } public async Task Install(UnityInstaller.Queue queue, UnityInstaller.QueueItem item, CancellationToken cancellation = default) @@ -121,7 +122,6 @@ public async Task Install(UnityInstaller.Queue queue, UnityInstaller.QueueItem i } var installPath = GetInstallationPath(installing.version, installationPaths); - // TODO: start info runas var result = await RunAsAdmin(item.filePath, $"/S /D={installPath}"); if (result.exitCode != 0) { @@ -134,9 +134,10 @@ public async Task Install(UnityInstaller.Queue queue, UnityInstaller.QueueItem i } } - public async Task MoveInstallation(Installation installation, string newPath, CancellationToken cancellation = default) + public Task MoveInstallation(Installation installation, string newPath, CancellationToken cancellation = default) { - // do nothing + // 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) @@ -165,7 +166,7 @@ public async Task PrepareInstall(UnityInstaller.Queue queue, string installation public async Task PromptForPasswordIfNecessary(CancellationToken cancellation = default) { // Don't care about password. The system will ask for elevated priviliges automatically - return true; + return await Task.FromResult(true); } public async Task Uninstall(Installation installation, CancellationToken cancellation = default) @@ -173,46 +174,52 @@ public async Task Uninstall(Installation installation, CancellationToken cancell 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}"); + throw new Exception($"Could not uninstall Unity. output: {result.output}, error: {result.error}."); } - Logger.LogDebug($"Unity {installation.version} uninstalled successfully"); + // 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 { - // TODO: Should folder be deleted even when uninstall command returns with exitcode != 0? - Logger.LogInformation($"Deleting folder path {installation.path} recursively"); - await Task.Delay(1000); // Wait for uninstallation + 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"); + Logger.LogDebug($"Folder path {installation.path} deleted."); + deletedFolder = true; } - catch (UnauthorizedAccessException _) + catch (UnauthorizedAccessException) { try { - // Sometimes access to folders and files are still in use by Unity uninstall, so we wait some more - await Task.Delay(3000); + // 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"); + Logger.LogDebug($"Folder path {installation.path} deleted at second attempt."); + deletedFolder = true; } catch (Exception e) { - Logger.LogError(e, $"Failed to delete folder path {installation.path} at second attempt"); + 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 (Exception e) { - Logger.LogError(e, $"Failed to delete folder path {installation.path}"); + 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(); + ILogger Logger = UnityInstaller.CreateLogger(); VersionMetadata installing; string installationPaths; @@ -233,7 +240,7 @@ public async Task Uninstall(Installation installation, CancellationToken cancell try { var p = Process.Start(startInfo); - p.WaitForExit(); + await p.WaitForExitAsync(); return (p.ExitCode, p.StandardOutput.ReadToEnd(), p.StandardError.ReadToEnd()); } catch (Exception) { @@ -302,13 +309,8 @@ public async Task Run(Installation installation, IEnumerable arguments, cmd.Start(); cmd.BeginOutputReadLine(); cmd.BeginErrorReadLine(); + await cmd.WaitForExitAsync(); // Let stdout and stderr flush - while (!cmd.HasExited) - { - await Task.Delay(100); - } - - cmd.WaitForExit(); // Let stdout and stderr flush Logger.LogInformation($"Unity exited with code {cmd.ExitCode}"); Environment.Exit(cmd.ExitCode); } diff --git a/sttz.InstallUnity/Installer/UnityInstaller.cs b/sttz.InstallUnity/Installer/UnityInstaller.cs index 1d861a3..41519f7 100644 --- a/sttz.InstallUnity/Installer/UnityInstaller.cs +++ b/sttz.InstallUnity/Installer/UnityInstaller.cs @@ -224,7 +224,7 @@ public UnityInstaller(Configuration config = null, string dataPath = null, ILogg Platform = new MacPlatform(); } else if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) { Logger.LogDebug("Loading platform integration for WIndows"); - Platform = new WIndowsPlatform(); + Platform = new WindowsPlatform(); } else { throw new NotImplementedException("Installer does not currently support the platform: " + System.Runtime.InteropServices.RuntimeInformation.OSDescription); } @@ -711,9 +711,14 @@ public void CleanUpDownloads(Queue queue) var fileName = Path.GetFileName(path); if (fileName == ".DS_Store" || fileName == "thumbs.db" || fileName == "desktop.ini") continue; - - if (!packageFilePaths.Contains(path)) { - throw new Exception("Unexpected file in downloads folder: " + path); + + if (!packageFileNames.Contains(path)) { + if (RuntimeInformation.IsOSPlatform(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); + } } } From 39adee421fe796d7261b784107deafedf4fdafe9 Mon Sep 17 00:00:00 2001 From: Eirik Wilhelmsen Date: Mon, 8 Aug 2022 13:40:44 +0200 Subject: [PATCH 25/36] Fixed misc --- Command/Command.csproj | 6 +- Tests/Tests.csproj | 2 +- sttz.InstallUnity/Installer/Configuration.cs | 6 +- .../Installer/Platforms/MacPlatform.cs | 13 +- .../Installer/Platforms/WIndowsPlatform.cs | 483 +++++++++--------- sttz.InstallUnity/sttz.InstallUnity.csproj | 4 +- 6 files changed, 256 insertions(+), 258 deletions(-) diff --git a/Command/Command.csproj b/Command/Command.csproj index 3bf2c86..c38ed7e 100644 --- a/Command/Command.csproj +++ b/Command/Command.csproj @@ -2,9 +2,9 @@ Exe - net7.0 - latest - win-x64 + net6.0 + win-x64;osx-x64 + 7.1 true true true diff --git a/Tests/Tests.csproj b/Tests/Tests.csproj index 8c34287..05cb0df 100644 --- a/Tests/Tests.csproj +++ b/Tests/Tests.csproj @@ -1,8 +1,8 @@ - win-x64 net7.0 + win-x64;osx-x64 latest false diff --git a/sttz.InstallUnity/Installer/Configuration.cs b/sttz.InstallUnity/Installer/Configuration.cs index 0751437..45c4e6d 100644 --- a/sttz.InstallUnity/Installer/Configuration.cs +++ b/sttz.InstallUnity/Installer/Configuration.cs @@ -74,9 +74,9 @@ public class Configuration // -------- Serialization -------- - /// - /// Save the configuration as JSON to the given path. - /// + /// + /// Save the configuration as JSON to the given path. + /// public bool Save(string path) { try { diff --git a/sttz.InstallUnity/Installer/Platforms/MacPlatform.cs b/sttz.InstallUnity/Installer/Platforms/MacPlatform.cs index 308790e..e96811d 100644 --- a/sttz.InstallUnity/Installer/Platforms/MacPlatform.cs +++ b/sttz.InstallUnity/Installer/Platforms/MacPlatform.cs @@ -350,10 +350,7 @@ public async Task Run(Installation installation, IEnumerable 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")) { @@ -381,12 +378,7 @@ public async Task Run(Installation installation, IEnumerable 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); } @@ -755,5 +747,4 @@ async Task CheckIsRoot(bool withSudo, CancellationToken cancellation) } } } - } diff --git a/sttz.InstallUnity/Installer/Platforms/WIndowsPlatform.cs b/sttz.InstallUnity/Installer/Platforms/WIndowsPlatform.cs index 728988a..2871af1 100644 --- a/sttz.InstallUnity/Installer/Platforms/WIndowsPlatform.cs +++ b/sttz.InstallUnity/Installer/Platforms/WIndowsPlatform.cs @@ -10,309 +10,316 @@ namespace sttz.InstallUnity { - public class WindowsPlatform : IInstallerPlatform - { - private string INSTALL_PATH => Path.Combine( - Environment.GetFolderPath(Environment.SpecialFolder.ProgramFiles), "Unity", "Hub", "Editor"); - string GetUserApplicationSupportDirectory() - { - return Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), - UnityInstaller.PRODUCT_NAME); - } +/// +/// Platform-specific installer code for Windows. +/// +public class WindowsPlatform : IInstallerPlatform +{ + private string INSTALL_PATH => Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.ProgramFiles), "Unity", "Hub", "Editor"); - public Task GetCurrentPlatform() - { - return Task.FromResult(CachePlatform.Windows); - } + string GetUserApplicationSupportDirectory() + { + return Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), + UnityInstaller.PRODUCT_NAME); + } - public Task> GetInstallablePlatforms() - { - IEnumerable platforms = new CachePlatform[] { CachePlatform.Windows }; - return Task.FromResult(platforms); - } + public Task GetCurrentPlatform() + { + return Task.FromResult(CachePlatform.Windows); + } - public string GetCacheDirectory() - { - return GetUserApplicationSupportDirectory(); - } + public Task> GetInstallablePlatforms() + { + IEnumerable platforms = new CachePlatform[] { CachePlatform.Windows }; + return Task.FromResult(platforms); + } - public string GetConfigurationDirectory() - { - return GetUserApplicationSupportDirectory(); - } + public string GetCacheDirectory() + { + return GetUserApplicationSupportDirectory(); + } - public string GetDownloadDirectory() - { - return Path.Combine(Path.GetTempPath(), UnityInstaller.PRODUCT_NAME); - } + public string GetConfigurationDirectory() + { + return GetUserApplicationSupportDirectory(); + } - public async Task IsAdmin(CancellationToken cancellation = default) - { + public string GetDownloadDirectory() + { + return Path.Combine(Path.GetTempPath(), UnityInstaller.PRODUCT_NAME); + } + + public Task IsAdmin(CancellationToken cancellation = default) + { #pragma warning disable CA1416 // Validate platform compatibility - return await Task.FromResult(new WindowsPrincipal(WindowsIdentity.GetCurrent()).IsInRole(WindowsBuiltInRole.Administrator)); + return Task.FromResult(new WindowsPrincipal(WindowsIdentity.GetCurrent()).IsInRole(WindowsBuiltInRole.Administrator)); #pragma warning restore CA1416 // Validate platform compatibility - } + } - public async Task CompleteInstall(bool aborted, CancellationToken cancellation = default) + public Task CompleteInstall(bool aborted, CancellationToken cancellation = default) + { + if (!installing.version.IsValid) + throw new InvalidOperationException("Not installing any version to complete"); + + if (!aborted) { - if (!installing.version.IsValid) - throw new InvalidOperationException("Not installing any version to complete"); + var executable = Path.Combine(installationPaths, "Editor", "Unity.exe"); + if (executable == null) return default; - if (!aborted) + var installation = new Installation() { - var executable = Path.Combine(installationPaths, "Editor", "Unity.exe"); - if (executable == null) return default; - - var installation = new Installation() - { - version = installing.version, - executable = executable, - path = installationPaths - }; + version = installing.version, + executable = executable, + path = installationPaths + }; - installing = default; + installing = default; - return await Task.FromResult(installation); - } - else - { - return default; - } + return Task.FromResult(installation); } - - public async Task> FindInstallations(CancellationToken cancellation = default) + else { - var hubInstallations = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ProgramFiles), "Unity", "Hub", "Editor"); - var defaultUnityPath = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ProgramFiles), "Unity", "Editor"); - var installUnityPath = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ProgramFiles), "Unity", "install-unity"); - var unityCandidates = new List(); - if (Directory.Exists(hubInstallations)) - unityCandidates.AddRange(Directory.GetDirectories(hubInstallations)); - if (Directory.Exists(defaultUnityPath)) - unityCandidates.Add(defaultUnityPath); - if (Directory.Exists(installUnityPath)) - unityCandidates.AddRange(Directory.GetDirectories(installUnityPath)); - var unityInstallations = new List(); - foreach (var unityCandidate in unityCandidates) - { - var modulesJsonPath = Path.Combine(unityCandidate, "Editor", "Unity.exe"); - if (!File.Exists(modulesJsonPath)) - { - Logger.LogDebug($"No Unity.exe in {unityCandidate}\\Editor"); - continue; - } - var versionInfo = FileVersionInfo.GetVersionInfo(modulesJsonPath); - var splitCharacter = versionInfo.ProductVersion.Contains("_") ? '_' : '.'; // Versions are on format 2020.3.34f1_xxxx or 2020.3.34f1.xxxx - Logger.LogDebug($"Found version {versionInfo.ProductVersion}"); - unityInstallations.Add(new Installation { - executable = modulesJsonPath, - path = unityCandidate, - version = new UnityVersion(versionInfo.ProductVersion.Substring(0, versionInfo.ProductVersion.LastIndexOf(splitCharacter))) - }); - } - return await Task.FromResult(unityInstallations); + return default; } + } - public async Task Install(UnityInstaller.Queue queue, UnityInstaller.QueueItem item, CancellationToken cancellation = default) + public async Task> FindInstallations(CancellationToken cancellation = default) + { + var hubInstallations = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ProgramFiles), "Unity", "Hub", "Editor"); + var defaultUnityPath = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ProgramFiles), "Unity", "Editor"); + var installUnityPath = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ProgramFiles), "Unity", "install-unity"); + + var unityCandidates = new List(); + if (Directory.Exists(hubInstallations)) + unityCandidates.AddRange(Directory.GetDirectories(hubInstallations)); + if (Directory.Exists(defaultUnityPath)) + unityCandidates.Add(defaultUnityPath); + if (Directory.Exists(installUnityPath)) + unityCandidates.AddRange(Directory.GetDirectories(installUnityPath)); + + var unityInstallations = new List(); + foreach (var unityCandidate in unityCandidates) { - if (item.package.name != PackageMetadata.EDITOR_PACKAGE_NAME && !installedEditor) + var modulesJsonPath = Path.Combine(unityCandidate, "Editor", "Unity.exe"); + if (!File.Exists(modulesJsonPath)) { - throw new InvalidOperationException("Cannot install package without installing editor first."); - } - - var installPath = GetInstallationPath(installing.version, installationPaths); - 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}"); + Logger.LogDebug($"No Unity.exe in {unityCandidate}\\Editor"); + continue; } + var versionInfo = FileVersionInfo.GetVersionInfo(modulesJsonPath); + var splitCharacter = versionInfo.ProductVersion.Contains("_") ? '_' : '.'; // Versions are on format 2020.3.34f1_xxxx or 2020.3.34f1.xxxx + + Logger.LogDebug($"Found version {versionInfo.ProductVersion}"); + unityInstallations.Add(new Installation { + executable = modulesJsonPath, + path = unityCandidate, + version = new UnityVersion(versionInfo.ProductVersion.Substring(0, versionInfo.ProductVersion.LastIndexOf(splitCharacter))) + }); + } + return await Task.FromResult(unityInstallations); + } - if (item.package.name == PackageMetadata.EDITOR_PACKAGE_NAME) - { - installedEditor = true; - } + public async Task Install(UnityInstaller.Queue queue, UnityInstaller.QueueItem item, CancellationToken cancellation = default) + { + if (item.package.name != PackageMetadata.EDITOR_PACKAGE_NAME && !installedEditor) + { + throw new InvalidOperationException("Cannot install package without installing editor first."); } - public Task MoveInstallation(Installation installation, string newPath, CancellationToken cancellation = default) + var installPath = GetInstallationPath(installing.version, installationPaths); + var result = await RunAsAdmin(item.filePath, $"/S /D={installPath}"); + if (result.exitCode != 0) { - // Don't need to move installation on Windows, Unity is installed in the correct location automatically. - return Task.CompletedTask; + throw new Exception($"Failed to install {item.filePath} output: {result.output} / {result.error}"); } - public async Task PrepareInstall(UnityInstaller.Queue queue, string installationPaths, CancellationToken cancellation = default) + if (item.package.name == PackageMetadata.EDITOR_PACKAGE_NAME) { - if (installing.version.IsValid) - throw new InvalidOperationException($"Already installing another version: {installing.version}"); + installedEditor = true; + } + } - installing = queue.metadata; - this.installationPaths = installationPaths; - installedEditor = false; + 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}"); - // Check for upgrading installation - if (!queue.items.Any(i => i.package.name == PackageMetadata.EDITOR_PACKAGE_NAME)) + installing = queue.metadata; + this.installationPaths = installationPaths; + installedEditor = false; + + // Check for upgrading installation + if (!queue.items.Any(i => i.package.name == PackageMetadata.EDITOR_PACKAGE_NAME)) + { + var installs = await FindInstallations(cancellation); + var existingInstall = installs.Where(i => i.version == queue.metadata.version).FirstOrDefault(); + if (existingInstall == null) { - 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; + throw new InvalidOperationException($"Not installing editor but version {queue.metadata.version} not already installed."); } + + installedEditor = true; } + } + + public Task PromptForPasswordIfNecessary(CancellationToken cancellation = default) + { + // Don't care about password. The system will ask for elevated priviliges automatically + return Task.FromResult(true); + } - public async Task PromptForPasswordIfNecessary(CancellationToken cancellation = default) + 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) { - // Don't care about password. The system will ask for elevated priviliges automatically - return await Task.FromResult(true); + throw new Exception($"Could not uninstall Unity. output: {result.output}, error: {result.error}."); } - 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; - // 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 { - 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 + // 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."); + Logger.LogDebug($"Folder path {installation.path} deleted at second attempt."); 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 (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 (Exception e) { - Logger.LogError(e, $"Failed to delete folder path {installation.path}."); + Logger.LogError(e, $"Failed to delete folder path {installation.path} at second attempt. Ignoring excess files."); // 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")}."); } + 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 -------- + // -------- Helpers -------- - ILogger Logger = UnityInstaller.CreateLogger(); + ILogger Logger = UnityInstaller.CreateLogger(); - VersionMetadata installing; - string installationPaths; - bool installedEditor; + VersionMetadata installing; + string installationPaths; + bool installedEditor; - async Task<(int exitCode, string output, string error)> RunAsAdmin(string filename, string arguments) + 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 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) - { - Logger.LogError($"Execution of {filename} with {arguments} failed!"); - throw; - } + var p = Process.Start(startInfo); + await p.WaitForExitAsync(); + return (p.ExitCode, p.StandardOutput.ReadToEnd(), p.StandardError.ReadToEnd()); + } catch (Exception) + { + Logger.LogError($"Execution of {filename} with {arguments} failed!"); + throw; } + } - string GetInstallationPath(UnityVersion version, string installationPaths) + string GetInstallationPath(UnityVersion version, string installationPaths) + { + string expanded = null; + if (!string.IsNullOrEmpty(installationPaths)) { - string expanded = null; - if (!string.IsNullOrEmpty(installationPaths)) - { - var comparison = StringComparison.OrdinalIgnoreCase; - var paths = installationPaths.Split(new char[] { ';' }, 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); - - return expanded; - } - } - - if (expanded != null) - { - return Helpers.GenerateUniqueFileName(expanded); - } - else + var comparison = StringComparison.OrdinalIgnoreCase; + var paths = installationPaths.Split(new char[] { ';' }, StringSplitOptions.RemoveEmptyEntries); + foreach (var path in paths) { - return Helpers.GenerateUniqueFileName(INSTALL_PATH); + 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); + + return expanded; } } - public async Task Run(Installation installation, IEnumerable arguments, bool child) + if (expanded != null) { - // 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 + return Helpers.GenerateUniqueFileName(expanded); + } + else + { + return Helpers.GenerateUniqueFileName(INSTALL_PATH); + } + } - Logger.LogInformation($"Unity exited with code {cmd.ExitCode}"); - Environment.Exit(cmd.ExitCode); + public async Task Run(Installation installation, IEnumerable 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/sttz.InstallUnity.csproj b/sttz.InstallUnity/sttz.InstallUnity.csproj index 531423e..57f618a 100644 --- a/sttz.InstallUnity/sttz.InstallUnity.csproj +++ b/sttz.InstallUnity/sttz.InstallUnity.csproj @@ -1,8 +1,8 @@ - win-x64 - net7.0 + net7.0 + win-x64;osx-x64 latest sttz.InstallUnity From 069df99324f9dde0afcc7a5bfc7f1517ef1d8a50 Mon Sep 17 00:00:00 2001 From: Eirik Wilhelmsen Date: Mon, 8 Aug 2022 14:45:40 +0200 Subject: [PATCH 26/36] Fix windows path and error handling --- sttz.InstallUnity/Installer/Configuration.cs | 5 +---- .../Installer/Platforms/WIndowsPlatform.cs | 12 ++++++++++-- 2 files changed, 11 insertions(+), 6 deletions(-) diff --git a/sttz.InstallUnity/Installer/Configuration.cs b/sttz.InstallUnity/Installer/Configuration.cs index 45c4e6d..2491918 100644 --- a/sttz.InstallUnity/Installer/Configuration.cs +++ b/sttz.InstallUnity/Installer/Configuration.cs @@ -67,10 +67,7 @@ public class Configuration [Description("Windows installation paths, separted by ; (first non-existing will be used, variables: {major} {minor} {patch} {type} {build} {hash}).")] - public string installPathWindows = - Environment.GetFolderPath(Environment.SpecialFolder.ProgramFiles) + "\\Unity\\Hub\\Editor\\{major}.{minor}.{patch}{type}{build};" - + Environment.GetFolderPath(Environment.SpecialFolder.ProgramFiles) + "\\Unity\\Editor\\{major}.{minor}.{patch}{type}{build};" - + Environment.GetFolderPath(Environment.SpecialFolder.ProgramFiles) + "\\Unity\\{major}.{minor}.{patch}{type}{build};"; + public string installPathWindows = Environment.GetFolderPath(Environment.SpecialFolder.ProgramFiles) + "\\Unity\\Hub\\Editor\\{major}.{minor}.{patch}{type}{build};"; // -------- Serialization -------- diff --git a/sttz.InstallUnity/Installer/Platforms/WIndowsPlatform.cs b/sttz.InstallUnity/Installer/Platforms/WIndowsPlatform.cs index 2871af1..8f38aaa 100644 --- a/sttz.InstallUnity/Installer/Platforms/WIndowsPlatform.cs +++ b/sttz.InstallUnity/Installer/Platforms/WIndowsPlatform.cs @@ -209,12 +209,20 @@ public async Task Uninstall(Installation installation, CancellationToken cancell 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}."); @@ -249,9 +257,9 @@ public async Task Uninstall(Installation installation, CancellationToken cancell var p = Process.Start(startInfo); await p.WaitForExitAsync(); return (p.ExitCode, p.StandardOutput.ReadToEnd(), p.StandardError.ReadToEnd()); - } catch (Exception) + } catch (Exception e) { - Logger.LogError($"Execution of {filename} with {arguments} failed!"); + Logger.LogError(e, $"Execution of {filename} with {arguments} failed!"); throw; } } From 20deaf66fbad5b7791b59806f095da57b3bd9c52 Mon Sep 17 00:00:00 2001 From: Eirik Wilhelmsen Date: Mon, 8 Aug 2022 14:52:40 +0200 Subject: [PATCH 27/36] Fixed case in filename --- .../Platforms/{WIndowsPlatform.cs => WindowsPlatform.cs} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename sttz.InstallUnity/Installer/Platforms/{WIndowsPlatform.cs => WindowsPlatform.cs} (100%) diff --git a/sttz.InstallUnity/Installer/Platforms/WIndowsPlatform.cs b/sttz.InstallUnity/Installer/Platforms/WindowsPlatform.cs similarity index 100% rename from sttz.InstallUnity/Installer/Platforms/WIndowsPlatform.cs rename to sttz.InstallUnity/Installer/Platforms/WindowsPlatform.cs From 35e57150243ca9858a0328ba5f9ec01926a27c59 Mon Sep 17 00:00:00 2001 From: Adrian Stutz Date: Sun, 4 Sep 2022 17:44:37 +0200 Subject: [PATCH 28/36] Fix capitalization --- sttz.InstallUnity/Installer/UnityInstaller.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/sttz.InstallUnity/Installer/UnityInstaller.cs b/sttz.InstallUnity/Installer/UnityInstaller.cs index 41519f7..18d95a3 100644 --- a/sttz.InstallUnity/Installer/UnityInstaller.cs +++ b/sttz.InstallUnity/Installer/UnityInstaller.cs @@ -223,7 +223,7 @@ public UnityInstaller(Configuration config = null, string dataPath = null, ILogg Logger.LogDebug("Loading platform integration for macOS"); Platform = new MacPlatform(); } else if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) { - Logger.LogDebug("Loading platform integration for WIndows"); + Logger.LogDebug("Loading platform integration for Windows"); Platform = new WindowsPlatform(); } else { throw new NotImplementedException("Installer does not currently support the platform: " + System.Runtime.InteropServices.RuntimeInformation.OSDescription); From 213418e9b75dbb2cf2f164de9ad86ef32a95dcfa Mon Sep 17 00:00:00 2001 From: Adrian Stutz Date: Sun, 4 Sep 2022 20:03:10 +0200 Subject: [PATCH 29/36] [Win] Path handling updates - Check we're not running as X86 on a non-X86 system to avoid using the "Program Files (x86)" folder - Default to installing directly in "Program Files" and not using the Unity Hub install location (like on macOS) - Use "{ProrgamFiles}" variable in configuration to make settings more portable --- sttz.InstallUnity/Installer/Configuration.cs | 7 +- .../Installer/Platforms/WindowsPlatform.cs | 87 ++++++++++++------- 2 files changed, 61 insertions(+), 33 deletions(-) diff --git a/sttz.InstallUnity/Installer/Configuration.cs b/sttz.InstallUnity/Installer/Configuration.cs index 2491918..edfbde3 100644 --- a/sttz.InstallUnity/Installer/Configuration.cs +++ b/sttz.InstallUnity/Installer/Configuration.cs @@ -66,8 +66,11 @@ public class Configuration + "/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}).")] - public string installPathWindows = Environment.GetFolderPath(Environment.SpecialFolder.ProgramFiles) + "\\Unity\\Hub\\Editor\\{major}.{minor}.{patch}{type}{build};"; + [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};" + + "{ProgramFiles}\\Unity {major}.{minor}.{patch}{type}{build};" + + "{ProgramFiles}\\Unity {major}.{minor}.{patch}{type}{build} ({hash});"; // -------- Serialization -------- diff --git a/sttz.InstallUnity/Installer/Platforms/WindowsPlatform.cs b/sttz.InstallUnity/Installer/Platforms/WindowsPlatform.cs index 8f38aaa..8d77823 100644 --- a/sttz.InstallUnity/Installer/Platforms/WindowsPlatform.cs +++ b/sttz.InstallUnity/Installer/Platforms/WindowsPlatform.cs @@ -4,6 +4,7 @@ using System.Diagnostics; using System.IO; using System.Linq; +using System.Runtime.InteropServices; using System.Security.Principal; using System.Threading; using System.Threading.Tasks; @@ -16,10 +17,37 @@ namespace sttz.InstallUnity /// public class WindowsPlatform : IInstallerPlatform { - private string INSTALL_PATH => Path.Combine( - Environment.GetFolderPath(Environment.SpecialFolder.ProgramFiles), "Unity", "Hub", "Editor"); + /// + /// Default installation path. + /// + static readonly string INSTALL_PATH = Path.Combine(ProgramFilesPath, "Unity"); + + /// + /// Paths where Unity installations are searched in. + /// + /// + static readonly string[] INSTALL_LOCATIONS = new string[] { + ProgramFilesPath, + Path.Combine(ProgramFilesPath, "Unity", "Editor"), + Path.Combine(ProgramFilesPath, "Unity", "Hub", "Editor"), + }; + + /// + /// Path to the program files directory. + /// + static string ProgramFilesPath { get { + if (RuntimeInformation.OSArchitecture != Architecture.X86 + && RuntimeInformation.ProcessArchitecture == 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 GetUserApplicationSupportDirectory() + string GetLocalApplicationDataDirectory() { return Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), UnityInstaller.PRODUCT_NAME); @@ -38,12 +66,12 @@ public Task> GetInstallablePlatforms() public string GetCacheDirectory() { - return GetUserApplicationSupportDirectory(); + return GetLocalApplicationDataDirectory(); } public string GetConfigurationDirectory() { - return GetUserApplicationSupportDirectory(); + return GetLocalApplicationDataDirectory(); } public string GetDownloadDirectory() @@ -87,37 +115,33 @@ public Task CompleteInstall(bool aborted, CancellationToken cancel public async Task> FindInstallations(CancellationToken cancellation = default) { - var hubInstallations = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ProgramFiles), "Unity", "Hub", "Editor"); - var defaultUnityPath = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ProgramFiles), "Unity", "Editor"); - var installUnityPath = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ProgramFiles), "Unity", "install-unity"); - - var unityCandidates = new List(); - if (Directory.Exists(hubInstallations)) - unityCandidates.AddRange(Directory.GetDirectories(hubInstallations)); - if (Directory.Exists(defaultUnityPath)) - unityCandidates.Add(defaultUnityPath); - if (Directory.Exists(installUnityPath)) - unityCandidates.AddRange(Directory.GetDirectories(installUnityPath)); - var unityInstallations = new List(); - foreach (var unityCandidate in unityCandidates) + foreach (var installPath in INSTALL_LOCATIONS) { - var modulesJsonPath = Path.Combine(unityCandidate, "Editor", "Unity.exe"); - if (!File.Exists(modulesJsonPath)) - { - Logger.LogDebug($"No Unity.exe in {unityCandidate}\\Editor"); + if (!Directory.Exists(installPath)) continue; + + 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}"); + unityInstallations.Add(new Installation { + executable = unityExePath, + path = unityCandidate, + version = new UnityVersion(versionInfo.ProductVersion.Substring(0, versionInfo.ProductVersion.LastIndexOf(splitCharacter))) + }); } - var versionInfo = FileVersionInfo.GetVersionInfo(modulesJsonPath); - var splitCharacter = versionInfo.ProductVersion.Contains("_") ? '_' : '.'; // Versions are on format 2020.3.34f1_xxxx or 2020.3.34f1.xxxx - - Logger.LogDebug($"Found version {versionInfo.ProductVersion}"); - unityInstallations.Add(new Installation { - executable = modulesJsonPath, - path = unityCandidate, - version = new UnityVersion(versionInfo.ProductVersion.Substring(0, versionInfo.ProductVersion.LastIndexOf(splitCharacter))) - }); } + return await Task.FromResult(unityInstallations); } @@ -280,6 +304,7 @@ string GetInstallationPath(UnityVersion version, string installationPaths) 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; } From 07b91e6e33ee33d5cab8acc265b4c49bd56092e3 Mon Sep 17 00:00:00 2001 From: Adrian Stutz Date: Sun, 4 Sep 2022 20:06:03 +0200 Subject: [PATCH 30/36] [Win] Fix check that is always true, actually check if Unity editor exe exists as expected path --- sttz.InstallUnity/Installer/Platforms/WindowsPlatform.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/sttz.InstallUnity/Installer/Platforms/WindowsPlatform.cs b/sttz.InstallUnity/Installer/Platforms/WindowsPlatform.cs index 8d77823..104c8e4 100644 --- a/sttz.InstallUnity/Installer/Platforms/WindowsPlatform.cs +++ b/sttz.InstallUnity/Installer/Platforms/WindowsPlatform.cs @@ -94,7 +94,7 @@ public Task CompleteInstall(bool aborted, CancellationToken cancel if (!aborted) { var executable = Path.Combine(installationPaths, "Editor", "Unity.exe"); - if (executable == null) return default; + if (!File.Exists(executable)) return default; var installation = new Installation() { From 8b284de6cc317a4aea439d8e81b49a3bbca68eca Mon Sep 17 00:00:00 2001 From: Adrian Stutz Date: Mon, 5 Sep 2022 20:42:09 +0200 Subject: [PATCH 31/36] [Win] Make paths configurable in which Unity installations are searched --- sttz.InstallUnity/Installer/Configuration.cs | 7 ++++- .../Installer/IInstallerPlatform.cs | 9 +++++++ .../Installer/Platforms/MacPlatform.cs | 5 ++++ .../Installer/Platforms/WindowsPlatform.cs | 26 ++++++++++++++++--- 4 files changed, 42 insertions(+), 5 deletions(-) diff --git a/sttz.InstallUnity/Installer/Configuration.cs b/sttz.InstallUnity/Installer/Configuration.cs index edfbde3..ffeecb5 100644 --- a/sttz.InstallUnity/Installer/Configuration.cs +++ b/sttz.InstallUnity/Installer/Configuration.cs @@ -65,13 +65,18 @@ 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};" + "{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 -------- /// diff --git a/sttz.InstallUnity/Installer/IInstallerPlatform.cs b/sttz.InstallUnity/Installer/IInstallerPlatform.cs index c6b260a..dd60f81 100644 --- a/sttz.InstallUnity/Installer/IInstallerPlatform.cs +++ b/sttz.InstallUnity/Installer/IInstallerPlatform.cs @@ -48,6 +48,15 @@ public interface IInstallerPlatform /// string GetConfigurationDirectory(); + /// + /// Set the configuration instance to use. + /// + /// + /// Note that other methods might be called before the configuraiton + /// is set, namely . + /// + void SetConfiguration(Configuration configuration); + /// /// The directory where cache files are stored. /// diff --git a/sttz.InstallUnity/Installer/Platforms/MacPlatform.cs b/sttz.InstallUnity/Installer/Platforms/MacPlatform.cs index e96811d..ff477d4 100644 --- a/sttz.InstallUnity/Installer/Platforms/MacPlatform.cs +++ b/sttz.InstallUnity/Installer/Platforms/MacPlatform.cs @@ -89,6 +89,11 @@ public string GetConfigurationDirectory() return GetUserApplicationSupportDirectory(); } + public void SetConfiguration(Configuration configuration) + { + // Not used + } + public string GetCacheDirectory() { return GetUserApplicationSupportDirectory(); diff --git a/sttz.InstallUnity/Installer/Platforms/WindowsPlatform.cs b/sttz.InstallUnity/Installer/Platforms/WindowsPlatform.cs index 104c8e4..4f7d405 100644 --- a/sttz.InstallUnity/Installer/Platforms/WindowsPlatform.cs +++ b/sttz.InstallUnity/Installer/Platforms/WindowsPlatform.cs @@ -25,7 +25,6 @@ public class WindowsPlatform : IInstallerPlatform /// /// Paths where Unity installations are searched in. /// - /// static readonly string[] INSTALL_LOCATIONS = new string[] { ProgramFilesPath, Path.Combine(ProgramFilesPath, "Unity", "Editor"), @@ -53,6 +52,11 @@ string GetLocalApplicationDataDirectory() UnityInstaller.PRODUCT_NAME); } + public void SetConfiguration(Configuration configuration) + { + this.configuration = configuration; + } + public Task GetCurrentPlatform() { return Task.FromResult(CachePlatform.Windows); @@ -115,12 +119,23 @@ public Task CompleteInstall(bool aborted, CancellationToken cancel public async Task> 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(); - foreach (var installPath in INSTALL_LOCATIONS) + 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"); @@ -133,7 +148,8 @@ public async Task> FindInstallations(CancellationToken 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}"); + Logger.LogDebug($"Found version {versionInfo.ProductVersion} at path: {unityCandidate}"); + unityInstallations.Add(new Installation { executable = unityExePath, path = unityCandidate, @@ -260,6 +276,8 @@ public async Task Uninstall(Installation installation, CancellationToken cancell ILogger Logger = UnityInstaller.CreateLogger(); + Configuration configuration; + VersionMetadata installing; string installationPaths; bool installedEditor; @@ -294,7 +312,7 @@ string GetInstallationPath(UnityVersion version, string installationPaths) if (!string.IsNullOrEmpty(installationPaths)) { var comparison = StringComparison.OrdinalIgnoreCase; - var paths = installationPaths.Split(new char[] { ';' }, StringSplitOptions.RemoveEmptyEntries); + var paths = installationPaths.Split(';', StringSplitOptions.RemoveEmptyEntries); foreach (var path in paths) { expanded = path.Trim(); From c0cc6b38ea1a4ec96638736c12b5bfd11113e2b9 Mon Sep 17 00:00:00 2001 From: Adrian Stutz Date: Mon, 5 Sep 2022 21:14:27 +0200 Subject: [PATCH 32/36] [Win] Fix returning default instead of Task leading to null reference exception --- sttz.InstallUnity/Installer/Platforms/WindowsPlatform.cs | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/sttz.InstallUnity/Installer/Platforms/WindowsPlatform.cs b/sttz.InstallUnity/Installer/Platforms/WindowsPlatform.cs index 4f7d405..e686162 100644 --- a/sttz.InstallUnity/Installer/Platforms/WindowsPlatform.cs +++ b/sttz.InstallUnity/Installer/Platforms/WindowsPlatform.cs @@ -98,7 +98,8 @@ public Task CompleteInstall(bool aborted, CancellationToken cancel if (!aborted) { var executable = Path.Combine(installationPaths, "Editor", "Unity.exe"); - if (!File.Exists(executable)) return default; + if (!File.Exists(executable)) + throw new Exception($"Unity exe not found at expected path after installation: {executable}"); var installation = new Installation() { @@ -113,7 +114,7 @@ public Task CompleteInstall(bool aborted, CancellationToken cancel } else { - return default; + return Task.FromResult(null); } } From c4375bcdbc9b753b6201395909963a896c412833 Mon Sep 17 00:00:00 2001 From: Adrian Stutz Date: Mon, 5 Sep 2022 21:29:38 +0200 Subject: [PATCH 33/36] [Win] Fix CompleteInstall not returning the right path to the installed Unity exe --- .../Installer/Platforms/WindowsPlatform.cs | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) mode change 100644 => 100755 sttz.InstallUnity/Installer/Platforms/WindowsPlatform.cs diff --git a/sttz.InstallUnity/Installer/Platforms/WindowsPlatform.cs b/sttz.InstallUnity/Installer/Platforms/WindowsPlatform.cs old mode 100644 new mode 100755 index e686162..2a528e5 --- a/sttz.InstallUnity/Installer/Platforms/WindowsPlatform.cs +++ b/sttz.InstallUnity/Installer/Platforms/WindowsPlatform.cs @@ -97,15 +97,15 @@ public Task CompleteInstall(bool aborted, CancellationToken cancel if (!aborted) { - var executable = Path.Combine(installationPaths, "Editor", "Unity.exe"); + var executable = Path.Combine(installPath, "Editor", "Unity.exe"); if (!File.Exists(executable)) - throw new Exception($"Unity exe not found at expected path after installation: {executable}"); + throw new Exception($"Unity exe not found at expected path after installation: {installPath}"); var installation = new Installation() { version = installing.version, executable = executable, - path = installationPaths + path = installPath }; installing = default; @@ -169,7 +169,6 @@ public async Task Install(UnityInstaller.Queue queue, UnityInstaller.QueueItem i throw new InvalidOperationException("Cannot install package without installing editor first."); } - var installPath = GetInstallationPath(installing.version, installationPaths); var result = await RunAsAdmin(item.filePath, $"/S /D={installPath}"); if (result.exitCode != 0) { @@ -194,7 +193,6 @@ public async Task PrepareInstall(UnityInstaller.Queue queue, string installation throw new InvalidOperationException($"Already installing another version: {installing.version}"); installing = queue.metadata; - this.installationPaths = installationPaths; installedEditor = false; // Check for upgrading installation @@ -209,6 +207,8 @@ public async Task PrepareInstall(UnityInstaller.Queue queue, string installation installedEditor = true; } + + installPath = GetInstallationPath(installing.version, installationPaths); } public Task PromptForPasswordIfNecessary(CancellationToken cancellation = default) @@ -280,7 +280,7 @@ public async Task Uninstall(Installation installation, CancellationToken cancell Configuration configuration; VersionMetadata installing; - string installationPaths; + string installPath; bool installedEditor; async Task<(int exitCode, string output, string error)> RunAsAdmin(string filename, string arguments) From 08663eb46356828cc2afc8e68010064fc5dc0ff3 Mon Sep 17 00:00:00 2001 From: Eirik Wilhelmsen Date: Fri, 23 Sep 2022 08:37:50 +0200 Subject: [PATCH 34/36] Add app.manifest for admin privileges and fix windows install path --- Command/Command.csproj | 1 + Command/app.manifest | 79 ++++++++++++++++++++ sttz.InstallUnity/Installer/Configuration.cs | 3 +- 3 files changed, 81 insertions(+), 2 deletions(-) create mode 100644 Command/app.manifest diff --git a/Command/Command.csproj b/Command/Command.csproj index c38ed7e..80457b0 100644 --- a/Command/Command.csproj +++ b/Command/Command.csproj @@ -20,6 +20,7 @@ https://github.com/sttz/install-unity git CLI;Unity;Installer + app.manifest 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 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/sttz.InstallUnity/Installer/Configuration.cs b/sttz.InstallUnity/Installer/Configuration.cs index ffeecb5..ca3037a 100644 --- a/sttz.InstallUnity/Installer/Configuration.cs +++ b/sttz.InstallUnity/Installer/Configuration.cs @@ -67,8 +67,7 @@ public class Configuration [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};" - + "{ProgramFiles}\\Unity {major}.{minor}.{patch}{type}{build};" + "{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}).")] From 93758394652c557eee8d693cd30a0e98ec4e9454 Mon Sep 17 00:00:00 2001 From: Eirik Wilhelmsen Date: Wed, 21 Feb 2024 10:34:34 +0100 Subject: [PATCH 35/36] Fix .net version in Command.csproj --- Command/Command.csproj | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Command/Command.csproj b/Command/Command.csproj index 80457b0..6db3a55 100644 --- a/Command/Command.csproj +++ b/Command/Command.csproj @@ -2,9 +2,9 @@ Exe - net6.0 + net7.0 win-x64;osx-x64 - 7.1 + latest true true true From 7a397983925368ff86ef7a6ea873b952f1071997 Mon Sep 17 00:00:00 2001 From: Eirik Wilhelmsen Date: Wed, 21 Feb 2024 10:52:16 +0100 Subject: [PATCH 36/36] Fix rebase errors --- .../Installer/Platforms/WindowsPlatform.cs | 53 ++++++++++++------- sttz.InstallUnity/Installer/UnityInstaller.cs | 16 +++--- 2 files changed, 42 insertions(+), 27 deletions(-) diff --git a/sttz.InstallUnity/Installer/Platforms/WindowsPlatform.cs b/sttz.InstallUnity/Installer/Platforms/WindowsPlatform.cs index 2a528e5..7ca8e36 100755 --- a/sttz.InstallUnity/Installer/Platforms/WindowsPlatform.cs +++ b/sttz.InstallUnity/Installer/Platforms/WindowsPlatform.cs @@ -4,11 +4,12 @@ using System.Diagnostics; using System.IO; using System.Linq; -using System.Runtime.InteropServices; using System.Security.Principal; using System.Threading; using System.Threading.Tasks; +using static sttz.InstallUnity.UnityReleaseAPIClient; + namespace sttz.InstallUnity { @@ -35,8 +36,8 @@ public class WindowsPlatform : IInstallerPlatform /// Path to the program files directory. /// static string ProgramFilesPath { get { - if (RuntimeInformation.OSArchitecture != Architecture.X86 - && RuntimeInformation.ProcessArchitecture == Architecture.X86) { + 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 @@ -57,14 +58,19 @@ public void SetConfiguration(Configuration configuration) this.configuration = configuration; } - public Task GetCurrentPlatform() + public async Task GetInstallableArchitectures() { - return Task.FromResult(CachePlatform.Windows); + var (_, arch) = await GetCurrentPlatform(); + if (arch == Architecture.X86_64) { + return Architecture.X86_64; + } else { + return Architecture.ARM64 | Architecture.X86_64; + } } - public Task> GetInstallablePlatforms() + public Task> GetInstallablePlatforms() { - IEnumerable platforms = new CachePlatform[] { CachePlatform.Windows }; + IEnumerable platforms = new Platform[] { Platform.Windows }; return Task.FromResult(platforms); } @@ -73,6 +79,11 @@ public string GetCacheDirectory() return GetLocalApplicationDataDirectory(); } + public Task<(Platform, Architecture)> GetCurrentPlatform() + { + return Task.FromResult((Platform.Windows, Architecture.X86_64)); + } + public string GetConfigurationDirectory() { return GetLocalApplicationDataDirectory(); @@ -92,7 +103,7 @@ public Task IsAdmin(CancellationToken cancellation = default) public Task CompleteInstall(bool aborted, CancellationToken cancellation = default) { - if (!installing.version.IsValid) + if (!installing.Version.IsValid) throw new InvalidOperationException("Not installing any version to complete"); if (!aborted) @@ -103,7 +114,7 @@ public Task CompleteInstall(bool aborted, CancellationToken cancel var installation = new Installation() { - version = installing.version, + version = installing.Version, executable = executable, path = installPath }; @@ -164,7 +175,7 @@ public async Task> FindInstallations(CancellationToken public async Task Install(UnityInstaller.Queue queue, UnityInstaller.QueueItem item, CancellationToken cancellation = default) { - if (item.package.name != PackageMetadata.EDITOR_PACKAGE_NAME && !installedEditor) + if (item.package is not EditorDownload && !installedEditor && upgradeOriginalPath == null) { throw new InvalidOperationException("Cannot install package without installing editor first."); } @@ -175,7 +186,7 @@ public async Task Install(UnityInstaller.Queue queue, UnityInstaller.QueueItem i throw new Exception($"Failed to install {item.filePath} output: {result.output} / {result.error}"); } - if (item.package.name == PackageMetadata.EDITOR_PACKAGE_NAME) + if (item.package is EditorDownload) { installedEditor = true; } @@ -189,26 +200,26 @@ public Task MoveInstallation(Installation installation, string newPath, Cancella 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; installedEditor = false; // Check for upgrading installation - 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."); } installedEditor = true; } - - installPath = GetInstallationPath(installing.version, installationPaths); + + installPath = GetInstallationPath(installing.Version, installationPaths); } public Task PromptForPasswordIfNecessary(CancellationToken cancellation = default) @@ -279,10 +290,14 @@ public async Task Uninstall(Installation installation, CancellationToken cancell 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(); diff --git a/sttz.InstallUnity/Installer/UnityInstaller.cs b/sttz.InstallUnity/Installer/UnityInstaller.cs index 18d95a3..3699780 100644 --- a/sttz.InstallUnity/Installer/UnityInstaller.cs +++ b/sttz.InstallUnity/Installer/UnityInstaller.cs @@ -222,7 +222,7 @@ public UnityInstaller(Configuration config = null, string dataPath = null, ILogg if (System.Runtime.InteropServices.RuntimeInformation.IsOSPlatform(System.Runtime.InteropServices.OSPlatform.OSX)) { Logger.LogDebug("Loading platform integration for macOS"); Platform = new MacPlatform(); - } else if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) { + } else if (System.Runtime.InteropServices.RuntimeInformation.IsOSPlatform(System.Runtime.InteropServices.OSPlatform.Windows)) { Logger.LogDebug("Loading platform integration for Windows"); Platform = new WindowsPlatform(); } else { @@ -303,7 +303,7 @@ public bool IsCacheOutdated(UnityVersion.Type type = UnityVersion.Type.Undefined /// Name of platform to update (only used for loading hub JSON) /// Undefined = update latest, others = update archive of type and higher types /// Task returning the newly discovered versions - public async Task> UpdateCache(Platform platform, Architecture architecture, UnityVersion.Type type = UnityVersion.Type.Undefined, CancellationToken cancellation = default) + public async Task> UpdateCache(Platform platform, UnityReleaseAPIClient.Architecture architecture, UnityVersion.Type type = UnityVersion.Type.Undefined, CancellationToken cancellation = default) { var added = new List(); @@ -348,7 +348,7 @@ public async Task> UpdateCache(Platform platform, A /// /// Unity version /// Name of platform - public IEnumerable GetDefaultPackages(VersionMetadata metadata, Platform platform, Architecture architecture) + public IEnumerable GetDefaultPackages(VersionMetadata metadata, Platform platform, UnityReleaseAPIClient.Architecture architecture) { var editor = metadata.GetEditorDownload(platform, architecture); if (editor == null) throw new ArgumentException($"No Unity version in cache for {platform}-{architecture}: {metadata.Version}"); @@ -361,7 +361,7 @@ public IEnumerable GetDefaultPackages(VersionMetadata metadata, Platform /// public IEnumerable ResolvePackages( VersionMetadata metadata, - Platform platform, Architecture architecture, + Platform platform, UnityReleaseAPIClient.Architecture architecture, IEnumerable packages, IList notFound = null ) { @@ -489,7 +489,7 @@ public string GetFileName(Download download) /// Location of the downloaded the packages /// Packages to download and/or install /// The queue list with the created queue items - public Queue CreateQueue(VersionMetadata metadata, Platform platform, Architecture architecture, string downloadPath, IEnumerable packages) + public Queue CreateQueue(VersionMetadata metadata, Platform platform, UnityReleaseAPIClient.Architecture architecture, string downloadPath, IEnumerable packages) { if (!metadata.Version.IsFullVersion) throw new ArgumentException("VersionMetadata.version needs to contain a full Unity version", nameof(metadata)); @@ -583,7 +583,7 @@ public async Task Process(InstallStep steps, Queue queue, Download string installationPaths = null; if (System.Runtime.InteropServices.RuntimeInformation.IsOSPlatform(System.Runtime.InteropServices.OSPlatform.OSX)) { installationPaths = Configuration.installPathMac; - } else if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) { + } 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: " + System.Runtime.InteropServices.RuntimeInformation.OSDescription); @@ -712,8 +712,8 @@ public void CleanUpDownloads(Queue queue) if (fileName == ".DS_Store" || fileName == "thumbs.db" || fileName == "desktop.ini") continue; - if (!packageFileNames.Contains(path)) { - if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) { + 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 {