Program.cs 21 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290
  1. using System;
  2. using System.Collections;
  3. using System.Collections.Generic;
  4. using System.IO;
  5. using System.Linq;
  6. using System.Net.Http;
  7. using System.Reflection;
  8. using System.Security.Cryptography;
  9. using System.Threading;
  10. using System.Threading.Tasks;
  11. using System.Web.Script.Serialization;
  12. using System.Windows;
  13. using System.Windows.Controls;
  14. using System.Windows.Media;
  15. using System.Windows.Media.Imaging;
  16. using System.Windows.Threading;
  17. internal static class Program {
  18. private const BindingFlags Static = BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Static;
  19. private const BindingFlags Instance = BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance;
  20. private static Assembly launcher = null!;
  21. private static int checks;
  22. private static readonly List<string> report = new();
  23. private static Type T(string name) => launcher.GetType("PCL." + name, true)!;
  24. private static object? Field(string type, string name) => T(type).GetField(name, Static)!.GetValue(null);
  25. private static object? Call(string type, string name, params object?[] args) =>
  26. T(type).GetMethods(Static).Single(m => m.Name == name && m.GetParameters().Length == args.Length).Invoke(null, args);
  27. private static object? CallOptional(string type, string name, params object?[] args) {
  28. var method = T(type).GetMethods(Static).Single(m => m.Name == name &&
  29. m.GetParameters()[0].ParameterType.IsInstanceOfType(args[0]));
  30. return method.Invoke(null, args.Concat(Enumerable.Repeat<object?>(Type.Missing, method.GetParameters().Length - args.Length)).ToArray());
  31. }
  32. private static void Set(string key, object value) => Call("Settings", "Set", key, value, null);
  33. private static object Control(object page, string name) =>
  34. page.GetType().GetProperty(name, Instance)?.GetValue(page) ??
  35. page.GetType().GetField(name, Instance)?.GetValue(page) ??
  36. throw new MissingMemberException(page.GetType().FullName, name);
  37. private static void Check(bool ok, string name) {
  38. if (!ok) throw new Exception("FAIL: " + name);
  39. checks++;
  40. report.Add("PASS: " + name);
  41. }
  42. private static void Pump(int milliseconds) {
  43. var until = DateTime.UtcNow.AddMilliseconds(milliseconds);
  44. while (DateTime.UtcNow < until) {
  45. var frame = new DispatcherFrame();
  46. Dispatcher.CurrentDispatcher.BeginInvoke(DispatcherPriority.Background, new Action(() => frame.Continue = false));
  47. Dispatcher.PushFrame(frame);
  48. Thread.Sleep(10);
  49. }
  50. }
  51. private static void Capture(FrameworkElement element, string path) {
  52. element.UpdateLayout();
  53. var bitmap = new RenderTargetBitmap((int)element.ActualWidth, (int)element.ActualHeight, 96, 96, PixelFormats.Pbgra32);
  54. bitmap.Render(element);
  55. var png = new PngBitmapEncoder();
  56. png.Frames.Add(BitmapFrame.Create(bitmap));
  57. using (var stream = File.Create(path)) png.Save(stream);
  58. }
  59. [STAThread]
  60. private static void Main(string[] args) {
  61. string output = Path.GetFullPath(args[1]);
  62. Directory.CreateDirectory(output);
  63. string errorPath = Path.Combine(output, "smoke-error.txt");
  64. if (File.Exists(errorPath)) File.Delete(errorPath);
  65. var transcript = new StreamWriter(Path.Combine(output, "test-console.txt")) { AutoFlush = true };
  66. Console.SetOut(transcript);
  67. Console.SetError(transcript);
  68. try {
  69. launcher = Assembly.LoadFrom(Path.GetFullPath(args[0]));
  70. Check(!launcher.GetManifestResourceNames().Contains("PCL.RedBuildCredentials.json"), "Public executable excludes private build credentials");
  71. Check(launcher.GetCustomAttribute<System.Runtime.Versioning.TargetFrameworkAttribute>()?.FrameworkName == ".NETFramework,Version=v4.8",
  72. "Executable declares .NET Framework 4.8, including modern path handling");
  73. var app = (Application)Activator.CreateInstance(T("Application"), true)!;
  74. // Pumping WPF queues Application.Startup even in a host. Keep the host
  75. // isolated from the launcher's normal splash screen and startup path.
  76. var startup = (StartupEventHandler)Delegate.CreateDelegate(typeof(StartupEventHandler), app, T("Application").GetMethod("Application_Startup", Instance)!);
  77. app.Startup -= startup;
  78. T("Application").GetMethod("InitializeComponent")!.Invoke(app, null);
  79. // WPF rejects null through the setter. Only the test host clears the
  80. // backing field to prevent automatic navigation to FormMain.xaml.
  81. typeof(Application).GetField("_startupUri", Instance)!.SetValue(app, null);
  82. var unhandled = (DispatcherUnhandledExceptionEventHandler)Delegate.CreateDelegate(
  83. typeof(DispatcherUnhandledExceptionEventHandler), app,
  84. T("Application").GetMethod("Application_DispatcherUnhandledException", Instance)!);
  85. app.DispatcherUnhandledException -= unhandled;
  86. app.DispatcherUnhandledException += (_, error) => {
  87. File.WriteAllText(Path.Combine(output, "smoke-error.txt"), error.Exception.ToString());
  88. Console.Error.WriteLine(error.Exception);
  89. Environment.Exit(1);
  90. };
  91. var core = Assembly.Load("MeloongCore");
  92. core.GetType("MeloongCore.Main")!.GetMethod("Init")!.Invoke(null, new object[] { "PCL" });
  93. Assembly.Load("MeloongCore.Wpf").GetType("MeloongCore.Wpf.Main")!.GetMethod("Init")!.Invoke(null, null);
  94. Assembly.Load("PCLCS");
  95. using (var resource = Application.GetResourceStream(new Uri("pack://application:,,,/Plain Craft Launcher 2;component/Images/Red/Splash.png")).Stream) {
  96. var splash = BitmapFrame.Create(resource, BitmapCreateOptions.None, BitmapCacheOption.OnLoad);
  97. Check(splash.PixelWidth == 256 && splash.PixelHeight == 256, "Hammer-and-sickle startup splash is embedded at 256 pixels");
  98. }
  99. using (var icon = System.Drawing.Icon.ExtractAssociatedIcon(launcher.Location))
  100. using (var bitmap = icon!.ToBitmap()) {
  101. bitmap.Save(Path.Combine(output, "Executable-icon.png"), System.Drawing.Imaging.ImageFormat.Png);
  102. int gold = 0, red = 0;
  103. for (int x = 0; x < bitmap.Width; x++) for (int y = 0; y < bitmap.Height; y++) {
  104. var pixel = bitmap.GetPixel(x, y);
  105. if (pixel.R > 200 && pixel.G > 130 && pixel.B < 180) gold++;
  106. if (pixel.R > 80 && pixel.G < 70 && pixel.B < 100) red++;
  107. }
  108. Check(gold > 5 && red > 5, "Executable icon contains the new red-and-gold emblem");
  109. }
  110. Check(((string)T("ModBase").GetField("VersionDisplay", Static)!.GetRawConstantValue()!).EndsWith("PCL Communist"), "Version text uses the requested product name");
  111. Check(launcher.GetCustomAttribute<AssemblyTitleAttribute>()?.Title == "PCL Communist" &&
  112. launcher.GetCustomAttribute<AssemblyProductAttribute>()?.Product == "PCL Communist", "Executable metadata uses PCL Communist");
  113. using (var licence = new StreamReader(launcher.GetManifestResourceStream("PCL.Licence.txt")!))
  114. Check(licence.ReadToEnd().Contains("PCL 分发有限许可"), "Distribution licence is embedded in the single executable");
  115. using (var agreement = new StreamReader(launcher.GetManifestResourceStream("PCL.CommunistAgreement.txt")!))
  116. Check(agreement.ReadToEnd().Contains("PCL Communist 共产共享协议"), "Communist sharing agreement is embedded for offline viewing");
  117. // Route every test setting to the isolated host directory, including registry settings.
  118. var entries = (IDictionary)T("Settings").GetField("Entries", Static)!.GetValue(null)!;
  119. foreach (DictionaryEntry item in entries) {
  120. object setting = item.Value;
  121. var source = setting.GetType().GetField("Source")!;
  122. if (source.GetValue(setting)!.ToString() == "Registry") source.SetValue(setting, Enum.Parse(source.FieldType, "Normal"));
  123. }
  124. Check((int)entries["UiLauncherTheme"]!.GetType().GetField("DefaultValue")!.GetValue(entries["UiLauncherTheme"])! == 15, "Soviet red is the fresh-install default");
  125. Check(!(bool)T("ModSecret").GetField("RestrictMcDownload", Static)!.GetRawConstantValue()!, "Minecraft downloads are unrestricted");
  126. Check(!string.IsNullOrWhiteSpace((string)Field("ModSecret", "OAuthClientId")!), "Microsoft public client ID is configured");
  127. Check((int)entries["ToolDownloadSource"]!.GetType().GetField("DefaultValue")!.GetValue(entries["ToolDownloadSource"])! == 0, "Mainland mirrors are preferred for files");
  128. Check((int)entries["ToolDownloadVersion"]!.GetType().GetField("DefaultValue")!.GetValue(entries["ToolDownloadVersion"])! == 0, "Mainland mirrors are preferred for manifests");
  129. bool Allowed(string url, string method = "GET", bool file = false) =>
  130. (bool)Call("RedPolicy", "IsNetworkAllowed", url, new HttpMethod(method), file)!;
  131. Check(Allowed("https://bmclapi2.bangbang93.com/mc/game/version_manifest.json"), "BMCLAPI manifest is allowed");
  132. Check(Allowed("https://piston-meta.mojang.com/v1/test.json"), "Mojang metadata fallback is allowed");
  133. Check(Allowed("https://cdn.example.org/pack.mrpack", file: true), "Custom pack file downloads are allowed");
  134. Check(Allowed("https://login.microsoftonline.com/consumers/oauth2/v2.0/devicecode", "POST"), "Microsoft device login is allowed");
  135. Check(Allowed("https://login.live.com/oauth20_token.srf", "POST"), "Microsoft token refresh is allowed");
  136. Check(Allowed("https://user.auth.xboxlive.com/user/authenticate", "POST"), "Xbox authentication is allowed");
  137. Check(Allowed("https://xsts.auth.xboxlive.com/xsts/authorize", "POST"), "XSTS authentication is allowed");
  138. Check(Allowed("https://api.minecraftservices.com/entitlements/mcstore"), "Minecraft ownership validation is allowed");
  139. Check(Allowed("https://api.minecraftservices.com/minecraft/profile"), "Authenticated Minecraft identity is allowed");
  140. Check(!Allowed("https://api.minecraftservices.com/minecraft/profile/skins", "POST"), "Skin uploads are blocked");
  141. Check(!Allowed("https://api.minecraftservices.com/minecraft/profile/capes/active", "PUT"), "Cape mutations are blocked");
  142. Check(!Allowed("https://sessionserver.mojang.com/session/minecraft/profile/id"), "Unrelated online skin lookups are blocked");
  143. Check(!Allowed("https://login.microsoftonline.com.evil.example/consumers/oauth2/v2.0/token", "POST"), "Lookalike authentication hosts are blocked");
  144. Check(!Allowed("https://pcl2-server-1253424809.file.myqcloud.com/ServerConfig.json", file: true), "Launcher config server is blocked even as a download");
  145. Check(!Allowed("https://ap-guangzhou.cls.tencentcs.com/track", file: true), "Telemetry is blocked even as a download");
  146. Check(!Allowed("https://example.org/Custom.xaml"), "Remote homepage requests are blocked");
  147. Check(!Allowed("https://auth.example.org/refresh", "POST"), "Third-party authentication is blocked");
  148. Check(!Allowed("ftp://example.org/pack.zip", file: true), "Unsupported URL schemes are rejected");
  149. Check(!Allowed("https://cdn.example.org/upload", "POST", true), "The file download channel cannot upload");
  150. Console.WriteLine("PASS: defaults and network policy");
  151. if (args.Skip(2).Contains("--download")) {
  152. var download = Task.Run(() => TestDownload(output));
  153. while (!download.IsCompleted) Pump(100);
  154. download.GetAwaiter().GetResult();
  155. File.WriteAllLines(Path.Combine(output, "download-results.txt"), report);
  156. Console.WriteLine("PASS: " + checks + " download and policy checks.");
  157. Environment.Exit(0);
  158. }
  159. Set("SystemLastVersionReg", 413);
  160. Set("UiLauncherTheme", 15);
  161. var window = (Window)Activator.CreateInstance(T("FormMain"))!;
  162. app.MainWindow = window;
  163. window.WindowStartupLocation = WindowStartupLocation.Manual;
  164. window.Left = -10000; window.Top = -10000;
  165. window.Width = 1000; window.Height = 650;
  166. window.Show();
  167. Pump(1000);
  168. Check(window.Title == "PCL Communist", "Main window uses the requested product name");
  169. Check(((Panel)Control(window, "PanMsg")).Children.Count == 0, "Startup reaches the main page without an agreement dialog");
  170. Call("ModSecret", "ThemeRefresh", 15);
  171. Check((int)Field("ModSecret", "ThemeNow")! == 15, "Default theme survives startup");
  172. Check(((FrameworkElement)Control(window, "CommunismBackdrop")).Visibility == Visibility.Visible, "Hammer-and-sickle background is visible");
  173. Check(((string)Call("ModMinecraft", "McSkinDownload", "https://textures.minecraft.net/texture/test")!).StartsWith("pack://"), "Online skins use bundled local fallback");
  174. Capture(window, Path.Combine(output, "Soviet-red-home.png"));
  175. var page = Activator.CreateInstance(T("PageSetupUI"))!;
  176. T("ModMain").GetField("FrmSetupUI", Static)!.SetValue(null, page);
  177. Call("ModSecret", "ThemeCheckAll", true);
  178. var ids = Enumerable.Range(0, 16).Concat(new[] { 42 }).ToArray();
  179. var colors = new HashSet<int>();
  180. foreach (int id in ids) {
  181. Check((bool)Call("ModSecret", "ThemeCheckOne", id)!, "Theme " + id + " is unlocked");
  182. Check(((UIElement)Control(page, "RadioLauncherTheme" + id)).IsEnabled, "Theme " + id + " is selectable");
  183. Call("ModSecret", "ThemeRefresh", id);
  184. Check((int)Field("ModSecret", "ThemeNow")! == id, "Theme " + id + " applies");
  185. colors.Add((int)Field("ModSecret", "ColorHue")!);
  186. }
  187. Check(colors.Count >= 13, "Theme selection changes actual colors");
  188. Check(!(bool)Call("ModSecret", "ThemeCheckOne", -1)!, "Invalid theme IDs are rejected");
  189. Set("UiLauncherHue", 123); Set("UiLauncherSat", 45);
  190. Set("UiLauncherLight", 30); Set("UiLauncherDelta", 10);
  191. Call("ModSecret", "ThemeRefresh", 14);
  192. Call("ModSecret", "ThemeRefresh", -1);
  193. Check((int)Field("ModSecret", "ColorHue")! == 123 && (int)Field("ModSecret", "ColorSat")! == 45 && (int)Field("ModSecret", "ColorLightAdjust")! == 10, "Custom HSL sliders affect the rendered palette");
  194. Call("ModSecret", "ThemeRefresh", 12);
  195. int hue = (int)Field("ModSecret", "ColorHue")!;
  196. Pump(100);
  197. Call("ModSecret", "ThemeRefresh", -1);
  198. Check((int)Field("ModSecret", "ColorHue")! != hue, "Rainbow theme changes over time");
  199. Call("ModSecret", "ThemeRefresh", 15);
  200. Call("ModSecret", "ThemeRefresh", 999);
  201. Check((int)Field("ModSecret", "ThemeNow")! == 15, "Invalid saved theme falls back to Soviet red");
  202. Set("UiLauncherTheme", 15);
  203. ((Border)Control(window, "PanMainRight")).Child = (UIElement)page;
  204. Pump(500);
  205. Capture(window, Path.Combine(output, "All-themes-unlocked.png"));
  206. var about = (UIElement)Activator.CreateInstance(T("PageOtherAbout"))!;
  207. ((Border)Control(window, "PanMainRight")).Child = about;
  208. Pump(200);
  209. about.GetType().GetMethod("PageOnEnter")!.Invoke(about, null);
  210. Pump(500);
  211. Check(((TextBlock)Control(about, "TextCommunistAgreement")).Text.Contains("使用权不以地区、身份、政治立场、赞助或付费为条件"), "About page shows the new sharing agreement without a network request");
  212. Check(((TextBlock)Control(about, "TextBundledLicence")).Text.Contains("PCL 分发有限许可"), "About page shows bundled distribution licence without a sidecar file");
  213. var agreementCard = (FrameworkElement)Control(about, "CardCommunistAgreement");
  214. agreementCard.GetType().GetProperty("UseAnimation")!.SetValue(agreementCard, false);
  215. agreementCard.GetType().GetProperty("IsSwapped")!.SetValue(agreementCard, false);
  216. Pump(200);
  217. ((ScrollViewer)Control(about, "PanBack")).ScrollToVerticalOffset(
  218. agreementCard.TranslatePoint(new Point(0, 0), (UIElement)Control(about, "PanMain")).Y);
  219. Pump(200);
  220. Capture(window, Path.Combine(output, "Communist-agreement.png"));
  221. File.WriteAllLines(Path.Combine(output, "smoke-results.txt"), report);
  222. Console.WriteLine("PASS: " + checks + " runtime checks; home and theme screenshots saved.");
  223. Environment.Exit(0);
  224. } catch (Exception error) {
  225. File.WriteAllText(Path.Combine(output, "smoke-error.txt"), error.ToString());
  226. Console.Error.WriteLine(error);
  227. Environment.Exit(1);
  228. }
  229. }
  230. private static void TestDownload(string output) {
  231. var serializer = new JavaScriptSerializer { MaxJsonLength = 8 * 1024 * 1024 };
  232. Dictionary<string, object> Json(string url) => serializer.Deserialize<Dictionary<string, object>>(
  233. (string)CallOptional("ModNet", "NetRequestByClientRetry", url)!);
  234. Dictionary<string, object> Version(Dictionary<string, object> manifest) =>
  235. ((IEnumerable)manifest["versions"]).Cast<Dictionary<string, object>>().Single(v => (string)v["id"] == "1.21.1");
  236. var mirror = Json("https://bmclapi2.bangbang93.com/mc/game/version_manifest.json");
  237. Check(Version(mirror).ContainsKey("url"), "BMCLAPI manifest loads through compiled request engine");
  238. Console.WriteLine(report.Last());
  239. var official = Json("https://launchermeta.mojang.com/mc/game/version_manifest.json");
  240. var version = Version(official);
  241. Check(version.ContainsKey("url"), "Official manifest fallback is reachable");
  242. var sources = ((IEnumerable<string>)Call("ModDownload", "DlSourceLauncherOrMetaGet", version["url"])!).ToArray();
  243. Check(new Uri(sources.First()).Host == "bmclapi2.bangbang93.com" && sources.Last() == (string)version["url"], "Version metadata uses mirror first with official fallback");
  244. var metadata = Json(sources.First());
  245. var client = (Dictionary<string, object>)((Dictionary<string, object>)metadata["downloads"])["client"];
  246. var officialMetadata = Json((string)version["url"]);
  247. var officialClient = (Dictionary<string, object>)((Dictionary<string, object>)officialMetadata["downloads"])["client"];
  248. Check((string)client["sha1"] == (string)officialClient["sha1"], "Mirror client hash matches official metadata");
  249. string expectedHash = (string)officialClient["sha1"];
  250. long expectedSize = Convert.ToInt64(officialClient["size"]);
  251. var checkerType = Assembly.Load("MeloongCore").GetTypes().Single(t => t.Name == "FileChecker");
  252. object Checker() {
  253. var checker = Activator.CreateInstance(checkerType)!;
  254. checkerType.GetField("Hash")!.SetValue(checker, expectedHash);
  255. checkerType.GetField("ActualSize")!.SetValue(checker, expectedSize);
  256. return checker;
  257. }
  258. var urls = ((IEnumerable<string>)Call("ModDownload", "DlSourceLauncherOrMetaGet", officialClient["url"])!).ToArray();
  259. Check(new Uri(urls.First()).Host == "bmclapi2.bangbang93.com" && urls.Last() == (string)officialClient["url"], "Client files use mirror first with official fallback");
  260. string target = Path.Combine(output, "minecraft-1.21.1-client.jar");
  261. if (File.Exists(target)) File.Delete(target);
  262. Console.WriteLine("Downloading Minecraft 1.21.1 client through compiled multi-thread downloader...");
  263. CallOptional("ModNet", "NetDownloadByLoader", new[] { urls.First() }, target, null, Checker());
  264. using (var sha = SHA1.Create()) using (var stream = File.OpenRead(target)) {
  265. Check(BitConverter.ToString(sha.ComputeHash(stream)).Replace("-", "").ToLowerInvariant() == expectedHash, "Actual BMCLAPI client download passes SHA1");
  266. }
  267. Check(new FileInfo(target).Length == expectedSize, "Downloaded client size matches official metadata");
  268. Console.WriteLine(report.Last());
  269. string fallback = Path.Combine(output, "minecraft-1.21.1-official-fallback.jar");
  270. if (File.Exists(fallback)) File.Delete(fallback);
  271. Console.WriteLine("Testing unavailable-source fallback to official client...");
  272. // A deliberately blocked first source fails locally, so the same engine
  273. // must advance to the real official client URL.
  274. CallOptional("ModNet", "NetDownloadByLoader", new[] {
  275. "https://pcl2-server-1253424809.file.myqcloud.com/disabled-client.jar", urls.Last()
  276. }, fallback, null, Checker());
  277. using (var sha = SHA1.Create()) using (var stream = File.OpenRead(fallback)) {
  278. Check(BitConverter.ToString(sha.ComputeHash(stream)).Replace("-", "").ToLowerInvariant() == expectedHash, "Unavailable first source falls back to official client and passes SHA1");
  279. }
  280. report.Add("Minecraft 1.21.1 client: " + expectedSize + " bytes; SHA1 " + expectedHash);
  281. }
  282. }