Program.cs 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254
  1. using System.IO.Compression;
  2. using System.Net;
  3. using System.Runtime.InteropServices;
  4. using System.Security.Cryptography;
  5. using System.Text;
  6. using System.Text.Json.Nodes;
  7. using Avalonia;
  8. using Avalonia.Controls;
  9. using Avalonia.Headless;
  10. using Avalonia.Media.Imaging;
  11. using Avalonia.Threading;
  12. using PCL.Communist;
  13. using PCL.Communist.Core;
  14. using PCL.Communist.ViewModels;
  15. using PCL.Communist.Views;
  16. var root = Path.Combine(Path.GetTempPath(), "pcl-cross-tests-" + Guid.NewGuid().ToString("N"));
  17. Directory.CreateDirectory(root);
  18. var windows = new PlatformInfo(PlatformKind.Windows, Architecture.X64, "10.0");
  19. var mac = new PlatformInfo(PlatformKind.MacOS, Architecture.X64, "14.0");
  20. var apple = new PlatformInfo(PlatformKind.MacOS, Architecture.Arm64, "14.0");
  21. var linux = new PlatformInfo(PlatformKind.Linux, Architecture.X64, "6.1");
  22. var android = new PlatformInfo(PlatformKind.Android, Architecture.Arm64, "14");
  23. var ios = new PlatformInfo(PlatformKind.IOS, Architecture.Arm64, "17");
  24. var count = 0;
  25. void Check(bool value, string message)
  26. {
  27. if (!value) throw new Exception("FAIL: " + message);
  28. count++;
  29. Console.WriteLine("PASS: " + message);
  30. }
  31. void Throws<T>(Action action, string message) where T : Exception
  32. {
  33. try { action(); }
  34. catch (T) { Check(true, message); return; }
  35. throw new Exception("FAIL: " + message);
  36. }
  37. async Task ThrowsAsync<T>(Func<Task> action, string message) where T : Exception
  38. {
  39. try { await action(); }
  40. catch (T) { Check(true, message); return; }
  41. throw new Exception("FAIL: " + message);
  42. }
  43. JsonObject Json(string json) => JsonNode.Parse(json)!.AsObject();
  44. string Hash(byte[] bytes) => Convert.ToHexString(SHA1.HashData(bytes)).ToLowerInvariant();
  45. JsonObject DownloadNode(string url, byte[] data, string? path = null)
  46. {
  47. var node = new JsonObject { ["url"] = url, ["sha1"] = Hash(data), ["size"] = data.Length };
  48. if (path is not null) node["path"] = path;
  49. return node;
  50. }
  51. byte[] NativeZip(string fileName)
  52. {
  53. using var stream = new MemoryStream();
  54. using (var zip = new ZipArchive(stream, ZipArchiveMode.Create, true))
  55. {
  56. using (var writer = new StreamWriter(zip.CreateEntry(fileName).Open())) writer.Write("native-fixture");
  57. using (var writer = new StreamWriter(zip.CreateEntry("META-INF/ignored").Open())) writer.Write("excluded");
  58. }
  59. return stream.ToArray();
  60. }
  61. try
  62. {
  63. Check(MinecraftRules.Allows(null, mac), "unconditional libraries are allowed");
  64. var rules = JsonNode.Parse("""[{"action":"allow"},{"action":"disallow","os":{"name":"windows"}}]""");
  65. Check(!MinecraftRules.Allows(rules, windows) && MinecraftRules.Allows(rules, mac), "rules use the last matching action");
  66. var armRules = JsonNode.Parse("""[{"action":"allow","os":{"name":"osx","arch":"aarch64"}}]""");
  67. Check(MinecraftRules.Allows(armRules, apple) && !MinecraftRules.Allows(armRules, mac), "Apple Silicon and Intel rules are distinct");
  68. var features = JsonNode.Parse("""[{"action":"allow","features":{"is_demo_user":true}}]""");
  69. Check(!MinecraftRules.Allows(features, linux), "demo and quick-play features default to disabled");
  70. var nativeDefinition = Json("""{"natives":{"windows":"natives-windows-${arch}","osx":"natives-osx","linux":"natives-linux"}}""");
  71. Check(MinecraftRules.NativeClassifier(nativeDefinition, mac) == "natives-osx", "macOS selects macOS natives");
  72. Check(MinecraftRules.NativeClassifier(nativeDefinition, windows) == "natives-windows-64", "Windows classifier architecture substitution");
  73. Throws<PlatformNotSupportedException>(() => MinecraftRules.NativeClassifier(nativeDefinition, android), "mobile cannot select desktop natives");
  74. Check(JavaRuntimeService.RuntimePlatform(apple) == "mac-os-arm64" && JavaRuntimeService.RuntimePlatform(mac) == "mac-os", "Java runtime keys distinguish Mac architectures");
  75. Throws<PlatformNotSupportedException>(() => JavaRuntimeService.RuntimePlatform(ios), "iOS cannot download a desktop JVM");
  76. Check(AccountSession.Offline("Notch").Uuid == "b50ad385829d3141a2167e7d7539ba7f", "offline UUID matches Java nameUUIDFromBytes");
  77. Throws<ArgumentException>(() => AccountSession.Offline("bad name;"), "offline player names are validated");
  78. foreach (var unsafePath in new[] { "../escape", "/absolute", "C:/windows", "dir\\file", "a/../../b" })
  79. Throws<InvalidDataException>(() => LauncherPaths.SafeChild(root, unsafePath), "reject path: " + unsafePath);
  80. var metadata = Json("""{"id":"fixture","mainClass":"net.minecraft.client.main.Main","libraries":[{"downloads":{"artifact":{"path":"example/lib/1/lib.jar"}}}],"arguments":{"jvm":["-Djava.library.path=${natives_directory}","-cp","${classpath}",{"rules":[{"action":"allow","os":{"name":"windows"}}],"value":"-Dos.name=Windows 10"}],"game":["--username","${auth_player_name}","--gameDir","${game_directory}",{"rules":[{"action":"allow","features":{"is_demo_user":true}}],"value":"--demo"}]}}""");
  81. var spacedPaths = new LauncherPaths(Path.Combine(root, "game with spaces"));
  82. var account = AccountSession.Offline("Player");
  83. var macPlan = GameLauncher.BuildPlan(metadata, mac, spacedPaths, "/jdk/bin/java", account, 2048);
  84. var windowsPlan = GameLauncher.BuildPlan(metadata, windows, spacedPaths, "java.exe", account, 2048);
  85. Check(macPlan.Arguments.Contains("-XstartOnFirstThread") && !macPlan.Arguments.Contains("-Dos.name=Windows 10"), "macOS starts Java on the first thread without Windows flags");
  86. Check(windowsPlan.Arguments.Contains("-Dos.name=Windows 10") && !windowsPlan.Arguments.Contains("-XstartOnFirstThread"), "Windows JVM arguments stay intact");
  87. Check(macPlan.Arguments[3].Contains(":") && windowsPlan.Arguments[3].Contains(";"), "classpath separators follow the target OS");
  88. Check(macPlan.CreateStartInfo().ArgumentList.Contains(spacedPaths.Root) && !macPlan.CreateStartInfo().UseShellExecute, "paths with spaces use argument lists without a shell");
  89. Check(!macPlan.Arguments.Contains("--demo") && !macPlan.Arguments.Any(a => a.Contains("${")), "conditional arguments and placeholders are resolved");
  90. Throws<PlatformNotSupportedException>(() => GameLauncher.BuildPlan(metadata, ios, spacedPaths, "java", account, 2048), "iOS is prevented from invoking desktop Java");
  91. Throws<InvalidOperationException>(() => GameLauncher.BuildPlan(metadata, mac, spacedPaths, "java", account with { ExpiresAt = DateTimeOffset.UtcNow.AddMinutes(-1) }, 2048), "expired sessions require login");
  92. var bytes = Encoding.UTF8.GetBytes("verified content");
  93. var badBytes = Encoding.UTF8.GetBytes("corrupted mirror");
  94. var handler = new FakeHandler(request => request.RequestUri!.Host == "bmclapi2.bangbang93.com" ? badBytes : bytes);
  95. using (var http = new HttpClient(handler))
  96. {
  97. var downloader = new DownloadClient(http);
  98. var target = Path.Combine(root, "download.bin");
  99. await downloader.FetchAsync(new("https://libraries.minecraft.net/test", target, Hash(bytes), bytes.Length));
  100. Check(File.ReadAllBytes(target).SequenceEqual(bytes) && handler.Requests.Count == 2, "corrupt mirror falls back to verified official bytes");
  101. await downloader.FetchAsync(new("https://libraries.minecraft.net/test", target, Hash(bytes), bytes.Length));
  102. Check(handler.Requests.Count == 2, "valid cached downloads skip the network");
  103. Check(!Directory.EnumerateFiles(root, "*.part", SearchOption.AllDirectories).Any(), "downloads leave no partial files");
  104. Throws<InvalidDataException>(() => downloader.Sources("http://insecure.example/file").ToArray(), "insecure download URLs are rejected");
  105. using var cancel = new CancellationTokenSource();
  106. cancel.Cancel();
  107. await ThrowsAsync<OperationCanceledException>(() => downloader.FetchAsync(new("https://example.test/file", Path.Combine(root, "cancelled.bin")), cancel.Token), "cancelled downloads do not publish files");
  108. }
  109. var zipPath = Path.Combine(root, "natives.zip");
  110. await File.WriteAllBytesAsync(zipPath, NativeZip("liblwjgl.dylib"));
  111. var nativeRoot = Path.Combine(root, "native-output");
  112. MinecraftInstaller.ExtractNatives(zipPath, nativeRoot, ["META-INF/"]);
  113. Check(File.Exists(Path.Combine(nativeRoot, "liblwjgl.dylib")) && !Directory.Exists(Path.Combine(nativeRoot, "META-INF")), "native extraction honors exclusions");
  114. await File.WriteAllBytesAsync(zipPath, NativeZip("../escape.dll"));
  115. Throws<InvalidDataException>(() => MinecraftInstaller.ExtractNatives(zipPath, nativeRoot, []), "native ZIP cannot escape its destination");
  116. var assetBytes = Encoding.UTF8.GetBytes("asset-data");
  117. var assetHash = Hash(assetBytes);
  118. var indexBytes = Encoding.UTF8.GetBytes(new JsonObject { ["objects"] = new JsonObject { ["sound/example.ogg"] = new JsonObject { ["hash"] = assetHash, ["size"] = assetBytes.Length } }, ["virtual"] = true }.ToJsonString());
  119. var macZip = NativeZip("libfixture.dylib");
  120. var winZip = NativeZip("fixture.dll");
  121. var client = Encoding.UTF8.GetBytes("client-jar");
  122. var library = Encoding.UTF8.GetBytes("library-jar");
  123. var installMetadata = new JsonObject
  124. {
  125. ["id"] = "fixture", ["mainClass"] = "net.minecraft.client.main.Main", ["downloads"] = new JsonObject { ["client"] = DownloadNode("https://fixture.test/client", client) },
  126. ["assetIndex"] = DownloadNode("https://fixture.test/index", indexBytes),
  127. ["libraries"] = new JsonArray(new JsonObject { ["name"] = "fixture:natives:1", ["natives"] = nativeDefinition["natives"]!.DeepClone(), ["downloads"] = new JsonObject
  128. {
  129. ["artifact"] = DownloadNode("https://fixture.test/lib", library, "fixture/lib.jar"),
  130. ["classifiers"] = new JsonObject { ["natives-osx"] = DownloadNode("https://fixture.test/mac", macZip, "fixture/mac.jar"), ["natives-windows-64"] = DownloadNode("https://fixture.test/win", winZip, "fixture/win.jar") }
  131. } })
  132. };
  133. installMetadata["assetIndex"]!["id"] = "fixture";
  134. var metadataBytes = Encoding.UTF8.GetBytes(installMetadata.ToJsonString());
  135. var responses = new Dictionary<string, byte[]>
  136. {
  137. ["/metadata"] = metadataBytes, ["/client"] = client, ["/index"] = indexBytes,
  138. ["/lib"] = library, ["/mac"] = macZip, ["/win"] = winZip, [$"/{assetHash[..2]}/{assetHash}"] = assetBytes
  139. };
  140. var installHandler = new FakeHandler(request => responses[request.RequestUri!.AbsolutePath]);
  141. using (var http = new HttpClient(installHandler))
  142. {
  143. var downloader = new DownloadClient(http, false);
  144. var version = new MinecraftVersion("fixture", "release", "https://fixture.test/metadata", Hash(metadataBytes));
  145. var paths = new LauncherPaths(Path.Combine(root, "mac-install"));
  146. await new MinecraftInstaller(downloader, paths).InstallAsync(version, mac);
  147. Check(File.Exists(Path.Combine(paths.VersionDirectory("fixture"), "natives", "MacOS-X64", "libfixture.dylib")), "Mac installation extracts the correct native library");
  148. Check(!installHandler.Requests.Any(u => u.AbsolutePath == "/win"), "Mac installation never downloads Windows natives");
  149. Check(File.Exists(Path.Combine(paths.Assets, "virtual", "fixture", "sound", "example.ogg")), "legacy virtual assets are materialized");
  150. await File.WriteAllTextAsync(Path.Combine(paths.Libraries, "fixture", "lib.jar"), "corrupt cached library");
  151. await new MinecraftInstaller(downloader, paths).RepairAsync("fixture", mac);
  152. Check(File.ReadAllBytes(Path.Combine(paths.Libraries, "fixture", "lib.jar")).SequenceEqual(library), "repair revalidates and replaces corrupt local libraries");
  153. Throws<PlatformNotSupportedException>(() => MinecraftRules.ValidateNativeArchitecture(installMetadata, apple), "legacy Intel natives are rejected on Apple Silicon before installation");
  154. var beforeMobile = installHandler.Requests.Count;
  155. var mobilePaths = new LauncherPaths(Path.Combine(root, "mobile-install"));
  156. await new MinecraftInstaller(downloader, mobilePaths).InstallAsync(version, android);
  157. Check(!Directory.Exists(mobilePaths.Libraries) && !installHandler.Requests.Skip(beforeMobile).Any(u => u.AbsolutePath is "/win" or "/mac"), "mobile downloads resources without desktop libraries");
  158. var mobileRuntime = new GameLauncher(android, mobilePaths, new JavaRuntimeService(downloader, mobilePaths));
  159. Check(!mobileRuntime.Capability.CanLaunch, "mobile runtime capability reports unavailable honestly");
  160. await ThrowsAsync<PlatformNotSupportedException>(() => mobileRuntime.LaunchAsync(metadata, account, null, 2048, null, CancellationToken.None), "mobile launch fails before any Java execution");
  161. }
  162. var loginRequests = new List<string>();
  163. using (var http = new HttpClient(new FakeHandler(request =>
  164. {
  165. loginRequests.Add(request.RequestUri!.Host + request.RequestUri.AbsolutePath);
  166. var response = request.RequestUri.AbsolutePath switch
  167. {
  168. "/consumers/oauth2/v2.0/devicecode" => """{"user_code":"ABCD1234","device_code":"private-device","verification_uri":"https://microsoft.com/link","expires_in":600,"interval":1}""",
  169. "/consumers/oauth2/v2.0/token" => """{"access_token":"oauth-private"}""",
  170. "/user/authenticate" => """{"Token":"xbl-private"}""",
  171. "/xsts/authorize" => """{"Token":"xsts-private","DisplayClaims":{"xui":[{"uhs":"user-hash"}]}}""",
  172. "/authentication/login_with_xbox" => """{"access_token":"mc-private","expires_in":3600}""",
  173. "/entitlements/mcstore" => """{"items":[{"name":"game_minecraft"}]}""",
  174. "/minecraft/profile" => """{"name":"TestPlayer","id":"00000000000000000000000000000000"}""",
  175. _ => throw new Exception("Unexpected authentication request")
  176. };
  177. return Encoding.UTF8.GetBytes(response);
  178. })))
  179. {
  180. DeviceCodePrompt? received = null;
  181. var session = await new MicrosoftLogin(http).LoginAsync(prompt => received = prompt);
  182. Check(received?.Code == "ABCD1234" && session.Name == "TestPlayer" && session.UserType == "msa", "device-code login completes Xbox, entitlement and Minecraft profile exchanges");
  183. Check(loginRequests.IndexOf("api.minecraftservices.com/entitlements/mcstore") < loginRequests.IndexOf("api.minecraftservices.com/minecraft/profile"), "game ownership is checked before accepting the profile");
  184. Check(session.ExpiresAt > DateTimeOffset.UtcNow.AddMinutes(50), "Microsoft sessions track token expiration");
  185. }
  186. if (args.Contains("--network"))
  187. {
  188. using var http = new HttpClient { Timeout = TimeSpan.FromMinutes(3) };
  189. var downloader = new DownloadClient(http, false);
  190. var installer = new MinecraftInstaller(downloader, new LauncherPaths(Path.Combine(root, "network")));
  191. var versions = await installer.GetVersionsAsync();
  192. var version = versions.Single(v => v.Id == "1.21.1");
  193. var versionPath = Path.Combine(root, "real-version.json");
  194. await downloader.FetchAsync(new(version.Url, versionPath, version.Sha1));
  195. var real = JsonNode.Parse(await File.ReadAllTextAsync(versionPath))!.AsObject();
  196. var clientPath = Path.Combine(root, "real-client.jar");
  197. await downloader.FetchAsync(MinecraftInstaller.Artifact(real["downloads"]!["client"]!, clientPath));
  198. Check(new FileInfo(clientPath).Length > 10000000, "real Minecraft 1.21.1 client download passes SHA1 validation");
  199. foreach (var platform in new[] { windows, mac, apple, linux })
  200. {
  201. var plan = GameLauncher.BuildPlan(real, platform, spacedPaths, "java", account, 2048);
  202. Check(plan.Arguments.Count > 15 && !plan.Arguments.Any(a => a.Contains("${")), "real launch arguments resolve for " + platform.DisplayName);
  203. }
  204. if (args.Contains("--java"))
  205. {
  206. var java = await new JavaRuntimeService(downloader, new LauncherPaths(Path.Combine(root, "runtime-test"))).ResolveAsync(real, PlatformInfo.Current, null);
  207. Check(java.Major == 21, "official Java 21 runtime installs and executes on the host");
  208. }
  209. }
  210. // Headless rendering exercises the same compiled XAML and bindings as every native entry point.
  211. AppBuilder.Configure<App>().UseHeadless(new AvaloniaHeadlessPlatformOptions { UseHeadlessDrawing = false }).UseSkia().WithInterFont().SetupWithoutStarting();
  212. var output = Path.GetFullPath(args.FirstOrDefault(a => a.StartsWith("--screenshots="))?.Split('=', 2)[1] ?? Path.Combine(root, "screenshots"));
  213. Directory.CreateDirectory(output);
  214. foreach (var (platform, width, height, file) in new[] { (mac, 820, 1000, "desktop.png"), (android, 390, 1100, "android.png"), (ios, 390, 1100, "ios.png") })
  215. {
  216. var model = new MainViewModel(platform, new LauncherPaths(Path.Combine(root, file, "minecraft")));
  217. model.Versions.Add(new("1.21.1", "release", "https://example.test/version", ""));
  218. model.SelectedVersion = model.Versions[0];
  219. Check(model.LaunchCommand.CanExecute(null) == !platform.IsMobile, "launch button capability matches " + platform.DisplayName);
  220. Check(model.Themes.Count == 17, "all 17 theme choices are available on " + platform.DisplayName);
  221. var window = new Window { Width = width, Height = height, Content = new MainView { DataContext = model } };
  222. window.Show();
  223. Dispatcher.UIThread.RunJobs();
  224. AvaloniaHeadlessPlatform.ForceRenderTimerTick();
  225. using var frame = window.CaptureRenderedFrame() ?? throw new Exception("Could not render " + file);
  226. frame.Save(Path.Combine(output, file), PngBitmapEncoderOptions.Default);
  227. Check(frame.PixelSize.Width == width, "rendered responsive view: " + file);
  228. window.Close();
  229. }
  230. Console.WriteLine($"Completed {count} checks. Screenshots: {output}");
  231. }
  232. finally { Directory.Delete(root, true); }
  233. sealed class FakeHandler(Func<HttpRequestMessage, byte[]> respond) : HttpMessageHandler
  234. {
  235. public List<Uri> Requests { get; } = [];
  236. protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
  237. {
  238. cancellationToken.ThrowIfCancellationRequested();
  239. byte[] content;
  240. lock (Requests) { Requests.Add(request.RequestUri!); content = respond(request); }
  241. return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK) { Content = new ByteArrayContent(content) });
  242. }
  243. }