├── .gitignore ├── Build ├── build-osx.sh └── notarization.entitlements ├── Changelog.md ├── Command ├── Arguments.cs ├── Command.csproj ├── ConsoleLogger │ ├── ConsoleLogger.cs │ └── ConsoleLoggerProvider.cs └── Program.cs ├── LICENSE ├── Readme.md ├── Tests ├── ArgumentsTests.cs └── Tests.csproj ├── install-unity.sln └── sttz.InstallUnity ├── Installer ├── Command.cs ├── Configuration.cs ├── Downloader.cs ├── Helpers.cs ├── IInstallerPlatform.cs ├── Platforms │ └── MacPlatform.cs ├── Scraper.cs ├── UnityInstaller.cs ├── UnityReleaseAPIClient.cs ├── UnityVersion.cs ├── VersionsCache.cs └── VirtualPackages.cs └── sttz.InstallUnity.csproj /.gitignore: -------------------------------------------------------------------------------- 1 | bin 2 | obj 3 | .vscode 4 | Releases 5 | -------------------------------------------------------------------------------- /Build/build-osx.sh: -------------------------------------------------------------------------------- 1 | #!/bin/zsh 2 | 3 | PROJECT="Command/Command.csproj" 4 | TARGET="net8.0" 5 | ARCHES=("osx-x64" "osx-arm64") 6 | SIGN_IDENTITY="Developer ID Application: Feist GmbH (DHNHQKSSYT)" 7 | ENTITLEMENTS="Build/notarization.entitlements" 8 | 9 | # Mapping of arche names used by .Net to the ones used by lipo 10 | typeset -A LIPO_ARCHES=() 11 | LIPO_ARCHES[osx-x64]=x86_64 12 | LIPO_ARCHES[osx-arm64]=arm64 13 | 14 | if [[ -z "$NOTARY_PROFILE" ]]; then 15 | echo "notarytool keychain profile not set in NOTARY_PROFILE" 16 | exit 1 17 | fi 18 | 19 | cd "$(dirname "$0")/.." 20 | 21 | # Extract version from project 22 | 23 | VERSION="$(sed -n 's/[\ ]*\(.*\)<\/Version>\r*/\1/p' "$PROJECT")" 24 | 25 | if [[ -z "$VERSION" ]]; then 26 | echo "Could not parse version from project: $PROJECT" 27 | exit 1 28 | fi 29 | 30 | # Build new executables, one per arch 31 | 32 | ARCH_ARGS=() 33 | for arch in $ARCHES; do 34 | dotnet publish \ 35 | -r "$arch" \ 36 | -c release \ 37 | -f "$TARGET" \ 38 | --self-contained \ 39 | "$PROJECT" \ 40 | || exit 1 41 | 42 | output="Command/bin/release/$TARGET/$arch/publish/Command" 43 | 44 | if [ ! -f "$output" ]; then 45 | echo "Could not find executable at path: $output" 46 | exit 1 47 | fi 48 | 49 | if [[ -z $LIPO_ARCHES[$arch] ]]; then 50 | echo "No lipo arch specified for .Net arch: $arch" 51 | exit 1 52 | fi 53 | 54 | ARCH_ARGS+=(-arch $LIPO_ARCHES[$arch] "$output") 55 | done 56 | 57 | # Merge, codesign, archive and notarize executable 58 | 59 | ARCHIVE="Releases/$VERSION" 60 | EXECUTABLE="$ARCHIVE/install-unity" 61 | ZIPARCHIVE="Releases/install-unity-$VERSION.zip" 62 | 63 | mkdir -p "$ARCHIVE" 64 | 65 | lipo -create $ARCH_ARGS -output "$EXECUTABLE" || exit 1 66 | 67 | codesign --force --timestamp --options=runtime --entitlements="$ENTITLEMENTS" --sign "$SIGN_IDENTITY" "$EXECUTABLE" || exit 1 68 | 69 | pushd "$ARCHIVE" 70 | zip "../install-unity-$VERSION.zip" "install-unity" || exit 1 71 | popd 72 | 73 | xcrun notarytool submit --wait --keychain-profile "$NOTARY_PROFILE" --wait --progress "$ZIPARCHIVE" || exit 1 74 | 75 | # Shasum for Homebrew 76 | 77 | shasum -a 256 "$ZIPARCHIVE" 78 | -------------------------------------------------------------------------------- /Build/notarization.entitlements: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | com.apple.security.cs.allow-unsigned-executable-memory 7 | 8 | 9 | 10 | -------------------------------------------------------------------------------- /Changelog.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | ### 2.13.0 (2025-05-02) 4 | * Add support for Unity 6.1 5 | * Handle the new "Supported" release stream Unity 6.1 is part of 6 | * Unpack packages directly to the destination instead of installing them. 7 | (Matches the behavior of Unity Hub and falls back to installing for packages without 8 | a destination defined in the metadata.) 9 | 10 | ### 2.12.1 (2024-05-22) 11 | * Convert new single-digit Unity versions (6+) to the internal four-digit one (6000+) 12 | * More strict version parsing, preventing arguments being misinterpreted as versions 13 | * Reduce cases where help isn't printed when requested because argument parsing failed 14 | * Version now includes Git commit hash when using `-v` 15 | * Fix "unity" editor package not selected by default 16 | * Fix some early errors not being colored, because `$CLICOLORS` was checked later 17 | * Fix "New major Unity versions" being shown in the overview when already installed 18 | * Update to .Net 8, use AOT compilation 19 | 20 | ### 2.12.0 (2023-05-13) 21 | * Use Unity's official Release API to get release and package information 22 | * Releases should appear quicker when Unity is slow to update their archive webpage 23 | * Can directly request information of a specific Unity version from the API 24 | * No need to load the whole archive, update can be stopped once the last known version is reached 25 | * Reduces number of requests and amount of data transferred when updating cache 26 | * Previously synthesized packages are now provided by Unity (Documentation, language packs and Android components) 27 | * Legacy scraper and ini-based system can still be used for irregular Unity releases 28 | * Split platform and architecture options (e.g. `--platform macOSIntel` becomes `--platform mac_os --arch x68_64`) 29 | * Added `--clear-cache` to force clearing the versions and package cache 30 | * Added `--redownload` to force redownloading all files 31 | * Improve handling of already downloaded or partially downloaded files 32 | * Speed up detecting of current platform (.Net now reports Apple Silicon properly) 33 | * Speed up detecting installed Unity versions by keeping command line invocations to a minimum 34 | * Removed support for Unity patch releases 35 | * Update to .Net 7 36 | 37 | ### 2.11.1 (2023-02-05) 38 | * Add warning when Spotlight is disabled and installations cannot be found 39 | * Update Android packages for Unity 2023.1 40 | * Fix discovery of beta and alpha releases 41 | * Fix Apple Silicon packages not saved in cache 42 | * Fix exception when cleaning up after installing additional packages to an installation at `/Applications/Unity` 43 | 44 | ### 2.11.0 (2022-09-03) 45 | * Add "--upgrade <version>" to `run` command to upgrade a project to a specific Unity version 46 | * Fix --allow-newer attempting to downgrade project if project Unity version is newer than installed versions 47 | * Drop support for mono, using .Net 6 exclusively now 48 | * Update Homebrew formula, now depends on dotnet instead of mono 49 | 50 | ### 2.10.2 (2022-03-24) 51 | * Fix installing additional Android packages failing depending on the install order 52 | * Fix unzip hanging the installation if some target files already exist 53 | * Fix formatting of exceptions thrown during the installation process 54 | * Show path of target Unity installation when installing additional packages 55 | 56 | ### 2.10.1 (2022-03-14) 57 | * Fix exception when downloading, because `MD5CryptoServiceProvider` got trimmed in build 58 | * Update dependencies 59 | 60 | ### 2.10.0 (2022-03-13) 61 | * Add support for installing Apple Silicon editor 62 | * Use `--platform macOSArm` to download the Apple Silicon editor on other platforms 63 | * Use `--platform macOSIntel` to install Intel editors on Apple Silicon 64 | * Distribute install-unity as universal binary (using .Net 6 single file support) 65 | * Fix Unity asking again to upgrade project when upgrade was already confirmed in the interactive prompt 66 | * Install Android SDK 30 on Unity 2019.4+ 67 | * Install Android Platform Tools 30.0.4 on Unity 2021.1+ 68 | 69 | ### 2.9.0 (2020-09-05) 70 | * `install` is now the default action, so e.g. `install-unity 2021.1` works 71 | * Add interactive prompt to upgrade a Unity project with `run` 72 | * Fix `--opt` not being able to set `downloadSubdirectory` and `installPathMac` 73 | * Fix Android SDK Build Tools version with Unity >= 2019.4 74 | 75 | ### 2.8.2 (2020-11-10) 76 | * Don't add Documentation package for alpha releases 77 | * Use Android SDK Platforms 29 for 2019.3+ 78 | * Use Android NDK r21d for 2021.1+ 79 | 80 | ### 2.8.1 (2020-07-11) 81 | * Add a warning when a project is upgraded with the run command 82 | * Fix prereleases showing up in overview under "New …" even if they're installed 83 | * Fix exception during progress bar rendering when resizing console or console is small 84 | * Disable progress bar when exception occurs during rendering instead of stopping installation 85 | 86 | ### 2.8.0 (2020-06-23) 87 | * Calling install-unity without a command shows patch updates and not installed newer minor versions 88 | * Run checks that the Unity version hash of the project and installation match 89 | * Run with --allow-newer skips Unity's project upgrade dialog 90 | * Improve output of run command, show next installed version to upgrade project to 91 | * Fix run not properly checking the build type of project and installation 92 | * Fix run with version argument not defaulting to final, now a/b is required to select alpha/beta 93 | 94 | ### 2.7.2 (2020-02-04) 95 | * Fix scraping of 2020.1 beta 96 | * Fix only load prerelease landing page once 97 | 98 | ### 2.7.1 (2020-02-04) 99 | * Always use full path for Unity's `-projectPath`, since it doesn't recognize short relative paths 100 | * Fix cache getting updated for commands that don't need it (`uninstall`, `run` and `create`) 101 | 102 | ### 2.7.0 (2020-01-14) 103 | * Improve handling of release candidates 104 | * Add delay when scraping, attempting to avoid network errors 105 | * Fix alpha releases sometimes getting scraped when scraping only beta releases 106 | * Switch back to CoreRT builds and sign/notraize them 107 | 108 | ### 2.6.0 (2019-11-28) 109 | * Add `create` command to create a basic Unity project 110 | * Reduce number of requests when loading beta/alpha releases by not loading release note pages for known versions 111 | * Fix sorting in list of new Unity versions 112 | * Fix status bars sliding down and properly clear them when complete 113 | 114 | ### 2.5.1 (2019-10-24) 115 | * Fix scraping of 2020.1 alpha releases 116 | * Fix run action not escaping arguments passed on to Unity 117 | * Switched to .Net Core 3 single file build, since CoreRT has been deprecated. This unfortunately increases binary size and might have slower startup, this will hopefully improve in the future. 118 | 119 | ### 2.5.0 (2019-09-18) 120 | * Support installing Documentation, Android SDK/NDK, OpenJDK and language packs 121 | * Better separation of hidden packages in `details` and automatically added packages in `install` 122 | * Prefix package name with = to skip adding its dependencies 123 | * Run detached is now default, use `--child` to run Unity as a child process 124 | * When running as child, Unity's log is forwarded to the console (use `-v` to see full log, not just errors) 125 | * Fix Unity's output leaking into the console when running Unity detached 126 | * Fix dependency getting added twice if it's been selected explicitly 127 | 128 | ### 2.4.0 (2019-08-18) 129 | * Support .Net Framework for better compatibility with mono 130 | * Add experimental Homebrew formula, see [sttz/homebrew-tap](https://github.com/sttz/homebrew-tap). 131 | 132 | ### 2.3.0 (2019-06-25) 133 | 134 | * Indicate installed version with a ✓︎ when using the list action 135 | * Fix using install-unity without a terminal 136 | * Fix EULA prompt defaulting to accept 137 | * Fix release notes URL not shown if there's only one update 138 | 139 | ### 2.2.0 (2019-05-27) 140 | 141 | * Discover all available prerelease versions, including alphas 142 | * Fix `--allow-newer` not working when only the build number is increased (e.g. b1 to b2) 143 | * Fix release notes URL for regular releases 144 | * Indicate new versions in `list` command with ⬆︎ 145 | * Increase maximum number of displayed new versions from 5 to 10 146 | 147 | ### 2.1.1 (2019-02-06) 148 | 149 | * Fix automatic detection of beta releases 150 | 151 | ### 2.1.0 (2018-12-17) 152 | 153 | * Use `unityhub://` urls for scraping, fixes discovery of 2018.3.0f2 and 2018.2.20f1 154 | * Allow passing `unityhub://` urls as version for `details` and `install` (like it's already possible with release notes urls) 155 | * Now `--upgrade` selects the next older installed version to remove, relative to the version being installed. Previously it would use the version pattern, which didn't work when using an exact version or an url. 156 | 157 | ### 2.0.1 (2018-12-10) 158 | 159 | * Add `--yolo` option to skip size and hash checks (required sometimes when Unity gets them wrong) 160 | * Fix package added twice when dependency has been selected manually 161 | * Fix exception when drawing progress bar 162 | * Minor output fixes 163 | 164 | ### 2.0.0 (2018-11-13) 165 | 166 | * Install alphas using their full version number or their release notes url 167 | * Fix scraping of beta releases 168 | 169 | ### 2.0.0-beta3 (2018-10-27) 170 | 171 | * Accept url to release notes as version argument in `install` and `details` 172 | * Fix guessed release notes url for regular Unity releases 173 | * Add message when old Unity version is removed during an upgrade to avoid the program to appear stalled 174 | * Small visual tweaks to progress output 175 | 176 | ### 2.0.0-beta2 (2018-10-01) 177 | 178 | * Add `--upgrade` to `install` to replace existing version after installation 179 | * Don't update outdated cache when using `list --installed` 180 | 181 | ### 2.0.0-beta1 (2018-08-13) 182 | 183 | * Rewritten as a library in C# 184 | * Improved command line interface and output 185 | * Faster installs thanks to parallelization 186 | * List installed versions and uninstall them 187 | * Substring package name matching (using `~NAME`) 188 | * Automatic selection of dependent packages 189 | * Better cleanup of aborted installs 190 | * Support for differentiating versions based on build hash 191 | * Retry downloads 192 | * Support for installing DMGs on macOS (Visual Studio for Mac) 193 | * Discover installations outside of `/Applications` on macOS 194 | * Select and run Unity with command line arguments 195 | * Patch releases only supported with full version number 196 | * Still a single executable without dependencies 197 | * Planned Windows and Linux support (help welcome) 198 | -------------------------------------------------------------------------------- /Command/Command.csproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Exe 5 | net8.0 6 | latest 7 | true 8 | true 9 | true 10 | 11 | 12 | 13 | 2.13.0 14 | Adrian Stutz (sttz.ch) 15 | install-unity CLI 16 | CLI for install-unity unofficial Unity installer library 17 | Copyright © Adrian Stutz. All rights Reserved 18 | true 19 | https://github.com/sttz/install-unity 20 | git 21 | CLI;Unity;Installer 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | -------------------------------------------------------------------------------- /Command/ConsoleLogger/ConsoleLogger.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Text; 4 | using System.Text.RegularExpressions; 5 | using Microsoft.Extensions.Logging; 6 | 7 | namespace sttz.NiceConsoleLogger 8 | { 9 | 10 | public class ConsoleLogger : ILogger 11 | { 12 | public static void ColorTest() 13 | { 14 | for (int i = -1; i < 16; i++) { 15 | ConsoleColor? bg = null, fg = null; 16 | if (i >= 0) { 17 | bg = (ConsoleColor)i; 18 | Console.WriteLine($"BackgroundColor = {bg}"); 19 | } 20 | 21 | for (int j = -1; j < 16; j++) { 22 | if (j >= 0) { 23 | fg = (ConsoleColor)j; 24 | } 25 | 26 | if (bg != null) Console.BackgroundColor = bg.Value; 27 | if (fg != null) Console.ForegroundColor = fg.Value; 28 | 29 | var name = "None"; 30 | if (fg != null) name = fg.Value.ToString("G"); 31 | 32 | Console.Write(" "); 33 | Console.Write(name); 34 | Console.Write(new string(' ', Console.BufferWidth - name.Length - 1)); 35 | 36 | Console.ResetColor(); 37 | } 38 | } 39 | } 40 | 41 | public static void Write(string input) 42 | { 43 | WriteColorString(ParseColorString(input)); 44 | } 45 | 46 | public static void WriteLine(string input) 47 | { 48 | WriteColorLine(ParseColorString(input)); 49 | } 50 | 51 | public Func Filter 52 | { 53 | get { return _filter; } 54 | set { 55 | if (value == null) { 56 | throw new ArgumentNullException(nameof(value)); 57 | } 58 | _filter = value; 59 | } 60 | } 61 | Func _filter; 62 | 63 | public string Name { get; } 64 | 65 | internal IExternalScopeProvider ScopeProvider { get; set; } 66 | 67 | public ConsoleLogger(string name, Func filter, bool includeScopes) 68 | : this(name, filter, includeScopes ? new LoggerExternalScopeProvider() : null) 69 | { 70 | } 71 | 72 | internal ConsoleLogger(string name, Func filter, IExternalScopeProvider scopeProvider) 73 | { 74 | if (name == null) throw new ArgumentNullException(nameof(name)); 75 | 76 | Name = name; 77 | Filter = filter ?? ((category, logLevel) => true); 78 | ScopeProvider = scopeProvider; 79 | } 80 | 81 | public IDisposable BeginScope(TState state) 82 | { 83 | return ScopeProvider?.Push(state) ?? default; 84 | } 85 | 86 | public bool IsEnabled(LogLevel logLevel) 87 | { 88 | if (logLevel == LogLevel.None) { 89 | return false; 90 | } 91 | 92 | return Filter(Name, logLevel); 93 | } 94 | 95 | public void Log(LogLevel logLevel, EventId eventId, TState state, Exception exception, Func formatter) 96 | { 97 | if (!IsEnabled(logLevel)) return; 98 | if (formatter == null) throw new ArgumentNullException(nameof(formatter)); 99 | 100 | var message = formatter(state, exception); 101 | if (!string.IsNullOrEmpty(message) || exception != null) { 102 | var builder = recycledBuilder; 103 | if (builder == null) { 104 | builder = new StringBuilder(); 105 | } 106 | 107 | var levelColor = GetLogLevelColor(logLevel); 108 | if (levelColor != null) { 109 | builder.Append("<"); 110 | builder.Append(levelColor); 111 | builder.Append(">"); 112 | } 113 | 114 | var levelPrefix = GetLogLevelPrefix(logLevel); 115 | if (levelPrefix != null) { 116 | builder.Append(levelPrefix); 117 | } 118 | 119 | if (!string.IsNullOrEmpty(message)) { 120 | builder.Append(message); 121 | } else if (exception != null) { 122 | builder.Append(exception.ToString()); 123 | } 124 | 125 | if (levelColor != null) { 126 | builder.Append(""); 129 | } 130 | 131 | if (builder.Length > 0) { 132 | var input = builder.ToString(); 133 | var colored = ParseColorString(input); 134 | WriteColorLine(colored); 135 | } 136 | 137 | builder.Clear(); 138 | if (builder.Capacity > 1024) { 139 | builder.Capacity = 1024; 140 | } 141 | recycledBuilder = builder; 142 | } 143 | } 144 | 145 | static StringBuilder recycledBuilder; 146 | 147 | static string GetLogLevelPrefix(LogLevel logLevel) 148 | { 149 | switch (logLevel) { 150 | case LogLevel.Trace: 151 | return "TRACE: "; 152 | case LogLevel.Debug: 153 | return "DEBUG: "; 154 | case LogLevel.Information: 155 | return null; 156 | case LogLevel.Warning: 157 | return "WARN: "; 158 | case LogLevel.Error: 159 | return "ERROR: "; 160 | case LogLevel.Critical: 161 | return "ERROR: "; 162 | default: 163 | throw new ArgumentOutOfRangeException(nameof(logLevel)); 164 | } 165 | } 166 | 167 | static string GetLogLevelColor(LogLevel logLevel) 168 | { 169 | switch (logLevel) { 170 | case LogLevel.Trace: 171 | return "gray"; 172 | case LogLevel.Debug: 173 | return "gray"; 174 | case LogLevel.Information: 175 | return null; 176 | case LogLevel.Warning: 177 | return "yellow"; 178 | case LogLevel.Error: 179 | return "red"; 180 | case LogLevel.Critical: 181 | return "red"; 182 | default: 183 | throw new ArgumentOutOfRangeException(nameof(logLevel)); 184 | } 185 | } 186 | 187 | static void WriteColorString(IEnumerable input) 188 | { 189 | foreach (var fragment in input) { 190 | if (fragment.fgColor != null) Console.ForegroundColor = fragment.fgColor.Value; 191 | if (fragment.bgColor != null) Console.BackgroundColor = fragment.bgColor.Value; 192 | Console.Write(fragment.text); 193 | Console.ResetColor(); 194 | } 195 | } 196 | 197 | static void WriteColorLine(IEnumerable input) 198 | { 199 | WriteColorString(input); 200 | Console.WriteLine(); 201 | } 202 | 203 | struct ColorString 204 | { 205 | public string text; 206 | public ConsoleColor? fgColor; 207 | public ConsoleColor? bgColor; 208 | } 209 | 210 | static Regex ColorTagRegex = new Regex(@"<(\/?)(\w+)(?: bg=(\w+))?>"); 211 | 212 | static IEnumerable ParseColorString(string input) 213 | { 214 | if (string.IsNullOrEmpty(input)) { 215 | return new ColorString[] { new ColorString() { text = "" } }; 216 | } 217 | 218 | var matches = ColorTagRegex.Matches(input); 219 | if (matches.Count == 0) { 220 | return new ColorString[] { new ColorString() { text = input } }; 221 | } 222 | 223 | // test: hello world! 224 | var colors = new List(); 225 | var currentColors = new ColorString(); 226 | colors.Add(currentColors); 227 | 228 | var pos = 0; 229 | var result = new List(matches.Count); 230 | foreach (Match match in matches) { 231 | if (match.Index > pos) { 232 | result.Add(new ColorString() { 233 | text = input.Substring(pos, match.Index - pos), 234 | fgColor = currentColors.fgColor, 235 | bgColor = currentColors.bgColor 236 | }); 237 | } 238 | 239 | ConsoleColor? fgColor = null, bgColor = null; 240 | if (!TryParseColor(match.Groups[2].Value, out fgColor) 241 | || (match.Groups[1].Length == 0 && !TryParseColor(match.Groups[3].Value, out bgColor))) { 242 | result.Add(new ColorString() { 243 | text = input.Substring(match.Index, match.Length), 244 | fgColor = currentColors.fgColor, 245 | bgColor = currentColors.bgColor 246 | }); 247 | pos = match.Index + match.Length; 248 | continue; 249 | } 250 | 251 | if (match.Groups[1].Length == 0) { 252 | currentColors = new ColorString() { 253 | fgColor = fgColor ?? currentColors.fgColor, 254 | bgColor = bgColor ?? currentColors.bgColor 255 | }; 256 | colors.Add(currentColors); 257 | } else { 258 | if (colors.Count == 1) { 259 | throw new ArgumentException($"End console color tag before any opening tags"); 260 | } 261 | var current = colors[colors.Count - 1].fgColor; 262 | if (colors[colors.Count - 1].fgColor != fgColor) { 263 | throw new ArgumentException($"Umatched console color tag: Expected {current}, got {fgColor}"); 264 | } 265 | colors.RemoveAt(colors.Count - 1); 266 | currentColors = colors[colors.Count - 1]; 267 | } 268 | 269 | pos = match.Index + match.Length; 270 | } 271 | 272 | if (pos < input.Length) { 273 | result.Add(new ColorString() { 274 | text = input.Substring(pos), 275 | fgColor = currentColors.fgColor, 276 | bgColor = currentColors.bgColor 277 | }); 278 | } 279 | 280 | return result; 281 | } 282 | 283 | static bool TryParseColor(string input, out ConsoleColor? color) 284 | { 285 | switch (input.ToLower()) { 286 | case "black": 287 | color = ConsoleColor.Black; 288 | return true; 289 | case "darkblue": 290 | color = ConsoleColor.DarkBlue; 291 | return true; 292 | case "darkgreen": 293 | color = ConsoleColor.DarkGreen; 294 | return true; 295 | case "darkcyan": 296 | color = ConsoleColor.DarkCyan; 297 | return true; 298 | case "darkred": 299 | color = ConsoleColor.DarkRed; 300 | return true; 301 | case "darkmagenta": 302 | color = ConsoleColor.DarkMagenta; 303 | return true; 304 | case "darkyellow": 305 | color = ConsoleColor.DarkYellow; 306 | return true; 307 | case "gray": 308 | color = ConsoleColor.Gray; 309 | return true; 310 | case "darkgray": 311 | color = ConsoleColor.DarkGray; 312 | return true; 313 | case "blue": 314 | color = ConsoleColor.Blue; 315 | return true; 316 | case "green": 317 | color = ConsoleColor.Green; 318 | return true; 319 | case "cyan": 320 | color = ConsoleColor.Cyan; 321 | return true; 322 | case "red": 323 | color = ConsoleColor.Red; 324 | return true; 325 | case "magenta": 326 | color = ConsoleColor.Magenta; 327 | return true; 328 | case "yellow": 329 | color = ConsoleColor.Yellow; 330 | return true; 331 | case "white": 332 | color = ConsoleColor.White; 333 | return true; 334 | case "inherit": 335 | color = null; 336 | return true; 337 | case "": 338 | color = null; 339 | return true; 340 | default: 341 | color = null; 342 | return false; 343 | } 344 | } 345 | } 346 | 347 | } 348 | -------------------------------------------------------------------------------- /Command/ConsoleLogger/ConsoleLoggerProvider.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using Microsoft.Extensions.Logging; 3 | 4 | namespace sttz.NiceConsoleLogger 5 | { 6 | 7 | [ProviderAlias("Console")] 8 | public class ConsoleLoggerProvider : ILoggerProvider, ISupportExternalScope 9 | { 10 | static readonly Func trueFilter = (cat, level) => true; 11 | 12 | Func filter; 13 | bool includeScopes; 14 | IExternalScopeProvider scopeProvider; 15 | 16 | public ConsoleLoggerProvider(Func filter = null, bool includeScopes = false) 17 | { 18 | this.filter = filter ?? trueFilter; 19 | this.includeScopes = includeScopes; 20 | } 21 | 22 | public ILogger CreateLogger(string categoryName) 23 | { 24 | return new ConsoleLogger(categoryName, filter, includeScopes ? scopeProvider : null); 25 | } 26 | 27 | public void Dispose() 28 | { 29 | // NOP 30 | } 31 | 32 | public void SetScopeProvider(IExternalScopeProvider scopeProvider) 33 | { 34 | this.scopeProvider = scopeProvider; 35 | } 36 | } 37 | 38 | public static class ConsoleLoggerExtensions 39 | { 40 | public static ILoggerFactory AddNiceConsole(this ILoggerFactory factory) 41 | { 42 | return factory.AddNiceConsole(includeScopes: false); 43 | } 44 | 45 | public static ILoggerFactory AddNiceConsole(this ILoggerFactory factory, bool includeScopes) 46 | { 47 | factory.AddNiceConsole((n, l) => l >= LogLevel.Information, includeScopes); 48 | return factory; 49 | } 50 | 51 | public static ILoggerFactory AddNiceConsole(this ILoggerFactory factory, LogLevel minLevel) 52 | { 53 | factory.AddNiceConsole(minLevel, includeScopes: false); 54 | return factory; 55 | } 56 | 57 | public static ILoggerFactory AddNiceConsole( 58 | this ILoggerFactory factory, 59 | LogLevel minLevel, 60 | bool includeScopes) 61 | { 62 | factory.AddNiceConsole((category, logLevel) => logLevel >= minLevel, includeScopes); 63 | return factory; 64 | } 65 | 66 | public static ILoggerFactory AddNiceConsole( 67 | this ILoggerFactory factory, 68 | Func filter) 69 | { 70 | factory.AddNiceConsole(filter, includeScopes: false); 71 | return factory; 72 | } 73 | 74 | public static ILoggerFactory AddNiceConsole( 75 | this ILoggerFactory factory, 76 | Func filter, 77 | bool includeScopes) 78 | { 79 | factory.AddProvider(new ConsoleLoggerProvider(filter, includeScopes)); 80 | return factory; 81 | } 82 | } 83 | 84 | } -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019 Adrian Stutz 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /Readme.md: -------------------------------------------------------------------------------- 1 | # install-unity 2 | 3 | A command-line utility to install any recent version of Unity. 4 | 5 | Currently only supports macOS (Intel & Apple Silicon) but support for Windows/Linux is possible, PRs welcome. 6 | 7 | ## Table of Contents 8 | 9 | * [Introduction](#introduction) 10 | * [Versions](#versions) 11 | * [Packages](#packages) 12 | * [Offline Install](#offline-install) 13 | * [Run](#run) 14 | * [Create](#create) 15 | * [CLI Help](#cli-help) 16 | * [Changelog](#changelog) 17 | 18 | # Introduction 19 | 20 | [Download the latest release here](https://github.com/sttz/install-unity/releases). install-unity is a self-contained executable and has no dependencies. 21 | 22 | Or you can install via [Homebrew](https://brew.sh) using [sttz/homebrew-tap](https://github.com/sttz/homebrew-tap), see the tap readme for instructions. 23 | 24 | Installing the latest release version of Unity is as simple as: 25 | 26 | install-unity install f 27 | 28 | ## Versions 29 | 30 | Most commands take a version as input, either to select the version to install or to filter the output. 31 | 32 | You can be as specific as you like, `2018.2.2f1`, `2018.2.2`, `2018.2`, `2018`, `f` or `2018.3b` are all valid version inputs. 33 | 34 | `install-unity` will scan for the available regular releases as well as the latest betas and alphas. 35 | 36 | install-unity list 37 | install-unity list a 38 | install-unity list 2019.1 39 | 40 | Will show the available versions and the argument acts as a filter. Without an argument, only regular releases are loaded and displayed. Add an argument including `b` or `a` to load and display either beta or both beta and alpha versions as well. 41 | 42 | In case install-unity fails to discover a release, it's also possible to pass a release notes or unity hub url instead of a version to `details` and `install`: 43 | 44 | install-unity details https://unity3d.com/unity/whats-new/unity-2018.3.0 45 | install-unity install unityhub://2018.3.0f2/6e9a27477296 46 | ## Packages 47 | 48 | The command above will install the default packages as specified by Unity. 49 | 50 | install-unity details 2018.2 51 | 52 | Will show the available packages for a given version. You can then select the packages you want to install with the `-p` or `--packages` option. The option can either be repeated or the names separated by space or comma: 53 | 54 | install-unity install 2018.2 --packages Unity,Documentation 55 | install-unity install f -p Unity Linux iOS Android 56 | install-unity install 2018.3b -p Unity -p Android -p Linux 57 | 58 | ## Apple Silicon 59 | 60 | By default, `install-unity` will download and install the Unity editor matching the current platform. 61 | 62 | Use `--platform macOSIntel` to download and install the Intel editor on Apple Silicon. 63 | 64 | Use `--platform macOSArm` on Intel to download the Apple Silicon editor. 65 | 66 | ## Offline Install 67 | 68 | install-unity can be used in a two-step process, first downloading the packages and then later installing them without needing an internet connection. 69 | 70 | install-unity install 2018.2 --packages all --data-path "~/Desktop/2018.2" --download 71 | 72 | Will download all available packages to `~/Desktop/Downloads` together with the necessary package metadata. 73 | 74 | install-unity install 2018.2 --pacakages all --data-path "~/Destop/2018.2" --install 75 | 76 | Will install those packages at a later time. Simply copy the folder together with the `install-unity` binary to another computer to do an offline installation there. 77 | 78 | You can download and install only a subset of the available packages. 79 | 80 | ## Run 81 | 82 | To select a Unity version from all the installed ones, use the run command. 83 | 84 | install-unity run f 85 | 86 | Will open the latest version of Unity installed. 87 | 88 | You can also use the path to a Unity project and install-unity will open it with the corresponding Unity version. 89 | 90 | install-unity run ~/Desktop/my-project 91 | 92 | It will only open with the exact version of Unity the project is set to. You can optionally allow it to be opened with a newer patch, minor or any version: 93 | 94 | install-unity run --allow-newer patch ~/Desktop/my-project 95 | 96 | You can pass [command line arguments](https://docs.unity3d.com/Manual/CommandLineArguments.html) along to Unity, e.g. to create a build from the command line (note the `--` to separate install-unity options from the ones passed on the Unity). 97 | 98 | install-unity run ~/Desktop/my-project -- -quit -batchmode -buildOSX64Player ~/Desktop/my-build 99 | 100 | By default, Unity is started as a separate process and install-unity will exit after Unity has been launched. To wait for Unity to quit and forward Unity's log output through install-unity, use the `--child` option: 101 | 102 | install-unity run ~/Desktop/my-project --child -v -- -quit -batchmode -buildOSX64Player ~/Desktop/my-build 103 | 104 | ## Create 105 | 106 | To start a basic Unity project, use the create command. The version pattern will select an installed Unity version and create a new project using it. 107 | 108 | install-unity create 2020.1 ~/Desktop/my-project 109 | 110 | The project will use Unity's default setup, including packages. Alternatively, you can create a minimal project that will start with an empty ´Packages/manifest.json`: 111 | 112 | install-unity create --type minimal 2020.1 ~/Desktop/my-project 113 | 114 | ## CLI Help 115 | 116 | ```` 117 | install-unity v2.13.0 118 | 119 | USAGE: install-unity [--help] [--version] [--verbose...] [--yes] [--update] 120 | [--clear-cache] [--data-path ] 121 | [--opt =...] 122 | 123 | GLOBAL OPTIONS: 124 | -h, --help Show this help 125 | --version Print the version of this program 126 | -v, --verbose Increase verbosity of output, can be repeated 127 | -y, --yes Don't prompt for confirmation (use with care) 128 | -u, --update Force an update of the versions cache 129 | --clear-cache Clear the versions cache before running any commands 130 | --data-path Store all data at the given path, also don't delete 131 | packages after install 132 | --opt = Set additional options. Use '--opt list' to show all 133 | options and their default value and '--opt save' to create an 134 | editable JSON config file. 135 | 136 | 137 | ACTIONS: 138 | 139 | ---- INSTALL (default): 140 | Download and install a version of Unity 141 | 142 | USAGE: install-unity [options] [install] [--packages ...] 143 | [--download] [--install] [--upgrade] 144 | [--platform none|mac_os|linux|windows|all] 145 | [--arch none|x86_64|arm64|all] [--redownload] [--yolo] 146 | [] 147 | 148 | OPTIONS: 149 | Pattern to match Unity version or release notes / unity hub 150 | url 151 | -p, --packages Select packages to download and install ('all' 152 | selects all available, '~NAME' matches substrings) 153 | --download Only download the packages (requires '--data-path') 154 | --install Install previously downloaded packages (requires 155 | '--data-path') 156 | --upgrade Replace existing matching Unity installation after successful 157 | install 158 | --platform none|mac_os|linux|windows|all Platform to download the 159 | packages for (only valid with '--download', default = current 160 | platform) 161 | --arch none|x86_64|arm64|all Architecture to download the packages for 162 | (default = current architecture) 163 | --redownload Force redownloading all files 164 | --yolo Skip size and hash checks of downloaded files 165 | 166 | 167 | ---- LIST: 168 | Get an overview of available or installed Unity versions 169 | 170 | USAGE: install-unity [options] list [--installed] 171 | [--platform none|mac_os|linux|windows|all] 172 | [--arch none|x86_64|arm64|all] [] 173 | 174 | OPTIONS: 175 | Pattern to match Unity version 176 | -i, --installed List installed versions of Unity 177 | --platform none|mac_os|linux|windows|all Platform to list the versions 178 | for (default = current platform) 179 | --arch none|x86_64|arm64|all Architecture to list the versions for 180 | (default = current architecture) 181 | 182 | 183 | ---- DETAILS: 184 | Show version information and all its available packages 185 | 186 | USAGE: install-unity [options] details 187 | [--platform none|mac_os|linux|windows|all] 188 | [--arch none|x86_64|arm64|all] [] 189 | 190 | OPTIONS: 191 | Pattern to match Unity version or release notes / unity hub 192 | url 193 | --platform none|mac_os|linux|windows|all Platform to show the details for 194 | (default = current platform) 195 | --arch none|x86_64|arm64|all Architecture to show the details for 196 | (default = current architecture) 197 | 198 | 199 | ---- UNINSTALL: 200 | Remove a previously installed version of Unity 201 | 202 | USAGE: install-unity [options] uninstall [] 203 | 204 | OPTIONS: 205 | Pattern to match Unity version or path to installation root 206 | 207 | 208 | ---- RUN: 209 | Execute a version of Unity or a Unity project, matching it to its Unity 210 | version 211 | 212 | USAGE: install-unity [options] run [--child] 213 | [--allow-newer none|hash|build|patch|minor|all] 214 | [--upgrade ] 215 | [...] 216 | 217 | OPTIONS: 218 | Pattern to match Unity version or path to a Unity project 219 | Arguments to launch Unity with (put a -- first to avoid 220 | Unity options being parsed as install-unity options) 221 | -c, --child Run Unity as a child process and forward its log output (only 222 | errors, use -v to see the full log) 223 | -a, --allow-newer none|hash|build|patch|minor|all Allow newer versions of 224 | Unity to open a project 225 | --upgrade Run the project with the highest installed Unity 226 | version matching the pattern 227 | 228 | 229 | ---- CREATE: 230 | Create a new empty Unity project 231 | 232 | USAGE: install-unity [options] create [--type ] [--open] 233 | 234 | 235 | OPTIONS: 236 | Pattern to match the Unity version to create the project with 237 | Path to the new Unity project 238 | --type Type of project to create (basic = standard 239 | project, minimal = no packages/modules) 240 | -o, --open Open the new project in the editor 241 | ```` 242 | 243 | # Legacy 244 | 245 | The old Python version of install-unity can be found in the [legacy](https://github.com/sttz/install-unity/tree/next) branch. 246 | -------------------------------------------------------------------------------- /Tests/ArgumentsTests.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Linq; 4 | using Xunit; 5 | 6 | namespace sttz.InstallUnity.Tests 7 | { 8 | 9 | public class ArgumentsTests 10 | { 11 | public InstallUnityCLI Parse(params string[] args) 12 | { 13 | var cli = new InstallUnityCLI(); 14 | InstallUnityCLI.ArgumentsDefinition.Parse(cli, args); 15 | return cli; 16 | } 17 | 18 | class Args 19 | { 20 | public string optionalArgument; 21 | public string requiredPositional; 22 | 23 | public static Args Parse(params string[] args) 24 | { 25 | var parsed = new Args(); 26 | var def = new Arguments() 27 | .Option((Args t, string v) => t.optionalArgument = v, "a", "arg").OptionalArgument(true) 28 | .Option((Args t, string v) => t.requiredPositional = v, 0).Required(); 29 | def.Parse(parsed, args); 30 | return parsed; 31 | } 32 | } 33 | 34 | [Fact] 35 | public void TestGlobalShortOptions() 36 | { 37 | Assert.Equal( 38 | " --help", 39 | Parse("-?").ToString() 40 | ); 41 | } 42 | 43 | [Fact] 44 | public void TestGlobalLongOptions() 45 | { 46 | Assert.Equal( 47 | " --help", 48 | Parse("--help").ToString() 49 | ); 50 | } 51 | 52 | [Fact] 53 | public void TestCombinedShortOptions() 54 | { 55 | Assert.Equal( 56 | " --help --verbose", 57 | Parse("-vh").ToString() 58 | ); 59 | } 60 | 61 | [Fact] 62 | public void TestRepeatedOption() 63 | { 64 | Assert.Equal( 65 | " --verbose --verbose", 66 | Parse("-vv").ToString() 67 | ); 68 | } 69 | 70 | [Fact] 71 | public void TestAction() 72 | { 73 | Assert.Equal( 74 | "list", 75 | Parse("list").ToString() 76 | ); 77 | } 78 | 79 | [Fact] 80 | public void TestCaseInsensitivity() 81 | { 82 | Assert.Equal( 83 | "list --installed", 84 | Parse("LIST", "-I").ToString() 85 | ); 86 | } 87 | 88 | [Fact] 89 | public void TestPositionalArg() 90 | { 91 | Assert.Equal( 92 | "list --verbose 2018", 93 | Parse("list", "-v", "2018").ToString() 94 | ); 95 | } 96 | 97 | [Fact] 98 | public void TestLocalOptions() 99 | { 100 | Assert.Equal( 101 | "list 2018 --installed", 102 | Parse("list", "2018", "-i").ToString() 103 | ); 104 | } 105 | 106 | [Fact] 107 | public void TestListOption() 108 | { 109 | Assert.Equal( 110 | "install f --packages Mac Linux Windows --download", 111 | Parse("install", "f", "-p", "Mac", "Linux", "Windows", "--download").ToString() 112 | ); 113 | 114 | Assert.Equal( 115 | "install f --packages Mac Linux Windows --download", 116 | Parse("install", "f", "-p", "Mac,Linux,Windows", "--download").ToString() 117 | ); 118 | 119 | Assert.Equal( 120 | "install f --packages Mac Linux Windows --download", 121 | Parse("install", "f", "--packages=Mac,Linux,Windows", "--download").ToString() 122 | ); 123 | 124 | Assert.Equal( 125 | "install f --packages Mac Linux Windows --download", 126 | Parse("install", "f", "--packages:Mac,Linux,Windows", "--download").ToString() 127 | ); 128 | } 129 | 130 | [Fact] 131 | public void TestCommaListOption() 132 | { 133 | Assert.Equal( 134 | "install f --packages Mac Linux Windows", 135 | Parse("install", "--packages", "Mac,Linux,Windows", "f").ToString() 136 | ); 137 | } 138 | 139 | [Fact] 140 | public void TestTerinatedListOption() 141 | { 142 | Assert.Equal( 143 | "install f --packages Mac Linux Windows", 144 | Parse("install", "--packages", "Mac", "Linux", "Windows", "--", "f").ToString() 145 | ); 146 | } 147 | 148 | [Fact] 149 | public void TestWindowsOption() 150 | { 151 | Assert.Equal( 152 | "install --verbose f --packages Mac Linux Windows", 153 | Parse("/v", "install", "f", "/packages", "Mac", "Linux", "Windows").ToString() 154 | ); 155 | } 156 | 157 | [Fact] 158 | public void TestRepeatedListOption() 159 | { 160 | Assert.Equal( 161 | "install --packages Mac Linux Windows", 162 | Parse("install", "-p", "Mac", "-p", "Linux", "-p", "Windows").ToString() 163 | ); 164 | } 165 | 166 | [Fact] 167 | public void TestPathArgument() 168 | { 169 | Assert.Equal( 170 | "uninstall /Applications/Unity", 171 | Parse("uninstall", "/Applications/Unity").ToString() 172 | ); 173 | } 174 | 175 | [Fact] 176 | public void TestInvalidAction() 177 | { 178 | var ex = Assert.Throws(() => Parse("liist")); 179 | Assert.StartsWith("Unrecognized Unity version/url 'liist'", ex.Message); 180 | } 181 | 182 | [Fact] 183 | public void TestInvalidPositional() 184 | { 185 | var ex = Assert.Throws(() => Parse("list", "2018", "2019")); 186 | Assert.Equal("Unexpected argument at position #1: 2019", ex.Message); 187 | } 188 | 189 | [Fact] 190 | public void TestInvalidShortOption() 191 | { 192 | var ex = Assert.Throws(() => Parse("-vz")); 193 | Assert.Equal("Unknown short option: z", ex.Message); 194 | } 195 | 196 | [Fact] 197 | public void TestInvalidLongOption() 198 | { 199 | var ex = Assert.Throws(() => Parse("--blah")); 200 | Assert.Equal("Unknown option: blah", ex.Message); 201 | } 202 | 203 | [Fact] 204 | public void TestInvalidLocalOptionUsedAsGlobal() 205 | { 206 | var ex = Assert.Throws(() => Parse("--installed")); 207 | Assert.Equal("Unknown option: installed", ex.Message); 208 | } 209 | 210 | [Fact] 211 | public void TestInvalidListOption() 212 | { 213 | var ex = Assert.Throws(() => Parse("install", "--packages")); 214 | Assert.Equal("Missing arguments for option: packages", ex.Message); 215 | 216 | ex = Assert.Throws(() => Parse("install", "--packages", "--install")); 217 | Assert.Equal("Missing arguments for option: packages", ex.Message); 218 | 219 | ex = Assert.Throws(() => Parse("install", "-pv Mac")); 220 | Assert.Equal("Missing arguments for option: p", ex.Message); 221 | } 222 | 223 | [Fact] 224 | public void TestRepeatablePositionalArgument() 225 | { 226 | Assert.Equal( 227 | "run 2018 --child -- -batchmode -nographics -quit", 228 | Parse("run", "-c", "2018", "--", "-batchmode", "-nographics", "-quit").ToString() 229 | ); 230 | } 231 | 232 | [Fact] 233 | public void TestPathsAndOptions() 234 | { 235 | Assert.Equal( 236 | "install --data-path /tmp/test 2018 --download", 237 | Parse("install", "2018", "--data-path", "/tmp/test", "--download").ToString() 238 | ); 239 | 240 | Assert.Equal( 241 | "install --data-path /tmp/test 2018 --download", 242 | Parse("install", "/dataPath", "/tmp/test", "/download", "2018").ToString() 243 | ); 244 | 245 | var ex = Assert.Throws(() => Parse("install", "/dataPath", "/download", "2018")); 246 | Assert.Equal("Missing argument for option: dataPath", ex.Message); 247 | 248 | Assert.Equal( 249 | "uninstall /Applications/Unity", 250 | Parse("uninstall", "/Applications/Unity").ToString() 251 | ); 252 | } 253 | 254 | [Fact] 255 | public void TestEnumArguments() 256 | { 257 | Assert.Equal( 258 | "run /tmp/test --allow-newer minor", 259 | Parse("run", "/tmp/test", "--allow-newer", "minor").ToString() 260 | ); 261 | 262 | var ex = Assert.Throws(() => Parse("run", "/tmp/test", "--allow-newer", "blah")); 263 | Assert.Equal("Invalid value for allow-newer: 'blah' (must be 'none', 'hash', 'build', 'patch', 'minor', 'all')", ex.Message); 264 | } 265 | 266 | [Fact] 267 | public void TestMissingPositionalArgument() 268 | { 269 | var ex = Assert.Throws(() => Args.Parse("-a")); 270 | Assert.Equal("Required argument #0 not set.", ex.Message); 271 | } 272 | 273 | [Fact] 274 | public void TestValidMissingArgument() 275 | { 276 | Args.Parse("test -a"); 277 | } 278 | } 279 | 280 | } 281 | -------------------------------------------------------------------------------- /Tests/Tests.csproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | net8.0 5 | latest 6 | false 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | -------------------------------------------------------------------------------- /install-unity.sln: -------------------------------------------------------------------------------- 1 |  2 | Microsoft Visual Studio Solution File, Format Version 12.00 3 | # Visual Studio 15 4 | VisualStudioVersion = 15.0.26124.0 5 | MinimumVisualStudioVersion = 15.0.26124.0 6 | Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Tests", "Tests\Tests.csproj", "{35B8FA0A-4815-428C-9FD5-614068FCE9F3}" 7 | EndProject 8 | Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "sttz.InstallUnity", "sttz.InstallUnity\sttz.InstallUnity.csproj", "{E09B8E21-7BB8-4E58-80DD-CB534750380F}" 9 | EndProject 10 | Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Command", "Command\Command.csproj", "{D169B6A6-3AA7-4ED3-B1F8-7C3E5E764BBC}" 11 | EndProject 12 | Global 13 | GlobalSection(SolutionConfigurationPlatforms) = preSolution 14 | Debug|Any CPU = Debug|Any CPU 15 | Debug|x64 = Debug|x64 16 | Debug|x86 = Debug|x86 17 | Release|Any CPU = Release|Any CPU 18 | Release|x64 = Release|x64 19 | Release|x86 = Release|x86 20 | EndGlobalSection 21 | GlobalSection(SolutionProperties) = preSolution 22 | HideSolutionNode = FALSE 23 | EndGlobalSection 24 | GlobalSection(ProjectConfigurationPlatforms) = postSolution 25 | {FF838F2B-B954-4D48-BA5B-B62F08AA0FF1}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 26 | {FF838F2B-B954-4D48-BA5B-B62F08AA0FF1}.Debug|Any CPU.Build.0 = Debug|Any CPU 27 | {FF838F2B-B954-4D48-BA5B-B62F08AA0FF1}.Debug|x64.ActiveCfg = Debug|Any CPU 28 | {FF838F2B-B954-4D48-BA5B-B62F08AA0FF1}.Debug|x64.Build.0 = Debug|Any CPU 29 | {FF838F2B-B954-4D48-BA5B-B62F08AA0FF1}.Debug|x86.ActiveCfg = Debug|Any CPU 30 | {FF838F2B-B954-4D48-BA5B-B62F08AA0FF1}.Debug|x86.Build.0 = Debug|Any CPU 31 | {FF838F2B-B954-4D48-BA5B-B62F08AA0FF1}.Release|Any CPU.ActiveCfg = Release|Any CPU 32 | {FF838F2B-B954-4D48-BA5B-B62F08AA0FF1}.Release|Any CPU.Build.0 = Release|Any CPU 33 | {FF838F2B-B954-4D48-BA5B-B62F08AA0FF1}.Release|x64.ActiveCfg = Release|Any CPU 34 | {FF838F2B-B954-4D48-BA5B-B62F08AA0FF1}.Release|x64.Build.0 = Release|Any CPU 35 | {FF838F2B-B954-4D48-BA5B-B62F08AA0FF1}.Release|x86.ActiveCfg = Release|Any CPU 36 | {FF838F2B-B954-4D48-BA5B-B62F08AA0FF1}.Release|x86.Build.0 = Release|Any CPU 37 | {35B8FA0A-4815-428C-9FD5-614068FCE9F3}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 38 | {35B8FA0A-4815-428C-9FD5-614068FCE9F3}.Debug|Any CPU.Build.0 = Debug|Any CPU 39 | {35B8FA0A-4815-428C-9FD5-614068FCE9F3}.Debug|x64.ActiveCfg = Debug|Any CPU 40 | {35B8FA0A-4815-428C-9FD5-614068FCE9F3}.Debug|x64.Build.0 = Debug|Any CPU 41 | {35B8FA0A-4815-428C-9FD5-614068FCE9F3}.Debug|x86.ActiveCfg = Debug|Any CPU 42 | {35B8FA0A-4815-428C-9FD5-614068FCE9F3}.Debug|x86.Build.0 = Debug|Any CPU 43 | {35B8FA0A-4815-428C-9FD5-614068FCE9F3}.Release|Any CPU.ActiveCfg = Release|Any CPU 44 | {35B8FA0A-4815-428C-9FD5-614068FCE9F3}.Release|Any CPU.Build.0 = Release|Any CPU 45 | {35B8FA0A-4815-428C-9FD5-614068FCE9F3}.Release|x64.ActiveCfg = Release|Any CPU 46 | {35B8FA0A-4815-428C-9FD5-614068FCE9F3}.Release|x64.Build.0 = Release|Any CPU 47 | {35B8FA0A-4815-428C-9FD5-614068FCE9F3}.Release|x86.ActiveCfg = Release|Any CPU 48 | {35B8FA0A-4815-428C-9FD5-614068FCE9F3}.Release|x86.Build.0 = Release|Any CPU 49 | {E09B8E21-7BB8-4E58-80DD-CB534750380F}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 50 | {E09B8E21-7BB8-4E58-80DD-CB534750380F}.Debug|Any CPU.Build.0 = Debug|Any CPU 51 | {E09B8E21-7BB8-4E58-80DD-CB534750380F}.Debug|x64.ActiveCfg = Debug|Any CPU 52 | {E09B8E21-7BB8-4E58-80DD-CB534750380F}.Debug|x64.Build.0 = Debug|Any CPU 53 | {E09B8E21-7BB8-4E58-80DD-CB534750380F}.Debug|x86.ActiveCfg = Debug|Any CPU 54 | {E09B8E21-7BB8-4E58-80DD-CB534750380F}.Debug|x86.Build.0 = Debug|Any CPU 55 | {E09B8E21-7BB8-4E58-80DD-CB534750380F}.Release|Any CPU.ActiveCfg = Release|Any CPU 56 | {E09B8E21-7BB8-4E58-80DD-CB534750380F}.Release|Any CPU.Build.0 = Release|Any CPU 57 | {E09B8E21-7BB8-4E58-80DD-CB534750380F}.Release|x64.ActiveCfg = Release|Any CPU 58 | {E09B8E21-7BB8-4E58-80DD-CB534750380F}.Release|x64.Build.0 = Release|Any CPU 59 | {E09B8E21-7BB8-4E58-80DD-CB534750380F}.Release|x86.ActiveCfg = Release|Any CPU 60 | {E09B8E21-7BB8-4E58-80DD-CB534750380F}.Release|x86.Build.0 = Release|Any CPU 61 | {D169B6A6-3AA7-4ED3-B1F8-7C3E5E764BBC}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 62 | {D169B6A6-3AA7-4ED3-B1F8-7C3E5E764BBC}.Debug|Any CPU.Build.0 = Debug|Any CPU 63 | {D169B6A6-3AA7-4ED3-B1F8-7C3E5E764BBC}.Debug|x64.ActiveCfg = Debug|Any CPU 64 | {D169B6A6-3AA7-4ED3-B1F8-7C3E5E764BBC}.Debug|x64.Build.0 = Debug|Any CPU 65 | {D169B6A6-3AA7-4ED3-B1F8-7C3E5E764BBC}.Debug|x86.ActiveCfg = Debug|Any CPU 66 | {D169B6A6-3AA7-4ED3-B1F8-7C3E5E764BBC}.Debug|x86.Build.0 = Debug|Any CPU 67 | {D169B6A6-3AA7-4ED3-B1F8-7C3E5E764BBC}.Release|Any CPU.ActiveCfg = Release|Any CPU 68 | {D169B6A6-3AA7-4ED3-B1F8-7C3E5E764BBC}.Release|Any CPU.Build.0 = Release|Any CPU 69 | {D169B6A6-3AA7-4ED3-B1F8-7C3E5E764BBC}.Release|x64.ActiveCfg = Release|Any CPU 70 | {D169B6A6-3AA7-4ED3-B1F8-7C3E5E764BBC}.Release|x64.Build.0 = Release|Any CPU 71 | {D169B6A6-3AA7-4ED3-B1F8-7C3E5E764BBC}.Release|x86.ActiveCfg = Release|Any CPU 72 | {D169B6A6-3AA7-4ED3-B1F8-7C3E5E764BBC}.Release|x86.Build.0 = Release|Any CPU 73 | EndGlobalSection 74 | EndGlobal 75 | -------------------------------------------------------------------------------- /sttz.InstallUnity/Installer/Command.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Diagnostics; 3 | using System.IO; 4 | using System.Text; 5 | using System.Threading; 6 | using System.Threading.Tasks; 7 | using Microsoft.Extensions.Logging; 8 | 9 | namespace sttz.InstallUnity 10 | { 11 | 12 | /// 13 | /// Helper class to run command-line programs. 14 | /// 15 | public static class Command 16 | { 17 | static ILogger Logger = UnityInstaller.CreateLogger("Command"); 18 | 19 | /// 20 | /// Run a command asynchronously. 21 | /// 22 | /// Command to execute 23 | /// Arguments to pass to the command 24 | /// Input to write to the process' standard input 25 | /// Token to stop the command 26 | /// A task that returns the command's exit code, standard output and standard error 27 | public static Task<(int exitCode, string output, string error)> Run( 28 | string command, 29 | string arguments, 30 | string input = null, 31 | CancellationToken cancellation = default 32 | ) { 33 | var startInfo = new ProcessStartInfo(); 34 | startInfo.FileName = command; 35 | startInfo.Arguments = arguments; 36 | return Run(startInfo, input, cancellation); 37 | } 38 | 39 | /// 40 | /// Run a command asynchronously. 41 | /// 42 | /// Command to execute 43 | /// Arguments to pass to the command 44 | /// Called for every standard output line 45 | /// Called for every standard error line 46 | /// Input to write to the process' standard input 47 | /// Token to stop the command 48 | /// A task that returns the command's exit code 49 | public static Task Run( 50 | string command, 51 | string arguments, 52 | Action onOutput, 53 | Action onError, 54 | string input = null, 55 | CancellationToken cancellation = default 56 | ) { 57 | var startInfo = new ProcessStartInfo(); 58 | startInfo.FileName = command; 59 | startInfo.Arguments = arguments; 60 | return Run(startInfo, onOutput, onError, input, cancellation); 61 | } 62 | 63 | /// 64 | /// Same as but 65 | /// returns standard output and error as string when the process exists instead of streaming them. 66 | /// 67 | /// Process start info 68 | /// Input to write to the process' standard input 69 | /// Token to stop the command 70 | /// A task that returns the command's exit code, standard output and standard error 71 | public async static Task<(int exitCode, string output, string error)> Run( 72 | ProcessStartInfo startInfo, 73 | string input = null, 74 | CancellationToken cancellation = default 75 | ) { 76 | var output = new StringBuilder(); 77 | Action outputReader = (string outputLine) => { 78 | output.AppendLine(outputLine); 79 | }; 80 | 81 | var error = new StringBuilder(); 82 | Action errorReader = (string errorLine) => { 83 | error.AppendLine(errorLine); 84 | }; 85 | 86 | var code = await Run(startInfo, outputReader, errorReader, input, cancellation); 87 | 88 | return (code, output.ToString(), error.ToString()); 89 | } 90 | 91 | /// 92 | /// Run a command asynchronously. 93 | /// 94 | /// 95 | /// Note that some of the startInfo configuration will be overwritten due to 96 | /// Process' constraints. UseShellExecute is set to false, RedirectStandardOutput 97 | /// and RedirectStandardError set to true. If an input is provided, 98 | /// RedirectStandardInput is also set to true. 99 | /// 100 | /// Process start info 101 | /// Called for every standard output line 102 | /// Called for every standard error line 103 | /// Input to write to the process' standard input 104 | /// Token to stop the command 105 | /// A task that returns the command's exit code 106 | public static Task Run( 107 | ProcessStartInfo startInfo, 108 | Action onOutput, 109 | Action onError, 110 | string input = null, 111 | CancellationToken cancellation = default 112 | ) { 113 | var commandName = Path.GetFileName(startInfo.FileName); 114 | 115 | var command = new Process(); 116 | command.StartInfo = startInfo; 117 | command.StartInfo.UseShellExecute = false; 118 | command.StartInfo.RedirectStandardOutput = true; 119 | command.StartInfo.RedirectStandardError = true; 120 | command.EnableRaisingEvents = true; 121 | 122 | if (!string.IsNullOrEmpty(input)) { 123 | command.StartInfo.RedirectStandardInput = true; 124 | } 125 | 126 | command.OutputDataReceived += (s, a) => { 127 | if (onOutput != null) { 128 | onOutput(a.Data); 129 | } 130 | }; 131 | command.ErrorDataReceived += (s, a) => { 132 | if (onError != null) { 133 | onError(a.Data); 134 | } 135 | }; 136 | 137 | var completion = new TaskCompletionSource(); 138 | command.Exited += (s, a) => { 139 | // Wait for stdin and stderr to flush 140 | // see https://github.com/dotnet/runtime/issues/18789 141 | while (!command.WaitForExit(10000)); 142 | command.WaitForExit(); 143 | 144 | Thread.Sleep(100); 145 | 146 | var exitCode = command.ExitCode; 147 | command.Close(); 148 | 149 | Logger.LogDebug($"{command.StartInfo.FileName} exited with code {exitCode}"); 150 | completion.SetResult(exitCode); 151 | }; 152 | 153 | if (cancellation.CanBeCanceled) { 154 | cancellation.Register(() => { 155 | if (command.HasExited) return; 156 | Logger.LogDebug($"Terminating {command.StartInfo.FileName}"); 157 | //command.Kill(); 158 | command.CloseMainWindow(); 159 | }); 160 | } 161 | 162 | try { 163 | Logger.LogDebug($"$ {command.StartInfo.FileName} {command.StartInfo.Arguments}"); 164 | command.Start(); 165 | 166 | command.BeginOutputReadLine(); 167 | command.BeginErrorReadLine(); 168 | 169 | if (!string.IsNullOrEmpty(input)) { 170 | var writer = new StreamWriter(command.StandardInput.BaseStream, new System.Text.UTF8Encoding(false)); 171 | writer.Write(input); 172 | writer.Close(); 173 | } 174 | } catch (Exception e) { 175 | if (onError != null) onError("Exception running " + commandName + ": " + e.Message); 176 | return Task.FromResult(-1); 177 | } 178 | 179 | return completion.Task; 180 | } 181 | } 182 | 183 | } 184 | -------------------------------------------------------------------------------- /sttz.InstallUnity/Installer/Configuration.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.ComponentModel; 4 | using System.IO; 5 | using System.Reflection; 6 | using Microsoft.Extensions.Logging; 7 | using Newtonsoft.Json; 8 | 9 | namespace sttz.InstallUnity 10 | { 11 | 12 | /// 13 | /// Configuration of the Unity installer. 14 | /// 15 | [JsonObject(MemberSerialization.Fields)] 16 | public class Configuration 17 | { 18 | [Description("After how many seconds the cache is considered to be outdated.")] 19 | public int cacheLifetime = 60 * 60 * 16; // 16 hours 20 | 21 | [Description("Maximum age of Unity releases to load when refreshing the cache (days).")] 22 | public int latestMaxAge = 90; // 90 days 23 | 24 | [Description("Delay between requests when scraping.")] 25 | public int scrapeDelayMs = 50; 26 | 27 | [Description("The default list of packages to install (null = use Unity's default).")] 28 | public string[] defaultPackages = null; 29 | 30 | [Description("Name of the subdirectory created to store downloaded packages ({0} = Unity version).")] 31 | public string downloadSubdirectory = "Unity {0}"; 32 | 33 | [Description("Maximum number of concurrent downloads.")] 34 | public int maxConcurrentDownloads = 4; 35 | 36 | [Description("Maximum number of concurrent packages being installed.")] 37 | public int maxConcurrentInstalls = 1; 38 | 39 | [Description("Try to resume partial downloads.")] 40 | public bool resumeDownloads = true; 41 | 42 | [Description("Time in seconds until HTTP requests time out.")] 43 | public int requestTimeout = 30; 44 | 45 | [Description("How often to retry downloads.")] 46 | public int retryCount = 4; 47 | 48 | [Description("Delay in seconds before download is retried.")] 49 | public int retryDelay = 5; 50 | 51 | [Description("Draw progress bars for hashing and downloading.")] 52 | public bool progressBar = true; 53 | 54 | [Description("The interval in milliseconds in which the progress bars are updated.")] 55 | public int progressRefreshInterval = 50; // 20 fps 56 | 57 | [Description("Update the download status text every n progress refresh intervals.")] 58 | public int statusRefreshEvery = 20; // 1 fps 59 | 60 | [Description("Enable colored console output.")] 61 | public bool enableColoredOutput = true; 62 | 63 | [Description("Mac installation paths, separated by ; (first non-existing will be used, variables: {major} {minor} {patch} {type} {build} {hash}).")] 64 | public string installPathMac = 65 | "/Applications/Unity {major}.{minor};" 66 | + "/Applications/Unity {major}.{minor}.{patch}{type}{build};" 67 | + "/Applications/Unity {major}.{minor}.{patch}{type}{build} ({hash})"; 68 | 69 | // -------- Serialization -------- 70 | 71 | /// 72 | /// Save the configuration as JSON to the given path. 73 | /// 74 | public bool Save(string path) 75 | { 76 | try { 77 | Directory.CreateDirectory(Path.GetDirectoryName(path)); 78 | var json = JsonConvert.SerializeObject(this, Formatting.Indented); 79 | File.WriteAllText(path, json); 80 | return true; 81 | } catch (Exception e) { 82 | UnityInstaller.GlobalLogger.LogError("Could not save configuration file: " + e.Message); 83 | return false; 84 | } 85 | } 86 | 87 | /// 88 | /// Load a configuration as JSON from the given path. 89 | /// 90 | public static Configuration Load(string path) 91 | { 92 | try { 93 | var json = File.ReadAllText(path); 94 | return JsonConvert.DeserializeObject(json); 95 | } catch (Exception e) { 96 | UnityInstaller.GlobalLogger.LogError("Could not read configuration file: " + e.Message); 97 | return null; 98 | } 99 | } 100 | 101 | // -------- Reflection -------- 102 | 103 | /// 104 | /// List all available options 105 | /// 106 | /// Name of the option 107 | /// Type of the option 108 | /// Description of the option 109 | public static IEnumerable<(string name, Type type, string description)> ListOptions() 110 | { 111 | var fields = typeof(Configuration).GetFields(BindingFlags.Instance | BindingFlags.Public); 112 | var info = new (string name, Type type, string description)[fields.Length]; 113 | var index = 0; 114 | foreach (var field in fields) { 115 | string description = null; 116 | var attr = (DescriptionAttribute)field.GetCustomAttribute(typeof(DescriptionAttribute)); 117 | if (attr != null) { 118 | description = attr.Description; 119 | } 120 | 121 | info[index++] = ( 122 | field.Name, 123 | field.FieldType, 124 | description 125 | ); 126 | } 127 | return info; 128 | } 129 | 130 | /// 131 | /// Set a configuration value by name. 132 | /// 133 | public void Set(string name, string value) 134 | { 135 | var field = typeof(Configuration).GetField(name, BindingFlags.Public | BindingFlags.Instance); 136 | if (field == null) { 137 | throw new ArgumentException($"No configuration value named {name} found.", nameof(name)); 138 | } 139 | 140 | object parsed = null; 141 | if (field.FieldType == typeof(string)) { 142 | parsed = value; 143 | } else if (field.FieldType == typeof(bool)) { 144 | parsed = bool.Parse(value); 145 | } else if (field.FieldType == typeof(int)) { 146 | parsed = int.Parse(value); 147 | } else if (field.FieldType == typeof(string[])) { 148 | parsed = value.Split(':'); 149 | } else { 150 | throw new Exception($"Field value type {field.FieldType} not yet supported."); 151 | } 152 | 153 | field.SetValue(this, parsed); 154 | } 155 | 156 | /// 157 | /// Get a configuration value by name. 158 | /// 159 | public string Get(string name) 160 | { 161 | var field = typeof(Configuration).GetField(name, BindingFlags.Public | BindingFlags.Instance); 162 | if (field == null) { 163 | throw new ArgumentException($"No configuration value named {name} found.", nameof(name)); 164 | } 165 | 166 | if (field.FieldType == typeof(string[])) { 167 | var array = (string[])field.GetValue(this); 168 | if (array != null) { 169 | return string.Join(":", array); 170 | } else { 171 | return ""; 172 | } 173 | } else { 174 | return field.GetValue(this).ToString(); 175 | } 176 | } 177 | } 178 | 179 | } 180 | -------------------------------------------------------------------------------- /sttz.InstallUnity/Installer/Downloader.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.IO; 4 | using System.Net; 5 | using System.Net.Http; 6 | using System.Net.Http.Headers; 7 | using System.Security.Cryptography; 8 | using System.Text; 9 | using System.Threading; 10 | using System.Threading.Tasks; 11 | using Microsoft.Extensions.Logging; 12 | 13 | namespace sttz.InstallUnity 14 | { 15 | 16 | /// 17 | /// Helper class to download files. 18 | /// 19 | public class Downloader 20 | { 21 | // -------- Settings -------- 22 | 23 | /// 24 | /// How to handle existing files. 25 | /// 26 | public enum ExistingFile 27 | { 28 | /// 29 | /// Undefined behaviour, will default to Resume. 30 | /// 31 | Undefined, 32 | 33 | /// 34 | /// Always redownload, overwriting existing files. 35 | /// 36 | Redownload, 37 | /// 38 | /// Try to hash and/or resume existing file, 39 | /// will fall back to redownloading and overwriting. 40 | /// 41 | Resume, 42 | /// 43 | /// Do not hash or touch existing files and complete immediately. 44 | /// 45 | Skip 46 | } 47 | 48 | /// 49 | /// Url of the file to download. 50 | /// 51 | public Uri Url { get; protected set; } 52 | 53 | /// 54 | /// Path to download the file to. 55 | /// 56 | public string TargetPath { get; protected set; } 57 | 58 | /// 59 | /// Expected size of the file. 60 | /// 61 | public long ExpectedSize { get; protected set; } 62 | 63 | /// 64 | /// Expected hash of the file (in WRC SRI format). 65 | /// 66 | public string ExpectedHash { get; protected set; } 67 | 68 | /// 69 | /// How to handle existing files. 70 | /// 71 | public ExistingFile Existing = ExistingFile.Resume; 72 | 73 | /// 74 | /// Buffer size used when downloading. 75 | /// 76 | public int BufferSize = 524288; 77 | 78 | /// 79 | /// How many blocks (of BufferSize or smaller) are used to calculate the transfer speed. 80 | /// Set to 0 to disable calculating speed. 81 | /// 82 | public int SpeedWindowBlocks = 5000; 83 | 84 | /// 85 | /// Time out used for requests (in seconds). 86 | /// Can only be set before a Downloader instance's first request is made. 87 | /// 88 | public int Timeout = 30; 89 | 90 | // -------- State -------- 91 | 92 | /// 93 | /// Describing the download's state. 94 | /// 95 | public enum State 96 | { 97 | /// 98 | /// Call Prepare to initialize instance. 99 | /// 100 | Uninitialized, 101 | 102 | /// 103 | /// Waiting to start download. 104 | /// 105 | Idle, 106 | 107 | /// 108 | /// Hashing existing file. 109 | /// 110 | Hashing, 111 | 112 | /// 113 | /// Downloading file. 114 | /// 115 | Downloading, 116 | 117 | /// 118 | /// Error occurred while downloading. 119 | /// 120 | Error, 121 | 122 | /// 123 | /// Download complete. 124 | /// 125 | Complete 126 | } 127 | 128 | /// 129 | /// Current state of the downloader. 130 | /// 131 | public State CurrentState { get; protected set; } 132 | 133 | /// 134 | /// Bytes processed so far (hashed and/or downloaded). 135 | /// 136 | public long BytesProcessed { get; protected set; } 137 | 138 | /// 139 | /// Total number of bytes of file being downloaded. 140 | /// 141 | public long BytesTotal { get; protected set; } 142 | 143 | /// 144 | /// Current hashing or download speed. 145 | /// 146 | public double BytesPerSecond { get; protected set; } 147 | 148 | /// 149 | /// The hash after the file has been downloaded. 150 | /// 151 | public byte[] Hash { get; protected set; } 152 | 153 | /// 154 | /// Event called for every of data processed. 155 | /// 156 | public event Action OnProgress; 157 | 158 | HttpClient client = new HttpClient(); 159 | 160 | ILogger Logger = UnityInstaller.CreateLogger(); 161 | 162 | Queue> blocks; 163 | System.Diagnostics.Stopwatch watch; 164 | 165 | // -------- Methods -------- 166 | 167 | /// 168 | /// Prepare a new download. 169 | /// 170 | public void Prepare(Uri url, string path, long expectedSize = -1, string expectedHash = null) 171 | { 172 | if (CurrentState == State.Hashing || CurrentState == State.Downloading) 173 | throw new InvalidOperationException($"A download is already in progress."); 174 | 175 | this.Url = url; 176 | this.TargetPath = path; 177 | this.ExpectedSize = expectedSize; 178 | this.ExpectedHash = expectedHash; 179 | 180 | Reset(); 181 | } 182 | 183 | /// 184 | /// Make the Downloader reusable with the same settings. 185 | /// 186 | public void Reset() 187 | { 188 | if (CurrentState == State.Hashing || CurrentState == State.Downloading) 189 | throw new InvalidOperationException($"Cannot call reset when a download is in progress."); 190 | 191 | CurrentState = State.Idle; 192 | BytesProcessed = 0; 193 | BytesTotal = ExpectedSize; 194 | Hash = null; 195 | 196 | if (SpeedWindowBlocks > 0) { 197 | blocks = new Queue>(SpeedWindowBlocks); 198 | watch = new System.Diagnostics.Stopwatch(); 199 | } else { 200 | blocks = null; 201 | watch = null; 202 | } 203 | 204 | if (Existing == ExistingFile.Undefined) { 205 | Existing = ExistingFile.Resume; 206 | } 207 | } 208 | 209 | /// 210 | /// Check if matches . 211 | /// 212 | public bool CheckHash() 213 | { 214 | if (Hash == null) throw new InvalidOperationException("No Hash set."); 215 | if (string.IsNullOrEmpty(ExpectedHash)) throw new InvalidOperationException("No ExpectedHash set."); 216 | 217 | var hash = SplitSRIHash(ExpectedHash); 218 | 219 | var base64Hash = Convert.ToBase64String(Hash); 220 | if (string.Equals(hash.value, base64Hash, StringComparison.OrdinalIgnoreCase)) 221 | return true; 222 | 223 | // Unity generates their hashes in a non-standard way 224 | // W3C SRI specifies the hash to be base64 encoded form the raw hash bytes 225 | // but Unity takes the hex-encoded string of the hash and base64-encodes that 226 | var hexBase64Hash = Convert.ToBase64String(Encoding.UTF8.GetBytes(Helpers.ToHexString(Hash))); 227 | if (string.Equals(hash.value, hexBase64Hash, StringComparison.OrdinalIgnoreCase)) 228 | return true; 229 | 230 | return false; 231 | } 232 | 233 | /// 234 | /// Check if an existing file's hash is valid (does not download any data). 235 | /// 236 | public async Task AssertExistingFileHash(CancellationToken cancellation = default) 237 | { 238 | if (string.IsNullOrEmpty(ExpectedHash)) throw new InvalidOperationException("No ExpectedHash set."); 239 | if (!File.Exists(TargetPath)) return; 240 | 241 | var hash = SplitSRIHash(ExpectedHash); 242 | HashAlgorithm hasher = null; 243 | if (hash.algorithm != null) { 244 | hasher = CreateHashAlgorithm(hash.algorithm); 245 | } 246 | 247 | using (var input = File.Open(TargetPath, FileMode.Open, FileAccess.Read)) { 248 | CurrentState = State.Hashing; 249 | await CopyToAsync(input, Stream.Null, hasher, cancellation); 250 | } 251 | hasher.TransformFinalBlock(new byte[0], 0, 0); 252 | Hash = hasher.Hash; 253 | 254 | if (!CheckHash()) { 255 | throw new Exception($"Existing file '{TargetPath}' does not match expected hash (got {Convert.ToBase64String(Hash)}, expected {hash.value})."); 256 | } 257 | } 258 | 259 | /// 260 | /// Start the download. 261 | /// 262 | public async Task Start(CancellationToken cancellation = default) 263 | { 264 | if (CurrentState != State.Idle) 265 | throw new InvalidOperationException("A download already in progress or instance not prepared."); 266 | 267 | try { 268 | HashAlgorithm hasher = null; 269 | if (!string.IsNullOrEmpty(ExpectedHash)) { 270 | var hash = SplitSRIHash(ExpectedHash); 271 | if (hash.algorithm != null) { 272 | hasher = CreateHashAlgorithm(hash.algorithm); 273 | } 274 | } 275 | 276 | var filename = Path.GetFileName(TargetPath); 277 | 278 | // Check existing file 279 | var mode = FileMode.Create; 280 | var startOffset = 0L; 281 | if (File.Exists(TargetPath)) { 282 | // Handle existing file from a previous download 283 | var existing = await HandleExistingFile(hasher, cancellation); 284 | if (existing.complete) { 285 | CurrentState = State.Complete; 286 | return; 287 | } else if (existing.startOffset > 0) { 288 | startOffset = existing.startOffset; 289 | mode = FileMode.Append; 290 | } else { 291 | startOffset = 0; 292 | mode = FileMode.Create; 293 | } 294 | } 295 | 296 | // Load headers 297 | var request = new HttpRequestMessage(HttpMethod.Get, Url); 298 | if (startOffset != 0) { 299 | request.Headers.Range = new RangeHeaderValue(startOffset, null); 300 | } 301 | 302 | if (client.Timeout != TimeSpan.FromSeconds(Timeout)) 303 | client.Timeout = TimeSpan.FromSeconds(Timeout); 304 | var response = await client.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, cancellation); 305 | 306 | if (response.StatusCode == HttpStatusCode.RequestedRangeNotSatisfiable) { 307 | // Disable resuming for next attempt 308 | Existing = ExistingFile.Redownload; 309 | throw new Exception($"Failed to resume, disabled resume for '{filename}' (HTTP Code 416)"); 310 | } 311 | 312 | response.EnsureSuccessStatusCode(); 313 | 314 | // Redownload whole file if resuming fails 315 | if (startOffset > 0 && response.StatusCode != HttpStatusCode.PartialContent) { 316 | Logger.LogInformation("Server does not support resuming download."); 317 | startOffset = 0; 318 | mode = FileMode.Create; 319 | } 320 | 321 | if (hasher != null) { 322 | hasher.Initialize(); 323 | 324 | // When resuming, hash already downloaded data 325 | if (startOffset > 0) { 326 | using (var input = File.Open(TargetPath, FileMode.Open, FileAccess.Read)) { 327 | CurrentState = State.Hashing; 328 | await CopyToAsync(input, Stream.Null, hasher, cancellation); 329 | } 330 | } 331 | } 332 | 333 | if (response.Content.Headers.ContentLength != null) { 334 | BytesTotal = response.Content.Headers.ContentLength.Value + startOffset; 335 | } 336 | 337 | // Download 338 | Directory.CreateDirectory(Path.GetDirectoryName(TargetPath)); 339 | using (Stream input = await response.Content.ReadAsStreamAsync(), output = File.Open(TargetPath, mode, FileAccess.Write)) { 340 | CurrentState = State.Downloading; 341 | BytesProcessed = startOffset; 342 | await CopyToAsync(input, output, hasher, cancellation); 343 | } 344 | 345 | if (hasher != null) { 346 | hasher.TransformFinalBlock(new byte[0], 0, 0); 347 | Hash = hasher.Hash; 348 | } 349 | 350 | if (Hash != null && !string.IsNullOrEmpty(ExpectedHash) && !CheckHash()) { 351 | if (string.IsNullOrEmpty(ExpectedHash)) { 352 | Logger.LogInformation($"Downloaded file '{filename}' with hash {Convert.ToBase64String(Hash)}"); 353 | CurrentState = State.Complete; 354 | } else if (CheckHash()) { 355 | Logger.LogInformation($"Downloaded file '{filename}' with expected hash {Convert.ToBase64String(Hash)}"); 356 | CurrentState = State.Complete; 357 | } else { 358 | throw new Exception($"Downloaded file '{filename}' does not match expected hash (got {Convert.ToBase64String(Hash)} but expected {ExpectedHash})"); 359 | } 360 | } else { 361 | Logger.LogInformation($"Downloaded file '{filename}'"); 362 | CurrentState = State.Complete; 363 | } 364 | } catch { 365 | CurrentState = State.Error; 366 | throw; 367 | } 368 | } 369 | 370 | async Task<(bool complete, long startOffset)> HandleExistingFile(HashAlgorithm hasher, CancellationToken cancellation) 371 | { 372 | if (Existing == ExistingFile.Skip) { 373 | // Complete without checking or resuming 374 | return (true, -1); 375 | } 376 | 377 | var filename = Path.GetFileName(TargetPath); 378 | 379 | if (Existing == ExistingFile.Resume) { 380 | var hashChecked = false; 381 | if (!string.IsNullOrEmpty(ExpectedHash) && hasher != null) { 382 | // If we have a hash, always check against hash first 383 | using (var input = File.Open(TargetPath, FileMode.Open, FileAccess.Read)) { 384 | CurrentState = State.Hashing; 385 | await CopyToAsync(input, Stream.Null, hasher, cancellation); 386 | } 387 | hasher.TransformFinalBlock(new byte[0], 0, 0); 388 | Hash = hasher.Hash; 389 | 390 | if (CheckHash()) { 391 | Logger.LogInformation($"Existing file '{filename}' has matching hash, skipping..."); 392 | return (true, -1); 393 | } else { 394 | hashChecked = true; 395 | } 396 | } 397 | 398 | if (ExpectedSize > 0) { 399 | var fileInfo = new FileInfo(TargetPath); 400 | if (fileInfo.Length >= ExpectedSize && !hashChecked) { 401 | // No hash and big enough, Assume file is good 402 | Logger.LogInformation($"Existing file '{filename}' cannot be checked for integrity, assuming it's ok..."); 403 | return (true, -1); 404 | 405 | } else { 406 | // File smaller than it should be, try resuming 407 | Logger.LogInformation($"Resuming partial download of '{filename}' ({Helpers.FormatSize(fileInfo.Length)} already downloaded)..."); 408 | return (false, fileInfo.Length); 409 | } 410 | } 411 | } 412 | 413 | // Force redownload from start 414 | Logger.LogWarning($"Redownloading existing file '{filename}'"); 415 | return (false, 0); 416 | } 417 | 418 | /// 419 | /// Helper method to copy the stream with a progress callback. 420 | /// 421 | async Task CopyToAsync(Stream input, Stream output, HashAlgorithm hasher, CancellationToken cancellation) 422 | { 423 | if (blocks != null) { 424 | watch.Restart(); 425 | blocks.Clear(); 426 | } 427 | 428 | var buffer = new byte[BufferSize]; 429 | long bytesInWindow = 0; 430 | while (true) { 431 | var read = await input.ReadAsync(buffer, 0, buffer.Length, cancellation); 432 | if (read == 0) 433 | break; 434 | 435 | await output.WriteAsync(buffer, 0, read, cancellation); 436 | BytesProcessed += read; 437 | 438 | if (blocks != null) { 439 | bytesInWindow += read; 440 | 441 | KeyValuePair windowStart = default; 442 | if (blocks.Count > 0) { 443 | windowStart = blocks.Peek(); 444 | } 445 | 446 | var windowLength = watch.ElapsedMilliseconds - windowStart.Key; 447 | BytesPerSecond = bytesInWindow / (windowLength / 1000.0); 448 | 449 | blocks.Enqueue(new KeyValuePair(watch.ElapsedMilliseconds, read)); 450 | if (blocks.Count > SpeedWindowBlocks) { 451 | bytesInWindow -= blocks.Dequeue().Value; 452 | } 453 | } 454 | 455 | if (OnProgress != null) OnProgress(this); 456 | if (hasher != null) hasher.TransformBlock(buffer, 0, read, null, 0); 457 | } 458 | 459 | if (blocks != null) { 460 | watch.Stop(); 461 | } 462 | } 463 | 464 | /// 465 | /// Split a WRC SRI string into hash algorithm and hash value. 466 | /// 467 | (string algorithm, string value) SplitSRIHash(string hash) 468 | { 469 | if (string.IsNullOrEmpty(hash)) 470 | return (null, null); 471 | 472 | var firstDash = hash.IndexOf('-'); 473 | if (firstDash < 0) return (null, hash); 474 | 475 | var hashName = hash.Substring(0, firstDash).ToLowerInvariant(); 476 | var hashValue = hash.Substring(firstDash + 1); 477 | 478 | return (hashName, hashValue); 479 | } 480 | 481 | /// 482 | /// Create a hash algorithm instance from a hash name. 483 | /// 484 | HashAlgorithm CreateHashAlgorithm(string hashName) 485 | { 486 | switch (hashName) { 487 | case "md5": return MD5.Create(); 488 | case "sha256": return SHA256.Create(); 489 | case "sha512": return SHA512.Create(); 490 | case "sha384": return SHA384.Create(); 491 | } 492 | 493 | Logger.LogError($"Unsupported hash algorithm: '{hashName}'"); 494 | return null; 495 | } 496 | } 497 | 498 | } 499 | -------------------------------------------------------------------------------- /sttz.InstallUnity/Installer/Helpers.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Collections.ObjectModel; 4 | using System.IO; 5 | using System.Text; 6 | 7 | namespace sttz.InstallUnity 8 | { 9 | 10 | /// 11 | /// Generic helper methods. 12 | /// 13 | public static class Helpers 14 | { 15 | static readonly string[] SizeNames = new string[] { 16 | "KB", "MB", "GB", "TB", "PB", "EB", "ZB", "YB" 17 | }; 18 | 19 | /// 20 | /// Nicely format a size in bytes for printing. 21 | /// 22 | /// Size in bytes 23 | /// Format string {0} is size and {1} size suffix 24 | /// Size formatted with appropriate size suffix (B, KB, MB, etc) 25 | public static string FormatSize(long bytes, string format = "{0:0.00} {1}") 26 | { 27 | if (bytes < 0) return "? B"; 28 | else if (bytes < 1024) return bytes + " B"; 29 | 30 | var size = bytes / 1024.0; 31 | var index = Math.Min((int)Math.Log(size, 1024), SizeNames.Length - 1); 32 | var amount = size / Math.Pow(1024, index); 33 | return string.Format(format, amount, SizeNames[index]); 34 | } 35 | 36 | /// 37 | /// Convert byte data into a hexadecimal string. 38 | /// 39 | public static string ToHexString(byte[] data) 40 | { 41 | var builder = new StringBuilder(); 42 | for (int i = 0; i < data.Length; i++) { 43 | builder.Append(data[i].ToString("x2")); 44 | } 45 | return builder.ToString(); 46 | } 47 | 48 | /// 49 | /// Generate a unique file or directory name. 50 | /// Appends (x) with increasing x until the name becomes unique. 51 | /// Returns the path unchanged if it doesn't exist. 52 | /// 53 | /// Input path to make unique 54 | /// Path that doesn't exist 55 | public static string GenerateUniqueFileName(string path) 56 | { 57 | if (!File.Exists(path) && !Directory.Exists(path)) 58 | return path; 59 | 60 | var dir = Path.GetDirectoryName(path); 61 | var name = Path.GetFileNameWithoutExtension(path); 62 | var ext = Path.GetExtension(path); 63 | var num = 2; 64 | 65 | string uniquePath; 66 | do { 67 | uniquePath = Path.Combine(dir, $"{name} ({num++}){ext}"); 68 | } while (File.Exists(uniquePath) || Directory.Exists(uniquePath)); 69 | 70 | return uniquePath; 71 | } 72 | 73 | /// 74 | /// Read a password from the console. 75 | /// The mask character will be used to provide feedback while the user is 76 | /// entering the password. 77 | /// 78 | public static string ReadPassword(char mask = '*') 79 | { 80 | var builder = new StringBuilder(); 81 | while (true) { 82 | var info = Console.ReadKey(true); 83 | if (info.Key == ConsoleKey.Enter) { 84 | Console.WriteLine(); 85 | return builder.ToString(); 86 | } else if (!char.IsControl(info.KeyChar)) { 87 | builder.Append(info.KeyChar); 88 | Console.Write(mask); 89 | } else if (info.Key == ConsoleKey.Backspace && builder.Length > 0) { 90 | builder.Remove(builder.Length - 1, 1); 91 | Console.Write("\b \b"); 92 | } 93 | } 94 | } 95 | 96 | static char[] NeedQuotesChars = new [] { ' ', '\t', '\n' }; 97 | 98 | /// 99 | /// Escape a command line argument. 100 | /// 101 | /// 102 | /// Based on work from Nate McMaster, Licensed under the Apache License, Version 2.0. 103 | /// In turn based on MSDN blog post: 104 | /// https://blogs.msdn.microsoft.com/twistylittlepassagesallalike/2011/04/23/everyone-quotes-command-line-arguments-the-wrong-way/ 105 | /// 106 | public static string EscapeArgument(string arg) 107 | { 108 | var sb = new StringBuilder(); 109 | 110 | var needsQuotes = arg.IndexOfAny(NeedQuotesChars) >= 0; 111 | var isQuoted = needsQuotes || (arg.Length > 1 && arg[0] == '"' && arg[arg.Length - 1] == '"'); 112 | 113 | if (needsQuotes) { 114 | sb.Append('"'); 115 | } 116 | 117 | for (int i = 0; i < arg.Length; ++i) { 118 | var backslashes = 0; 119 | 120 | // Consume all backslashes 121 | while (i < arg.Length && arg[i] == '\\') { 122 | backslashes++; 123 | i++; 124 | } 125 | 126 | if (i == arg.Length && isQuoted) { 127 | // Escape any backslashes at the end of the arg when the argument is also quoted. 128 | // This ensures the outside quote is interpreted as an argument delimiter 129 | sb.Append('\\', 2 * backslashes); 130 | } else if (i == arg.Length) { 131 | // At then end of the arg, which isn't quoted, 132 | // just add the backslashes, no need to escape 133 | sb.Append('\\', backslashes); 134 | } else if (arg[i] == '"') { 135 | // Escape any preceding backslashes and the quote 136 | sb.Append('\\', (2 * backslashes) + 1); 137 | sb.Append('"'); 138 | } else { 139 | // Output any consumed backslashes and the character 140 | sb.Append('\\', backslashes); 141 | sb.Append(arg[i]); 142 | } 143 | } 144 | 145 | if (needsQuotes) { 146 | sb.Append('"'); 147 | } 148 | 149 | return sb.ToString(); 150 | } 151 | 152 | /// 153 | /// Add a range of items to a collection. 154 | /// 155 | public static void AddRange(this Collection collection, IEnumerable items) 156 | { 157 | foreach (var item in items) { 158 | collection.Add(item); 159 | } 160 | } 161 | 162 | /// 163 | /// Prompt the user on the console for an one-character answer. 164 | /// 165 | /// Prompt to ask user 166 | /// Possible one-character answers (uppercase = default) 167 | /// Chosen character out of given options 168 | public static char ConsolePrompt(string prompt, string options) 169 | { 170 | while (true) { 171 | Console.WriteLine(); 172 | Console.Write($"{prompt} [{options}]: "); 173 | 174 | var input = Console.ReadKey(); 175 | Console.WriteLine(); 176 | 177 | // Choose default option on enter 178 | if (input.Key == ConsoleKey.Enter) { 179 | for (var i = 0; i < options.Length; i++) { 180 | if (char.IsUpper(options[i])) { 181 | return options[i]; 182 | } 183 | } 184 | } 185 | 186 | for (var i = 0; i < options.Length; i++) { 187 | if (char.ToLower(options[i]) == char.ToLower(input.KeyChar)) { 188 | return options[i]; 189 | } 190 | } 191 | 192 | // Repeat on invalid input 193 | } 194 | } 195 | 196 | /// 197 | /// Replace with custom StringComparison (only available in .netstandard). 198 | /// 199 | /// The string to replace in 200 | /// The old value to look for 201 | /// The new value to replace it with 202 | /// The comparison to use 203 | /// 204 | public static string Replace(string input, string oldValue, string newValue, StringComparison comparison) 205 | { 206 | if (input == null) 207 | throw new ArgumentException("input cannot be null", "input"); 208 | if (string.IsNullOrEmpty(oldValue)) 209 | throw new ArgumentException("oldValue cannot be null or empty", "oldValue"); 210 | if (newValue == null) 211 | throw new ArgumentException("newValue cannot be null", "newValue"); 212 | 213 | var output = ""; 214 | var start = 0; 215 | while (start < input.Length) { 216 | var index = input.IndexOf(oldValue, start, comparison); 217 | if (index >= 0) { 218 | output += input.Substring(start, index - start) + newValue; 219 | start = index + oldValue.Length; 220 | } else { 221 | output += input.Substring(start); 222 | break; 223 | } 224 | } 225 | return output; 226 | } 227 | } 228 | 229 | } 230 | -------------------------------------------------------------------------------- /sttz.InstallUnity/Installer/IInstallerPlatform.cs: -------------------------------------------------------------------------------- 1 | using System.Collections.Generic; 2 | using System.Threading; 3 | using System.Threading.Tasks; 4 | 5 | using static sttz.InstallUnity.UnityReleaseAPIClient; 6 | 7 | namespace sttz.InstallUnity 8 | { 9 | 10 | /// 11 | /// Class representing an existing Unity installation. 12 | /// 13 | public class Installation 14 | { 15 | /// 16 | /// Path to the Unity installation root. 17 | /// 18 | public string path; 19 | 20 | /// 21 | /// Path to the Unity executable. 22 | /// 23 | public string executable; 24 | 25 | /// 26 | /// Version of the installation. 27 | /// 28 | public UnityVersion version; 29 | } 30 | 31 | /// 32 | /// Interface for the platform-specific installer implementation. 33 | /// 34 | public interface IInstallerPlatform 35 | { 36 | /// 37 | /// The platform that should be used by default. 38 | /// 39 | Task<(Platform, Architecture)> GetCurrentPlatform(); 40 | 41 | /// 42 | /// Get architectures that can be installed on the current platform. 43 | /// 44 | Task GetInstallableArchitectures(); 45 | 46 | /// 47 | /// The path to the file where settings are stored. 48 | /// 49 | string GetConfigurationDirectory(); 50 | 51 | /// 52 | /// The directory where cache files are stored. 53 | /// 54 | string GetCacheDirectory(); 55 | 56 | /// 57 | /// The directory where downloaded files are temporarily stored. 58 | /// 59 | string GetDownloadDirectory(); 60 | 61 | /// 62 | /// Check wether the user is admin when installing. 63 | /// 64 | Task IsAdmin(CancellationToken cancellation = default); 65 | 66 | /// 67 | /// Prompt for the admin password if it's necessary to install Unity. 68 | /// 69 | /// If the password was acquired successfully 70 | Task PromptForPasswordIfNecessary(CancellationToken cancellation = default); 71 | 72 | /// 73 | /// Find all existing Unity installations. 74 | /// 75 | Task> FindInstallations(CancellationToken cancellation = default); 76 | 77 | /// 78 | /// Move an existing Unity installation. 79 | /// 80 | Task MoveInstallation(Installation installation, string newPath, CancellationToken cancellation = default); 81 | 82 | /// 83 | /// Prepare to install the given version of Unity. 84 | /// 85 | /// The installation queue to prepare 86 | /// Paths to try to move the installation to (see ) 87 | /// Cancellation token 88 | Task PrepareInstall(UnityInstaller.Queue queue, string installationPaths, CancellationToken cancellation = default); 89 | 90 | /// 91 | /// Install a package ( has to be called first). 92 | /// 93 | /// The installation queue 94 | /// The queue item to install 95 | /// Cancellation token 96 | /// 97 | Task Install(UnityInstaller.Queue queue, UnityInstaller.QueueItem item, CancellationToken cancellation = default); 98 | 99 | /// 100 | /// Complete an installation after all packages have been installed. 101 | /// 102 | /// Cancellation token 103 | Task CompleteInstall(bool aborted, CancellationToken cancellation = default); 104 | 105 | /// 106 | /// Uninstall a Unity installation. 107 | /// 108 | Task Uninstall(Installation instalation, CancellationToken cancellation = default); 109 | 110 | /// 111 | /// Run a Unity installation with the given arguments. 112 | /// 113 | /// Unity installation to run 114 | /// Arguments to pass to Unity 115 | /// Wether to run Unity as a child process and forward its standard input/error and log 116 | Task Run(Installation installation, IEnumerable arguments, bool child); 117 | } 118 | 119 | } 120 | -------------------------------------------------------------------------------- /sttz.InstallUnity/Installer/Scraper.cs: -------------------------------------------------------------------------------- 1 | using IniParser.Parser; 2 | using Microsoft.Extensions.Logging; 3 | using System; 4 | using System.Collections.Generic; 5 | using System.Linq; 6 | using System.Net.Http; 7 | using System.Text.RegularExpressions; 8 | using System.Threading; 9 | using System.Threading.Tasks; 10 | 11 | using static sttz.InstallUnity.UnityReleaseAPIClient; 12 | 13 | namespace sttz.InstallUnity 14 | { 15 | 16 | // Examples of JSON and ini files: 17 | // https://public-cdn.cloud.unity3d.com/hub/prod/releases-darwin.json 18 | // https://download.unity3d.com/download_unity/9fd71167a288/unity-2017.1.4f1-osx.ini 19 | // http://download.unity3d.com/download_unity/787658998520/unity-2018.2.0f2-osx.ini 20 | // https://beta.unity3d.com/download/48afb4a72b1a/unity-2018.2.1f1-linux.ini 21 | // https://netstorage.unity3d.com/unity/1a9968d9f99c/unity-2018.2.1f1-win.ini 22 | 23 | // https://netstorage.unity3d.com/unity and http://download.unity3d.com/download_unity seem to be interchangeable 24 | 25 | /// 26 | /// Discover available Unity versions. 27 | /// 28 | public class Scraper 29 | { 30 | // -------- Version Indices -------- 31 | 32 | /// 33 | /// Base URL of Unity homepage. 34 | /// 35 | const string UNITY_BASE_URL = "https://unity.com"; 36 | 37 | /// 38 | /// HTML archive of Unity releases. 39 | /// 40 | const string UNITY_ARCHIVE = "https://unity.com/releases/editor/archive"; 41 | 42 | /// 43 | /// Landing page for Unity beta releases. 44 | /// 45 | const string UNITY_BETA = "https://unity.com/releases/editor/beta"; 46 | 47 | /// 48 | /// Landing page for Unity alpha releases. 49 | /// 50 | const string UNITY_ALPHA = "https://unity.com/releases/editor/alpha"; 51 | 52 | // -------- Release Notes -------- 53 | 54 | /// 55 | /// HTML release notes of final Unity releases (append a version without type or build number, e.g. 2018.2.1) 56 | /// 57 | const string UNITY_RELEASE_NOTES_FINAL = "https://unity.com/releases/editor/whats-new/"; 58 | 59 | /// 60 | /// HTML release notes of alpha Unity releases (append a full alpha version string) 61 | /// 62 | const string UNITY_RELEASE_NOTES_ALPHA = "https://unity.com/releases/editor/alpha/"; 63 | 64 | /// 65 | /// HTML release notes of beta Unity releases (append a full beta version string) 66 | /// 67 | const string UNITY_RELEASE_NOTES_BETA = "https://unity.com/releases/editor/beta/"; 68 | 69 | // -------- INIs -------- 70 | 71 | /// 72 | /// Base URL where INIs are located (append version hash). 73 | /// 74 | const string INI_BASE_URL = "https://download.unity3d.com/download_unity/"; 75 | 76 | /// 77 | /// Base URL where INIs of beta/alpha versions are located (append version hash). 78 | /// 79 | const string INI_BETA_BASE_URL = "http://beta.unity3d.com/download/"; 80 | 81 | /// 82 | /// Name of INI file with packages information (replace {0} with version and {1} with osx or win). 83 | /// 84 | const string UNITY_INI_FILENAME = "unity-{0}-{1}.ini"; 85 | 86 | // -------- Regular Expressions -------- 87 | 88 | /// 89 | /// Regex to extract version information from unityhub URL. 90 | /// 91 | static readonly Regex UNITYHUB_RE = new Regex(@"unityhub:\/\/(\d+\.\d+\.\d+\w\d+)\/([0-9a-f]{12})"); 92 | 93 | /// 94 | /// Regex to extract version information from installer download URL. 95 | /// 96 | static readonly Regex UNITY_DOWNLOAD_RE = new Regex(@"https?:\/\/[\w.-]+unity3d\.com\/[\w\/.-]+\/([0-9a-f]{12})\/(?:[^\/]+\/)[\w\/.-]+-(\d+\.\d+\.\d+\w\d+)[\w\/.-]+"); 97 | 98 | /// 99 | /// Regex to extract available prerelease versions from landing page. 100 | /// 101 | static readonly Regex UNITY_PRERELEASE_RE = new Regex(@"\/releases\/editor\/(alpha|beta)\/(\d+\.\d+\.\d+\w\d+)"); 102 | 103 | // -------- Scraper -------- 104 | 105 | static HttpClient client = new HttpClient(); 106 | 107 | ILogger Logger = UnityInstaller.CreateLogger(); 108 | 109 | /// 110 | /// Load the available final versions. 111 | /// 112 | /// 113 | /// Task returning the discovered versions 114 | public async Task> LoadFinal(CancellationToken cancellation = default) 115 | { 116 | Logger.LogInformation($"Scraping latest releases for {UnityVersion.Type.Final} from '{UNITY_ARCHIVE}'"); 117 | var response = await client.GetAsync(UNITY_ARCHIVE, cancellation); 118 | if (!response.IsSuccessStatusCode) { 119 | Logger.LogWarning($"Failed to scrape url '{UNITY_ARCHIVE}' ({response.StatusCode})"); 120 | return Enumerable.Empty(); 121 | } 122 | 123 | var html = await response.Content.ReadAsStringAsync(); 124 | Logger.LogTrace($"Got response: {html}"); 125 | 126 | return ExtractFromHtml(html, ReleaseStream.None).Values; 127 | } 128 | 129 | /// 130 | /// Load the available beta and/or alpha versions. 131 | /// 132 | /// 133 | /// Task returning the discovered versions 134 | public async Task> LoadPrerelease(bool includeAlpha, IEnumerable knownVersions = null, int scrapeDelay = 50, CancellationToken cancellation = default) 135 | { 136 | var results = new Dictionary(); 137 | 138 | if (includeAlpha) { 139 | await LoadPrerelease(UNITY_ALPHA, ReleaseStream.Alpha, results, knownVersions, scrapeDelay, cancellation); 140 | } 141 | 142 | await LoadPrerelease(UNITY_BETA, ReleaseStream.Beta, results, knownVersions, scrapeDelay, cancellation); 143 | 144 | return results.Values; 145 | } 146 | 147 | /// 148 | /// Load the available prerelase versions from a alpha/beta landing page. 149 | /// 150 | async Task LoadPrerelease(string url, ReleaseStream stream, Dictionary results, IEnumerable knownVersions = null, int scrapeDelay = 50, CancellationToken cancellation = default) 151 | { 152 | // Load major version's individual prerelease page to get individual versions 153 | Logger.LogInformation($"Scraping latest prereleases from '{url}'"); 154 | await Task.Delay(scrapeDelay); 155 | var response = await client.GetAsync(url, cancellation); 156 | if (!response.IsSuccessStatusCode) { 157 | Logger.LogWarning($"Failed to scrape url '{url}' ({response.StatusCode})"); 158 | return; 159 | } 160 | 161 | var html = await response.Content.ReadAsStringAsync(); 162 | Logger.LogTrace($"Got response: {html}"); 163 | 164 | var versionMatches = UNITY_PRERELEASE_RE.Matches(html); 165 | foreach (Match versionMatch in versionMatches) { 166 | var version = new UnityVersion(versionMatch.Groups[2].Value); 167 | if (results.ContainsKey(version)) continue; 168 | if (knownVersions != null && knownVersions.Contains(version)) continue; 169 | 170 | // Load version's release notes to get download links 171 | var prereleaseUrl = UNITY_BASE_URL + versionMatch.Value; 172 | Logger.LogInformation($"Scraping {versionMatch.Groups[1].Value} {version} from '{prereleaseUrl}'"); 173 | await Task.Delay(scrapeDelay); 174 | response = await client.GetAsync(prereleaseUrl, cancellation); 175 | if (!response.IsSuccessStatusCode) { 176 | Logger.LogWarning($"Could not load release notes at url '{prereleaseUrl}' ({response.StatusCode})"); 177 | continue; 178 | } 179 | 180 | html = await response.Content.ReadAsStringAsync(); 181 | Logger.LogTrace($"Got response: {html}"); 182 | ExtractFromHtml(html, stream, results); 183 | } 184 | } 185 | 186 | /// 187 | /// Get the INI base URL for the given version type. 188 | /// 189 | string GetIniBaseUrl(UnityVersion.Type type) 190 | { 191 | if (type == UnityVersion.Type.Beta || type == UnityVersion.Type.Alpha) { 192 | return INI_BETA_BASE_URL; 193 | } else { 194 | return INI_BASE_URL; 195 | } 196 | } 197 | 198 | /// 199 | /// Extract the versions and the base URLs from the html string. 200 | /// 201 | Dictionary ExtractFromHtml(string html, ReleaseStream stream, Dictionary results = null) 202 | { 203 | var matches = UNITYHUB_RE.Matches(html); 204 | results = results ?? new Dictionary(); 205 | foreach (Match match in matches) { 206 | var version = new UnityVersion(match.Groups[1].Value); 207 | version.hash = match.Groups[2].Value; 208 | 209 | VersionMetadata metadata = default; 210 | if (!results.TryGetValue(version, out metadata)) { 211 | if (stream == ReleaseStream.None) 212 | metadata = CreateEmptyVersion(version, stream); 213 | } 214 | 215 | metadata.baseUrl = GetIniBaseUrl(version.type) + version.hash + "/"; 216 | results[version] = metadata; 217 | } 218 | 219 | matches = UNITY_DOWNLOAD_RE.Matches(html); 220 | foreach (Match match in matches) { 221 | var version = new UnityVersion(match.Groups[2].Value); 222 | version.hash = match.Groups[1].Value; 223 | 224 | VersionMetadata metadata = default; 225 | if (!results.TryGetValue(version, out metadata)) { 226 | metadata = CreateEmptyVersion(version, stream); 227 | } 228 | 229 | metadata.baseUrl = GetIniBaseUrl(version.type) + version.hash + "/"; 230 | results[version] = metadata; 231 | } 232 | 233 | return results; 234 | } 235 | 236 | /// 237 | /// Convert a UnityHub URL to a VersionMetadata struct. 238 | /// 239 | /// 240 | /// Conversion is completely offline, no http lookup is made. 241 | /// 242 | public VersionMetadata UnityHubUrlToVersion(string url) 243 | { 244 | var match = UNITYHUB_RE.Match(url); 245 | if (!match.Success) return default(VersionMetadata); 246 | 247 | var version = new UnityVersion(match.Groups[1].Value); 248 | version.hash = match.Groups[2].Value; 249 | 250 | var metadata = CreateEmptyVersion(version, ReleaseStream.None); 251 | metadata.baseUrl = GetIniBaseUrl(version.type) + version.hash + "/"; 252 | 253 | return metadata; 254 | } 255 | 256 | /// 257 | /// Try to load the metadata from a version by guessing its release notes URL. 258 | /// 259 | /// 260 | /// The version must include major, minor and patch components. 261 | /// For beta and alpha releases, it must also contain the build component. 262 | /// If no type is set, final is assumed. 263 | /// 264 | /// The version 265 | /// The metadata or the default value if the version couldn't be found. 266 | public async Task LoadExact(UnityVersion version, CancellationToken cancellation = default) 267 | { 268 | if (version.major < 0 || version.minor < 0 || version.patch < 0) { 269 | throw new ArgumentException("The Unity version is incomplete (major, minor or patch missing)", nameof(version)); 270 | } 271 | if (version.type != UnityVersion.Type.Final && version.type != UnityVersion.Type.Undefined && version.build < 0) { 272 | throw new ArgumentException("The Unity version is incomplete (build missing)", nameof(version)); 273 | } 274 | 275 | var stream = GuessStreamFromVersion(version); 276 | var url = GetReleaseNotesUrl(stream, version); 277 | if (url == null) { 278 | throw new ArgumentException("The Unity version type is not supported: " + version.type, nameof(version)); 279 | } 280 | 281 | Logger.LogInformation($"Guessed release notes url for exact version {version}: {url}"); 282 | return await LoadUrl(url, cancellation); 283 | } 284 | 285 | /// 286 | /// Try to load metadata from a version by scraping a custom URL. 287 | /// 288 | /// URL to a HTML page to look for Unity versions. 289 | /// The first Unity version found at URL or the default value if none could be found. 290 | public async Task LoadUrl(string url, CancellationToken cancellation = default) 291 | { 292 | Logger.LogInformation($"Trying to find Unity version at url: {url}"); 293 | 294 | var response = await client.GetAsync(url, cancellation); 295 | if (!response.IsSuccessStatusCode) { 296 | return default; 297 | } 298 | 299 | var html = await response.Content.ReadAsStringAsync(); 300 | Logger.LogTrace($"Got response: {html}"); 301 | return ExtractFromHtml(html, ReleaseStream.None).Values.FirstOrDefault(); 302 | } 303 | 304 | /// 305 | /// Load the packages of a Unity version. 306 | /// The VersionMetadata must have iniUrl set. 307 | /// 308 | /// Version metadata with iniUrl. 309 | /// Name of platform to load the packages for 310 | /// A Task returning the metadata with packages filled in. 311 | public async Task LoadPackages(VersionMetadata metadata, Platform platform, Architecture architecture, CancellationToken cancellation = default) 312 | { 313 | if (!metadata.Version.IsFullVersion) { 314 | throw new ArgumentException("Unity version needs to be a full version", nameof(metadata)); 315 | } 316 | 317 | if (platform == Platform.Mac_OS && architecture == Architecture.ARM64 && metadata.Version < new UnityVersion(2021, 2)) { 318 | throw new ArgumentException("Apple Silicon builds are only available from Unity 2021.2", nameof(metadata)); 319 | } 320 | 321 | string platformName = platform switch { 322 | Platform.Mac_OS => "osx", 323 | Platform.Windows => "win", 324 | Platform.Linux => "linux", 325 | _ => throw new NotImplementedException("Invalid platform name: " + platform) 326 | }; 327 | 328 | if (string.IsNullOrEmpty(metadata.baseUrl)) { 329 | throw new ArgumentException("VersionMetadata.baseUrl is not set for " + metadata.Version, nameof(metadata)); 330 | } 331 | 332 | var url = metadata.baseUrl + string.Format(UNITY_INI_FILENAME, metadata.Version.ToString(false), platformName); 333 | Logger.LogInformation($"Loading packages for {metadata.Version} and {platformName} from '{url}'"); 334 | var response = await client.GetAsync(url, cancellation); 335 | response.EnsureSuccessStatusCode(); 336 | 337 | var ini = await response.Content.ReadAsStringAsync(); 338 | Logger.LogTrace($"Got response: {ini}"); 339 | 340 | var parser = new IniDataParser(); 341 | IniParser.Model.IniData data = null; 342 | try { 343 | data = parser.Parse(ini); 344 | } catch (Exception e) { 345 | Logger.LogWarning($"Error parsing ini file, trying again with skipping invalid lines... ({e.Message})"); 346 | parser.Configuration.SkipInvalidLines = true; 347 | data = parser.Parse(ini); 348 | } 349 | 350 | var editorDownload = new EditorDownload(); 351 | editorDownload.platform = platform; 352 | editorDownload.architecture = architecture; 353 | editorDownload.modules = new List(); 354 | 355 | // Create modules from all entries 356 | var allModules = new Dictionary(StringComparer.OrdinalIgnoreCase); 357 | foreach (var section in data.Sections) { 358 | if (section.SectionName.Equals(EditorDownload.ModuleId, StringComparison.OrdinalIgnoreCase)) { 359 | SetDownloadKeys(editorDownload, section); 360 | continue; 361 | } 362 | 363 | var module = new Module(); 364 | module.id = section.SectionName; 365 | 366 | SetDownloadKeys(module, section); 367 | SetModuleKeys(module, section); 368 | 369 | allModules.Add(module.id, module); 370 | } 371 | 372 | // Add virtual packages 373 | foreach (var virutal in VirtualPackages.GeneratePackages(metadata.Version, editorDownload)) { 374 | allModules.Add(virutal.id, virutal); 375 | } 376 | 377 | // Register sub-modules with their parents 378 | foreach (var module in allModules.Values) { 379 | if (module.parentModuleId == null) continue; 380 | 381 | if (!allModules.TryGetValue(module.parentModuleId, out var parentModule)) 382 | throw new Exception($"Missing parent module '{module.parentModuleId}' for modules '{module.id}'"); 383 | 384 | if (parentModule.subModules == null) 385 | parentModule.subModules = new List(); 386 | 387 | parentModule.subModules.Add(module); 388 | module.parentModule = parentModule; 389 | } 390 | 391 | // Register remaining root modules with main editor download 392 | foreach (var possibleRoot in allModules.Values) { 393 | if (possibleRoot.parentModule != null) 394 | continue; 395 | 396 | editorDownload.modules.Add(possibleRoot); 397 | } 398 | 399 | Logger.LogInformation($"Found {allModules.Count} packages"); 400 | 401 | // Patch editor URL to point to Apple Silicon editor 402 | // The old ini system probably won't be updated to include Apple Silicon variants 403 | if (platform == Platform.Mac_OS && architecture == Architecture.ARM64) { 404 | // Change e.g. 405 | // https://download.unity3d.com/download_unity/e50cafbb4399/MacEditorInstaller/Unity.pkg 406 | // to 407 | // https://download.unity3d.com/download_unity/e50cafbb4399/MacEditorInstallerArm64/Unity.pkg 408 | var editorUrl = editorDownload.url; 409 | if (!editorUrl.StartsWith("MacEditorInstaller/")) { 410 | throw new Exception($"Cannot convert to Apple Silicon editor URL: Does not start with 'MacEditorInstaller' (got '{editorUrl}')"); 411 | } 412 | editorUrl = editorUrl.Replace("MacEditorInstaller/", "MacEditorInstallerArm64/"); 413 | editorDownload.url = editorUrl; 414 | 415 | // Clear fields that are now invalid 416 | editorDownload.integrity = null; 417 | } 418 | 419 | metadata.SetEditorDownload(editorDownload); 420 | return metadata; 421 | } 422 | 423 | void SetDownloadKeys(Download download, IniParser.Model.SectionData section) 424 | { 425 | foreach (var pair in section.Keys) { 426 | switch (pair.KeyName) { 427 | case "url": 428 | download.url = pair.Value; 429 | break; 430 | case "extension": 431 | download.type = pair.Value switch { 432 | "txt" => FileType.TEXT, 433 | "zip" => FileType.ZIP, 434 | "pkg" => FileType.PKG, 435 | "exe" => FileType.EXE, 436 | "po" => FileType.PO, 437 | "dmg" => FileType.DMG, 438 | _ => FileType.Undefined, 439 | }; 440 | break; 441 | case "size": 442 | download.downloadSize.value = long.Parse(pair.Value); 443 | download.downloadSize.unit = "BYTE"; 444 | break; 445 | case "installedsize": 446 | download.installedSize.value = long.Parse(pair.Value); 447 | download.installedSize.unit = "BYTE"; 448 | break; 449 | case "md5": 450 | download.integrity = $"md5-{pair.Value}"; 451 | break; 452 | } 453 | } 454 | } 455 | 456 | void SetModuleKeys(Module download, IniParser.Model.SectionData section) 457 | { 458 | var eulaUrl1 = section.Keys["eulaurl1"]; 459 | if (eulaUrl1 != null) { 460 | var eulaMessage = section.Keys["eulamessage"]; 461 | var eulaUrl2 = section.Keys["eulaurl2"]; 462 | 463 | var eulaCount = (eulaUrl2 != null ? 2 : 1); 464 | download.eula = new(eulaCount); 465 | 466 | download.eula.Add(new Eula() { 467 | message = eulaMessage, 468 | label = section.Keys["eulalabel1"], 469 | url = eulaUrl1 470 | }); 471 | 472 | if (eulaCount > 1) { 473 | download.eula.Add(new Eula() { 474 | message = eulaMessage, 475 | label = section.Keys["eulalabel2"], 476 | url = eulaUrl2 477 | }); 478 | } 479 | } 480 | 481 | foreach (var pair in section.Keys) { 482 | switch (pair.KeyName) { 483 | case "title": 484 | download.name = pair.Value; 485 | break; 486 | case "description": 487 | download.description = pair.Value; 488 | break; 489 | case "install": 490 | download.preSelected = bool.Parse(pair.Value); 491 | break; 492 | case "mandatory": 493 | download.required = bool.Parse(pair.Value); 494 | break; 495 | case "hidden": 496 | download.hidden = bool.Parse(pair.Value); 497 | break; 498 | case "sync": 499 | download.parentModuleId = pair.Value; 500 | break; 501 | } 502 | } 503 | } 504 | 505 | /// 506 | /// Create a new empty version. 507 | /// 508 | static VersionMetadata CreateEmptyVersion(UnityVersion version, ReleaseStream stream) 509 | { 510 | var meta = new VersionMetadata(); 511 | meta.release = new Release(); 512 | meta.release.version = version; 513 | meta.release.shortRevision = version.hash; 514 | 515 | if (stream == ReleaseStream.None) 516 | stream = GuessStreamFromVersion(version); 517 | meta.release.stream = stream; 518 | 519 | return meta; 520 | } 521 | 522 | /// 523 | /// Guess the release stream based on the Unity version. 524 | /// 525 | public static ReleaseStream GuessStreamFromVersion(UnityVersion version) 526 | { 527 | if (version.type == UnityVersion.Type.Alpha) { 528 | return ReleaseStream.Alpha; 529 | } else if (version.type == UnityVersion.Type.Beta) { 530 | return ReleaseStream.Beta; 531 | } else if (version.major >= 2017 && version.major <= 2019 && version.minor == 4) { 532 | return ReleaseStream.LTS; 533 | } else if (version.major >= 2020 && version.minor == 3) { 534 | return ReleaseStream.LTS; 535 | } else if (version.major < 6000) { 536 | return ReleaseStream.Tech; 537 | } else { 538 | return ReleaseStream.Supported; 539 | } 540 | } 541 | 542 | /// 543 | /// Guess the release notes URL for a version. 544 | /// 545 | public static string GetReleaseNotesUrl(ReleaseStream stream, UnityVersion version) 546 | { 547 | switch (stream) { 548 | case ReleaseStream.Alpha: 549 | return UNITY_RELEASE_NOTES_ALPHA + version.ToString(false); 550 | case ReleaseStream.Beta: 551 | return UNITY_RELEASE_NOTES_BETA + version.ToString(false); 552 | default: 553 | return UNITY_RELEASE_NOTES_FINAL + $"{version.major}.{version.minor}.{version.patch}"; 554 | } 555 | } 556 | } 557 | 558 | } 559 | -------------------------------------------------------------------------------- /sttz.InstallUnity/Installer/UnityReleaseAPIClient.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.Extensions.Logging; 2 | using Newtonsoft.Json; 3 | using System; 4 | using System.Collections; 5 | using System.Collections.Generic; 6 | using System.Net; 7 | using System.Net.Http; 8 | using System.Runtime.Serialization; 9 | using System.Threading; 10 | using System.Threading.Tasks; 11 | 12 | namespace sttz.InstallUnity 13 | { 14 | 15 | /// 16 | /// Client for the official Unity Release API. 17 | /// Providing the latest Unity editor releases and associated packages. 18 | /// https://services.docs.unity.com/release/v1/index.html#tag/Release/operation/getUnityReleases 19 | /// 20 | public class UnityReleaseAPIClient 21 | { 22 | // -------- Types -------- 23 | 24 | /// 25 | /// Different Unity release streams. 26 | /// 27 | [Flags] 28 | public enum ReleaseStream 29 | { 30 | None = 0, 31 | 32 | Alpha = 1<<0, 33 | Beta = 1<<1, 34 | Tech = 1<<2, 35 | LTS = 1<<3, 36 | Supported = 1<<4, 37 | 38 | ReleaseMask = Tech | LTS | Supported, 39 | PrereleaseMask = Alpha | Beta, 40 | 41 | All = -1, 42 | } 43 | 44 | /// 45 | /// Platforms the Unity editor runs on. 46 | /// 47 | [Flags] 48 | public enum Platform 49 | { 50 | None, 51 | 52 | Mac_OS = 1<<0, 53 | Linux = 1<<1, 54 | Windows = 1<<2, 55 | 56 | All = -1, 57 | } 58 | 59 | /// 60 | /// CPU architectures the Unity editor supports (on some platforms). 61 | /// 62 | [Flags] 63 | public enum Architecture 64 | { 65 | None = 0, 66 | 67 | X86_64 = 1<<10, 68 | ARM64 = 1<<11, 69 | 70 | All = -1, 71 | } 72 | 73 | /// 74 | /// Different file types of downloads and links. 75 | /// 76 | public enum FileType 77 | { 78 | Undefined, 79 | 80 | TEXT, 81 | TAR_GZ, 82 | TAR_XZ, 83 | ZIP, 84 | PKG, 85 | EXE, 86 | PO, 87 | DMG, 88 | LZMA, 89 | LZ4, 90 | MD, 91 | PDF 92 | } 93 | 94 | /// 95 | /// Response from the Releases API. 96 | /// 97 | [JsonObject(MemberSerialization.Fields)] 98 | public class Response 99 | { 100 | /// 101 | /// Return wether the request was successful. 102 | /// 103 | public bool IsSuccess => ((int)status >= 200 && (int)status <= 299); 104 | 105 | // -------- Response Fields -------- 106 | 107 | /// 108 | /// Start offset from the returned results. 109 | /// 110 | public int offset; 111 | /// 112 | /// Limit of results returned. 113 | /// 114 | public int limit; 115 | /// 116 | /// Total number of results. 117 | /// 118 | public int total; 119 | 120 | /// 121 | /// The release results. 122 | /// 123 | public List results; 124 | 125 | // -------- Error fields -------- 126 | 127 | /// 128 | /// Error code. 129 | /// 130 | public HttpStatusCode status; 131 | /// 132 | /// Title of the error. 133 | /// 134 | public string title; 135 | /// 136 | /// Error detail description. 137 | /// 138 | public string detail; 139 | } 140 | 141 | /// 142 | /// A specific release of the Unity editor. 143 | /// 144 | [JsonObject(MemberSerialization.Fields)] 145 | public class Release 146 | { 147 | /// 148 | /// Version of the editor. 149 | /// 150 | public UnityVersion version; 151 | /// 152 | /// The Git Short Revision of the Unity Release. 153 | /// 154 | public string shortRevision; 155 | 156 | /// 157 | /// Date and time of the release. 158 | /// 159 | public DateTime releaseDate; 160 | /// 161 | /// Link to the release notes. 162 | /// 163 | public ReleaseNotes releaseNotes; 164 | /// 165 | /// Stream this release is part of. 166 | /// 167 | public ReleaseStream stream; 168 | /// 169 | /// The SKU family of the Unity Release. 170 | /// Possible values: CLASSIC or DOTS 171 | /// 172 | public string skuFamily; 173 | /// 174 | /// The indicator for whether the Unity Release is the recommended LTS 175 | /// 176 | public bool recommended; 177 | /// 178 | /// Deep link to open this release in Unity Hub. 179 | /// 180 | public string unityHubDeepLink; 181 | 182 | /// 183 | /// Editor downloads of this release. 184 | /// 185 | public List downloads; 186 | 187 | /// 188 | /// The Third Party Notices of the Unity Release. 189 | /// 190 | public List thirdPartyNotices; 191 | 192 | [OnDeserialized] 193 | internal void OnDeserializedMethod(StreamingContext context) 194 | { 195 | // Copy the short revision to the UnityVersion struct 196 | if (string.IsNullOrEmpty(version.hash) && !string.IsNullOrEmpty(shortRevision)) { 197 | version.hash = shortRevision; 198 | } 199 | } 200 | } 201 | 202 | /// 203 | /// Unity editor release notes. 204 | /// 205 | [JsonObject(MemberSerialization.Fields)] 206 | public struct ReleaseNotes 207 | { 208 | /// 209 | /// Url to the release notes. 210 | /// 211 | public string url; 212 | /// 213 | /// Type of the release notes. 214 | /// (Only seen "MD" so far.) 215 | /// 216 | public FileType type; 217 | } 218 | 219 | /// 220 | /// Third party notices associated with a Unity release. 221 | /// 222 | [JsonObject(MemberSerialization.Fields)] 223 | public struct ThirdPartyNotice 224 | { 225 | /// 226 | /// The original file name of the Unity Release Third Party Notice. 227 | /// 228 | public string originalFileName; 229 | /// 230 | /// The URL of the Unity Release Third Party Notice. 231 | /// 232 | public string url; 233 | /// 234 | /// Type of the release notes. 235 | /// 236 | public FileType type; 237 | } 238 | 239 | /// 240 | /// An Unity editor download, including available modules. 241 | /// 242 | [JsonObject(MemberSerialization.Fields)] 243 | public abstract class Download 244 | { 245 | /// 246 | /// Url to download. 247 | /// 248 | public string url; 249 | /// 250 | /// Integrity hash (hash prefixed by hash type plus dash, seen md5 and sha384). 251 | /// 252 | public string integrity; 253 | /// 254 | /// Type of download. 255 | /// (Only seen "DMG", "PKG", "ZIP" and "PO" so far) 256 | /// 257 | public FileType type; 258 | /// 259 | /// Size of the download. 260 | /// 261 | public FileSize downloadSize; 262 | /// 263 | /// Size required on disk. 264 | /// 265 | public FileSize installedSize; 266 | 267 | /// 268 | /// ID of the download. 269 | /// 270 | public abstract string Id { get; } 271 | } 272 | 273 | /// 274 | /// Main editor download. 275 | /// 276 | [JsonObject(MemberSerialization.Fields)] 277 | public class EditorDownload : Download 278 | { 279 | /// 280 | /// The Id of the editor download pseudo-module. 281 | /// 282 | public const string ModuleId = "unity"; 283 | 284 | /// 285 | /// Platform of the editor. 286 | /// 287 | public Platform platform; 288 | /// 289 | /// Architecture of the editor. 290 | /// 291 | public Architecture architecture; 292 | /// 293 | /// Available modules for this editor version. 294 | /// 295 | public List modules; 296 | 297 | /// 298 | /// Editor downloads all have the fixed "Unity" ID. 299 | /// 300 | public override string Id => ModuleId; 301 | 302 | /// 303 | /// Dictionary of all modules, including sub-modules. 304 | /// 305 | public Dictionary AllModules { get { 306 | if (_allModules == null) { 307 | _allModules = new Dictionary(StringComparer.OrdinalIgnoreCase); 308 | if (modules != null) { 309 | foreach (var module in modules) { 310 | AddModulesRecursive(module); 311 | } 312 | } 313 | } 314 | return _allModules; 315 | } } 316 | [NonSerialized] Dictionary _allModules; 317 | 318 | void AddModulesRecursive(Module module) 319 | { 320 | if (string.IsNullOrEmpty(module.id)) { 321 | throw new Exception($"EditorDownload.AllModules: Module is missing ID"); 322 | } 323 | 324 | if (!_allModules.TryAdd(module.id, module)) { 325 | throw new Exception($"EditorDownload.AllModules: Multiple modules with id '{module.id}'"); 326 | } 327 | 328 | if (module.subModules != null) { 329 | foreach (var subModule in module.subModules) { 330 | if (subModule == null) continue; 331 | AddModulesRecursive(subModule); 332 | } 333 | } 334 | } 335 | } 336 | 337 | /// 338 | /// Size description of a download or space required for install. 339 | /// 340 | [JsonObject(MemberSerialization.Fields)] 341 | public struct FileSize 342 | { 343 | /// 344 | /// Size value. 345 | /// 346 | public long value; 347 | /// 348 | /// Unit of the value. 349 | /// Possible vaues: BYTE, KILOBYTE, MEGABYTE, GIGABYTE 350 | /// (Only seen "BYTE" so far.) 351 | /// 352 | public string unit; 353 | 354 | /// 355 | /// Return the size in bytes, converting from the source unit when necessary. 356 | /// 357 | public long GetBytes() 358 | { 359 | switch (unit) { 360 | case "BYTE": 361 | return value; 362 | case "KILOBYTE": 363 | return value * 1024; 364 | case "MEGABYTE": 365 | return value * 1024 * 1024; 366 | case "GIGABYTE": 367 | return value * 1024 * 1024 * 1024; 368 | default: 369 | throw new Exception($"FileSize: Unhandled size unit '{unit}'"); 370 | } 371 | } 372 | 373 | /// 374 | /// Create a new instance with the given amount of bytes. 375 | /// 376 | public static FileSize FromBytes(long bytes) 377 | => new FileSize() { value = bytes, unit = "BYTE" }; 378 | 379 | /// 380 | /// Create a new instance with the given amount of bytes. 381 | /// 382 | public static FileSize FromMegaBytes(long megaBytes) 383 | => new FileSize() { value = megaBytes, unit = "MEGABYTE" }; 384 | } 385 | 386 | /// 387 | /// A module of an editor. 388 | /// 389 | [JsonObject(MemberSerialization.Fields)] 390 | public class Module : Download 391 | { 392 | /// 393 | /// Identifier of the module. 394 | /// 395 | public string id; 396 | /// 397 | /// Slug identifier of the module. 398 | /// 399 | public string slug; 400 | /// 401 | /// Name of the module. 402 | /// 403 | public string name; 404 | /// 405 | /// Description of the module. 406 | /// 407 | public string description; 408 | /// 409 | /// Category type of the module. 410 | /// 411 | public string category; 412 | /// 413 | /// Wether this module is required for its parent module. 414 | /// 415 | public bool required; 416 | /// 417 | /// Wether this module is hidden from the user. 418 | /// 419 | public bool hidden; 420 | /// 421 | /// Wether this module is installed by default. 422 | /// 423 | public bool preSelected; 424 | /// 425 | /// Where to install the module to (can contain the {UNITY_PATH} variable). 426 | /// 427 | public string destination; 428 | /// 429 | /// How to rename the installed directory. 430 | /// 431 | public PathRename extractedPathRename; 432 | /// 433 | /// EULAs the user should accept before installing. 434 | /// 435 | public List eula; 436 | /// 437 | /// Sub-Modules of this module. 438 | /// 439 | public List subModules; 440 | 441 | /// 442 | /// Modules return their dynamic id. 443 | /// 444 | public override string Id => id; 445 | /// 446 | /// Id of the parent module. 447 | /// 448 | [NonSerialized] public string parentModuleId; 449 | /// 450 | /// The parent module that lists this sub-module (null = part of main editor module). 451 | /// 452 | [NonSerialized] public Module parentModule; 453 | /// 454 | /// Used to track automatically added dependencies. 455 | /// 456 | [NonSerialized] public bool addedAutomatically; 457 | 458 | [OnDeserialized] 459 | internal void OnDeserializedMethod(StreamingContext context) 460 | { 461 | if (subModules != null) { 462 | // Set ourself as parent module on sub-modules 463 | foreach (var sub in subModules) { 464 | if (sub == null) continue; 465 | sub.parentModule = this; 466 | sub.parentModuleId = id; 467 | } 468 | } 469 | } 470 | } 471 | 472 | /// 473 | /// EULA of a module. 474 | /// 475 | [JsonObject(MemberSerialization.Fields)] 476 | public struct Eula 477 | { 478 | /// 479 | /// URL to the EULA. 480 | /// 481 | public string url; 482 | /// 483 | /// Type of content at the url. 484 | /// (Only seen "TEXT" so far.) 485 | /// 486 | public FileType type; 487 | /// 488 | /// Label for this EULA. 489 | /// 490 | public string label; 491 | /// 492 | /// Explanation message for the user. 493 | /// 494 | public string message; 495 | } 496 | 497 | /// 498 | /// Path rename instruction. 499 | /// 500 | [JsonObject(MemberSerialization.Fields)] 501 | public struct PathRename 502 | { 503 | /// 504 | /// Path to rename from (can contain the {UNITY_PATH} variable). 505 | /// 506 | public string from; 507 | /// 508 | /// Path to rename to (can contain the {UNITY_PATH} variable). 509 | /// 510 | public string to; 511 | 512 | /// 513 | /// Wether both a from and to path are set. 514 | /// 515 | public bool IsSet => (!string.IsNullOrEmpty(from) && !string.IsNullOrEmpty(to)); 516 | } 517 | 518 | // -------- API -------- 519 | 520 | /// 521 | /// Order of the results returned by the API. 522 | /// 523 | [Flags] 524 | public enum ResultOrder 525 | { 526 | /// 527 | /// Default order (release date descending). 528 | /// 529 | Default = 0, 530 | 531 | // -------- Sorting Cireteria -------- 532 | 533 | /// 534 | /// Order by release date. 535 | /// 536 | ReleaseDate = 1<<0, 537 | 538 | // -------- Sorting Order -------- 539 | 540 | /// 541 | /// Return results in ascending order. 542 | /// 543 | Ascending = 1<<30, 544 | /// 545 | /// Return results in descending order. 546 | /// 547 | Descending = 1<<31, 548 | } 549 | 550 | /// 551 | /// Request parameters of the Unity releases API. 552 | /// 553 | public class RequestParams 554 | { 555 | /// 556 | /// Version filter, applied as full-text search on the version string. 557 | /// 558 | public string version = null; 559 | /// 560 | /// Unity release streams to load (can set multiple flags in bitmask). 561 | /// 562 | public ReleaseStream stream = ReleaseStream.All; 563 | /// 564 | /// Platforms to load (can set multiple flags in bitmask). 565 | /// 566 | public Platform platform = Platform.All; 567 | /// 568 | /// Architectures to load (can set multiple flags in bitmask). 569 | /// 570 | public Architecture architecture = Architecture.All; 571 | 572 | /// 573 | /// How many results to return (1-25). 574 | /// 575 | public int limit = 10; 576 | /// 577 | /// Offset of the first result returned 578 | /// 579 | public int offset = 0; 580 | /// 581 | /// Order of returned results. 582 | /// 583 | public ResultOrder order; 584 | } 585 | 586 | /// 587 | /// Maximum number of requests that can be made per second. 588 | /// 589 | public const int MaxRequestsPerSecond = 10; 590 | /// 591 | /// Maximum number of requests that can be made per 30 minutes. 592 | /// (Not currently tracked by the client.) 593 | /// 594 | public const int MaxRequestsPerHalfHour = 1000; 595 | 596 | /// 597 | /// Send a basic request to the Release API. 598 | /// 599 | public async Task Send(RequestParams request, CancellationToken cancellation = default) 600 | { 601 | var parameters = new List>(); 602 | parameters.Add(new (nameof(RequestParams.limit), request.limit.ToString("R"))); 603 | parameters.Add(new (nameof(RequestParams.offset), request.offset.ToString("R"))); 604 | 605 | if (!string.IsNullOrEmpty(request.version)) { 606 | parameters.Add(new (nameof(RequestParams.version), request.version)); 607 | } 608 | if (request.stream != ReleaseStream.All) { 609 | AddArrayParameters(parameters, nameof(RequestParams.stream), StreamValues, request.stream); 610 | } 611 | if (request.platform != Platform.All) { 612 | AddArrayParameters(parameters, nameof(RequestParams.platform), PlatformValues, request.platform); 613 | } 614 | if (request.architecture != Architecture.All) { 615 | AddArrayParameters(parameters, nameof(RequestParams.architecture), ArchitectureValues, request.architecture); 616 | } 617 | if (request.order != ResultOrder.Default) { 618 | if (request.order.HasFlag(ResultOrder.ReleaseDate)) { 619 | var dir = (request.order.HasFlag(ResultOrder.Descending) ? "_DESC" : "_ASC"); 620 | parameters.Add(new (nameof(RequestParams.order), "RELEASE_DATE" + dir)); 621 | } 622 | } 623 | 624 | var query = await new FormUrlEncodedContent(parameters).ReadAsStringAsync(cancellation); 625 | Logger.LogDebug($"Sending request to Unity Releases API with query '{Endpoint + query}'"); 626 | 627 | var timeSinceLastRequest = DateTime.Now - lastRequestTime; 628 | var minRequestInterval = TimeSpan.FromSeconds(1) / MaxRequestsPerSecond; 629 | if (timeSinceLastRequest < minRequestInterval) { 630 | // Delay request to not exceed max requests per second 631 | await Task.Delay(minRequestInterval - timeSinceLastRequest); 632 | } 633 | 634 | lastRequestTime = DateTime.Now; 635 | var response = await client.GetAsync(Endpoint + query, cancellation); 636 | 637 | var json = await response.Content.ReadAsStringAsync(cancellation); 638 | Logger.LogTrace($"Received response from Unity Releases API ({response.StatusCode}): {json}"); 639 | if (string.IsNullOrEmpty(json)) { 640 | throw new Exception($"Got empty response from Unity Releases API (code {response.StatusCode})"); 641 | } 642 | 643 | var parsedResponse = JsonConvert.DeserializeObject(json); 644 | if (parsedResponse.status == 0) { 645 | parsedResponse.status = response.StatusCode; 646 | } 647 | 648 | return parsedResponse; 649 | } 650 | 651 | /// 652 | /// Load all releases for the given request, making multiple 653 | /// paginated requests to the API. 654 | /// 655 | /// The request to send, the limit and offset fields will be modified 656 | /// Limit returned results to not make too many requests 657 | /// Cancellation token 658 | /// The results returned from the API 659 | public async Task> LoadAll(RequestParams request, int maxResults = 200, CancellationToken cancellation = default) 660 | { 661 | request.limit = 25; 662 | 663 | int maxTotal = 0, currentOffset = 0; 664 | var releases = new List(); 665 | Response response; 666 | do { 667 | response = await Send(request, cancellation); 668 | if (!response.IsSuccess) { 669 | throw new Exception($"Unity Release API request failed: {response.title} - {response.detail}"); 670 | } 671 | 672 | releases.AddRange(response.results); 673 | currentOffset += response.results.Count; 674 | 675 | request.offset += response.results.Count; 676 | 677 | } while (currentOffset < maxTotal && response.results.Count > 0); 678 | 679 | return releases; 680 | } 681 | 682 | /// 683 | /// Load all latest releases from the given time period, 684 | /// making multiple paginated requests to the API. 685 | /// 686 | /// The request to send, the limit, offset and order fields will be modified 687 | /// The period to load releases from 688 | /// Cancellation token 689 | /// The results returned from the API, can contain releases older than the given period 690 | public async Task> LoadLatest(RequestParams request, TimeSpan period, CancellationToken cancellation = default) 691 | { 692 | request.limit = 25; 693 | request.order = ResultOrder.ReleaseDate | ResultOrder.Descending; 694 | 695 | var releases = new List(); 696 | var now = DateTime.Now; 697 | Response response = null; 698 | do { 699 | response = await Send(request, cancellation); 700 | if (!response.IsSuccess) { 701 | throw new Exception($"Unity Release API request failed: {response.title} - {response.detail}"); 702 | } else if (response.results.Count == 0) { 703 | break; 704 | } 705 | 706 | releases.AddRange(response.results); 707 | request.offset += response.results.Count; 708 | 709 | var oldestReleaseDate = response.results[^1].releaseDate; 710 | var releasedSince = now - oldestReleaseDate; 711 | if (releasedSince > period) { 712 | break; 713 | } 714 | 715 | } while (true); 716 | 717 | return releases; 718 | } 719 | 720 | /// 721 | /// Try to find a release based on version string search. 722 | /// 723 | public async Task FindRelease(UnityVersion version, Platform platform, Architecture architecture, CancellationToken cancellation = default) 724 | { 725 | var req = new RequestParams(); 726 | req.limit = 1; 727 | req.order = ResultOrder.ReleaseDate | ResultOrder.Descending; 728 | 729 | req.platform = platform; 730 | req.architecture = architecture; 731 | 732 | // Set release stream based on input version 733 | req.stream = ReleaseStream.ReleaseMask; 734 | if (version.type == UnityVersion.Type.Beta) req.stream |= ReleaseStream.Beta; 735 | if (version.type == UnityVersion.Type.Alpha) req.stream |= ReleaseStream.Beta | ReleaseStream.Alpha; 736 | 737 | // Only add version if not just release type 738 | if (version.major >= 0) { 739 | // Build up version for a sub-string search (e.g. 2022b won't return any results) 740 | var searchString = version.major.ToString(); 741 | if (version.minor >= 0) { 742 | searchString += "." + version.minor; 743 | if (version.patch >= 0) { 744 | searchString += "." + version.patch; 745 | if (version.type != UnityVersion.Type.Undefined) { 746 | searchString += (char)version.type; 747 | if (version.build >= 0) { 748 | searchString += version.build; 749 | } 750 | } 751 | } 752 | } 753 | req.version = searchString; 754 | } 755 | 756 | var result = await Send(req, cancellation); 757 | if (!result.IsSuccess) { 758 | throw new Exception($"Unity Release API request failed: {result.title} - {result.detail}"); 759 | } else if (result.results.Count == 0) { 760 | return null; 761 | } 762 | 763 | return result.results[0]; 764 | } 765 | 766 | // -------- Implementation -------- 767 | 768 | ILogger Logger = UnityInstaller.CreateLogger(); 769 | 770 | static HttpClient client = new HttpClient(); 771 | static DateTime lastRequestTime = DateTime.MinValue; 772 | 773 | /// 774 | /// Endpoint of the releases API. 775 | /// 776 | const string Endpoint = "https://services.api.unity.com/unity/editor/release/v1/releases?"; 777 | 778 | /// 779 | /// Query string values for streams. 780 | /// 781 | static readonly Dictionary StreamValues = new() { 782 | { ReleaseStream.Alpha, "ALPHA" }, 783 | { ReleaseStream.Beta, "BETA" }, 784 | { ReleaseStream.Tech, "TECH" }, 785 | { ReleaseStream.LTS, "LTS" }, 786 | { ReleaseStream.Supported, "SUPPORTED" }, 787 | }; 788 | /// 789 | /// Query string values for platforms. 790 | /// 791 | static readonly Dictionary PlatformValues = new() { 792 | { Platform.Mac_OS, "MAC_OS" }, 793 | { Platform.Linux, "LINUX" }, 794 | { Platform.Windows, "WINDOWS" }, 795 | }; 796 | /// 797 | /// Query string values for architectures. 798 | /// 799 | static readonly Dictionary ArchitectureValues = new() { 800 | { Architecture.X86_64, "X86_64" }, 801 | { Architecture.ARM64, "ARM64" }, 802 | }; 803 | 804 | /// 805 | /// Iterate all the single bits set in the given enum value. 806 | /// (This does not check if the set bit is defined in the enum.) 807 | /// 808 | static IEnumerable IterateBits(T value) 809 | where T : struct, System.Enum 810 | { 811 | var number = (int)(object)value; 812 | for (int i = 0; i < 32; i++) { 813 | var flag = 1 << i; 814 | if ((number & flag) != 0) 815 | yield return (T)(object)flag; 816 | } 817 | } 818 | 819 | /// 820 | /// Check the given bitmask enum for single set bits, look up those values 821 | /// in the given dictionary and then add them to the query. 822 | /// 823 | static void AddArrayParameters(List> query, string name, Dictionary values, T bitmask) 824 | where T : struct, System.Enum 825 | { 826 | foreach (var flag in IterateBits(bitmask)) { 827 | if (!values.TryGetValue(flag, out var value)) { 828 | // ERROR: Value not found 829 | continue; 830 | } 831 | query.Add(new (name, value)); 832 | } 833 | } 834 | } 835 | 836 | } 837 | -------------------------------------------------------------------------------- /sttz.InstallUnity/Installer/UnityVersion.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Text.RegularExpressions; 4 | using Newtonsoft.Json; 5 | 6 | namespace sttz.InstallUnity 7 | { 8 | 9 | /// 10 | /// Unity version in the form of e.g. 2018.2.1f3 11 | /// 12 | public struct UnityVersion : IComparable, IComparable, IEquatable 13 | { 14 | /// 15 | /// Unity release types. 16 | /// 17 | public enum Type: ushort { 18 | Undefined = '\0', 19 | /// 20 | /// Regular Unity release. 21 | /// 22 | Final = 'f', 23 | /// 24 | /// Unity beta release. 25 | /// 26 | Beta = 'b', 27 | /// 28 | /// Unity alpha release. 29 | /// 30 | Alpha = 'a' 31 | } 32 | 33 | // -------- Fields -------- 34 | 35 | /// 36 | /// The major version number. 37 | /// 38 | public int major; 39 | 40 | /// 41 | /// The minor version number. 42 | /// 43 | public int minor; 44 | 45 | /// 46 | /// The patch version number. 47 | /// 48 | public int patch; 49 | 50 | /// 51 | /// The build type. 52 | /// 53 | public Type type; 54 | 55 | /// 56 | /// The build number. 57 | /// 58 | public int build; 59 | 60 | /// 61 | /// Unique hash of the build. 62 | /// 63 | public string hash; 64 | 65 | // -------- Configuration -------- 66 | 67 | /// 68 | /// Regex used to parse Unity version strings. 69 | /// Everything except the major version is optional. 70 | /// 71 | static readonly Regex VERSION_REGEX = new Regex(@"^(\d+)?(?:\.(\d+)(?:\.(\d+))?)?(?:(\w)(?:(\d+))?)?(?: \(([0-9a-f]{12})\))?$"); 72 | 73 | /// 74 | /// Regex to match a Unity version hash. 75 | /// 76 | static readonly Regex HASH_REGEX = new Regex(@"^([0-9a-f]{12})$"); 77 | 78 | /// 79 | /// Get the sorting strength for a release type. 80 | /// 81 | public static int GetSortingForType(Type type) 82 | { 83 | switch (type) { 84 | case Type.Final: 85 | return 3; 86 | case Type.Beta: 87 | return 2; 88 | case Type.Alpha: 89 | return 1; 90 | default: 91 | return 0; 92 | } 93 | } 94 | 95 | /// 96 | /// Types sorted from unstable to stable. 97 | /// 98 | public static readonly Type[] SortedTypes = new Type[] { 99 | Type.Alpha, Type.Beta, Type.Final, Type.Undefined 100 | }; 101 | 102 | /// 103 | /// Enumerate release types starting with the given type in 104 | /// increasing stableness. 105 | /// 106 | public static IEnumerable EnumerateMoreStableTypes(Type startingWithType) 107 | { 108 | var index = Array.IndexOf(SortedTypes, startingWithType); 109 | if (index < 0) { 110 | throw new ArgumentException("Invalid release type: " + startingWithType, nameof(startingWithType)); 111 | } 112 | 113 | for (int i = index; i < SortedTypes.Length; i++) { 114 | yield return SortedTypes[i]; 115 | } 116 | } 117 | 118 | // -------- API -------- 119 | 120 | /// 121 | /// Undefined Unity version that has all number components set to -1. 122 | /// 123 | public static readonly UnityVersion Undefined = new UnityVersion(-1); 124 | 125 | /// 126 | /// Create a new Unity version. 127 | /// 128 | public UnityVersion(int major = -1, int minor = -1, int patch = -1, Type type = Type.Undefined, int build = -1, string hash = null) 129 | { 130 | this.major = major; 131 | this.minor = minor; 132 | this.patch = patch; 133 | this.type = type; 134 | this.build = build; 135 | this.hash = hash; 136 | } 137 | 138 | /// 139 | /// Create a new Unity version from a string. 140 | /// 141 | /// 142 | /// The string must contain at least the major version. minor, patch and build are optional 143 | /// but the latter can only be set if the previous are (.e.g. setting patch but not minor 144 | /// is not possible). The build type can always be set. 145 | /// 146 | /// e.g. 147 | /// 2018 148 | /// 2018.1b 149 | /// 2018.1.1f3 150 | /// 151 | public UnityVersion(string version) 152 | { 153 | major = minor = patch = build = -1; 154 | type = Type.Undefined; 155 | hash = null; 156 | 157 | if (string.IsNullOrEmpty(version)) return; 158 | 159 | var match = VERSION_REGEX.Match(version); 160 | if (match.Success) { 161 | if (match.Groups[1].Success) { 162 | major = int.Parse(match.Groups[1].Value); 163 | if (major > 0 && major < 2000) { 164 | // Convert Unity 6+ to actual major version 6000 165 | major *= 1000; 166 | } 167 | 168 | if (match.Groups[2].Success) { 169 | minor = int.Parse(match.Groups[2].Value); 170 | if (match.Groups[3].Success) { 171 | patch = int.Parse(match.Groups[3].Value); 172 | if (match.Groups[5].Success) { 173 | build = int.Parse(match.Groups[5].Value); 174 | } 175 | } 176 | } 177 | } 178 | 179 | if (match.Groups[4].Success) { 180 | type = (Type)match.Groups[4].Value[0]; 181 | if (!Enum.IsDefined(typeof(Type), type)) { 182 | type = Type.Undefined; 183 | } 184 | } 185 | 186 | if (match.Groups[6].Success) { 187 | hash = match.Groups[6].Value; 188 | } 189 | } else { 190 | match = HASH_REGEX.Match(version); 191 | if (match.Success) { 192 | hash = match.Groups[1].Value; 193 | } 194 | } 195 | } 196 | 197 | /// 198 | /// Check wether the version is valid. 199 | /// 200 | /// 201 | /// The version needs to contain at least the major version or build type. 202 | /// Minor can only be specified if major is. 203 | /// Patch can only be specified if minor is. 204 | /// Build can only be specified if patch is. 205 | /// 206 | [JsonIgnore] 207 | public bool IsValid { 208 | get { 209 | if (major <= 0 && type == Type.Undefined && hash == null) return false; 210 | if (minor >= 0 && major < 0) return false; 211 | if (patch >= 0 && minor < 0) return false; 212 | if (build >= 0 && patch < 0) return false; 213 | return true; 214 | } 215 | } 216 | 217 | /// 218 | /// Wether all components of the version are set. 219 | /// 220 | [JsonIgnore] 221 | public bool IsFullVersion { 222 | get { 223 | return major >= 0 && minor >= 0 && patch >= 0 && type != Type.Undefined && build >= 0; 224 | } 225 | } 226 | 227 | /// 228 | /// Check if this version matches another, ignoring any components that aren't set. 229 | /// 230 | /// 231 | /// Version component matching is done exactly, with the only difference that components 232 | /// can be -1, in which case that component is ignored. 233 | /// The type is however compared relatively and the order of the versions does matter 234 | /// in this case (e.g. `a.FuzzyMatch(b)` is not equivalent to `b.FuzzyMatch(a)`). 235 | /// In case the type is compared, lower priority types of `this` version match 236 | /// higher priority types of the `other` version. i.e. type Beta also matches 237 | /// Patch and Final types but Final type does not match any other. 238 | /// 239 | public bool FuzzyMatches(UnityVersion other, bool allowTypeUpgrade = true) 240 | { 241 | if (major >= 0 && other.major >= 0 && major != other.major) return false; 242 | if (minor >= 0 && other.minor >= 0 && minor != other.minor) return false; 243 | if (patch >= 0 && other.patch >= 0 && patch != other.patch) return false; 244 | if (type != Type.Undefined && other.type != Type.Undefined) { 245 | if (allowTypeUpgrade) { 246 | if (GetSortingForType(type) > GetSortingForType(other.type)) return false; 247 | } else { 248 | if (type != other.type) return false; 249 | } 250 | } 251 | if (build >= 0 && other.build >= 0 && build != other.build) return false; 252 | if (hash != null && other.hash != null && hash != other.hash) return false; 253 | return true; 254 | } 255 | 256 | /// 257 | /// Check if either the version hashes match or the full version matches. 258 | /// 259 | public bool MatchesVersionOrHash(UnityVersion other) 260 | { 261 | if (hash != null && other.hash != null) { 262 | return hash == other.hash; 263 | } 264 | 265 | return major == other.major 266 | && minor == other.minor 267 | && patch == other.patch 268 | && type == other.type 269 | && build == other.build; 270 | } 271 | 272 | public string ToString(bool withHash) 273 | { 274 | if (!IsValid) return $"undefined"; 275 | 276 | var version = ""; 277 | if (major >= 0) version = major.ToString(); 278 | if (minor >= 0) version += "." + minor; 279 | if (patch >= 0) version += "." + patch; 280 | if (type != Type.Undefined) version += (char)type; 281 | if (build >= 0) version += build; 282 | if (withHash && hash != null) version += " (" + hash + ")"; 283 | 284 | return version; 285 | } 286 | 287 | override public string ToString() 288 | { 289 | return ToString(true); 290 | } 291 | 292 | // -------- IComparable -------- 293 | 294 | public int CompareTo(object obj) 295 | { 296 | if (obj is UnityVersion) { 297 | return CompareTo((UnityVersion)obj); 298 | } else { 299 | throw new ArgumentException("Argument is not a UnityVersion instance.", "obj"); 300 | } 301 | } 302 | 303 | public int CompareTo(UnityVersion other) 304 | { 305 | int result; 306 | 307 | if (major >= 0 && other.major >= 0) { 308 | result = major.CompareTo(other.major); 309 | if (result != 0) return result; 310 | } 311 | 312 | if (minor >= 0 && other.minor >= 0) { 313 | result = minor.CompareTo(other.minor); 314 | if (result != 0) return result; 315 | } 316 | 317 | if (patch >= 0 && other.patch >= 0) { 318 | result = patch.CompareTo(other.patch); 319 | if (result != 0) return result; 320 | } 321 | 322 | if (type != Type.Undefined && other.type != Type.Undefined) { 323 | result = GetSortingForType(type).CompareTo(GetSortingForType(other.type)); 324 | if (result != 0) return result; 325 | } 326 | 327 | if (build >= 0 && other.build >= 0) { 328 | result = build.CompareTo(other.build); 329 | if (result != 0) return result; 330 | } 331 | 332 | if (hash != null && other.hash != null) { 333 | result = string.CompareOrdinal(hash, other.hash); 334 | if (result != 0) return result; 335 | } 336 | 337 | return 0; 338 | } 339 | 340 | // -------- IEquatable -------- 341 | 342 | override public bool Equals(object obj) 343 | { 344 | if (obj is UnityVersion) { 345 | return Equals((UnityVersion)obj); 346 | } else { 347 | return false; 348 | } 349 | } 350 | 351 | override public int GetHashCode() 352 | { 353 | int code = 0; 354 | code |= (major & 0x00000FFF) << 20; 355 | code |= (minor & 0x0000000F) << 16; 356 | code |= (patch & 0x0000000F) << 12; 357 | code |= ((ushort)type & 0x0000000F) << 8; 358 | code |= (build & 0x000000FF); 359 | return code; 360 | } 361 | 362 | public bool Equals(UnityVersion other) 363 | { 364 | return major == other.major 365 | && minor == other.minor 366 | && patch == other.patch 367 | && type == other.type 368 | && build == other.build 369 | && (hash == null || other.hash == null || hash == other.hash); 370 | } 371 | 372 | // -------- Operators -------- 373 | 374 | public static bool operator ==(UnityVersion lhs, UnityVersion rhs) 375 | { 376 | return lhs.Equals(rhs); 377 | } 378 | 379 | public static bool operator !=(UnityVersion lhs, UnityVersion rhs) 380 | { 381 | return !lhs.Equals(rhs); 382 | } 383 | 384 | public static bool operator <(UnityVersion lhs, UnityVersion rhs) 385 | { 386 | return lhs.CompareTo(rhs) < 0; 387 | } 388 | 389 | public static bool operator >(UnityVersion lhs, UnityVersion rhs) 390 | { 391 | return lhs.CompareTo(rhs) > 0; 392 | } 393 | 394 | public static bool operator <=(UnityVersion lhs, UnityVersion rhs) 395 | { 396 | return lhs.CompareTo(rhs) <= 0; 397 | } 398 | 399 | public static bool operator >=(UnityVersion lhs, UnityVersion rhs) 400 | { 401 | return lhs.CompareTo(rhs) >= 0; 402 | } 403 | 404 | public static explicit operator UnityVersion(string versionString) 405 | { 406 | return new UnityVersion(versionString); 407 | } 408 | } 409 | 410 | } 411 | -------------------------------------------------------------------------------- /sttz.InstallUnity/Installer/VersionsCache.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.Extensions.Logging; 2 | using Newtonsoft.Json; 3 | using System; 4 | using System.Collections; 5 | using System.Collections.Generic; 6 | using System.IO; 7 | using System.Linq; 8 | 9 | using static sttz.InstallUnity.UnityReleaseAPIClient; 10 | 11 | namespace sttz.InstallUnity 12 | { 13 | 14 | /// 15 | /// Information about a Unity version available to install. 16 | /// 17 | public struct VersionMetadata 18 | { 19 | /// 20 | /// Create a new version from a release. 21 | /// 22 | public static VersionMetadata FromRelease(Release release) 23 | { 24 | return new VersionMetadata() { release = release }; 25 | } 26 | 27 | /// 28 | /// The release metadata, in the format of the Unity Release API. 29 | /// 30 | public Release release; 31 | 32 | /// 33 | /// Shortcut to the Unity version of this release. 34 | /// 35 | public UnityVersion Version => release?.version ?? default; 36 | 37 | /// 38 | /// Base URL of where INIs are stored. 39 | /// 40 | public string baseUrl; 41 | 42 | /// 43 | /// Determine wether the packages metadata has been loaded. 44 | /// 45 | public bool HasDownload(Platform platform, Architecture architecture) 46 | { 47 | return GetEditorDownload(platform, architecture) != null; 48 | } 49 | 50 | /// 51 | /// Get platform specific packages without adding virtual packages. 52 | /// 53 | public EditorDownload GetEditorDownload(Platform platform, Architecture architecture) 54 | { 55 | if (release.downloads == null) 56 | return null; 57 | 58 | foreach (var editor in release.downloads) { 59 | if (editor.platform == platform && editor.architecture == architecture) 60 | return editor; 61 | } 62 | 63 | return null; 64 | } 65 | 66 | /// 67 | /// Set platform specific packages. 68 | /// 69 | public void SetEditorDownload(EditorDownload download) 70 | { 71 | if (release.downloads == null) 72 | release.downloads = new List(); 73 | 74 | for (int i = 0; i < release.downloads.Count; i++) { 75 | var editor = release.downloads[i]; 76 | if (editor.platform == download.platform && editor.architecture == download.architecture) { 77 | // Replace existing download 78 | release.downloads[i] = download; 79 | return; 80 | } 81 | } 82 | 83 | // Add new download 84 | release.downloads.Add(download); 85 | } 86 | 87 | /// 88 | /// Find a package by identifier, ignoring case. 89 | /// 90 | public Module GetModule(Platform platform, Architecture architecture, string id) 91 | { 92 | var editor = GetEditorDownload(platform, architecture); 93 | if (editor == null) return null; 94 | 95 | foreach (var module in editor.modules) { 96 | if (module.id.Equals(id, StringComparison.OrdinalIgnoreCase)) 97 | return module; 98 | } 99 | 100 | return null; 101 | } 102 | } 103 | 104 | /// 105 | /// Index of available Unity versions. 106 | /// 107 | public class VersionsCache : IEnumerable 108 | { 109 | string dataFilePath; 110 | Cache cache; 111 | 112 | ILogger Logger = UnityInstaller.CreateLogger(); 113 | 114 | /// 115 | /// Version of cache format. 116 | /// 117 | const int CACHE_FORMAT = 3; 118 | 119 | /// 120 | /// Data written out to JSON file. 121 | /// 122 | struct Cache 123 | { 124 | public int format; 125 | public List versions; 126 | public Dictionary updated; 127 | } 128 | 129 | /// 130 | /// Create a new database. 131 | /// 132 | /// Path to the database file. 133 | public VersionsCache(string dataFilePath) 134 | { 135 | this.dataFilePath = dataFilePath; 136 | 137 | if (File.Exists(dataFilePath)) { 138 | try { 139 | var json = File.ReadAllText(dataFilePath); 140 | cache = JsonConvert.DeserializeObject(json); 141 | if (cache.format != CACHE_FORMAT) { 142 | Logger.LogInformation($"Cache format is outdated, resetting cache."); 143 | cache = new Cache(); 144 | } else { 145 | SortVersions(); 146 | Logger.LogInformation($"Loaded versions cache from '{dataFilePath}'"); 147 | } 148 | } catch (Exception e) { 149 | Console.Error.WriteLine("ERROR: Could not read versions database file: " + e.Message); 150 | Console.Error.WriteLine(e.InnerException); 151 | } 152 | } 153 | 154 | cache.format = CACHE_FORMAT; 155 | 156 | if (cache.versions == null) { 157 | Logger.LogInformation("Creating a new empty versions cache"); 158 | cache.versions = new List(); 159 | } 160 | if (cache.updated == null) { 161 | cache.updated = new Dictionary(); 162 | } 163 | } 164 | 165 | /// 166 | /// Sort versions in descending order. 167 | /// 168 | void SortVersions() 169 | { 170 | cache.versions.Sort((m1, m2) => m2.release.version.CompareTo(m1.release.version)); 171 | } 172 | 173 | /// 174 | /// Save the versions database. 175 | /// 176 | /// Wether the database was saved successfully. 177 | public bool Save() 178 | { 179 | try { 180 | Directory.CreateDirectory(Path.GetDirectoryName(dataFilePath)); 181 | var json = JsonConvert.SerializeObject(cache, Formatting.Indented); 182 | File.WriteAllText(dataFilePath, json); 183 | Logger.LogDebug($"Saved versions cache to '{dataFilePath}'"); 184 | return true; 185 | } catch (Exception e) { 186 | Console.Error.WriteLine("ERROR: Could not save versions database file: " + e.Message); 187 | return false; 188 | } 189 | } 190 | 191 | /// 192 | /// Remove all versions in the database. 193 | /// 194 | public void Clear() 195 | { 196 | cache.versions.Clear(); 197 | cache.updated.Clear(); 198 | Logger.LogDebug("Cleared versions cache"); 199 | } 200 | 201 | /// 202 | /// Add a version to the database. Existing version will be overwritten. 203 | /// 204 | /// True if the version didn't exist in the cache, false if it was only updated. 205 | public bool Add(VersionMetadata metadata) 206 | { 207 | for (int i = 0; i < cache.versions.Count; i++) { 208 | if (cache.versions[i].Version == metadata.Version) { 209 | UpdateVersion(i, metadata); 210 | Logger.LogDebug($"Updated version in cache: {metadata.Version}"); 211 | return false; 212 | } 213 | } 214 | 215 | cache.versions.Add(metadata); 216 | SortVersions(); 217 | Logger.LogDebug($"Added version to cache: {metadata.Version}"); 218 | return true; 219 | } 220 | 221 | /// 222 | /// Add multiple version to the database. Existing version will be overwritten. 223 | /// 224 | /// Pass in an optional IList, which gets filled with the added versions that weren't in the cache. 225 | public void Add(IEnumerable metadatas, IList newVersions = null) 226 | { 227 | foreach (var metadata in metadatas) { 228 | for (int i = 0; i < cache.versions.Count; i++) { 229 | if (cache.versions[i].Version == metadata.Version) { 230 | UpdateVersion(i, metadata); 231 | Logger.LogDebug($"Updated version in cache: {metadata.Version}"); 232 | goto continueOuter; 233 | } 234 | } 235 | cache.versions.Add(metadata); 236 | if (newVersions != null) newVersions.Add(metadata); 237 | Logger.LogDebug($"Added version to cache: {metadata.Version}"); 238 | continueOuter:; 239 | } 240 | 241 | SortVersions(); 242 | } 243 | 244 | /// 245 | /// Update a version, merging its platform-specific data. 246 | /// 247 | void UpdateVersion(int index, VersionMetadata with) 248 | { 249 | var existing = cache.versions[index]; 250 | 251 | // Same release instance, nothing to update 252 | if (existing.release == with.release) 253 | return; 254 | 255 | if (with.baseUrl != null) { 256 | existing.baseUrl = with.baseUrl; 257 | } 258 | foreach (var editor in with.release.downloads) { 259 | existing.SetEditorDownload(editor); 260 | } 261 | 262 | cache.versions[index] = existing; 263 | } 264 | 265 | /// 266 | /// Get a version from the database. 267 | /// 268 | /// 269 | /// If the version is incomplete, the latest version matching will be returned. 270 | /// 271 | public VersionMetadata Find(UnityVersion version) 272 | { 273 | if (version.IsFullVersion) { 274 | // Do exact match 275 | foreach (var metadata in cache.versions) { 276 | if (version.MatchesVersionOrHash(metadata.Version)) { 277 | return metadata; 278 | } 279 | } 280 | return default; 281 | } 282 | 283 | // Do fuzzy match 284 | foreach (var metadata in cache.versions) { 285 | if (version.FuzzyMatches(metadata.Version)) { 286 | return metadata; 287 | } 288 | } 289 | return default; 290 | } 291 | 292 | /// 293 | /// Get the time the cache was last updated. 294 | /// 295 | /// Release type to check for 296 | /// The last update time or DateTime.MinValue if the cache was never updated. 297 | public DateTime GetLastUpdate(UnityVersion.Type type) 298 | { 299 | DateTime time; 300 | if (!cache.updated.TryGetValue(type, out time)) { 301 | return DateTime.MinValue; 302 | } else { 303 | return time; 304 | } 305 | } 306 | 307 | /// 308 | /// Set the time the cache was updated. 309 | /// 310 | public void SetLastUpdate(UnityVersion.Type type, DateTime time) 311 | { 312 | cache.updated[type] = time; 313 | } 314 | 315 | // -------- IEnumerable ------ 316 | 317 | public IEnumerator GetEnumerator() 318 | { 319 | return cache.versions.GetEnumerator(); 320 | } 321 | 322 | IEnumerator IEnumerable.GetEnumerator() 323 | { 324 | return cache.versions.GetEnumerator(); 325 | } 326 | } 327 | 328 | } 329 | -------------------------------------------------------------------------------- /sttz.InstallUnity/Installer/VirtualPackages.cs: -------------------------------------------------------------------------------- 1 | using System.Collections.Generic; 2 | using System.Linq; 3 | using System; 4 | 5 | using static sttz.InstallUnity.UnityReleaseAPIClient; 6 | 7 | namespace sttz.InstallUnity 8 | { 9 | 10 | /// 11 | /// Implementation of UnityHub's dynamically generated packages. 12 | /// 13 | public static class VirtualPackages 14 | { 15 | public static IEnumerable GeneratePackages(UnityVersion version, EditorDownload editor) 16 | { 17 | return Generator(version, editor).ToList(); 18 | } 19 | 20 | static string[] Localizations_2018_1 = new string[] { "ja", "ko" }; 21 | static string[] Localizations_2018_2 = new string[] { "ja", "ko", "zh-cn" }; 22 | static string[] Localizations_2019_1 = new string[] { "ja", "ko", "zh-hans", "zh-hant" }; 23 | 24 | static Dictionary LanguageNames = new Dictionary(StringComparer.OrdinalIgnoreCase) { 25 | { "ja", "日本語" }, 26 | { "ko", "한국어" }, 27 | { "zh-cn", "简体中文" }, 28 | { "zh-hant", "繁體中文" }, 29 | { "zh-hans", "简体中文" }, 30 | }; 31 | 32 | static IEnumerable Generator(UnityVersion version, EditorDownload editor) 33 | { 34 | var v = version; 35 | var allPackages = editor.AllModules; 36 | 37 | // Documentation 38 | if (v.major >= 2018 39 | && !allPackages.ContainsKey("Documentation") 40 | && v.type != UnityVersion.Type.Alpha) { 41 | yield return new Module() { 42 | id = "Documentation", 43 | name = "Documentation", 44 | description = "Offline Documentation", 45 | url = $"https://storage.googleapis.com/docscloudstorage/{v.major}.{v.minor}/UnityDocumentation.zip", 46 | type = FileType.ZIP, 47 | preSelected = true, 48 | destination = "{UNITY_PATH}", 49 | downloadSize = FileSize.FromMegaBytes(350), // Conservative estimate based on 2019.2 50 | installedSize = FileSize.FromMegaBytes(650), // " 51 | }; 52 | } 53 | 54 | // Language packs 55 | if (v.major >= 2018) { 56 | string[] localizations; 57 | if (v.major == 2018 && v.minor == 1) { 58 | localizations = Localizations_2018_1; 59 | } else if (v.major == 2018) { 60 | localizations = Localizations_2018_2; 61 | } else { 62 | localizations = Localizations_2019_1; 63 | } 64 | 65 | foreach (var loc in localizations) { 66 | yield return new Module() { 67 | id = LanguageNames[loc], 68 | name = LanguageNames[loc], 69 | description = $"{LanguageNames[loc]} Language Pack", 70 | url = $"https://new-translate.unity3d.jp/v1/live/54/{v.major}.{v.minor}/{loc}", 71 | type = FileType.PO, 72 | destination = "{UNITY_PATH}/Unity.app/Contents/Localization", 73 | downloadSize = FileSize.FromMegaBytes(2), // Conservative estimate based on 2019.2 74 | installedSize = FileSize.FromMegaBytes(2), // " 75 | }; 76 | } 77 | } 78 | 79 | // Android dependencies 80 | if (v.major >= 2019 && allPackages.ContainsKey("Android")) { 81 | // Android SDK & NDK & stuff 82 | yield return new Module() { 83 | id = "Android SDK & NDK Tools", 84 | name = "Android SDK & NDK Tools", 85 | description = "Android SDK & NDK Tools 26.1.1", 86 | url = $"https://dl.google.com/android/repository/sdk-tools-darwin-4333796.zip", 87 | destination = "{UNITY_PATH}/PlaybackEngines/AndroidPlayer/SDK", 88 | downloadSize = FileSize.FromMegaBytes(148), 89 | installedSize = FileSize.FromMegaBytes(174), 90 | parentModuleId = "Android", 91 | eula = new() { 92 | new Eula() { 93 | url = "https://dl.google.com/dl/android/repository/repository2-1.xml", 94 | label = "Android SDK and NDK License Terms from Google", 95 | message = "Please review and accept the license terms before downloading and installing Android\'s SDK and NDK.", 96 | } 97 | }, 98 | }; 99 | 100 | // Android platform tools 101 | if (v.major < 2021) { 102 | yield return new Module() { 103 | id = "Android SDK Platform Tools", 104 | name = "Android SDK Platform Tools", 105 | description = "Android SDK Platform Tools 28.0.1", 106 | url = $"https://dl.google.com/android/repository/platform-tools_r28.0.1-darwin.zip", 107 | destination = "{UNITY_PATH}/PlaybackEngines/AndroidPlayer/SDK", 108 | downloadSize = FileSize.FromMegaBytes(5), 109 | installedSize = FileSize.FromMegaBytes(16), 110 | hidden = true, 111 | parentModuleId = "Android SDK & NDK Tools", 112 | }; 113 | } else if (v.major <= 2022) { 114 | yield return new Module() { 115 | id = "Android SDK Platform Tools", 116 | name = "Android SDK Platform Tools", 117 | description = "Android SDK Platform Tools 30.0.4", 118 | url = $"https://dl.google.com/android/repository/fbad467867e935dce68a0296b00e6d1e76f15b15.platform-tools_r30.0.4-darwin.zip", 119 | destination = "{UNITY_PATH}/PlaybackEngines/AndroidPlayer/SDK", 120 | downloadSize = FileSize.FromMegaBytes(10), 121 | installedSize = FileSize.FromMegaBytes(30), 122 | hidden = true, 123 | parentModuleId = "Android SDK & NDK Tools", 124 | }; 125 | } else { 126 | yield return new Module() { 127 | id = "Android SDK Platform Tools", 128 | name = "Android SDK Platform Tools", 129 | description = "Android SDK Platform Tools 32.0.0", 130 | url = $"https://dl.google.com/android/repository/platform-tools_r32.0.0-darwin.zip", 131 | destination = "{UNITY_PATH}/PlaybackEngines/AndroidPlayer/SDK", 132 | downloadSize = FileSize.FromBytes(18500000), 133 | installedSize = FileSize.FromBytes(48684075), 134 | hidden = true, 135 | parentModuleId = "Android SDK & NDK Tools" 136 | }; 137 | } 138 | 139 | // Android SDK platform & build tools 140 | if (v.major == 2019 && v.minor <= 3) { 141 | yield return new Module() { 142 | id = "Android SDK Build Tools", 143 | name = "Android SDK Build Tools", 144 | description = "Android SDK Build Tools 28.0.3", 145 | url = $"https://dl.google.com/android/repository/build-tools_r28.0.3-macosx.zip", 146 | destination = "{UNITY_PATH}/PlaybackEngines/AndroidPlayer/SDK/build-tools", 147 | downloadSize = FileSize.FromMegaBytes(53), 148 | installedSize = FileSize.FromMegaBytes(120), 149 | hidden = true, 150 | parentModuleId = "Android SDK & NDK Tools", 151 | extractedPathRename = new PathRename() { 152 | from = "{UNITY_PATH}/PlaybackEngines/AndroidPlayer/SDK/build-tools/android-9", 153 | to = "{UNITY_PATH}/PlaybackEngines/AndroidPlayer/SDK/build-tools/28.0.3" 154 | }, 155 | }; 156 | yield return new Module() { 157 | id = "Android SDK Platforms", 158 | name = "Android SDK Platforms", 159 | description = "Android SDK Platforms 28 r06", 160 | url = $"https://dl.google.com/android/repository/platform-28_r06.zip", 161 | destination = "{UNITY_PATH}/PlaybackEngines/AndroidPlayer/SDK/platforms", 162 | downloadSize = FileSize.FromMegaBytes(61), 163 | installedSize = FileSize.FromMegaBytes(121), 164 | hidden = true, 165 | parentModuleId = "Android SDK & NDK Tools", 166 | extractedPathRename = new PathRename() { 167 | from = "{UNITY_PATH}/PlaybackEngines/AndroidPlayer/SDK/platforms/android-9", 168 | to = "{UNITY_PATH}/PlaybackEngines/AndroidPlayer/SDK/platforms/android-28" 169 | } 170 | }; 171 | } else if (v.major <= 2022) { 172 | yield return new Module() { 173 | id = "Android SDK Build Tools", 174 | name = "Android SDK Build Tools", 175 | description = "Android SDK Build Tools 30.0.2", 176 | url = $"https://dl.google.com/android/repository/5a6ceea22103d8dec989aefcef309949c0c42f1d.build-tools_r30.0.2-macosx.zip", 177 | destination = "{UNITY_PATH}/PlaybackEngines/AndroidPlayer/SDK/build-tools", 178 | downloadSize = FileSize.FromMegaBytes(49), 179 | installedSize = FileSize.FromMegaBytes(129), 180 | hidden = true, 181 | parentModuleId = "Android SDK & NDK Tools", 182 | extractedPathRename = new PathRename() { 183 | from = "{UNITY_PATH}/PlaybackEngines/AndroidPlayer/SDK/build-tools/android-11", 184 | to = "{UNITY_PATH}/PlaybackEngines/AndroidPlayer/SDK/build-tools/30.0.2" 185 | } 186 | }; 187 | yield return new Module() { 188 | id = "Android SDK Platforms", 189 | name = "Android SDK Platforms", 190 | description = "Android SDK Platforms 30 r03", 191 | url = $"https://dl.google.com/android/repository/platform-30_r03.zip", 192 | destination = "{UNITY_PATH}/PlaybackEngines/AndroidPlayer/SDK/platforms", 193 | downloadSize = FileSize.FromMegaBytes(52), 194 | installedSize = FileSize.FromMegaBytes(116), 195 | hidden = true, 196 | parentModuleId = "Android SDK & NDK Tools", 197 | extractedPathRename = new PathRename() { 198 | from = "{UNITY_PATH}/PlaybackEngines/AndroidPlayer/SDK/platforms/android-11", 199 | to = "{UNITY_PATH}/PlaybackEngines/AndroidPlayer/SDK/platforms/android-30" 200 | } 201 | }; 202 | } else { 203 | yield return new Module() { 204 | id = "Android SDK Build Tools", 205 | name = "Android SDK Build Tools", 206 | description = "Android SDK Build Tools 32.0.0", 207 | url = $"https://dl.google.com/android/repository/5219cc671e844de73762e969ace287c29d2e14cd.build-tools_r32-macosx.zip", 208 | destination = "{UNITY_PATH}/PlaybackEngines/AndroidPlayer/SDK/build-tools", 209 | downloadSize = FileSize.FromBytes(50400000), 210 | installedSize = FileSize.FromBytes(138655842), 211 | hidden = true, 212 | parentModuleId = "Android SDK & NDK Tools", 213 | extractedPathRename = new PathRename() { 214 | from = "{UNITY_PATH}/PlaybackEngines/AndroidPlayer/SDK/build-tools/android-12", 215 | to = "{UNITY_PATH}/PlaybackEngines/AndroidPlayer/SDK/build-tools/32.0.0" 216 | } 217 | }; 218 | yield return new Module() { 219 | id = "Android SDK Platforms", 220 | name = "Android SDK Platforms", 221 | description = "Android SDK Platforms 31", 222 | url = $"https://dl.google.com/android/repository/platform-31_r01.zip", 223 | destination = "{UNITY_PATH}/PlaybackEngines/AndroidPlayer/SDK/platforms", 224 | downloadSize = FileSize.FromBytes(53900000), 225 | installedSize = FileSize.FromBytes(91868884), 226 | hidden = true, 227 | parentModuleId = "Android SDK & NDK Tools", 228 | extractedPathRename = new PathRename() { 229 | from = "{UNITY_PATH}/PlaybackEngines/AndroidPlayer/SDK/platforms/android-12", 230 | to = "{UNITY_PATH}/PlaybackEngines/AndroidPlayer/SDK/platforms/android-31" 231 | } 232 | }; 233 | yield return new Module() { 234 | id = "Android SDK Platforms", 235 | name = "Android SDK Platforms", 236 | description = "Android SDK Platforms 32", 237 | url = $"https://dl.google.com/android/repository/platform-32_r01.zip", 238 | destination = "{UNITY_PATH}/PlaybackEngines/AndroidPlayer/SDK/platforms", 239 | downloadSize = FileSize.FromBytes(63000000), 240 | installedSize = FileSize.FromBytes(101630444), 241 | hidden = true, 242 | parentModuleId = "Android SDK & NDK Tools", 243 | extractedPathRename = new PathRename() { 244 | from = "{UNITY_PATH}/PlaybackEngines/AndroidPlayer/SDK/platforms/android-12", 245 | to = "{UNITY_PATH}/PlaybackEngines/AndroidPlayer/SDK/platforms/android-32" 246 | } 247 | }; 248 | yield return new Module() { 249 | id = "Android SDK Command Line Tools", 250 | name = "Android SDK Command Line Tools", 251 | description = "Android SDK Command Line Tools 6.0", 252 | url = $"https://dl.google.com/android/repository/commandlinetools-mac-8092744_latest.zip", 253 | destination = "{UNITY_PATH}/PlaybackEngines/AndroidPlayer/SDK/cmdline-tools", 254 | downloadSize = FileSize.FromBytes(119650616), 255 | installedSize = FileSize.FromBytes(119651596), 256 | hidden = true, 257 | parentModuleId = "Android SDK & NDK Tools", 258 | extractedPathRename = new PathRename() { 259 | from = "{UNITY_PATH}/PlaybackEngines/AndroidPlayer/SDK/cmdline-tools/cmdline-tools", 260 | to = "{UNITY_PATH}/PlaybackEngines/AndroidPlayer/SDK/cmdline-tools/6.0" 261 | } 262 | }; 263 | } 264 | 265 | // Android NDK 266 | if (v.major == 2019 && v.minor <= 2) { 267 | yield return new Module() { 268 | id = "Android NDK 16b", 269 | name = "Android NDK 16b", 270 | description = "Android NDK r16b", 271 | url = $"https://dl.google.com/android/repository/android-ndk-r16b-darwin-x86_64.zip", 272 | destination = "{UNITY_PATH}/PlaybackEngines/AndroidPlayer", 273 | downloadSize = FileSize.FromMegaBytes(770), 274 | installedSize = FileSize.FromMegaBytes(2700), 275 | hidden = true, 276 | parentModuleId = "Android SDK & NDK Tools", 277 | extractedPathRename = new PathRename() { 278 | from = "{UNITY_PATH}/PlaybackEngines/AndroidPlayer/android-ndk-r16b", 279 | to = "{UNITY_PATH}/PlaybackEngines/AndroidPlayer/NDK" 280 | } 281 | }; 282 | } else if (v.major <= 2020) { 283 | yield return new Module() { 284 | id = "Android NDK 19", 285 | name = "Android NDK 19", 286 | description = "Android NDK r19", 287 | url = $"https://dl.google.com/android/repository/android-ndk-r19-darwin-x86_64.zip", 288 | destination = "{UNITY_PATH}/PlaybackEngines/AndroidPlayer", 289 | downloadSize = FileSize.FromMegaBytes(770), 290 | installedSize = FileSize.FromMegaBytes(2700), 291 | hidden = true, 292 | parentModuleId = "Android SDK & NDK Tools", 293 | extractedPathRename = new PathRename() { 294 | from = "{UNITY_PATH}/PlaybackEngines/AndroidPlayer/android-ndk-r19", 295 | to = "{UNITY_PATH}/PlaybackEngines/AndroidPlayer/NDK" 296 | } 297 | }; 298 | } else if (v.major <= 2022) { 299 | yield return new Module() { 300 | id = "Android NDK 21d", 301 | name = "Android NDK 21d", 302 | description = "Android NDK r21d", 303 | url = $"https://dl.google.com/android/repository/android-ndk-r21d-darwin-x86_64.zip", 304 | destination = "{UNITY_PATH}/PlaybackEngines/AndroidPlayer", 305 | downloadSize = FileSize.FromMegaBytes(1065), 306 | installedSize = FileSize.FromMegaBytes(3922), 307 | hidden = true, 308 | parentModuleId = "Android SDK & NDK Tools", 309 | extractedPathRename = new PathRename() { 310 | from = "{UNITY_PATH}/PlaybackEngines/AndroidPlayer/android-ndk-r21d", 311 | to = "{UNITY_PATH}/PlaybackEngines/AndroidPlayer/NDK" 312 | } 313 | }; 314 | } else { 315 | yield return new Module() { 316 | id = "Android NDK 23b", 317 | name = "Android NDK 23b", 318 | description = "Android NDK r23b", 319 | url = $"https://dl.google.com/android/repository/android-ndk-r23b-darwin.dmg", 320 | destination = "{UNITY_PATH}/PlaybackEngines/AndroidPlayer/NDK", 321 | downloadSize = FileSize.FromBytes(1400000000), 322 | installedSize = FileSize.FromBytes(4254572698), 323 | hidden = true, 324 | parentModuleId = "Android SDK & NDK Tools", 325 | extractedPathRename = new PathRename() { 326 | from = "{UNITY_PATH}/PlaybackEngines/AndroidPlayer/NDK/Contents/NDK", 327 | to = "{UNITY_PATH}/PlaybackEngines/AndroidPlayer/NDK" 328 | } 329 | }; 330 | } 331 | 332 | // Android JDK 333 | if (v.major >= 2023) { 334 | yield return new Module() { 335 | id = "OpenJDK", 336 | name = "OpenJDK", 337 | description = "Android Open JDK 11.0.14.1+1", 338 | url = $"https://download.unity3d.com/download_unity/open-jdk/open-jdk-mac-x64/jdk11.0.14.1-1_236fc2e31a8b6da32fbcf8624815f509c51605580cb2c6285e55510362f272f8.zip", 339 | destination = "{UNITY_PATH}/PlaybackEngines/AndroidPlayer/OpenJDK", 340 | downloadSize = FileSize.FromBytes(118453231), 341 | installedSize = FileSize.FromBytes(230230237), 342 | parentModuleId = "Android", 343 | }; 344 | } else if (v.major > 2019 || v.minor >= 2) { 345 | yield return new Module() { 346 | id = "OpenJDK", 347 | name = "OpenJDK", 348 | description = "Android Open JDK 8u172-b11", 349 | url = $"http://download.unity3d.com/download_unity/open-jdk/open-jdk-mac-x64/jdk8u172-b11_4be8440cc514099cfe1b50cbc74128f6955cd90fd5afe15ea7be60f832de67b4.zip", 350 | destination = "{UNITY_PATH}/PlaybackEngines/AndroidPlayer/OpenJDK", 351 | downloadSize = FileSize.FromMegaBytes(73), 352 | installedSize = FileSize.FromMegaBytes(165), 353 | parentModuleId = "Android", 354 | }; 355 | } 356 | } 357 | } 358 | } 359 | 360 | } 361 | -------------------------------------------------------------------------------- /sttz.InstallUnity/sttz.InstallUnity.csproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | net8.0 5 | latest 6 | sttz.InstallUnity 7 | true 8 | 9 | 10 | 11 | 2.13.0 12 | Adrian Stutz (sttz.ch) 13 | install-unity 14 | install-unity unofficial Unity installer library 15 | Copyright © Adrian Stutz. All rights Reserved 16 | true 17 | https://github.com/sttz/install-unity 18 | git 19 | Unity;Installer 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | --------------------------------------------------------------------------------