From 2fd2bc7b2be674cde45e0c512128819e0114681a Mon Sep 17 00:00:00 2001 From: Bagus Nur Listiyono Date: Sun, 5 Jul 2026 23:56:52 +0700 Subject: [PATCH 01/50] [DB] Add blob table --- CollapseLauncher/Classes/Helper/Database/DBHandler.cs | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/CollapseLauncher/Classes/Helper/Database/DBHandler.cs b/CollapseLauncher/Classes/Helper/Database/DBHandler.cs index 69b5bb6e6e..5add617718 100644 --- a/CollapseLauncher/Classes/Helper/Database/DBHandler.cs +++ b/CollapseLauncher/Classes/Helper/Database/DBHandler.cs @@ -163,6 +163,10 @@ public static async Task Init(bool redirectThrow = false, bool bypassEnableFlag await _database .Execute($"CREATE TABLE IF NOT EXISTS \"uid-{_userIdHash}\" (Id INTEGER PRIMARY KEY AUTOINCREMENT, 'key' TEXT UNIQUE NOT NULL, 'value' TEXT)"); + + await + _database + .Execute($"CREATE TABLE IF NOT EXISTS \"uid-{_userIdHash}-blob\" (Id INTEGER PRIMARY KEY AUTOINCREMENT, 'key' TEXT UNIQUE NOT NULL, 'value' BLOB)"); _isFirstInit = false; } else LogWriteLine("[DbHandler::Init] Reinitializing database system..."); From 6340502b40b67856370cb0af0b3c4f2ca9646224 Mon Sep 17 00:00:00 2001 From: Bagus Nur Listiyono Date: Sun, 5 Jul 2026 23:57:14 +0700 Subject: [PATCH 02/50] [DB] Allow blob upload --- .../Classes/Helper/Database/DBHandler.cs | 39 ++++++++++++++----- 1 file changed, 30 insertions(+), 9 deletions(-) diff --git a/CollapseLauncher/Classes/Helper/Database/DBHandler.cs b/CollapseLauncher/Classes/Helper/Database/DBHandler.cs index 5add617718..f44a32f097 100644 --- a/CollapseLauncher/Classes/Helper/Database/DBHandler.cs +++ b/CollapseLauncher/Classes/Helper/Database/DBHandler.cs @@ -282,24 +282,43 @@ private static void Dispose() return null; } - public static async Task StoreKeyValue(string key, string value, bool redirectThrow = false) + public static async Task StoreKeyValue(string key, string value, bool redirectThrow = false, + bool isBlob = false, byte[]? blobValue = null) { if (!(IsEnabled ?? false)) return; #if DEBUG var t = Stopwatch.StartNew(); var r = new Random(); var sId = Math.Abs(r.Next(0, 1000).ToString().GetHashCode()); - LogWriteLine($"[DBHandler::StoreKeyValue][{sId}] Invoked!\r\n\tKey: {key}\r\n\tValue: {value}", LogType.Debug, - true); + if (isBlob) + { + LogWriteLine($"[DBHandler::StoreKeyValue][{sId}] Invoked!\r\n\tKey: {key}\r\n\tIS BLOB", LogType.Debug, + true); + } + else + { + LogWriteLine($"[DBHandler::StoreKeyValue][{sId}] Invoked!\r\n\tKey: {key}\r\n\tValue: {value}", LogType.Debug, + true); + } + #endif for (var i = 0; i < MaxAttempts; i++) { - var retVal = await StoreKeyValueInternal(key, value); + var retVal = await StoreKeyValueInternal(key, value, isBlob, blobValue); if (retVal.result == 200) { #if DEBUG - LogWriteLine($"[DBHandler::StoreKeyValue][{sId}] Saved value!\r\n\tKey: {key}\r\n\tValue: {value}", - LogType.Debug, true); + if (isBlob) + { + LogWriteLine($"[DBHandler::StoreKeyValue][{sId}] Saved value!\r\n\tKey: {key}\r\n\tIS BLOB", + LogType.Debug, true); + } + else + { + LogWriteLine($"[DBHandler::StoreKeyValue][{sId}] Saved value!\r\n\tKey: {key}\r\n\tValue: {value}", + LogType.Debug, true); + } + #endif return; } @@ -390,16 +409,18 @@ public static async Task StoreKeyValue(string key, string value, bool redirectTh } } - private static async Task<(int result, Exception? exceptionValue)> StoreKeyValueInternal(string key, string value) + private static async Task<(int result, Exception? exceptionValue)> StoreKeyValueInternal(string key, string value, bool isBlob = false, byte[]? blobValue = null) { try { if (_database == null) await Init(true); + var tableName = "uid-" + _userIdHash + (isBlob ? "-blob" : ""); + object dbValue = isBlob && blobValue != null ? blobValue : value; // Create key for storing value, if key already exist, just update the value (key column is set to UNIQUE) - var command = $"INSERT INTO \"uid-{_userIdHash}\" (key, value) VALUES (?, ?) " + + var command = $"INSERT INTO \"{tableName}\" (key, value) VALUES (?, ?) " + $"ON CONFLICT(key) DO UPDATE SET value = ?"; - var parameters = new object[] { key, value, value }; + var parameters = new object[] { key, dbValue, dbValue }; await _database!.Execute(command, parameters); return (200, null); // 200: OK From 14fe04dc75dc069444a0fa9554e3139ef7f5ad44 Mon Sep 17 00:00:00 2001 From: Bagus Nur Listiyono Date: Sun, 5 Jul 2026 23:58:16 +0700 Subject: [PATCH 03/50] [GSP] Allow Uploading Game Settings to Database WARNING - Broken UI - Static text --- .../BaseClass/ImportExportBase.cs | 41 ++++++++++++- .../Interfaces/IGameSettingsUniversal.cs | 6 +- .../GameSettingsPages/GameSettingsPageBase.cs | 58 +++++++++++++++++++ .../GenshinGameSettingsPage.xaml | 39 +++++++++++++ 4 files changed, 141 insertions(+), 3 deletions(-) diff --git a/CollapseLauncher/Classes/GameManagement/GameSettings/BaseClass/ImportExportBase.cs b/CollapseLauncher/Classes/GameManagement/GameSettings/BaseClass/ImportExportBase.cs index e907634086..345a6b1c9e 100644 --- a/CollapseLauncher/Classes/GameManagement/GameSettings/BaseClass/ImportExportBase.cs +++ b/CollapseLauncher/Classes/GameManagement/GameSettings/BaseClass/ImportExportBase.cs @@ -1,4 +1,6 @@ using CollapseLauncher.Helper; +using CollapseLauncher.Helper.Database; +using CollapseLauncher.Helper.Metadata; using CollapseLauncher.Interfaces; using Hi3Helper; using Hi3Helper.EncTool; @@ -190,11 +192,11 @@ private void ReadV3Values(Stream fs, string? gameBasePath) ImportStreamToFiles(stream, gameBasePath); } - public async Task ExportSettings(bool isCompressed = true, string? parentPathToImport = null, string[]? relativePathToImport = null) + public async Task ExportSettings(bool isCompressed = true, string? parentPathToImport = null, string[]? relativePathToImport = null, string? path = null) { try { - string path = await FileDialogNative.GetFileSavePicker(new Dictionary { { "Collapse Registry", "*.clreg" } }, Locale.Current.Lang?._GameSettingsPage?.SettingsRegExportTitle); + path ??= await FileDialogNative.GetFileSavePicker(new Dictionary { { "Collapse Registry", "*.clreg" } }, Locale.Current.Lang?._GameSettingsPage?.SettingsRegExportTitle); EnsureFileSaveHasExtension(ref path, ".clreg"); if (string.IsNullOrEmpty(path)) throw new OperationCanceledException(Locale.Current.Lang?._GameSettingsPage?.SettingsRegErr1); @@ -533,5 +535,40 @@ protected virtual void ReadBinary(EndianBinaryReader reader, string valueName) _ = reader.Read(val, 0, len); RegistryRoot?.SetValue(valueName, val, RegistryValueKind.Binary); } + + + # region database + + private string GameTypeValue => (GameVersionManager?.GameType is not GameNameType.Plugin + ? GameVersionManager?.GameType.ToString() : GameVersionManager?.GameName.Replace(" ", "")) ?? "UNKNOWN"; + private string KeySettings => $"{GameTypeValue}-{GameVersionManager?.GameRegion}-gs"; + private string KeyLastUpdated => $"{GameTypeValue}-{GameVersionManager?.GameRegion}-gs-lu"; + + public async Task PushToDatabase() + { + try + { + string path = Path.GetTempFileName(); + _ = await ExportSettings(false, null, null, path); + + if (!File.Exists(path)) return null; + + var fi = new FileInfo(path); + if (fi.Length == 0) return null; + byte[] fileBytes = await File.ReadAllBytesAsync(path); + await DbHandler.StoreKeyValue(KeySettings, "", true, true, fileBytes); + await DbHandler.StoreKeyValue(KeyLastUpdated, DateTimeOffset.UtcNow.ToUnixTimeSeconds().ToString(), true); + fi.Delete(); + } + catch (Exception ex) + { + Console.WriteLine(ex); + return ex; + } + + return null; + } + + #endregion } } diff --git a/CollapseLauncher/Classes/Interfaces/IGameSettingsUniversal.cs b/CollapseLauncher/Classes/Interfaces/IGameSettingsUniversal.cs index 46281787a5..47559ff999 100644 --- a/CollapseLauncher/Classes/Interfaces/IGameSettingsUniversal.cs +++ b/CollapseLauncher/Classes/Interfaces/IGameSettingsUniversal.cs @@ -23,6 +23,10 @@ public interface IGameSettingsExportable RegistryKey? RefreshRegistryRoot(); Task ImportSettings(string? gameBasePath = null); - Task ExportSettings(bool isCompressed = true, string? parentPathToImport = null, string[]? relativePathToImport = null); + + Task ExportSettings(bool isCompressed = true, string? parentPathToImport = null, + string[]? relativePathToImport = null, string? path = null); + + Task PushToDatabase(); } } diff --git a/CollapseLauncher/XAMLs/MainApp/Pages/GameSettingsPages/GameSettingsPageBase.cs b/CollapseLauncher/XAMLs/MainApp/Pages/GameSettingsPages/GameSettingsPageBase.cs index 548c2d905e..0e26dd658c 100644 --- a/CollapseLauncher/XAMLs/MainApp/Pages/GameSettingsPages/GameSettingsPageBase.cs +++ b/CollapseLauncher/XAMLs/MainApp/Pages/GameSettingsPages/GameSettingsPageBase.cs @@ -196,6 +196,64 @@ async Task Impl() } } } + + protected virtual async void OnRegistryDbUploadButtonClick(object sender, RoutedEventArgs args) + { + await SuspendRegistryMonitorOnActionAsync(Impl); + return; + + async Task Impl() + { + try + { + Exception? exc = await (Settings?.PushToDatabase() ?? Task.FromResult(null)); + + if (exc != null) throw exc; + SetApplyTextStatus("Lang._GameSettingsPage.SettingsRegExported"); + } + catch (OperationCanceledException) + { + SetApplyTextStatus("Lang._GameSettingsPage.SettingsRegErr1", true); + } + catch (Exception ex) + { + Logger.LogWriteLine($"[GSP Module] An error has occurred while trying to exporting the registry!\r\n{ex}", LogType.Error, true); + SetApplyTextStatus(ex.Message, true); + ErrorSender.SendException(ex); + SentryHelper.ExceptionHandler(ex); + } + } + } + + protected virtual async void OnRegistryDbDownloadButtonClick(object sender, RoutedEventArgs args) + { + await SuspendRegistryMonitorOnActionAsync(Impl); + return; + + async Task Impl() + { + try + { + throw new NotImplementedException(); + Exception? exc = await (Settings?.PushToDatabase() ?? Task.FromResult(null)); + + if (exc != null) throw exc; + SetApplyTextStatus("Lang._GameSettingsPage.SettingsRegExported"); + } + catch (OperationCanceledException) + { + SetApplyTextStatus("Lang._GameSettingsPage.SettingsRegErr1", true); + } + catch (Exception ex) + { + Logger.LogWriteLine($"[GSP Module] An error has occurred while trying to exporting the registry!\r\n{ex}", LogType.Error, true); + SetApplyTextStatus(ex.Message, true); + ErrorSender.SendException(ex); + SentryHelper.ExceptionHandler(ex); + } + } + } + protected virtual void SuspendRegistryMonitorOnAction(Action action) { diff --git a/CollapseLauncher/XAMLs/MainApp/Pages/GameSettingsPages/GenshinGameSettingsPage.xaml b/CollapseLauncher/XAMLs/MainApp/Pages/GameSettingsPages/GenshinGameSettingsPage.xaml index 0c3aae292a..4bdba9e211 100644 --- a/CollapseLauncher/XAMLs/MainApp/Pages/GameSettingsPages/GenshinGameSettingsPage.xaml +++ b/CollapseLauncher/XAMLs/MainApp/Pages/GameSettingsPages/GenshinGameSettingsPage.xaml @@ -1149,6 +1149,7 @@ + + + + + From ce88b3f50db8c59d987f7d278fb75de684a38f1a Mon Sep 17 00:00:00 2001 From: Bagus Nur Listiyono Date: Mon, 6 Jul 2026 00:44:24 +0700 Subject: [PATCH 04/50] [DB] Handle BLOB Query --- .../Classes/Helper/Database/DBHandler.cs | 62 +++++++++++++++---- 1 file changed, 51 insertions(+), 11 deletions(-) diff --git a/CollapseLauncher/Classes/Helper/Database/DBHandler.cs b/CollapseLauncher/Classes/Helper/Database/DBHandler.cs index f44a32f097..50fd1d623e 100644 --- a/CollapseLauncher/Classes/Helper/Database/DBHandler.cs +++ b/CollapseLauncher/Classes/Helper/Database/DBHandler.cs @@ -2,6 +2,7 @@ using Hi3Helper.SentryHelper; using Libsql.Client; using System; +using System.Collections.Generic; using System.Diagnostics; using System.Linq; using System.Text; @@ -222,7 +223,7 @@ private static void Dispose() private const int MaxAttempts = 5; - public static async Task QueryKey(string key, bool redirectThrow = false) + public static async Task QueryKey(string key, bool redirectThrow = false, bool isBlob = false) { if (!(IsEnabled ?? false)) return null; #if DEBUG @@ -233,7 +234,7 @@ private static void Dispose() #endif for (var i = 0; i < MaxAttempts; i++) { - var retVal = await QueryKeyInternal(key + var retVal = await QueryKeyInternal(key, isBlob #if DEBUG , sId #endif @@ -241,8 +242,16 @@ private static void Dispose() if (retVal.result == 200) { #if DEBUG - LogWriteLine($"[DBHandler::QueryKey][{sId}] Got value!\r\n\tKey: {key}\r\n\tValue:\r\n{retVal.returnedValue}", - LogType.Debug, true); + if (isBlob) + { + LogWriteLine($"[DBHandler::QueryKey][{sId}] Got value!\r\n\tKey: {key}\r\n\tIS BLOB", + LogType.Debug, true); + } + else + { + LogWriteLine($"[DBHandler::QueryKey][{sId}] Got value!\r\n\tKey: {key}\r\n\tValue:\r\n{retVal.returnedValue}", + LogType.Debug, true); + } #endif return retVal.returnedValue; } @@ -361,7 +370,7 @@ public static async Task StoreKeyValue(string key, string value, bool redirectTh #region Private Methods - private static async Task<(int result, string? returnedValue, Exception? exceptionValue)> QueryKeyInternal(string key + private static async Task<(int result, string? returnedValue, Exception? exceptionValue)> QueryKeyInternal(string key, bool isBlob #if DEBUG , int sId = 0 #endif @@ -370,22 +379,53 @@ public static async Task StoreKeyValue(string key, string value, bool redirectTh try { if (_database == null) await Init(true); + var tableName = "uid-" + _userIdHash + (isBlob ? "-blob" : ""); // Get table row for exact key var rs = await _database! - .Execute($"SELECT value FROM \"uid-{_userIdHash}\" WHERE key = ?", key); + .Execute($"SELECT value FROM \"{tableName}\" WHERE key = ?", key); if (rs == null) { return (200, null, null); } + + string str = ""; - // freaking black magic to convert the column row to the value - var str = - string.Join("", rs.Rows.Select(row => string.Join("", row.Select(x => x.ToString())))); + if (isBlob) + { + var firstRow = rs.Rows.FirstOrDefault(); + object? rcv = firstRow?.FirstOrDefault(); + + if (rcv is Blob { Value: IEnumerable byteEnumerable }) + { + str = Convert.ToHexString(byteEnumerable.ToArray()); + } + // ReSharper disable once ConvertTypeCheckPatternToNullCheck + else if (rcv is Blob { Value: byte[] directBytes }) + { + str = Convert.ToHexString(directBytes); + } + } + else + { + // freaking black magic to convert the column row to the value + str = + string.Join("", rs.Rows.Select(row => string.Join("", row.Select(x => x.ToString())))); + } + #if DEBUG - LogWriteLine($"[DBHandler::QueryKey][{sId}] Got value!\r\n\tKey: {key}\r\n\tValue:\r\n{str}", LogType.Debug, - true); + if (isBlob) + { + LogWriteLine($"[DBHandler::QueryKey][{sId}] Got value!\r\n\tKey: {key}\r\n\tIS BLOB", LogType.Debug, + true); + } + else + { + LogWriteLine($"[DBHandler::QueryKey][{sId}] Got value!\r\n\tKey: {key}\r\n\tValue:\r\n{str}", LogType.Debug, + true); + } + #endif return (200, str, null); // 200: OK, return value } From 65dc8d5a4d74dc2b6bed7010a0dcb316124fba8e Mon Sep 17 00:00:00 2001 From: Bagus Nur Listiyono Date: Mon, 6 Jul 2026 00:44:54 +0700 Subject: [PATCH 05/50] [GSP] Allow Applying Game Settings From Database --- .../BaseClass/ImportExportBase.cs | 35 +++++++++++++++++-- .../Interfaces/IGameSettingsUniversal.cs | 3 +- .../GameSettingsPages/GameSettingsPageBase.cs | 5 +-- .../GenshinGameSettingsPage.xaml | 1 + 4 files changed, 39 insertions(+), 5 deletions(-) diff --git a/CollapseLauncher/Classes/GameManagement/GameSettings/BaseClass/ImportExportBase.cs b/CollapseLauncher/Classes/GameManagement/GameSettings/BaseClass/ImportExportBase.cs index 345a6b1c9e..50b1e78d8a 100644 --- a/CollapseLauncher/Classes/GameManagement/GameSettings/BaseClass/ImportExportBase.cs +++ b/CollapseLauncher/Classes/GameManagement/GameSettings/BaseClass/ImportExportBase.cs @@ -3,6 +3,7 @@ using CollapseLauncher.Helper.Metadata; using CollapseLauncher.Interfaces; using Hi3Helper; +using Hi3Helper.Data; using Hi3Helper.EncTool; using Hi3Helper.UABT; using Hi3Helper.UABT.Binary; @@ -72,11 +73,11 @@ public RegistryKey? RegistryRoot return RegistryRoot; } - public async Task ImportSettings(string? gameBasePath = null) + public async Task ImportSettings(string? gameBasePath = null, string? path = null) { try { - string path = await FileDialogNative.GetFilePicker(new Dictionary { { "Collapse Registry", "*.clreg" } }, Locale.Current.Lang?._GameSettingsPage?.SettingsRegImportTitle); + path ??= await FileDialogNative.GetFilePicker(new Dictionary { { "Collapse Registry", "*.clreg" } }, Locale.Current.Lang?._GameSettingsPage?.SettingsRegImportTitle); if (string.IsNullOrEmpty(path)) throw new OperationCanceledException(Locale.Current.Lang?._GameSettingsPage?.SettingsRegErr1); @@ -569,6 +570,36 @@ protected virtual void ReadBinary(EndianBinaryReader reader, string valueName) return null; } + public async Task GetFromDatabase() + { + try + { + var retval = await DbHandler.QueryKey(KeySettings, true, true); + + if (retval == null) throw new NullReferenceException(); + + string path = Path.GetTempFileName(); + await File.WriteAllBytesAsync(path, Convert.FromHexString(retval)); + + string? gameBasePath = null; + if (GameVersionManager?.GameType == GameNameType.Zenless) + { + gameBasePath = ConverterTool.NormalizePath(GameVersionManager?.GameDirPath); + } + + await ImportSettings(gameBasePath, path); + + File.Delete(path); + } + catch (Exception ex) + { + Console.WriteLine(ex); + return ex; + } + + return null; + } + #endregion } } diff --git a/CollapseLauncher/Classes/Interfaces/IGameSettingsUniversal.cs b/CollapseLauncher/Classes/Interfaces/IGameSettingsUniversal.cs index 47559ff999..0d5be1c1b9 100644 --- a/CollapseLauncher/Classes/Interfaces/IGameSettingsUniversal.cs +++ b/CollapseLauncher/Classes/Interfaces/IGameSettingsUniversal.cs @@ -22,11 +22,12 @@ public interface IGameSettingsExportable RegistryKey? RegistryRoot { get; } RegistryKey? RefreshRegistryRoot(); - Task ImportSettings(string? gameBasePath = null); + Task ImportSettings(string? gameBasePath = null, string? path = null); Task ExportSettings(bool isCompressed = true, string? parentPathToImport = null, string[]? relativePathToImport = null, string? path = null); Task PushToDatabase(); + Task GetFromDatabase(); } } diff --git a/CollapseLauncher/XAMLs/MainApp/Pages/GameSettingsPages/GameSettingsPageBase.cs b/CollapseLauncher/XAMLs/MainApp/Pages/GameSettingsPages/GameSettingsPageBase.cs index 0e26dd658c..9ce2f0a880 100644 --- a/CollapseLauncher/XAMLs/MainApp/Pages/GameSettingsPages/GameSettingsPageBase.cs +++ b/CollapseLauncher/XAMLs/MainApp/Pages/GameSettingsPages/GameSettingsPageBase.cs @@ -209,6 +209,7 @@ async Task Impl() Exception? exc = await (Settings?.PushToDatabase() ?? Task.FromResult(null)); if (exc != null) throw exc; + // TODO: Apply localization SetApplyTextStatus("Lang._GameSettingsPage.SettingsRegExported"); } catch (OperationCanceledException) @@ -234,10 +235,10 @@ async Task Impl() { try { - throw new NotImplementedException(); - Exception? exc = await (Settings?.PushToDatabase() ?? Task.FromResult(null)); + Exception? exc = await (Settings?.GetFromDatabase() ?? Task.FromResult(null)); if (exc != null) throw exc; + // TODO: Apply localization SetApplyTextStatus("Lang._GameSettingsPage.SettingsRegExported"); } catch (OperationCanceledException) diff --git a/CollapseLauncher/XAMLs/MainApp/Pages/GameSettingsPages/GenshinGameSettingsPage.xaml b/CollapseLauncher/XAMLs/MainApp/Pages/GameSettingsPages/GenshinGameSettingsPage.xaml index 4bdba9e211..993184eb18 100644 --- a/CollapseLauncher/XAMLs/MainApp/Pages/GameSettingsPages/GenshinGameSettingsPage.xaml +++ b/CollapseLauncher/XAMLs/MainApp/Pages/GameSettingsPages/GenshinGameSettingsPage.xaml @@ -1173,6 +1173,7 @@ + - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/CollapseLauncher/XAMLs/MainApp/Pages/GameSettingsPages/StarRailGameSettingsPage.xaml b/CollapseLauncher/XAMLs/MainApp/Pages/GameSettingsPages/StarRailGameSettingsPage.xaml index 2c2dd57946..4db04f64e9 100644 --- a/CollapseLauncher/XAMLs/MainApp/Pages/GameSettingsPages/StarRailGameSettingsPage.xaml +++ b/CollapseLauncher/XAMLs/MainApp/Pages/GameSettingsPages/StarRailGameSettingsPage.xaml @@ -849,75 +849,110 @@ + extension:UIElementExtensions.UniformCornerRadius="-1" + Shadow="{StaticResource SharedShadow}" + Translation="0,0,32"> - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/CollapseLauncher/XAMLs/MainApp/Pages/GameSettingsPages/ZenlessGameSettingsPage.xaml b/CollapseLauncher/XAMLs/MainApp/Pages/GameSettingsPages/ZenlessGameSettingsPage.xaml index 9240186b37..ced6a2296c 100644 --- a/CollapseLauncher/XAMLs/MainApp/Pages/GameSettingsPages/ZenlessGameSettingsPage.xaml +++ b/CollapseLauncher/XAMLs/MainApp/Pages/GameSettingsPages/ZenlessGameSettingsPage.xaml @@ -1105,77 +1105,110 @@ + Translation="0,0,32"> - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + From 24a2a1ba7084d9ced966dd07c6d07edcfcf46237 Mon Sep 17 00:00:00 2001 From: Kemal Setya Adhi Date: Wed, 26 Aug 2026 21:57:42 +0700 Subject: [PATCH 11/50] [skip ci] Update README.md --- README.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index d24c2b33e3..c8c7d1d3cf 100644 --- a/README.md +++ b/README.md @@ -65,11 +65,11 @@ Not only that, this launcher also has some advanced features for **Genshin Impac > ### You can find the list of features on our [new website](https://collapselauncher.com/features.html)! # Download Ready-To-Use Builds -[](https://github.com/CollapseLauncher/Collapse/releases/download/CL-v1.84.5/CollapseLauncher-stable-Setup.exe) -> **Note**: The version for this build is `1.84.5` (Released on: August 25th, 2026). +[](https://github.com/CollapseLauncher/Collapse/releases/download/CL-v1.84.6/CollapseLauncher-stable-Setup.exe) +> **Note**: The version for this build is `1.84.6` (Released on: August 26th, 2026). -[](https://github.com/CollapseLauncher/Collapse/releases/download/CL-v1.84.5-pre/CollapseLauncher-preview-Setup.exe) -> **Note**: The version for this build is `1.84.5` (Released on: August 25th, 2026). +[](https://github.com/CollapseLauncher/Collapse/releases/download/CL-v1.84.6-pre/CollapseLauncher-preview-Setup.exe) +> **Note**: The version for this build is `1.84.6` (Released on: August 26th, 2026). To view all releases, [**click here**](https://github.com/neon-nyan/CollapseLauncher/releases). From 02c4cf4e834eb0fc64882c672a4302db4858b573 Mon Sep 17 00:00:00 2001 From: Kemal Setya Adhi Date: Sat, 5 Sep 2026 06:47:07 +0700 Subject: [PATCH 12/50] Refactor Discord RPC manager + Fix presence setter concurrency + Fix UI thread hung due to Discord RPC initialization + Cache multiple start offset by caching the offset under PresetConfig's HashID. + Avoid calling multiple App Config key string and instead using its own property instead. --- .../DiscordPresence/DiscordActivityType.cs | 15 + .../DiscordPresence/DiscordPresenceManager.cs | 476 ------------------ .../DiscordPresence/DiscordRpcManager.cs | 448 +++++++++++++++++ .../Base/InstallManagerBase.cs | 9 +- .../Plugins/PluginGameInstallWrapper.cs | 8 +- .../Classes/Properties/InnerLauncherConfig.cs | 15 +- .../RegionManagement/RegionManagement.cs | 4 +- .../XAMLs/MainApp/MainPage.xaml.cs | 6 +- .../XAMLs/MainApp/Pages/CachesPage.xaml.cs | 2 +- .../GenshinGameSettingsPage.xaml.cs | 2 +- .../HonkaiGameSettingsPage.xaml.cs | 2 +- .../StarRailGameSettingsPage.xaml.cs | 2 +- .../ZenlessGameSettingsPage.xaml.cs | 2 +- .../MainApp/Pages/HomePage.GameLauncher.cs | 4 +- .../XAMLs/MainApp/Pages/HomePage.xaml.cs | 7 +- .../XAMLs/MainApp/Pages/RepairPage.xaml.cs | 2 +- .../XAMLs/MainApp/Pages/SettingsPage.xaml.cs | 18 +- 17 files changed, 499 insertions(+), 523 deletions(-) create mode 100644 CollapseLauncher/Classes/DiscordPresence/DiscordActivityType.cs delete mode 100644 CollapseLauncher/Classes/DiscordPresence/DiscordPresenceManager.cs create mode 100644 CollapseLauncher/Classes/DiscordPresence/DiscordRpcManager.cs diff --git a/CollapseLauncher/Classes/DiscordPresence/DiscordActivityType.cs b/CollapseLauncher/Classes/DiscordPresence/DiscordActivityType.cs new file mode 100644 index 0000000000..4a05430ec8 --- /dev/null +++ b/CollapseLauncher/Classes/DiscordPresence/DiscordActivityType.cs @@ -0,0 +1,15 @@ +#pragma warning disable IDE0130 + +namespace CollapseLauncher.DiscordPresence; + +public enum DiscordActivityType +{ + None, + Idle, + Play, + Update, + Repair, + Cache, + GameSettings, + AppSettings +} diff --git a/CollapseLauncher/Classes/DiscordPresence/DiscordPresenceManager.cs b/CollapseLauncher/Classes/DiscordPresence/DiscordPresenceManager.cs deleted file mode 100644 index d4f172e4e0..0000000000 --- a/CollapseLauncher/Classes/DiscordPresence/DiscordPresenceManager.cs +++ /dev/null @@ -1,476 +0,0 @@ -using CollapseLauncher.Helper; -using CollapseLauncher.Helper.Metadata; -using CollapseLauncher.Helper.Update; -using CollapseLauncher.Plugins; -using DiscordRPC; -using DiscordRPC.Entities; -using DiscordRPC.Message; -using Hi3Helper; -using System; -using System.Diagnostics.CodeAnalysis; -using System.Runtime.CompilerServices; -using System.Threading.Tasks.Dataflow; -using static Hi3Helper.Shared.Region.LauncherConfig; -// ReSharper disable PartialTypeWithSinglePart -// ReSharper disable StringLiteralTypo -// ReSharper disable SwitchStatementHandlesSomeKnownEnumValuesWithDefault -#pragma warning disable IDE0130 - -#nullable enable -namespace CollapseLauncher.DiscordPresence -{ - #region Enums - - public enum ActivityType - { - None, - Idle, - Play, - Update, - Repair, - Cache, - GameSettings, - AppSettings - } - - #endregion - - public sealed partial class DiscordPresenceManager : IDisposable - { - #region Properties - - public bool IsRpcEnabled - { - get => field = GetAppConfigValue("EnableDiscordRPC"); - set - { - if (field == value) return; - field = value; - - SetAndSaveConfigValue("EnableDiscordRPC", value); - if (value) SetupPresence(null); - else DisablePresence(); - } - } - - private const string CollapseLogoExt = "https://collapselauncher.com/img/logo@2x.webp"; - - private DiscordRpcClient? _client; - - private RichPresence? _presence; - private ActivityType _activityType; - private DateTime? _lastPlayTime; - private bool _firstTimeConnect = true; - private readonly ActionBlock _presenceUpdateQueue; - - private bool _cachedIsIdleEnabled = true; - - public bool IdleEnabled - { - get - { - bool value = GetAppConfigValue("EnableDiscordIdleStatus"); - _cachedIsIdleEnabled = value; - return value; - } - set - { - SetAndSaveConfigValue("EnableDiscordIdleStatus", value); - _cachedIsIdleEnabled = value; - } - } - - #endregion - - public DiscordPresenceManager(bool initialStart = true) - { - _presenceUpdateQueue = new ActionBlock(_ => _client?.SetPresence(_presence), - new ExecutionDataflowBlockOptions - { - MaxMessagesPerTask = 1, - MaxDegreeOfParallelism = 1, - EnsureOrdered = true - }); - - if (!initialStart) - { - return; - } - - // Prepare idle cached setting - Logger.LogWriteLine($"Doing initial start for Discord RPC!\r\n\tIdle status : {IdleEnabled}", - LogType.Scheme); - } - - // Deconstruct and dispose unmanaged resources - ~DiscordPresenceManager() - { - Dispose(); - } - - public void Dispose() - { - // Dispose Discord RPC client - DisablePresence(); - - // Suppress the GC from finalization - GC.SuppressFinalize(this); - } - - private void EnablePresence(ulong applicationId) - { - if (!IsRpcEnabled) return; - _firstTimeConnect = true; - - // Flush and dispose the session - DisablePresence(); - - // Initialize Discord RPC client - _client = new DiscordRpcClient(applicationId.ToString(), ILoggerHelper.GetILogger("DiscordRPC")); - - _client.OnReady += OnReady; - _client.OnPresenceUpdate += OnPresenceUpdate; - - if (!_client.Initialize()) - { - Logger.LogWriteLine("Error initializing Discord Presence.", LogType.Warning, true); - return; - } - - Logger.LogWriteLine("Discord Presence is Enabled!"); - } - - private void OnReady(object? sender, ReadyMessage? msg) - { - Logger.LogWriteLine($"Connected to Discord with user {msg?.User?.Username}"); - if (!_firstTimeConnect) - { - // Restart Discord RPC client - _firstTimeConnect = true; - SetupPresence(null); - } - else - { - // Restore our last activity - if (!(!_cachedIsIdleEnabled && - _activityType is ActivityType.Idle or ActivityType.None)) - { - SetActivity(_activityType); - } - - _firstTimeConnect = false; - } - } - - private static void OnPresenceUpdate(object? sender, PresenceMessage? msg) - { - if (msg?.Presence == null) - { - Logger.LogWriteLine("Activity cleared!"); - } - else - { - Logger.LogWriteLine(msg.Presence.State == null - ? $"Activity updated! => {msg.Presence.Details}" - : $"Activity updated! => {msg.Presence.Details} - {msg.Presence.State}"); - } - } - - public void DisablePresence() - { - _client?.SetPresence(null); - _client?.Dispose(); - _client = null; - } - - private static ulong GetDiscordPresenceId(PresetConfig presetConfig) - { - return presetConfig.GameName switch - { - "Honkai: Star Rail" => AppDiscordApplicationIDHsr, - "Honkai Impact 3rd" => AppDiscordApplicationIDHi3, - "Genshin Impact" => AppDiscordApplicationIDGi, - "Zenless Zone Zero" => AppDiscordApplicationIDZzz, - _ => TryGetPresenceFromPlugin(presetConfig) - }; - - static ulong TryGetPresenceFromPlugin(PresetConfig presetConfig) - { - if (presetConfig is not PluginPresetConfigWrapper { DiscordPresenceContext : { IsFeatureAvailable: true } discordContext } || - discordContext.PresenceId == 0) - { - return AppDiscordApplicationID; // Default - } - - return discordContext.PresenceId; - } - } - - public void SetupPresence(PresetConfig? presetConfig) - { - bool isGameStatusEnabled = GetAppConfigValue("EnableDiscordGameStatus"); - if (!IsRpcEnabled || !isGameStatusEnabled) return; - - string gameTitle = MetadataHelper.CurrentGameTitleName; - string gameRegion = MetadataHelper.CurrentGameRegionName; - - if (presetConfig == null && - !MetadataHelper.TryGetGameConfig(gameTitle, gameRegion, out presetConfig)) - { - return; - } - - if (GetDiscordPresenceId(presetConfig) is var presenceId && presenceId == 0) - { - Logger.LogWriteLine("Discord Presence (Unknown Game)", LogType.Error, true); - } - - EnablePresence(presenceId); - } - - public void SetActivity(ActivityType activity, DateTime? activityOffset = null) - { - if (!IsRpcEnabled) return; - - //_lastAttemptedActivityType = activity; - _activityType = activity; - - switch (activity) - { - case ActivityType.Play: - { - bool isGameStatusEnabled = GetAppConfigValue("EnableDiscordGameStatus").ToBool(); - BuildActivityGameStatus((isGameStatusEnabled ? Locale.Current.Lang?._Misc?.DiscordRP_InGame : Locale.Current.Lang?._Misc?.DiscordRP_Play) ?? "", - isGameStatusEnabled, activityOffset); - break; - } - case ActivityType.Update: - { - bool isGameStatusEnabled = GetAppConfigValue("EnableDiscordGameStatus").ToBool(); - BuildActivityGameStatus(Locale.Current.Lang?._Misc?.DiscordRP_Update ?? "", isGameStatusEnabled); - break; - } - case ActivityType.Repair: - BuildActivityAppStatus(Locale.Current.Lang?._Misc?.DiscordRP_Repair ?? ""); - break; - case ActivityType.Cache: - BuildActivityAppStatus(Locale.Current.Lang?._Misc?.DiscordRP_Cache ?? ""); - break; - case ActivityType.GameSettings: - BuildActivityAppStatus(Locale.Current.Lang?._Misc?.DiscordRP_GameSettings ?? ""); - break; - case ActivityType.AppSettings: - BuildActivityAppStatus(Locale.Current.Lang?._Misc?.DiscordRP_AppSettings ?? ""); - break; - case ActivityType.Idle: - _lastPlayTime = null; - if (_cachedIsIdleEnabled) - { - BuildActivityAppStatus(Locale.Current.Lang?._Misc?.DiscordRP_Idle ?? ""); - } - else - { - _presence = null; // Clear presence - } - - break; - default: - _presence = new RichPresence - { - Details = Locale.Current.Lang?._Misc?.DiscordRP_Default, - Assets = new Assets - { - LargeImageKey = "launcher-logo-new", - LargeImageText = - $"Collapse Launcher v{LauncherUpdateHelper.LauncherCurrentVersionString} {(IsPreview ? "Preview" : "Stable")}" - }, - Timestamps = null! - }; - break; - } - - UpdateActivity(); - } - - private void BuildActivityGameStatus(string activityName, bool isGameStatusEnabled, DateTime? activityOffset = null) - { - string curGameName = MetadataHelper.CurrentGameTitleName; - string curGameRegion = MetadataHelper.CurrentGameRegionName; - - if (string.IsNullOrEmpty(curGameName) || string.IsNullOrEmpty(curGameRegion) || - !MetadataHelper.TryGetGameConfig(curGameName, curGameRegion, out PresetConfig? presetConfig)) - return; - - string curGameNameTranslate = MetadataHelper.GetTranslatedTitle(curGameName); - string curGameRegionTranslate = MetadataHelper.GetTranslatedRegion(curGameRegion); - - if (TryBuildActivityGameStatusFromPlugin(activityName, - curGameNameTranslate, - curGameRegionTranslate, - isGameStatusEnabled, - activityOffset, - presetConfig, - out _presence)) - { - return; - } - - _presence = new RichPresence - { - Details = $"{activityName} {(!isGameStatusEnabled ? curGameNameTranslate : null)}", - State = $"{Locale.Current.Lang?._Misc?.DiscordRP_Region} {curGameRegionTranslate}", - Assets = new Assets - { - LargeImageKey = $"game-{presetConfig.GameType.ToString().ToLower()}-logo", - LargeImageText = $"{curGameNameTranslate} - {curGameRegionTranslate}", - SmallImageKey = "launcher-logo-new", - SmallImageText = $"Collapse Launcher v{LauncherUpdateHelper.LauncherCurrentVersionString} " - + $"{(IsPreview ? "Preview" : "Stable")}" - }, - Timestamps = new Timestamps - { - Start = GetCachedStartPlayTime(activityOffset) - } - }; - } - - private bool TryBuildActivityGameStatusFromPlugin( - string activityName, - string? translatedGameName, - string? translatedRegionName, - bool isGameStatusEnabled, - DateTime? activityOffset, - PresetConfig presetConfig, - [NotNullWhen(true)] out RichPresence? presence) - { - Unsafe.SkipInit(out presence); - - if (presetConfig is not PluginPresetConfigWrapper asPluginPresetConfig || - !asPluginPresetConfig.DiscordPresenceContext.IsFeatureAvailable) - { - return false; - } - - string? largeIconUrl = asPluginPresetConfig.DiscordPresenceContext.LargeIconUrl; - string? largeIconTooltip = asPluginPresetConfig.DiscordPresenceContext.LargeIconTooltip; - string? smallIconUrl = asPluginPresetConfig.DiscordPresenceContext.SmallIconUrl; - string? smallIconTooltip = asPluginPresetConfig.DiscordPresenceContext.SmallIconTooltip; - - presence = new RichPresence - { - Details = $"{activityName} {(!isGameStatusEnabled ? translatedGameName : null)}", - State = $"{Locale.Current.Lang?._Misc?.DiscordRP_Region} {translatedRegionName}", - Assets = new Assets - { - LargeImageKey = largeIconUrl ?? CollapseLogoExt, - LargeImageText = largeIconTooltip ?? $"{translatedGameName} - {translatedRegionName}", - SmallImageKey = smallIconUrl ?? CollapseLogoExt, - SmallImageText = smallIconTooltip ?? - $"Collapse Launcher v{LauncherUpdateHelper.LauncherCurrentVersionString} " - + $"{(IsPreview ? "Preview" : "Stable")}" - }, - Timestamps = new Timestamps - { - Start = GetCachedStartPlayTime(activityOffset) - } - }; - - return true; - } - - private DateTime GetCachedStartPlayTime(DateTime? activityOffset) - { - _lastPlayTime ??= activityOffset; - _lastPlayTime ??= DateTime.UtcNow; - return _lastPlayTime.Value; - } - - private void BuildActivityAppStatus(string activityName) - { - string curGameName = MetadataHelper.CurrentGameTitleName; - string curGameRegion = MetadataHelper.CurrentGameRegionName; - - if (string.IsNullOrEmpty(curGameName) || string.IsNullOrEmpty(curGameRegion) || - !MetadataHelper.TryGetGameConfig(curGameName, curGameRegion, out PresetConfig? presetConfig)) - return; - - string curGameNameTranslate = MetadataHelper.GetTranslatedTitle(curGameName); - string curGameRegionTranslate = MetadataHelper.GetTranslatedRegion(curGameRegion); - - if (TryBuildActivityAppStatusFromPlugin(activityName, - curGameNameTranslate, - curGameRegionTranslate, - presetConfig, - out _presence)) - { - return; - } - - _presence = new RichPresence - { - Details = activityName, - State = $"{Locale.Current.Lang?._Misc?.DiscordRP_Region} {curGameRegionTranslate}", - Assets = new Assets - { - LargeImageKey = $"game-{presetConfig.GameType.ToString().ToLower()}-logo", - LargeImageText = curGameNameTranslate, - SmallImageKey = "launcher-logo-new", - SmallImageText = $"Collapse Launcher v{LauncherUpdateHelper.LauncherCurrentVersionString} " - + $"{(IsPreview ? "Preview" : "Stable")}" - }, - Timestamps = null! - }; - } - - private static bool TryBuildActivityAppStatusFromPlugin( - string activityName, - string? translatedGameName, - string? translatedRegionName, - PresetConfig presetConfig, - [NotNullWhen(true)] out RichPresence? presence) - { - Unsafe.SkipInit(out presence); - - if (presetConfig is not PluginPresetConfigWrapper asPluginPresetConfig || - !asPluginPresetConfig.DiscordPresenceContext.IsFeatureAvailable) - { - return false; - } - - string? largeIconUrl = asPluginPresetConfig.DiscordPresenceContext.LargeIconUrl; - string? largeIconTooltip = asPluginPresetConfig.DiscordPresenceContext.LargeIconTooltip; - string? smallIconUrl = asPluginPresetConfig.DiscordPresenceContext.SmallIconUrl; - string? smallIconTooltip = asPluginPresetConfig.DiscordPresenceContext.SmallIconTooltip; - - presence = new RichPresence - { - Details = activityName, - State = $"{Locale.Current.Lang?._Misc?.DiscordRP_Region} {translatedRegionName}", - Assets = new Assets - { - LargeImageKey = largeIconUrl ?? CollapseLogoExt, - LargeImageText = largeIconTooltip ?? translatedGameName, - SmallImageKey = smallIconUrl ?? CollapseLogoExt, - SmallImageText = smallIconTooltip ?? - $"Collapse Launcher v{LauncherUpdateHelper.LauncherCurrentVersionString} " - + $"{(IsPreview ? "Preview" : "Stable")}" - }, - Timestamps = null! - }; - - return true; - } - - private void UpdateActivity() - { - try - { - _presenceUpdateQueue.Post(_presence); - } - catch (Exception ex) - { - Logger.LogWriteLine($"Error when updating Discord Presence Activity\r\n{ex}", LogType.Error, true); - } - } - } -} \ No newline at end of file diff --git a/CollapseLauncher/Classes/DiscordPresence/DiscordRpcManager.cs b/CollapseLauncher/Classes/DiscordPresence/DiscordRpcManager.cs new file mode 100644 index 0000000000..097a705142 --- /dev/null +++ b/CollapseLauncher/Classes/DiscordPresence/DiscordRpcManager.cs @@ -0,0 +1,448 @@ +using CollapseLauncher.Helper; +using CollapseLauncher.Helper.Metadata; +using CollapseLauncher.Helper.Update; +using CollapseLauncher.Plugins; +using DiscordRPC; +using DiscordRPC.Entities; +using DiscordRPC.Message; +using Hi3Helper; +using Hi3Helper.LocaleSourceGen; +using Hi3Helper.Shared.Region; +using Microsoft.Extensions.Logging; +using System; +using System.Collections.Concurrent; +using System.Runtime.CompilerServices; +using System.Threading; +using System.Threading.Channels; + +#pragma warning disable IDE0130 + +#nullable enable +namespace CollapseLauncher.DiscordPresence; + +public partial class DiscordRpcManager : IDisposable +{ + public bool IsDisposed; + + public DiscordRpcClient? Client; + public readonly Thread PresenceSetThread; + public readonly Channel PresenceSetChannel; + + private ulong _currentPresenceId = LauncherConfig.AppDiscordApplicationID; + private DiscordActivityType _currentActivityStatus; + + private readonly EventWaitHandle _isReadyWaitHandle = new(false, EventResetMode.ManualReset); + + public bool IsGameStatusEnabled + { + get => LauncherConfig.GetAppConfigValue("EnableDiscordGameStatus"); + set + { + LauncherConfig.SetAndSaveConfigValue("EnableDiscordGameStatus", value); + SetActivity(_currentActivityStatus); // Refresh activity status + } + } + + public bool IsShowOnIdle + { + get => LauncherConfig.GetAppConfigValue("EnableDiscordIdleStatus"); + set + { + LauncherConfig.SetAndSaveConfigValue("EnableDiscordIdleStatus", value); + SetActivity(_currentActivityStatus); // Refresh activity status + } + } + + public bool IsEnabled + { + get => LauncherConfig.GetAppConfigValue("EnableDiscordRPC"); + set + { + bool isPreviouslyEnabled = LauncherConfig.GetAppConfigValue("EnableDiscordRPC"); + LauncherConfig.SetAndSaveConfigValue("EnableDiscordRPC", value); + + if (value) Start(); + else Stop(); + + // Refresh activity status if it was previously disabled. + if (!isPreviouslyEnabled && + value != isPreviouslyEnabled) + { + SetActivity(_currentActivityStatus); + } + } + } + + private readonly ConcurrentDictionary _cachedStartTimes = []; + private readonly ILogger _sharedLogger; + private PresetConfig? _currentPresetConfig; + + public DiscordRpcManager() + { + _sharedLogger = ILoggerHelper.GetILogger("DiscordRPC"); + PresenceSetChannel = Channel.CreateUnbounded(new UnboundedChannelOptions + { + SingleWriter = true + }); + + PresenceSetThread = new Thread(PresenceSetterInvoke) + { + IsBackground = true + }; + PresenceSetThread.Start(); + + // Initialize from start if enabled and IsShowOnIdle == true + if (IsEnabled && IsShowOnIdle) + { + Start(); + } + } + + public void Dispose() + { + if (Interlocked.Exchange(ref IsDisposed, true)) + return; + + // Stop presence RPC + Stop(); + + // Complete the writer and dispose the wait handle. + PresenceSetChannel.Writer.TryComplete(); + _isReadyWaitHandle.Dispose(); + } + + public async void PresenceSetterInvoke(object? ctx) + { + try + { + ChannelReader reader = PresenceSetChannel.Reader; + + while (!IsDisposed && await reader.WaitToReadAsync()) + { + while (reader.TryRead(out RichPresence? presence)) + { + if (IsDisposed) + { + return; + } + + // Blocks and wait until the ready signal is set. + _isReadyWaitHandle.WaitOne(); + Client?.SetPresence(presence); + } + } + } + catch (ObjectDisposedException) + { + // ignore + + // From @neon-nyan: + // The reason why this is ignored, is because the disposed exception will + // come from the _isReadyWaitHandle. The EventWaitHandle doesn't have such + // property or field to check whether the handle is already disposed or + // not anyway, so we just yeet the exception. + } + catch (Exception e) + { + _sharedLogger.LogError(e, "An error has occurred while setting presence on the RPC client."); + } + } + + public void Stop() + { + DiscordRpcClient? oldClient = Interlocked.Exchange(ref Client, null); + if (oldClient == null) + { + return; + } + + // Reset the channel by flushing all pending presence + while (PresenceSetChannel.Reader.TryRead(out _)) { } + + if (!Volatile.Read(ref IsDisposed)) + { + // Reset wait handle and block presence update until the + // client is ready or started. + _isReadyWaitHandle.Reset(); + } + + oldClient.OnReady -= EventClientOnReady; + oldClient.OnPresenceUpdate -= EventClientOnPresenceUpdate; + oldClient.Dispose(); + } + + public void Start() + { + // If not enabled, choose to not initialize the client. + if (!IsEnabled) + { + return; + } + + ulong presenceId = _currentPresenceId == 0 + ? LauncherConfig.AppDiscordApplicationID + : _currentPresenceId; + + // Initialize new client and replace the field atomically. + Interlocked.Exchange(ref Client, new DiscordRpcClient($"{presenceId}", _sharedLogger)); + Client.OnReady += EventClientOnReady; + Client.OnPresenceUpdate += EventClientOnPresenceUpdate; + if (!Client.Initialize()) + { + _sharedLogger.LogInformation("Failed while trying to initialize the client!"); + } + } + + private void EventClientOnReady(object sender, ReadyMessage? msg) + { + _sharedLogger.LogInformation("Connected to Discord with user {username}", msg?.User?.Username); + _isReadyWaitHandle.Set(); // Unblock the presence update thread. + } + + private void EventClientOnPresenceUpdate(object sender, PresenceMessage? msg) + { + if (msg?.Presence == null) + { + _sharedLogger.LogInformation("Activity cleared!"); + } + else + { + _sharedLogger.LogInformation("Activity updated! => {msg}", msg.Presence.State == null + ? msg.Presence.Details + : $"{msg.Presence.Details} - {msg.Presence.State}"); + } + } + + public void SetPresence(PresetConfig? config) + { + Interlocked.Exchange(ref _currentPresetConfig, config); + if (config == null) + { + Interlocked.Exchange(ref _currentPresenceId, 0); + return; + } + + ulong presenceId = GetDiscordPresenceId(config); + Interlocked.Exchange(ref _currentPresenceId, presenceId); + + // We intentionally stop and start the client to refresh / re-create the client + // with the new presence ID. + Stop(); + Start(); + } + + public void SetActivity(DiscordActivityType type = DiscordActivityType.None, DateTime? specifiedStartTime = null) + { + Interlocked.Exchange(ref _currentActivityStatus, type); + + // Prevent from exhausting the Presence channel if not enabled. + if (!IsEnabled) return; + + // If IsShowOnIdle == false and the activity type is None or Idle, + // tries to stop the RPC for a while to disconnect it from Discord and + // remove the RPC display. + if (!IsShowOnIdle && type is DiscordActivityType.None or DiscordActivityType.Idle) + { + Stop(); + return; + } + + // Make sure to re-enable the client if it was previously disposed due to + // IsShowOnIdle == false and type is None or Idle. + if (Volatile.Read(ref Client) == null && + !IsDisposed && + IsEnabled) + { + Start(); + } + + LangParamsMisc? langMisc = Locale.Current.Lang?._Misc; + RichPresence presence = type switch + { + DiscordActivityType.Play => PresenceBuilder.BuildTimedState(IsGameStatusEnabled ? langMisc?.DiscordRP_InGame : langMisc?.DiscordRP_Play, this, specifiedStartTime), + DiscordActivityType.Update => PresenceBuilder.BuildTimedState(langMisc?.DiscordRP_Update, this, specifiedStartTime), + DiscordActivityType.Idle => PresenceBuilder.BuildIdleState(this), + DiscordActivityType.Repair => PresenceBuilder.BuildGenericState(langMisc?.DiscordRP_Repair, this), + DiscordActivityType.Cache => PresenceBuilder.BuildGenericState(langMisc?.DiscordRP_Cache, this), + DiscordActivityType.GameSettings => PresenceBuilder.BuildGenericState(langMisc?.DiscordRP_GameSettings, this), + DiscordActivityType.AppSettings => PresenceBuilder.BuildGenericState(langMisc?.DiscordRP_AppSettings, this), + _ => new RichPresence + { + Details = Locale.Current.Lang?._Misc?.DiscordRP_Default, + Assets = new Assets + { + LargeImageKey = PresenceBuilder.DefaultLauncherLogo, + LargeImageText = PresenceBuilder.DefaultLauncherLogoTooltip + }, + Timestamps = null! + } + }; + + PresenceSetChannel.Writer.TryWrite(presence); + } + + private static ulong GetDiscordPresenceId(PresetConfig presetConfig) + { + return presetConfig.GameName switch + { + "Honkai: Star Rail" => LauncherConfig.AppDiscordApplicationIDHsr, + "Honkai Impact 3rd" => LauncherConfig.AppDiscordApplicationIDHi3, + "Genshin Impact" => LauncherConfig.AppDiscordApplicationIDGi, + "Zenless Zone Zero" => LauncherConfig.AppDiscordApplicationIDZzz, + _ => TryGetPresenceFromPlugin(presetConfig) + }; + + static ulong TryGetPresenceFromPlugin(PresetConfig presetConfig) + { + if (presetConfig is not PluginPresetConfigWrapper { DiscordPresenceContext: { IsFeatureAvailable: true } discordContext } || + discordContext.PresenceId == 0) + { + return LauncherConfig.AppDiscordApplicationID; // Default + } + + return discordContext.PresenceId; + } + } + + private static class PresenceBuilder + { + private const string CollapseLogoExt = "https://collapselauncher.com/img/logo@2x.webp"; + + public const string DefaultLauncherLogo = "launcher-logo-new"; + public static readonly string DefaultLauncherLogoTooltip = $"Collapse Launcher v{LauncherUpdateHelper.LauncherCurrentVersionString} " + + $"{(LauncherConfig.IsPreview ? "Preview" : "Stable")}"; + + public static RichPresence BuildTimedState(string? activityName, DiscordRpcManager manager, DateTime? specifiedStartTime = null) + { + bool isGameStatusEnabled = manager.IsGameStatusEnabled; + PresetConfig? presetConfig = manager._currentPresetConfig; + + int presetConfigHashId = presetConfig?.HashID ?? 0; + + // Try to get the existing start offset or create a new one if not exist. + DateTime startOffset = manager._cachedStartTimes.GetOrAdd(presetConfigHashId, specifiedStartTime ?? DateTime.UtcNow); + TryGetGameIconsAndTranslatedNames(presetConfig, + out string? largeIconUrl, + out string? largeIconTooltip, + out string? smallIconUrl, + out string? smallIconTooltip, + out string? translatedGameName, + out string? translatedGameRegion); + + return new RichPresence + { + Details = $"{activityName} {(!isGameStatusEnabled ? translatedGameName : null)}", + State = $"{Locale.Current.Lang?._Misc?.DiscordRP_Region} {translatedGameRegion}", + Assets = new Assets + { + LargeImageKey = largeIconUrl, + LargeImageText = largeIconTooltip, + SmallImageKey = smallIconUrl, + SmallImageText = smallIconTooltip + }, + Timestamps = new Timestamps + { + Start = startOffset + } + }; + } + + public static RichPresence BuildGenericState(string? activityName, DiscordRpcManager manager) + { + PresetConfig? presetConfig = manager._currentPresetConfig; + TryGetGameIconsAndTranslatedNames(presetConfig, + out string? largeIconUrl, + out string? largeIconTooltip, + out string? smallIconUrl, + out string? smallIconTooltip, + out _, + out string? translatedGameRegion); + + return new RichPresence + { + Details = activityName, + State = $"{Locale.Current.Lang?._Misc?.DiscordRP_Region} {translatedGameRegion}", + Assets = new Assets + { + LargeImageKey = largeIconUrl, + LargeImageText = largeIconTooltip, + SmallImageKey = smallIconUrl, + SmallImageText = smallIconTooltip + }, + Timestamps = null! + }; + } + + public static RichPresence BuildIdleState(DiscordRpcManager manager) + { + // Try to remove existing cached start time (Reset) + PresetConfig? presetConfig = manager._currentPresetConfig; + int presetConfigHashId = presetConfig?.GetHashCode() ?? 0; + manager._cachedStartTimes.TryRemove(presetConfigHashId, out _); + return BuildGenericState(Locale.Current.Lang?._Misc?.DiscordRP_Idle, manager); + } + + private static void TryGetGameIconsAndTranslatedNames( + PresetConfig? presetConfig, + out string? largeIconUrl, + out string? largeIconTooltip, + out string? smallIconUrl, + out string? smallIconTooltip, + out string? translatedGameName, + out string? translatedGameRegion) + { + Unsafe.SkipInit(out largeIconUrl); + Unsafe.SkipInit(out largeIconTooltip); + Unsafe.SkipInit(out smallIconUrl); + Unsafe.SkipInit(out smallIconTooltip); + + string? currentGameName = presetConfig?.GameName; + string? currentGameRegion = presetConfig?.ZoneName; + translatedGameName = MetadataHelper.GetTranslatedTitle(currentGameName); + translatedGameRegion = MetadataHelper.GetTranslatedRegion(currentGameRegion); + + // Try to get icons from plugin if available. + TryGetPluginGameIcons(presetConfig, + out largeIconUrl, + out largeIconTooltip, + out smallIconUrl, + out smallIconTooltip, + out bool isPluginGame); + + largeIconUrl ??= isPluginGame ? CollapseLogoExt : $"game-{presetConfig?.GameType.ToString().ToLower()}-logo"; + largeIconTooltip ??= $"{translatedGameName} - {translatedGameRegion}"; + smallIconUrl ??= isPluginGame ? CollapseLogoExt : DefaultLauncherLogo; + smallIconTooltip ??= DefaultLauncherLogoTooltip; + } + + private static void TryGetPluginGameIcons(PresetConfig? presetConfig, + out string? largeIconUrl, + out string? largeIconTooltip, + out string? smallIconUrl, + out string? smallIconTooltip, + out bool isPluginGame) + { + Unsafe.SkipInit(out largeIconUrl); + Unsafe.SkipInit(out largeIconTooltip); + Unsafe.SkipInit(out smallIconUrl); + Unsafe.SkipInit(out smallIconTooltip); + Unsafe.SkipInit(out isPluginGame); + + if (presetConfig is not PluginPresetConfigWrapper asPluginPresetConfig) + { + return; + } + + isPluginGame = true; + if (!asPluginPresetConfig.DiscordPresenceContext.IsFeatureAvailable) + { + return; + } + + largeIconUrl = asPluginPresetConfig.DiscordPresenceContext.LargeIconUrl; + largeIconTooltip = asPluginPresetConfig.DiscordPresenceContext.LargeIconTooltip; + smallIconUrl = asPluginPresetConfig.DiscordPresenceContext.SmallIconUrl; + smallIconTooltip = asPluginPresetConfig.DiscordPresenceContext.SmallIconTooltip; + } + } +} diff --git a/CollapseLauncher/Classes/InstallManagement/Base/InstallManagerBase.cs b/CollapseLauncher/Classes/InstallManagement/Base/InstallManagerBase.cs index 094de6a0e1..a330d94fff 100644 --- a/CollapseLauncher/Classes/InstallManagement/Base/InstallManagerBase.cs +++ b/CollapseLauncher/Classes/InstallManagement/Base/InstallManagerBase.cs @@ -158,7 +158,6 @@ public InstallManagerBase( $"{Path.GetFileNameWithoutExtension(gameVersionManager.GamePreset.GameExecutableName)}_Data\\Persistent"; _gameStreamingAssetsFolderBasePath = $"{Path.GetFileNameWithoutExtension(gameVersionManager.GamePreset.GameExecutableName)}_Data\\StreamingAssets"; - UpdateCompletenessStatus(CompletenessStatus.Idle); } protected void ResetToken() @@ -3233,7 +3232,7 @@ public void UpdateCompletenessStatus(CompletenessStatus status) Status.IsCompleted = false; Status.IsCanceled = false; #if !DISABLEDISCORD - InnerLauncherConfig.AppDiscordPresence.SetActivity(ActivityType.Update); + InnerLauncherConfig.AppDiscordPresence.SetActivity(DiscordActivityType.Update); #endif break; case CompletenessStatus.Completed: @@ -3244,7 +3243,7 @@ public void UpdateCompletenessStatus(CompletenessStatus status) Status.IsProgressAllIndetermined = false; Status.IsProgressPerFileIndetermined = false; #if !DISABLEDISCORD - InnerLauncherConfig.AppDiscordPresence.SetActivity(ActivityType.Idle); + InnerLauncherConfig.AppDiscordPresence.SetActivity(DiscordActivityType.Idle); #endif // HACK: Fix the progress not achieving 100% while completed lock (Progress) @@ -3261,7 +3260,7 @@ public void UpdateCompletenessStatus(CompletenessStatus status) Status.IsProgressAllIndetermined = false; Status.IsProgressPerFileIndetermined = false; #if !DISABLEDISCORD - InnerLauncherConfig.AppDiscordPresence.SetActivity(ActivityType.Idle); + InnerLauncherConfig.AppDiscordPresence.SetActivity(DiscordActivityType.Idle); #endif break; case CompletenessStatus.Idle: @@ -3272,7 +3271,7 @@ public void UpdateCompletenessStatus(CompletenessStatus status) Status.IsProgressAllIndetermined = false; Status.IsProgressPerFileIndetermined = false; #if !DISABLEDISCORD - InnerLauncherConfig.AppDiscordPresence.SetActivity(ActivityType.Idle); + InnerLauncherConfig.AppDiscordPresence.SetActivity(DiscordActivityType.Idle); #endif break; } diff --git a/CollapseLauncher/Classes/Plugins/PluginGameInstallWrapper.cs b/CollapseLauncher/Classes/Plugins/PluginGameInstallWrapper.cs index 4d59a69b65..f6fc94a65d 100644 --- a/CollapseLauncher/Classes/Plugins/PluginGameInstallWrapper.cs +++ b/CollapseLauncher/Classes/Plugins/PluginGameInstallWrapper.cs @@ -829,7 +829,7 @@ public void UpdateCompletenessStatus(CompletenessStatus status) Status.IsCompleted = false; Status.IsCanceled = false; #if !DISABLEDISCORD - InnerLauncherConfig.AppDiscordPresence.SetActivity(ActivityType.Update); + InnerLauncherConfig.AppDiscordPresence.SetActivity(DiscordActivityType.Update); #endif break; case CompletenessStatus.Completed: @@ -840,7 +840,7 @@ public void UpdateCompletenessStatus(CompletenessStatus status) Status.IsProgressAllIndetermined = false; Status.IsProgressPerFileIndetermined = false; #if !DISABLEDISCORD - InnerLauncherConfig.AppDiscordPresence.SetActivity(ActivityType.Idle); + InnerLauncherConfig.AppDiscordPresence.SetActivity(DiscordActivityType.Idle); #endif lock (Progress) { @@ -856,7 +856,7 @@ public void UpdateCompletenessStatus(CompletenessStatus status) Status.IsProgressAllIndetermined = false; Status.IsProgressPerFileIndetermined = false; #if !DISABLEDISCORD - InnerLauncherConfig.AppDiscordPresence.SetActivity(ActivityType.Idle); + InnerLauncherConfig.AppDiscordPresence.SetActivity(DiscordActivityType.Idle); #endif break; case CompletenessStatus.Idle: @@ -867,7 +867,7 @@ public void UpdateCompletenessStatus(CompletenessStatus status) Status.IsProgressAllIndetermined = false; Status.IsProgressPerFileIndetermined = false; #if !DISABLEDISCORD - InnerLauncherConfig.AppDiscordPresence.SetActivity(ActivityType.Idle); + InnerLauncherConfig.AppDiscordPresence.SetActivity(DiscordActivityType.Idle); #endif break; } diff --git a/CollapseLauncher/Classes/Properties/InnerLauncherConfig.cs b/CollapseLauncher/Classes/Properties/InnerLauncherConfig.cs index 56e2579c52..bb2b6ac383 100644 --- a/CollapseLauncher/Classes/Properties/InnerLauncherConfig.cs +++ b/CollapseLauncher/Classes/Properties/InnerLauncherConfig.cs @@ -49,18 +49,9 @@ public enum AppMode public static bool IsSkippingUpdateCheck = false; public static AppThemeMode CurrentAppTheme; #if !DISABLEDISCORD - public static DiscordPresenceManager AppDiscordPresence + public static DiscordRpcManager AppDiscordPresence { - get - { - if (field != null) return field; - - bool isEnableDiscord = GetAppConfigValue("EnableDiscordRPC"); - field = new DiscordPresenceManager(isEnableDiscord); - AppDiscordPresence.SetActivity(ActivityType.Idle); - - return field; - } + get => field ??= new DiscordRpcManager(); } #endif public static bool IsAppThemeLight => @@ -73,7 +64,7 @@ public static DiscordPresenceManager AppDiscordPresence public static void SaveLocalNotificationData() { - NotificationPush localNotificationData = new NotificationPush + NotificationPush localNotificationData = new() { AppPushIgnoreMsgIds = NotificationData?.AppPushIgnoreMsgIds, RegionPushIgnoreMsgIds = NotificationData?.RegionPushIgnoreMsgIds diff --git a/CollapseLauncher/Classes/RegionManagement/RegionManagement.cs b/CollapseLauncher/Classes/RegionManagement/RegionManagement.cs index 5d61f80c5e..a33361e46f 100644 --- a/CollapseLauncher/Classes/RegionManagement/RegionManagement.cs +++ b/CollapseLauncher/Classes/RegionManagement/RegionManagement.cs @@ -395,8 +395,8 @@ private async Task LoadRegionRootButton() LogWriteLine($"Region changed to {gameRegion.ZoneFullname}", LogType.Scheme, true); #if !DISABLEDISCORD - if (AppDiscordPresence.IsRpcEnabled) - AppDiscordPresence.SetupPresence(gameRegion); + if (AppDiscordPresence.IsEnabled) + AppDiscordPresence.SetPresence(gameRegion); #endif } diff --git a/CollapseLauncher/XAMLs/MainApp/MainPage.xaml.cs b/CollapseLauncher/XAMLs/MainApp/MainPage.xaml.cs index 979c2f79d8..be8ba59619 100644 --- a/CollapseLauncher/XAMLs/MainApp/MainPage.xaml.cs +++ b/CollapseLauncher/XAMLs/MainApp/MainPage.xaml.cs @@ -213,7 +213,7 @@ private async Task InitializeStartup() bool isEnableDiscord = GetAppConfigValue("EnableDiscordRPC"); if (isEnableDiscord) { - InnerLauncherConfig.AppDiscordPresence.SetupPresence(presetConfig); + InnerLauncherConfig.AppDiscordPresence.SetPresence(presetConfig); } } @@ -841,8 +841,8 @@ private async void ChangeToActivatedRegion() if (await LoadRegionFromCurrentConfigV2(preset, gameName, gameRegion)) { #if !DISABLEDISCORD - if (InnerLauncherConfig.AppDiscordPresence.IsRpcEnabled && !sameRegion) - InnerLauncherConfig.AppDiscordPresence.SetupPresence(preset); + if (InnerLauncherConfig.AppDiscordPresence.IsEnabled && !sameRegion) + InnerLauncherConfig.AppDiscordPresence.SetPresence(preset); #endif InvokeLoadingRegionPopup(false); LauncherFrame.BackStack.Clear(); diff --git a/CollapseLauncher/XAMLs/MainApp/Pages/CachesPage.xaml.cs b/CollapseLauncher/XAMLs/MainApp/Pages/CachesPage.xaml.cs index 3561607a95..b892c0ff7a 100644 --- a/CollapseLauncher/XAMLs/MainApp/Pages/CachesPage.xaml.cs +++ b/CollapseLauncher/XAMLs/MainApp/Pages/CachesPage.xaml.cs @@ -270,7 +270,7 @@ or GameInstallStateEnum.InstalledHavePlugin else { #if !DISABLEDISCORD - AppDiscordPresence.SetActivity(ActivityType.Cache); + AppDiscordPresence.SetActivity(DiscordActivityType.Cache); #endif } } diff --git a/CollapseLauncher/XAMLs/MainApp/Pages/GameSettingsPages/GenshinGameSettingsPage.xaml.cs b/CollapseLauncher/XAMLs/MainApp/Pages/GameSettingsPages/GenshinGameSettingsPage.xaml.cs index f5686f44bb..02df515d75 100644 --- a/CollapseLauncher/XAMLs/MainApp/Pages/GameSettingsPages/GenshinGameSettingsPage.xaml.cs +++ b/CollapseLauncher/XAMLs/MainApp/Pages/GameSettingsPages/GenshinGameSettingsPage.xaml.cs @@ -119,7 +119,7 @@ or GameInstallStateEnum.InstalledHavePlugin else { #if !DISABLEDISCORD - InnerLauncherConfig.AppDiscordPresence.SetActivity(ActivityType.GameSettings); + InnerLauncherConfig.AppDiscordPresence.SetActivity(DiscordActivityType.GameSettings); #endif } } diff --git a/CollapseLauncher/XAMLs/MainApp/Pages/GameSettingsPages/HonkaiGameSettingsPage.xaml.cs b/CollapseLauncher/XAMLs/MainApp/Pages/GameSettingsPages/HonkaiGameSettingsPage.xaml.cs index ae37036866..b960275197 100644 --- a/CollapseLauncher/XAMLs/MainApp/Pages/GameSettingsPages/HonkaiGameSettingsPage.xaml.cs +++ b/CollapseLauncher/XAMLs/MainApp/Pages/GameSettingsPages/HonkaiGameSettingsPage.xaml.cs @@ -77,7 +77,7 @@ or GameInstallStateEnum.InstalledHavePlugin else { #if !DISABLEDISCORD - InnerLauncherConfig.AppDiscordPresence.SetActivity(ActivityType.GameSettings); + InnerLauncherConfig.AppDiscordPresence.SetActivity(DiscordActivityType.GameSettings); #endif } } diff --git a/CollapseLauncher/XAMLs/MainApp/Pages/GameSettingsPages/StarRailGameSettingsPage.xaml.cs b/CollapseLauncher/XAMLs/MainApp/Pages/GameSettingsPages/StarRailGameSettingsPage.xaml.cs index e03af741b3..9c713edf9c 100644 --- a/CollapseLauncher/XAMLs/MainApp/Pages/GameSettingsPages/StarRailGameSettingsPage.xaml.cs +++ b/CollapseLauncher/XAMLs/MainApp/Pages/GameSettingsPages/StarRailGameSettingsPage.xaml.cs @@ -110,7 +110,7 @@ or GameInstallStateEnum.InstalledHavePlugin else { #if !DISABLEDISCORD - InnerLauncherConfig.AppDiscordPresence.SetActivity(ActivityType.GameSettings); + InnerLauncherConfig.AppDiscordPresence.SetActivity(DiscordActivityType.GameSettings); #endif } } diff --git a/CollapseLauncher/XAMLs/MainApp/Pages/GameSettingsPages/ZenlessGameSettingsPage.xaml.cs b/CollapseLauncher/XAMLs/MainApp/Pages/GameSettingsPages/ZenlessGameSettingsPage.xaml.cs index a4ebb2518b..238d03b259 100644 --- a/CollapseLauncher/XAMLs/MainApp/Pages/GameSettingsPages/ZenlessGameSettingsPage.xaml.cs +++ b/CollapseLauncher/XAMLs/MainApp/Pages/GameSettingsPages/ZenlessGameSettingsPage.xaml.cs @@ -191,7 +191,7 @@ or GameInstallStateEnum.InstalledHavePlugin else { #if !DISABLEDISCORD - InnerLauncherConfig.AppDiscordPresence.SetActivity(ActivityType.GameSettings); + InnerLauncherConfig.AppDiscordPresence.SetActivity(DiscordActivityType.GameSettings); #endif } } diff --git a/CollapseLauncher/XAMLs/MainApp/Pages/HomePage.GameLauncher.cs b/CollapseLauncher/XAMLs/MainApp/Pages/HomePage.GameLauncher.cs index a9b47d07c6..773a868d08 100644 --- a/CollapseLauncher/XAMLs/MainApp/Pages/HomePage.GameLauncher.cs +++ b/CollapseLauncher/XAMLs/MainApp/Pages/HomePage.GameLauncher.cs @@ -833,7 +833,7 @@ private async Task CheckRunningGameInstance(PresetConfig presetConfig, Cancellat #if !DISABLEDISCORD if (ToggleRegionPlayingRpc) - AppDiscordPresence.SetActivity(ActivityType.Play, fromActivityOffset.ToUniversalTime()); + AppDiscordPresence.SetActivity(DiscordActivityType.Play, fromActivityOffset.ToUniversalTime()); #endif int height = gameSettings.SettingsScreen?.height ?? 0; @@ -892,7 +892,7 @@ Task ProcessAwaiter(CancellationToken x) => PlaytimeRunningStack.Visibility = Visibility.Collapsed; #if !DISABLEDISCORD - AppDiscordPresence.SetActivity(ActivityType.Idle); + AppDiscordPresence.SetActivity(DiscordActivityType.Idle); #endif } catch (TaskCanceledException) diff --git a/CollapseLauncher/XAMLs/MainApp/Pages/HomePage.xaml.cs b/CollapseLauncher/XAMLs/MainApp/Pages/HomePage.xaml.cs index ecc601b4fd..cea41b50a8 100644 --- a/CollapseLauncher/XAMLs/MainApp/Pages/HomePage.xaml.cs +++ b/CollapseLauncher/XAMLs/MainApp/Pages/HomePage.xaml.cs @@ -98,7 +98,7 @@ private void OnPropertyChanged([CallerMemberName] string? propertyName = null) private int barWidth; private int consoleWidth; - private readonly bool IsRpcEnabled_QS = AppDiscordPresence.IsRpcEnabled; + private readonly bool IsRpcEnabled_QS = AppDiscordPresence.IsEnabled; public static int RefreshRateDefault => 500; public static int RefreshRateSlow => 1000; @@ -220,7 +220,10 @@ private async void Page_Loaded(object sender, RoutedEventArgs e) } #if !DISABLEDISCORD - AppDiscordPresence.SetActivity(ActivityType.Idle); + if (!CurrentGameProperty.IsGameRunning) + { + AppDiscordPresence.SetActivity(DiscordActivityType.Idle); + } #endif if (IsGameStatusComingSoon || IsGameStatusPreRegister) diff --git a/CollapseLauncher/XAMLs/MainApp/Pages/RepairPage.xaml.cs b/CollapseLauncher/XAMLs/MainApp/Pages/RepairPage.xaml.cs index 64184ff0dd..04c225f6d8 100644 --- a/CollapseLauncher/XAMLs/MainApp/Pages/RepairPage.xaml.cs +++ b/CollapseLauncher/XAMLs/MainApp/Pages/RepairPage.xaml.cs @@ -278,7 +278,7 @@ or GameInstallStateEnum.InstalledHavePlugin #if !DISABLEDISCORD else { - InnerLauncherConfig.AppDiscordPresence.SetActivity(ActivityType.Repair); + InnerLauncherConfig.AppDiscordPresence.SetActivity(DiscordActivityType.Repair); } #endif } diff --git a/CollapseLauncher/XAMLs/MainApp/Pages/SettingsPage.xaml.cs b/CollapseLauncher/XAMLs/MainApp/Pages/SettingsPage.xaml.cs index 6853f95e5b..b023ae2cad 100644 --- a/CollapseLauncher/XAMLs/MainApp/Pages/SettingsPage.xaml.cs +++ b/CollapseLauncher/XAMLs/MainApp/Pages/SettingsPage.xaml.cs @@ -289,7 +289,7 @@ private void Page_Loaded(object sender, RoutedEventArgs e) InitializeSettingsSearch(); #if !DISABLEDISCORD - AppDiscordPresence.SetActivity(ActivityType.AppSettings); + AppDiscordPresence.SetActivity(DiscordActivityType.AppSettings); #endif } @@ -703,7 +703,7 @@ private bool IsDiscordRpcEnabled { get { - bool e = AppDiscordPresence.IsRpcEnabled; + bool e = AppDiscordPresence.IsEnabled; ToggleDiscordGameStatus.IsEnabled = e; if (e) { @@ -730,25 +730,21 @@ private bool IsDiscordRpcEnabled ToggleDiscordIdleStatus.Visibility = Visibility.Collapsed; } - AppDiscordPresence.IsRpcEnabled = value; + AppDiscordPresence.IsEnabled = value; ToggleDiscordGameStatus.IsEnabled = value; } } private bool IsDiscordGameStatusEnabled { - get => GetAppConfigValue("EnableDiscordGameStatus"); - set - { - SetAndSaveConfigValue("EnableDiscordGameStatus", value); - AppDiscordPresence.SetupPresence(null); - } + get => AppDiscordPresence.IsGameStatusEnabled; + set => AppDiscordPresence.IsGameStatusEnabled = value; } private bool IsDiscordIdleStatusEnabled { - get => AppDiscordPresence.IdleEnabled; - set => AppDiscordPresence.IdleEnabled = value; + get => AppDiscordPresence.IsShowOnIdle; + set => AppDiscordPresence.IsShowOnIdle = value; } #else private bool IsDiscordRPCEnabled From e0515550da9b8d7d0c2c71d981392b4b05520708 Mon Sep 17 00:00:00 2001 From: Kemal Setya Adhi Date: Sat, 5 Sep 2026 06:56:34 +0700 Subject: [PATCH 13/50] [DiscordRPC] Use null presence while IsShowOnIdle == false instead of Stop() --- .../DiscordPresence/DiscordRpcManager.cs | 44 +++++++------------ 1 file changed, 17 insertions(+), 27 deletions(-) diff --git a/CollapseLauncher/Classes/DiscordPresence/DiscordRpcManager.cs b/CollapseLauncher/Classes/DiscordPresence/DiscordRpcManager.cs index 097a705142..051d81c588 100644 --- a/CollapseLauncher/Classes/DiscordPresence/DiscordRpcManager.cs +++ b/CollapseLauncher/Classes/DiscordPresence/DiscordRpcManager.cs @@ -28,8 +28,8 @@ public partial class DiscordRpcManager : IDisposable public readonly Thread PresenceSetThread; public readonly Channel PresenceSetChannel; - private ulong _currentPresenceId = LauncherConfig.AppDiscordApplicationID; - private DiscordActivityType _currentActivityStatus; + private ulong _lastPresenceId = LauncherConfig.AppDiscordApplicationID; + private DiscordActivityType _lastActivityStatus; private readonly EventWaitHandle _isReadyWaitHandle = new(false, EventResetMode.ManualReset); @@ -39,7 +39,7 @@ public bool IsGameStatusEnabled set { LauncherConfig.SetAndSaveConfigValue("EnableDiscordGameStatus", value); - SetActivity(_currentActivityStatus); // Refresh activity status + SetActivity(_lastActivityStatus); // Refresh activity status to the last one } } @@ -49,7 +49,7 @@ public bool IsShowOnIdle set { LauncherConfig.SetAndSaveConfigValue("EnableDiscordIdleStatus", value); - SetActivity(_currentActivityStatus); // Refresh activity status + SetActivity(_lastActivityStatus); // Refresh activity status to the last one } } @@ -68,7 +68,7 @@ public bool IsEnabled if (!isPreviouslyEnabled && value != isPreviouslyEnabled) { - SetActivity(_currentActivityStatus); + SetActivity(_lastActivityStatus); } } } @@ -91,8 +91,8 @@ public DiscordRpcManager() }; PresenceSetThread.Start(); - // Initialize from start if enabled and IsShowOnIdle == true - if (IsEnabled && IsShowOnIdle) + // Initialize from start if enabled + if (IsEnabled) { Start(); } @@ -179,9 +179,9 @@ public void Start() return; } - ulong presenceId = _currentPresenceId == 0 + ulong presenceId = _lastPresenceId == 0 ? LauncherConfig.AppDiscordApplicationID - : _currentPresenceId; + : _lastPresenceId; // Initialize new client and replace the field atomically. Interlocked.Exchange(ref Client, new DiscordRpcClient($"{presenceId}", _sharedLogger)); @@ -218,12 +218,12 @@ public void SetPresence(PresetConfig? config) Interlocked.Exchange(ref _currentPresetConfig, config); if (config == null) { - Interlocked.Exchange(ref _currentPresenceId, 0); + Interlocked.Exchange(ref _lastPresenceId, 0); return; } ulong presenceId = GetDiscordPresenceId(config); - Interlocked.Exchange(ref _currentPresenceId, presenceId); + Interlocked.Exchange(ref _lastPresenceId, presenceId); // We intentionally stop and start the client to refresh / re-create the client // with the new presence ID. @@ -233,22 +233,12 @@ public void SetPresence(PresetConfig? config) public void SetActivity(DiscordActivityType type = DiscordActivityType.None, DateTime? specifiedStartTime = null) { - Interlocked.Exchange(ref _currentActivityStatus, type); + Interlocked.Exchange(ref _lastActivityStatus, type); // Prevent from exhausting the Presence channel if not enabled. if (!IsEnabled) return; - // If IsShowOnIdle == false and the activity type is None or Idle, - // tries to stop the RPC for a while to disconnect it from Discord and - // remove the RPC display. - if (!IsShowOnIdle && type is DiscordActivityType.None or DiscordActivityType.Idle) - { - Stop(); - return; - } - - // Make sure to re-enable the client if it was previously disposed due to - // IsShowOnIdle == false and type is None or Idle. + // Make sure to re-enable the client if it was not initialized while the manager is not disposed yet. if (Volatile.Read(ref Client) == null && !IsDisposed && IsEnabled) @@ -257,16 +247,16 @@ public void SetActivity(DiscordActivityType type = DiscordActivityType.None, Dat } LangParamsMisc? langMisc = Locale.Current.Lang?._Misc; - RichPresence presence = type switch + RichPresence? presence = type switch { DiscordActivityType.Play => PresenceBuilder.BuildTimedState(IsGameStatusEnabled ? langMisc?.DiscordRP_InGame : langMisc?.DiscordRP_Play, this, specifiedStartTime), DiscordActivityType.Update => PresenceBuilder.BuildTimedState(langMisc?.DiscordRP_Update, this, specifiedStartTime), - DiscordActivityType.Idle => PresenceBuilder.BuildIdleState(this), + DiscordActivityType.Idle => IsShowOnIdle ? PresenceBuilder.BuildIdleState(this) : null, DiscordActivityType.Repair => PresenceBuilder.BuildGenericState(langMisc?.DiscordRP_Repair, this), DiscordActivityType.Cache => PresenceBuilder.BuildGenericState(langMisc?.DiscordRP_Cache, this), DiscordActivityType.GameSettings => PresenceBuilder.BuildGenericState(langMisc?.DiscordRP_GameSettings, this), DiscordActivityType.AppSettings => PresenceBuilder.BuildGenericState(langMisc?.DiscordRP_AppSettings, this), - _ => new RichPresence + _ => IsShowOnIdle ? new RichPresence { Details = Locale.Current.Lang?._Misc?.DiscordRP_Default, Assets = new Assets @@ -275,7 +265,7 @@ public void SetActivity(DiscordActivityType type = DiscordActivityType.None, Dat LargeImageText = PresenceBuilder.DefaultLauncherLogoTooltip }, Timestamps = null! - } + } : null }; PresenceSetChannel.Writer.TryWrite(presence); From dda16b3e733e25ff644c795ad5d609ac35726f35 Mon Sep 17 00:00:00 2001 From: Kemal Setya Adhi Date: Sat, 5 Sep 2026 07:08:23 +0700 Subject: [PATCH 14/50] [DiscordRPC] Share class creation with BuildGenericState --- .../DiscordPresence/DiscordRpcManager.cs | 38 ++++++------------- 1 file changed, 12 insertions(+), 26 deletions(-) diff --git a/CollapseLauncher/Classes/DiscordPresence/DiscordRpcManager.cs b/CollapseLauncher/Classes/DiscordPresence/DiscordRpcManager.cs index 051d81c588..1571687ca4 100644 --- a/CollapseLauncher/Classes/DiscordPresence/DiscordRpcManager.cs +++ b/CollapseLauncher/Classes/DiscordPresence/DiscordRpcManager.cs @@ -156,7 +156,7 @@ public void Stop() return; } - // Reset the channel by flushing all pending presence + // Reset the channel by flushing all pending presences while (PresenceSetChannel.Reader.TryRead(out _)) { } if (!Volatile.Read(ref IsDisposed)) @@ -311,33 +311,19 @@ public static RichPresence BuildTimedState(string? activityName, DiscordRpcManag // Try to get the existing start offset or create a new one if not exist. DateTime startOffset = manager._cachedStartTimes.GetOrAdd(presetConfigHashId, specifiedStartTime ?? DateTime.UtcNow); - TryGetGameIconsAndTranslatedNames(presetConfig, - out string? largeIconUrl, - out string? largeIconTooltip, - out string? smallIconUrl, - out string? smallIconTooltip, - out string? translatedGameName, - out string? translatedGameRegion); - return new RichPresence - { - Details = $"{activityName} {(!isGameStatusEnabled ? translatedGameName : null)}", - State = $"{Locale.Current.Lang?._Misc?.DiscordRP_Region} {translatedGameRegion}", - Assets = new Assets - { - LargeImageKey = largeIconUrl, - LargeImageText = largeIconTooltip, - SmallImageKey = smallIconUrl, - SmallImageText = smallIconTooltip - }, - Timestamps = new Timestamps - { - Start = startOffset - } - }; + string? currentGameName = presetConfig?.GameName; + string? translatedGameName = MetadataHelper.GetTranslatedTitle(currentGameName); + + return BuildGenericState($"{activityName} {(!isGameStatusEnabled ? translatedGameName : null)}", + manager, + new Timestamps + { + Start = startOffset + }); } - public static RichPresence BuildGenericState(string? activityName, DiscordRpcManager manager) + public static RichPresence BuildGenericState(string? activityName, DiscordRpcManager manager, Timestamps? timestamps = null) { PresetConfig? presetConfig = manager._currentPresetConfig; TryGetGameIconsAndTranslatedNames(presetConfig, @@ -359,7 +345,7 @@ public static RichPresence BuildGenericState(string? activityName, DiscordRpcMan SmallImageKey = smallIconUrl, SmallImageText = smallIconTooltip }, - Timestamps = null! + Timestamps = timestamps }; } From 9549891f4767315ac27fb71efd091184a88660bf Mon Sep 17 00:00:00 2001 From: Kemal Setya Adhi Date: Sat, 5 Sep 2026 07:10:05 +0700 Subject: [PATCH 15/50] [DiscordRPC] Remove unused out args --- CollapseLauncher/Classes/DiscordPresence/DiscordRpcManager.cs | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/CollapseLauncher/Classes/DiscordPresence/DiscordRpcManager.cs b/CollapseLauncher/Classes/DiscordPresence/DiscordRpcManager.cs index 1571687ca4..9c3764d42b 100644 --- a/CollapseLauncher/Classes/DiscordPresence/DiscordRpcManager.cs +++ b/CollapseLauncher/Classes/DiscordPresence/DiscordRpcManager.cs @@ -331,7 +331,6 @@ public static RichPresence BuildGenericState(string? activityName, DiscordRpcMan out string? largeIconTooltip, out string? smallIconUrl, out string? smallIconTooltip, - out _, out string? translatedGameRegion); return new RichPresence @@ -364,7 +363,6 @@ private static void TryGetGameIconsAndTranslatedNames( out string? largeIconTooltip, out string? smallIconUrl, out string? smallIconTooltip, - out string? translatedGameName, out string? translatedGameRegion) { Unsafe.SkipInit(out largeIconUrl); @@ -374,7 +372,7 @@ private static void TryGetGameIconsAndTranslatedNames( string? currentGameName = presetConfig?.GameName; string? currentGameRegion = presetConfig?.ZoneName; - translatedGameName = MetadataHelper.GetTranslatedTitle(currentGameName); + string? translatedGameName = MetadataHelper.GetTranslatedTitle(currentGameName); translatedGameRegion = MetadataHelper.GetTranslatedRegion(currentGameRegion); // Try to get icons from plugin if available. From 6f0eeec36adef8a44c63099b08701768bbea5cbf Mon Sep 17 00:00:00 2001 From: Kemal Setya Adhi Date: Sat, 5 Sep 2026 19:20:37 +0700 Subject: [PATCH 16/50] Bump version --- CollapseLauncher/CollapseLauncher.csproj | 2 +- Hi3Helper.Win32 | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/CollapseLauncher/CollapseLauncher.csproj b/CollapseLauncher/CollapseLauncher.csproj index f66aa6163e..b219111bdd 100644 --- a/CollapseLauncher/CollapseLauncher.csproj +++ b/CollapseLauncher/CollapseLauncher.csproj @@ -16,7 +16,7 @@ $(Company). neon-nyan, Cry0, bagusnl, shatyuka, gablm. Copyright 2022-2026 $(Company) - 1.84.6 + 1.84.7 preview x64 diff --git a/Hi3Helper.Win32 b/Hi3Helper.Win32 index 9478014fdc..ff564bec95 160000 --- a/Hi3Helper.Win32 +++ b/Hi3Helper.Win32 @@ -1 +1 @@ -Subproject commit 9478014fdce9a1b42f38e17145fb8c8d173190fc +Subproject commit ff564bec95894ef8da5e4dfb4f08e3057080cdfc From c40d142ff20bbdbb141a64d6acbdcbb53c5c77d4 Mon Sep 17 00:00:00 2001 From: Kemal Setya Adhi Date: Sat, 5 Sep 2026 22:41:39 +0700 Subject: [PATCH 17/50] Migrate from SharpHDiffPatch to SharpHPatchZ --- .../Base/InstallManagerBase.cs | 463 ++++++++---------- CollapseLauncher/packages.lock.json | 8 +- Hi3Helper.EncTool | 2 +- Hi3Helper.Sophon | 2 +- 4 files changed, 217 insertions(+), 258 deletions(-) diff --git a/CollapseLauncher/Classes/InstallManagement/Base/InstallManagerBase.cs b/CollapseLauncher/Classes/InstallManagement/Base/InstallManagerBase.cs index a330d94fff..7947cf1c86 100644 --- a/CollapseLauncher/Classes/InstallManagement/Base/InstallManagerBase.cs +++ b/CollapseLauncher/Classes/InstallManagement/Base/InstallManagerBase.cs @@ -14,7 +14,6 @@ using Hi3Helper; using Hi3Helper.Data; using Hi3Helper.EncTool.Parser.AssetIndex; -using Hi3Helper.Win32.ManagedTools; using Hi3Helper.Http; using Hi3Helper.Http.Legacy; using Hi3Helper.LocaleSourceGen; @@ -22,12 +21,14 @@ using Hi3Helper.SentryHelper; using Hi3Helper.Shared.ClassStruct; using Hi3Helper.Shared.Region; +using Hi3Helper.Win32.ManagedTools; using Microsoft.UI.Text; using Microsoft.UI.Xaml; using Microsoft.UI.Xaml.Controls; using Microsoft.Win32; -using SharpHDiffPatch.Core; -using SharpHDiffPatch.Core.Event; +using SharpHPatchZ; +using SharpHPatchZ.Header; +using SharpHPatchZ.Header.Metadata; using System; using System.Collections.Generic; using System.Diagnostics; @@ -314,15 +315,14 @@ protected virtual async ValueTask StartDeltaPatch(IRepairAssetIndex repair UpdateStatus(); // Start the patching process - HDiffPatch.LogVerbosity = Verbosity.Verbose; - EventListener.PatchEvent += DeltaPatchCheckProgress; - EventListener.LoggerEvent += DeltaPatchCheckLogEvent; - await Task.Run(() => - { - HDiffPatch patch = new HDiffPatch(); - patch.Initialize(patchProperty.PatchPath); - patch.Patch(ingredientPath, previousPath, true, Token!.Token, false, true); - }).ConfigureAwait(false); + ProgressCallback progressCallback = ProgressCallback.CreateFromManaged(DeltaPatchProgress); + using HDiffInfo hdiffInfo = await HPatch.CreateInstanceAsync(patchProperty.PatchPath, Token!.Token); + Exception? resultException = await HPatch.PatchAsync(hdiffInfo, patchProperty.PatchPath, ingredientPath, + previousPath, PatchOptions.BigBuffer, progressCallback, + token: Token.Token); + + if (resultException != null) + throw resultException; // Remove ingredient folder Directory.Delete(ingredientPath, true); @@ -345,11 +345,6 @@ await Task.Run(() => LogWriteLine($"Error has occurred while performing delta-patch!\r\n{ex}", LogType.Error, true); throw; } - finally - { - EventListener.PatchEvent -= DeltaPatchCheckProgress; - EventListener.LoggerEvent -= DeltaPatchCheckLogEvent; - } } protected virtual async ValueTask GetAndDownloadDeltaPatchPreReq( @@ -1347,38 +1342,45 @@ private string GetBasePersistentDirectory(string basePath, string input) private async Task FileHdiffPatcherInner(string patchPath, string sourceBasePath, string destPath, CancellationToken token) { - HDiffPatch patcher = new HDiffPatch(); - patcher.Initialize(patchPath); - token.ThrowIfCancellationRequested(); + FileInfo patchFileInfo = new(patchPath); + FileInfo sourceFileInfo = new(sourceBasePath); + FileInfo targetFileInfo = new(destPath); + + using HDiffInfo hdiffInfo = await HPatch.CreateInstanceAsync(patchFileInfo.FullName, token); + long newFileSize = GetHDiffNewSize(hdiffInfo); - Task task = Task.Run(() => + try { - try + ProgressCallback progressCallback = ProgressCallback.CreateFromManaged(EventListener_PatchEvent); + Exception? resultException = await HPatch.PatchAsync(hdiffInfo, + patchFileInfo.FullName, + sourceFileInfo.FullName, + targetFileInfo.FullName, + PatchOptions.BigBuffer, + progressCallback, + token); + if (resultException != null) + throw resultException; + + targetFileInfo.TryMoveTo(sourceFileInfo); + } + catch (Exception ex) when (!token.IsCancellationRequested) + { + if (ex is not InvalidDataException or InvalidOperationException) { - patcher.Patch(sourceBasePath, destPath, true, token, false, true); - File.Move(destPath, sourceBasePath, true); + throw; } - catch (InvalidDataException ex) when (!token.IsCancellationRequested) - { - // ignored - // Get the base and new target file size - long newFileSize = HDiffPatch.GetHDiffNewSize(patchPath); - FileInfo fileInfo = new FileInfo(sourceBasePath); - long refFileSize = fileInfo.Exists ? fileInfo.Length : 0; - // Check if the throw happened for different file, then rethrow - if (newFileSize != refFileSize) - throw; + FileInfo fileInfo = new(sourceBasePath); + long refFileSize = fileInfo.Exists ? fileInfo.Length : 0; - // Otherwise, log the error - SentryHelper.ExceptionHandler(ex, SentryHelper.ExceptionType.UnhandledOther); - LogWriteLine($"New: {newFileSize} == Ref: {refFileSize}. File is already new. Skipping! {sourceBasePath}", LogType.Warning, true); - } - }, token); - await task; + // Check if the throw happened for different file, then rethrow + if (newFileSize != refFileSize) + throw; - if (task.Exception != null) - throw task.Exception; + // Otherwise, log the error + LogWriteLine($"New: {newFileSize} == Ref: {refFileSize}. File is already new. Skipping! {sourceBasePath}", LogType.Warning, true); + } } protected virtual async Task> GetHDiffMapEntryList(string gameDir) @@ -1441,7 +1443,7 @@ protected virtual async Task> GetHDiffMapEntryList(string ga protected virtual async Task ApplyHDiffMap() { - string gameDir = GamePath; + string gameDir = GamePath; List hDiffMapEntries = await GetHDiffMapEntryList(gameDir); if (hDiffMapEntries.Count == 0) @@ -1456,169 +1458,159 @@ protected virtual async Task ApplyHDiffMap() ProgressAllCountTotal = 1; ProgressAllCountFound = hDiffMapEntries.Count; - HDiffPatch.LogVerbosity = Verbosity.Verbose; - EventListener.LoggerEvent += EventListener_PatchLogEvent; - EventListener.PatchEvent += EventListener_PatchEvent; + ProgressCallback progressCallback = ProgressCallback.CreateFromManaged(EventListener_PatchEvent); + Task parallelTask = Parallel.ForEachAsync(hDiffMapEntries, new ParallelOptions + { + MaxDegreeOfParallelism = ThreadCount, + CancellationToken = Token!.Token + }, + PatchWorker); - try + await parallelTask; + + return; + + async ValueTask PatchWorker(HDiffMapEntry entry, CancellationToken workerToken) { - Task parallelTask = Parallel.ForEachAsync(hDiffMapEntries, new ParallelOptions - { - MaxDegreeOfParallelism = ThreadCount, - CancellationToken = Token!.Token - }, - async (entry, ctx) => - { - Status.ActivityStatus = - $"{Locale.Current.Lang?._Misc?.Patching}: {string.Format(Locale.Current.Lang?._Misc?.PerFromTo ?? "", ProgressAllCountTotal, - ProgressAllCountFound)}"; - Status.ActivityStatusInternet = false; + Status.ActivityStatus = $"{Locale.Current.Lang?._Misc?.Patching}: {string.Format(Locale.Current.Lang?._Misc?.PerFromTo ?? "", ProgressAllCountTotal, ProgressAllCountFound)}"; + Status.ActivityStatusInternet = false; - bool isSuccess = false; - - FileInfo sourcePath = new FileInfo(GetBasePersistentDirectory(gameDir, entry.SourceFileName)) - .StripAlternateDataStream().EnsureNoReadOnly(out bool isSourceExist); - string sourcePathDir = sourcePath.DirectoryName ?? ""; - FileInfo patchPath = new FileInfo(Path.Combine(gameDir, entry.PatchFileName ?? "")) - .StripAlternateDataStream().EnsureNoReadOnly(out bool isPatchExist); - string targetPathBasedOnSource = Path.Combine(sourcePathDir, Path.GetFileName(entry.TargetFileName ?? "")); - FileInfo targetPath = new FileInfo(targetPathBasedOnSource) - .EnsureCreationOfDirectory() - .StripAlternateDataStream() - .EnsureNoReadOnly(); - FileInfo targetPathTemp = new FileInfo(targetPath + "_tmp") - .StripAlternateDataStream().EnsureNoReadOnly(); + bool isSuccess = false; - try + FileInfo sourcePath = new FileInfo(GetBasePersistentDirectory(gameDir, entry.SourceFileName)) + .StripAlternateDataStream() + .EnsureNoReadOnly(out bool isSourceExist); + string sourcePathDir = sourcePath.DirectoryName ?? ""; + FileInfo patchPath = new FileInfo(Path.Combine(gameDir, entry.PatchFileName ?? "")) + .StripAlternateDataStream() + .EnsureNoReadOnly(out bool isPatchExist); + string targetPathBasedOnSource = Path.Combine(sourcePathDir, Path.GetFileName(entry.TargetFileName ?? "")); + FileInfo targetPath = new FileInfo(targetPathBasedOnSource) + .EnsureCreationOfDirectory() + .StripAlternateDataStream() + .EnsureNoReadOnly(); + FileInfo targetPathTemp = new FileInfo(targetPath + "_tmp") + .StripAlternateDataStream().EnsureNoReadOnly(); + + try + { + if (string.IsNullOrEmpty(entry.SourceFileName) || !isPatchExist || !isSourceExist) { - if (string.IsNullOrEmpty(entry.SourceFileName)) - { - ForceUpdateProgress(entry); - return; - } + ForceUpdateProgress(entry); + return; + } - if (!isPatchExist || !isSourceExist) - { - ForceUpdateProgress(entry); - return; - } + if (isSourceExist && sourcePath.Length != + entry.SourceFileSize) + { + ForceUpdateProgress(entry); + LogWriteLine($"[InstallManagerBase::ApplyHDiffMap] Source file size mismatch: {sourcePath.FullName} ({sourcePath.Length} != {entry.SourceFileSize})", + LogType.Warning, true); + return; + } - if (isSourceExist && sourcePath.Length != entry.SourceFileSize) + byte[] sourceLocalHash = + entry.SourceMD5Hash?.Length switch { - ForceUpdateProgress(entry); - LogWriteLine($"[InstallManagerBase::ApplyHDiffMap] Source file size mismatch: {sourcePath.FullName} ({sourcePath.Length} != {entry.SourceFileSize})", LogType.Warning, true); - return; - } - - byte[] sourceLocalHash = entry.SourceMD5Hash?.Length switch - { - > 8 and 16 => await GetCryptoHashAsync(sourcePath, null, false, true, Token.Token), - > 4 => await GetHashAsync(sourcePath, false, true, Token.Token), - _ => await GetHashAsync(sourcePath, false, true, Token.Token) - }; + > 8 and 16 => await GetCryptoHashAsync(sourcePath, null, false, true, workerToken), + > 4 => await GetHashAsync(sourcePath, false, true, workerToken), + _ => await GetHashAsync(sourcePath, false, true, workerToken) + }; - if (!sourceLocalHash.AsSpan().SequenceEqual(entry.SourceMD5Hash)) - { - ForceUpdateProgress(entry); - LogWriteLine("[InstallManagerBase::ApplyHDiffMap] Source file or patch has mismatch hash!\r\n" - + $"Source file: {sourcePath.FullName}\r\nLocal Hash: {HexTool.BytesToHexUnsafe(sourceLocalHash)}\r\nRemote Hash: {HexTool.BytesToHexUnsafe(entry.SourceMD5Hash)}", - LogType.Warning, - true); - return; - } + if (!sourceLocalHash.AsSpan().SequenceEqual(entry.SourceMD5Hash)) + { + ForceUpdateProgress(entry); + LogWriteLine("[InstallManagerBase::ApplyHDiffMap] Source file or patch has mismatch hash!\r\n" + + $"Source file: {sourcePath.FullName}\r\nLocal Hash: {HexTool.BytesToHexUnsafe(sourceLocalHash)}\r\nRemote Hash: {HexTool.BytesToHexUnsafe(entry.SourceMD5Hash)}", + LogType.Warning, + true); + return; + } - LogWriteLine($"Patching file {entry.SourceFileName} to {entry.TargetFileName}...", LogType.Default, true); - UpdateProgressBase(); - UpdateStatus(); + LogWriteLine($"Patching file {entry.SourceFileName} to {entry.TargetFileName}...", LogType.Default, true); + UpdateProgressBase(); + UpdateStatus(); - await Task.Factory.StartNew(state => - { - CancellationToken thisInnerCtx = (CancellationToken)(state ?? CancellationToken.None); - try - { - thisInnerCtx.ThrowIfCancellationRequested(); - HDiffPatch patcher = new HDiffPatch(); - patcher.Initialize(patchPath.FullName); - patcher.Patch(sourcePath.FullName, targetPathTemp.FullName, true, thisInnerCtx, false, true); - isSuccess = true; - } - catch (InvalidDataException ex) when (!thisInnerCtx.IsCancellationRequested) - { - // ignored - // Get the base and new target file size - long newFileSize = HDiffPatch.GetHDiffNewSize(patchPath.FullName); - long refFileSize = targetPath.Exists ? targetPath.Length : 0; - - // Check if the throw happened for different file, then rethrow - if (newFileSize != refFileSize) - throw; - - // Otherwise, log the error - SentryHelper.ExceptionHandler(ex, SentryHelper.ExceptionType.UnhandledOther); - LogWriteLine($"New: {newFileSize} == Ref: {refFileSize}. File is already new. Skipping! {targetPath.FullName}", LogType.Warning, true); - } - }, - ctx, - ctx, - TaskCreationOptions.DenyChildAttach, - TaskScheduler.Default); - } - catch (OperationCanceledException) + try { - await Token.CancelAsync(); - LogWriteLine("Cancelling patching process!...", LogType.Warning, true); - throw; + using HDiffInfo hdiffInfo = await HPatch.CreateInstanceAsync(patchPath.FullName, workerToken); + Exception? resultException = await HPatch.PatchAsync( + hdiffInfo, + patchPath.FullName, + sourcePath.FullName, + targetPathTemp.FullName, + PatchOptions.BigBuffer, + progressCallback, + workerToken); + + if (resultException != null) + throw resultException; + + isSuccess = true; } - catch (Exception ex) + catch (Exception) when (!workerToken.IsCancellationRequested) { - await SentryHelper.ExceptionHandler_ForLoopAsync(ex); - LogWriteLine( - $"Error while patching file: {entry.SourceFileName ?? string.Empty} to: {entry.TargetFileName ?? string.Empty}. Skipping!\r\n{ex}", - LogType.Warning, - true); + // ignored + // Get the base and new target file size + long newFileSize = GetHDiffNewSize(patchPath.FullName); + long refFileSize = targetPath.Exists ? targetPath.Length : 0; - ForceUpdateProgress(entry); + // Check if the throw happened for different file, then rethrow + if (newFileSize != refFileSize) + throw; + + // Otherwise, log the error + LogWriteLine($"New: {newFileSize} == Ref: {refFileSize}. File is already new. Skipping! {targetPath.FullName}", LogType.Warning, true); } - finally - { - Interlocked.Increment(ref ProgressAllCountTotal); - if (!string.IsNullOrEmpty(entry.PatchFileName)) - { - _ = patchPath.TryDeleteFile(); - } + } + catch (OperationCanceledException) + { + LogWriteLine("Cancelling patching process!...", LogType.Warning, true); + await Token.CancelAsync(); + throw; + } + catch (Exception ex) + { + await SentryHelper.ExceptionHandler_ForLoopAsync(ex); + LogWriteLine($"Error while patching file: {entry.SourceFileName} to: {entry.TargetFileName ?? string.Empty}. Skipping!\r\n{ex}", + LogType.Warning, + true); - if (isSuccess && entry.CanDeleteSource) - { - sourcePath.Refresh(); - _ = sourcePath.TryDeleteFile(); - } + ForceUpdateProgress(entry); + } + finally + { + Interlocked.Increment(ref ProgressAllCountTotal); + if (!string.IsNullOrEmpty(entry.PatchFileName)) + { + _ = patchPath.TryDeleteFile(); + } - targetPathTemp.Refresh(); - if (targetPathTemp.Exists) - { - _ = targetPathTemp.TryMoveTo(targetPath); - } + if (isSuccess && entry.CanDeleteSource) + { + sourcePath.Refresh(); + _ = sourcePath.TryDeleteFile(); } - }); - await parallelTask; - } - finally - { - EventListener.LoggerEvent -= EventListener_PatchLogEvent; - EventListener.PatchEvent -= EventListener_PatchEvent; + targetPathTemp.Refresh(); + if (targetPathTemp.Exists) + { + _ = targetPathTemp.TryMoveTo(targetPath); + } + } } - return; - void ForceUpdateProgress(HDiffMapEntry entry) { lock (Progress) { Progress.ProgressAllSizeCurrent += entry.TargetFileSize; - Progress.ProgressAllPercentage = ConverterTool.ToPercentage(Progress.ProgressAllSizeTotal, Progress.ProgressAllSizeCurrent); + Progress.ProgressAllPercentage = + ConverterTool.ToPercentage(Progress.ProgressAllSizeTotal, Progress.ProgressAllSizeCurrent); Progress.ProgressAllSpeed = CalculateSpeed(entry.TargetFileSize); - Progress.ProgressAllTimeLeft = ConverterTool.ToTimeSpanRemain(Progress.ProgressAllSizeTotal, Progress.ProgressAllSizeCurrent, Progress.ProgressAllSpeed); + Progress.ProgressAllTimeLeft = + ConverterTool.ToTimeSpanRemain(Progress.ProgressAllSizeTotal, Progress.ProgressAllSizeCurrent, + Progress.ProgressAllSpeed); } UpdateProgress(); @@ -1640,10 +1632,6 @@ public virtual async Task ApplyHdiffListPatch() ProgressAllCountTotal = 1; ProgressAllCountFound = hdiffEntry.Count; - HDiffPatch.LogVerbosity = Verbosity.Verbose; - EventListener.LoggerEvent += EventListener_PatchLogEvent; - EventListener.PatchEvent += EventListener_PatchEvent; - Task parallelTask = Parallel.ForEachAsync(hdiffEntry, new ParallelOptions { CancellationToken = Token!.Token, @@ -1726,17 +1714,12 @@ public virtual async Task ApplyHdiffListPatch() await SentryHelper.ExceptionHandlerAsync(innerExceptionsFirst, SentryHelper.ExceptionType.UnhandledOther); throw innerExceptionsFirst; } - finally - { - EventListener.LoggerEvent -= EventListener_PatchLogEvent; - EventListener.PatchEvent -= EventListener_PatchEvent; - } } - private void EventListener_PatchEvent(object? sender, PatchEvent e) + private void EventListener_PatchEvent(long totalWritten, long totalSize, int written) { - Interlocked.Add(ref ProgressAllSizeCurrent, e.Read); - double speed = CalculateSpeed(e.Read); + Interlocked.Add(ref ProgressAllSizeCurrent, written); + double speed = CalculateSpeed(written); if (!CheckIfNeedRefreshStopwatch()) { @@ -1753,32 +1736,6 @@ private void EventListener_PatchEvent(object? sender, PatchEvent e) UpdateProgress(); } - private void EventListener_PatchLogEvent(object? sender, LoggerEvent e) - { - if (HDiffPatch.LogVerbosity == Verbosity.Quiet - || (HDiffPatch.LogVerbosity == Verbosity.Debug - && !(e.LogLevel == Verbosity.Debug || - e.LogLevel == Verbosity.Verbose || - e.LogLevel == Verbosity.Info)) - || (HDiffPatch.LogVerbosity == Verbosity.Verbose - && !(e.LogLevel == Verbosity.Verbose || - e.LogLevel == Verbosity.Info)) - || (HDiffPatch.LogVerbosity == Verbosity.Info - && e.LogLevel != Verbosity.Info)) - { - return; - } - - LogType type = e.LogLevel switch - { - Verbosity.Verbose => LogType.Debug, - Verbosity.Debug => LogType.Debug, - _ => LogType.Default - }; - - LogWriteLine(e.Message, type, true); - } - public virtual List TryGetHDiffList() { List _out = []; @@ -1815,7 +1772,7 @@ public virtual List TryGetHDiffList() try { - prop.fileSize = HDiffPatch.GetHDiffNewSize(filePath); + prop.fileSize = GetHDiffNewSize(filePath); LogWriteLine($"hdiff entry: {prop.remoteName}", LogType.Default, true); _out.Add(prop); @@ -1839,6 +1796,36 @@ public virtual List TryGetHDiffList() return _out; } + protected static long GetHDiffNewSize(string filePath) + { + HDiffInfo hdiffInfo = HPatch.CreateInstance(filePath); + + try + { + return GetHDiffNewSize(hdiffInfo); + } + finally + { + hdiffInfo.Dispose(); + } + } + + protected static unsafe long GetHDiffNewSize(HDiffInfo hdiffInfo) + { + if (!hdiffInfo.TryGetPatchMetadata(out PatchMetadata patchMetadata)) + { + throw new InvalidOperationException("File is not a supported HDIFF file"); + } + + if (hdiffInfo.TryGetDirectoryPatchMetadata(out DirectoryPatchMetadata dirPatchMetadata)) + { + return dirPatchMetadata.OutputPathCountSizeInfoP->Size + + dirPatchMetadata.SameFilePathCountSizeInfoP->Size; + } + + return patchMetadata.DiffNewSize; + } + protected virtual string GetLanguageLocaleCodeByID(int id) { return id switch @@ -3311,53 +3298,25 @@ protected void UpdateProgressBase() base.UpdateProgress(); } - protected void DeltaPatchCheckProgress(object? sender, PatchEvent e) + private void DeltaPatchProgress(long totalWritten, long totalSize, int currentlyWritten) { + double speed = CalculateSpeed(currentlyWritten); if (!CheckIfNeedRefreshStopwatch()) { return; } - lock (Progress) - { - Progress.ProgressAllPercentage = e.ProgressPercentage; - Progress.ProgressAllTimeLeft = e.TimeLeft; - Progress.ProgressAllSpeed = e.Speed; - Progress.ProgressAllSizeTotal = e.TotalSizeToBePatched; - Progress.ProgressAllSizeCurrent = e.CurrentSizePatched; - } + Progress.ProgressAllPercentage = Math.Round(totalWritten / (double)totalSize * 100, 2); + Progress.ProgressAllTimeLeft = TimeSpan.FromSeconds((totalSize - totalWritten) / speed.UnNanOrInfinity()); + Progress.ProgressAllSpeed = speed; + Progress.ProgressAllSizeTotal = totalSize; + Progress.ProgressAllSizeCurrent = totalWritten; Status.IsProgressAllIndetermined = false; UpdateProgressBase(); UpdateStatus(); } - protected void DeltaPatchCheckLogEvent(object? sender, LoggerEvent e) - { - if (HDiffPatch.LogVerbosity == Verbosity.Quiet - || (HDiffPatch.LogVerbosity == Verbosity.Debug - && !(e.LogLevel == Verbosity.Debug || - e.LogLevel == Verbosity.Verbose || - e.LogLevel == Verbosity.Info)) - || (HDiffPatch.LogVerbosity == Verbosity.Verbose - && !(e.LogLevel == Verbosity.Verbose || - e.LogLevel == Verbosity.Info)) - || (HDiffPatch.LogVerbosity == Verbosity.Info - && e.LogLevel != Verbosity.Info)) - { - return; - } - - LogType type = e.LogLevel switch - { - Verbosity.Verbose => LogType.Debug, - Verbosity.Debug => LogType.Debug, - _ => LogType.Default - }; - - LogWriteLine(e.Message, type, true); - } - protected void DeltaPatchCheckProgress(object? sender, TotalPerFileProgress e) { if (!CheckIfNeedRefreshStopwatch()) diff --git a/CollapseLauncher/packages.lock.json b/CollapseLauncher/packages.lock.json index eb8aee73ad..cfc9baa374 100644 --- a/CollapseLauncher/packages.lock.json +++ b/CollapseLauncher/packages.lock.json @@ -459,10 +459,10 @@ "resolved": "6.9.0", "contentHash": "qQIvEwuvjAB6fDLVLLcDj/5f8n5jOyPyHjj3a/GQ1ogTLLQqsSxgQj1fEEquNT9HQuj4ZTyCg3c1DCBMUIvJGQ==" }, - "SharpHDiffPatch.Core": { + "SharpHPatchZ": { "type": "Transitive", - "resolved": "2.4.2", - "contentHash": "omycz2nSYxHS/vzDrS3EBQOJimtd8I9LXiGqM4AmOA3OQEueVHDjKg9mWWv8sufgTXaM1SUXK2hs/Q/ssJtPmA==", + "resolved": "3.0.0", + "contentHash": "Rr8IGWnHMEOSNwwTlKy+C6ddD0tMm/hgKtW72GuAv08xOGxuVYV+Mv6XPIQneB3WX1sYyOWOrdCdO5PZ5hn/Og==", "dependencies": { "Hi3Helper.ZstdNet": "1.6.7", "System.IO.Hashing": "10.0.11", @@ -566,7 +566,7 @@ "dependencies": { "Google.Protobuf": "[3.36.0, )", "Hi3Helper.ZstdNet": "[1.6.7, )", - "SharpHDiffPatch.Core": "[2.4.2, )", + "SharpHPatchZ": "[3.0.0, )", "System.IO.Hashing": "[10.0.11, )" } }, diff --git a/Hi3Helper.EncTool b/Hi3Helper.EncTool index 25e55e532c..7eb65a779f 160000 --- a/Hi3Helper.EncTool +++ b/Hi3Helper.EncTool @@ -1 +1 @@ -Subproject commit 25e55e532c641eee68bafc9ecebb9214ba76101d +Subproject commit 7eb65a779feae9b499e7b81b9e2e58558ef82d24 diff --git a/Hi3Helper.Sophon b/Hi3Helper.Sophon index e281aa8f75..edc4fac3fb 160000 --- a/Hi3Helper.Sophon +++ b/Hi3Helper.Sophon @@ -1 +1 @@ -Subproject commit e281aa8f751613dfb920122bf7344a4bad06e07d +Subproject commit edc4fac3fb9386b9b64913d3180a4198b949c5ad From 1729b6446f667de8407659bc78b275af98c79889 Mon Sep 17 00:00:00 2001 From: Kemal Setya Adhi Date: Sun, 6 Sep 2026 00:59:30 +0700 Subject: [PATCH 18/50] Update usage and NuGet --- .../Base/InstallManagerBase.cs | 9 ++--- CollapseLauncher/CollapseLauncher.csproj | 4 +- CollapseLauncher/packages.lock.json | 38 +++++++++---------- H.NotifyIcon | 2 +- Hi3Helper.Core/Hi3Helper.Core.csproj | 2 +- Hi3Helper.Core/packages.lock.json | 12 +++--- Hi3Helper.EncTool | 2 +- Hi3Helper.Sophon | 2 +- 8 files changed, 34 insertions(+), 37 deletions(-) diff --git a/CollapseLauncher/Classes/InstallManagement/Base/InstallManagerBase.cs b/CollapseLauncher/Classes/InstallManagement/Base/InstallManagerBase.cs index 7947cf1c86..560767b58b 100644 --- a/CollapseLauncher/Classes/InstallManagement/Base/InstallManagerBase.cs +++ b/CollapseLauncher/Classes/InstallManagement/Base/InstallManagerBase.cs @@ -315,10 +315,9 @@ protected virtual async ValueTask StartDeltaPatch(IRepairAssetIndex repair UpdateStatus(); // Start the patching process - ProgressCallback progressCallback = ProgressCallback.CreateFromManaged(DeltaPatchProgress); using HDiffInfo hdiffInfo = await HPatch.CreateInstanceAsync(patchProperty.PatchPath, Token!.Token); Exception? resultException = await HPatch.PatchAsync(hdiffInfo, patchProperty.PatchPath, ingredientPath, - previousPath, PatchOptions.BigBuffer, progressCallback, + previousPath, PatchOptions.BigBuffer, DeltaPatchProgress, token: Token.Token); if (resultException != null) @@ -1351,13 +1350,12 @@ private async Task FileHdiffPatcherInner(string patchPath, string sourceBasePath try { - ProgressCallback progressCallback = ProgressCallback.CreateFromManaged(EventListener_PatchEvent); Exception? resultException = await HPatch.PatchAsync(hdiffInfo, patchFileInfo.FullName, sourceFileInfo.FullName, targetFileInfo.FullName, PatchOptions.BigBuffer, - progressCallback, + EventListener_PatchEvent, token); if (resultException != null) throw resultException; @@ -1458,7 +1456,6 @@ protected virtual async Task ApplyHDiffMap() ProgressAllCountTotal = 1; ProgressAllCountFound = hDiffMapEntries.Count; - ProgressCallback progressCallback = ProgressCallback.CreateFromManaged(EventListener_PatchEvent); Task parallelTask = Parallel.ForEachAsync(hDiffMapEntries, new ParallelOptions { MaxDegreeOfParallelism = ThreadCount, @@ -1540,7 +1537,7 @@ async ValueTask PatchWorker(HDiffMapEntry entry, CancellationToken workerToken) sourcePath.FullName, targetPathTemp.FullName, PatchOptions.BigBuffer, - progressCallback, + EventListener_PatchEvent, workerToken); if (resultException != null) diff --git a/CollapseLauncher/CollapseLauncher.csproj b/CollapseLauncher/CollapseLauncher.csproj index b219111bdd..186f595730 100644 --- a/CollapseLauncher/CollapseLauncher.csproj +++ b/CollapseLauncher/CollapseLauncher.csproj @@ -277,7 +277,7 @@ - + all runtime; build; native; contentfiles; analyzers; buildtransitive @@ -303,7 +303,7 @@ - + diff --git a/CollapseLauncher/packages.lock.json b/CollapseLauncher/packages.lock.json index cfc9baa374..7afb0b0a28 100644 --- a/CollapseLauncher/packages.lock.json +++ b/CollapseLauncher/packages.lock.json @@ -147,9 +147,9 @@ }, "Microsoft.Web.WebView2": { "type": "Direct", - "requested": "[1.0.4129.50, )", - "resolved": "1.0.4129.50", - "contentHash": "BuJC70c7SCl8wlvojLZbc/E5ONmGyG3fhAMmpftCpKerxae//TxgKOTELRK2QRD7m7jigNFQRf6cfVT3ZiIQoQ==" + "requested": "[1.0.4191.47, )", + "resolved": "1.0.4191.47", + "contentHash": "Snb6mlTpuz6ZFjWMwIdg28Xp6kAUMy3zaLUyGbFSaw+/AJKlwoX8EiaWJ1eUMfKyJHksPkFjJHl1LIB7kX+0AQ==" }, "Microsoft.Windows.CsWinRT": { "type": "Direct", @@ -243,9 +243,9 @@ }, "ThisAssembly.Constants": { "type": "Direct", - "requested": "[2.1.2, )", - "resolved": "2.1.2", - "contentHash": "rq7HoR45a4H1NM8KPG+rOPhv6z36wpB088+tB6KCbltBsnx1uwCpS3IvLmMZh3EOnZarRjXE9oiVgGMFCCJ1Wg==" + "requested": "[2.1.5, )", + "resolved": "2.1.5", + "contentHash": "JB4nLzpPXTpCyZh60wdxA4aRjfx7Ao7sjlH8O+trTZMn5X75ZVPNJ6kK7VRVYhZWlIPfgex7augNWzPiCz0lJA==" }, "TurnerSoftware.DinoDNS": { "type": "Direct", @@ -301,8 +301,8 @@ }, "Google.Protobuf": { "type": "Transitive", - "resolved": "3.36.0", - "contentHash": "wDPg8sKlmA8we1bqnC9DYKwLog4PNJ4GYTWV9FkzlGvfs3kNCSt2x0Qe31SBOeBPS2JGEZ/lNfil/Bkwx18Ufg==" + "resolved": "3.36.1", + "contentHash": "77AqPEoaY1ODE+syYBHti0jXiwQq0J/fUr/fRyYhNlc9oKtH5dZZEr/OLKtdKNVG83PRnCYB2r8B80ZrObzOGQ==" }, "Hi3Helper.SourceGen": { "type": "Transitive", @@ -456,13 +456,13 @@ }, "Sentry": { "type": "Transitive", - "resolved": "6.9.0", - "contentHash": "qQIvEwuvjAB6fDLVLLcDj/5f8n5jOyPyHjj3a/GQ1ogTLLQqsSxgQj1fEEquNT9HQuj4ZTyCg3c1DCBMUIvJGQ==" + "resolved": "6.10.0", + "contentHash": "jDiLbMSNuwMNJmJc5l8XaO3bgHxTbBkttqqSLQTDP415ucNM4F/Ckktg3L/Ww6v2Skcwt7wFC7vSW+f8W12Vqg==" }, "SharpHPatchZ": { "type": "Transitive", - "resolved": "3.0.0", - "contentHash": "Rr8IGWnHMEOSNwwTlKy+C6ddD0tMm/hgKtW72GuAv08xOGxuVYV+Mv6XPIQneB3WX1sYyOWOrdCdO5PZ5hn/Og==", + "resolved": "3.1.0", + "contentHash": "3o+WTgAnNjzw5qWpmUAGwAgwvmC9dYYbT0ZiH+3hQVX/sdRerkPHuUsr7JxqXi5opKCQzhWaw/TZxXFme78EGQ==", "dependencies": { "Hi3Helper.ZstdNet": "1.6.7", "System.IO.Hashing": "10.0.11", @@ -530,13 +530,13 @@ "Hi3Helper.EncTool": "[1.0.0, )", "Hi3Helper.Win32": "[1.0.0, )", "Microsoft.Windows.CsWinRT": "[2.3.1, )", - "Sentry": "[6.9.0, )" + "Sentry": "[6.10.0, )" } }, "hi3helper.enctool": { "type": "Project", "dependencies": { - "Google.Protobuf": "[3.36.0, )", + "Google.Protobuf": "[3.36.1, )", "Hi3Helper.Http": "[2.0.1, )", "Hi3Helper.Win32": "[1.0.0, )", "System.IO.Hashing": "[10.0.11, )" @@ -564,9 +564,9 @@ "hi3helper.sophon": { "type": "Project", "dependencies": { - "Google.Protobuf": "[3.36.0, )", + "Google.Protobuf": "[3.36.1, )", "Hi3Helper.ZstdNet": "[1.6.7, )", - "SharpHPatchZ": "[3.0.0, )", + "SharpHPatchZ": "[3.1.0, )", "System.IO.Hashing": "[10.0.11, )" } }, @@ -643,9 +643,9 @@ }, "Microsoft.Web.WebView2": { "type": "Direct", - "requested": "[1.0.4129.50, )", - "resolved": "1.0.4129.50", - "contentHash": "BuJC70c7SCl8wlvojLZbc/E5ONmGyG3fhAMmpftCpKerxae//TxgKOTELRK2QRD7m7jigNFQRf6cfVT3ZiIQoQ==" + "requested": "[1.0.4191.47, )", + "resolved": "1.0.4191.47", + "contentHash": "Snb6mlTpuz6ZFjWMwIdg28Xp6kAUMy3zaLUyGbFSaw+/AJKlwoX8EiaWJ1eUMfKyJHksPkFjJHl1LIB7kX+0AQ==" }, "PhotoSauce.NativeCodecs.Libheif": { "type": "Direct", diff --git a/H.NotifyIcon b/H.NotifyIcon index fe2c3647a3..8cadbd2a7a 160000 --- a/H.NotifyIcon +++ b/H.NotifyIcon @@ -1 +1 @@ -Subproject commit fe2c3647a3de1d2534ff3cf4c9ccfd6401543f10 +Subproject commit 8cadbd2a7a5d06473083ec59174de0ca04427c15 diff --git a/Hi3Helper.Core/Hi3Helper.Core.csproj b/Hi3Helper.Core/Hi3Helper.Core.csproj index f2285a8bfc..2ec692478a 100644 --- a/Hi3Helper.Core/Hi3Helper.Core.csproj +++ b/Hi3Helper.Core/Hi3Helper.Core.csproj @@ -45,7 +45,7 @@ - + diff --git a/Hi3Helper.Core/packages.lock.json b/Hi3Helper.Core/packages.lock.json index 19da3c222b..ab2e2a562d 100644 --- a/Hi3Helper.Core/packages.lock.json +++ b/Hi3Helper.Core/packages.lock.json @@ -16,14 +16,14 @@ }, "Sentry": { "type": "Direct", - "requested": "[6.9.0, )", - "resolved": "6.9.0", - "contentHash": "qQIvEwuvjAB6fDLVLLcDj/5f8n5jOyPyHjj3a/GQ1ogTLLQqsSxgQj1fEEquNT9HQuj4ZTyCg3c1DCBMUIvJGQ==" + "requested": "[6.10.0, )", + "resolved": "6.10.0", + "contentHash": "jDiLbMSNuwMNJmJc5l8XaO3bgHxTbBkttqqSLQTDP415ucNM4F/Ckktg3L/Ww6v2Skcwt7wFC7vSW+f8W12Vqg==" }, "Google.Protobuf": { "type": "Transitive", - "resolved": "3.36.0", - "contentHash": "wDPg8sKlmA8we1bqnC9DYKwLog4PNJ4GYTWV9FkzlGvfs3kNCSt2x0Qe31SBOeBPS2JGEZ/lNfil/Bkwx18Ufg==" + "resolved": "3.36.1", + "contentHash": "77AqPEoaY1ODE+syYBHti0jXiwQq0J/fUr/fRyYhNlc9oKtH5dZZEr/OLKtdKNVG83PRnCYB2r8B80ZrObzOGQ==" }, "Microsoft.Extensions.DependencyInjection.Abstractions": { "type": "Transitive", @@ -46,7 +46,7 @@ "hi3helper.enctool": { "type": "Project", "dependencies": { - "Google.Protobuf": "[3.36.0, )", + "Google.Protobuf": "[3.36.1, )", "Hi3Helper.Http": "[2.0.1, )", "Hi3Helper.Win32": "[1.0.0, )", "System.IO.Hashing": "[10.0.11, )" diff --git a/Hi3Helper.EncTool b/Hi3Helper.EncTool index 7eb65a779f..47eeba101e 160000 --- a/Hi3Helper.EncTool +++ b/Hi3Helper.EncTool @@ -1 +1 @@ -Subproject commit 7eb65a779feae9b499e7b81b9e2e58558ef82d24 +Subproject commit 47eeba101eb7a34d8eb94251ad022ad7215f1d04 diff --git a/Hi3Helper.Sophon b/Hi3Helper.Sophon index edc4fac3fb..ea21b18838 160000 --- a/Hi3Helper.Sophon +++ b/Hi3Helper.Sophon @@ -1 +1 @@ -Subproject commit edc4fac3fb9386b9b64913d3180a4198b949c5ad +Subproject commit ea21b18838579add755fe1edd709b2f3daf0fb7c From 4c6ae6eb15404a9282c556ecaa1a3bc84a70e7cd Mon Sep 17 00:00:00 2001 From: Kemal Setya Adhi Date: Wed, 9 Sep 2026 22:42:30 +0700 Subject: [PATCH 19/50] Update NuGet --- CollapseLauncher/CollapseLauncher.csproj | 8 +- CollapseLauncher/packages.lock.json | 98 +++++++++++------------ ColorThief | 2 +- H.NotifyIcon | 2 +- Hi3Helper.Core/packages.lock.json | 24 +++--- Hi3Helper.EncTool | 2 +- Hi3Helper.EncTool.Test/packages.lock.json | 6 +- Hi3Helper.Plugin.Core | 2 +- Hi3Helper.SharpDiscordRPC | 2 +- Hi3Helper.Sophon | 2 +- Hi3Helper.Win32 | 2 +- ImageEx | 2 +- InnoSetupHelper/InnoSetupHelper.csproj | 2 +- InnoSetupHelper/packages.lock.json | 12 +-- global.json | 2 +- 15 files changed, 84 insertions(+), 84 deletions(-) diff --git a/CollapseLauncher/CollapseLauncher.csproj b/CollapseLauncher/CollapseLauncher.csproj index 186f595730..7648badc8a 100644 --- a/CollapseLauncher/CollapseLauncher.csproj +++ b/CollapseLauncher/CollapseLauncher.csproj @@ -274,9 +274,9 @@ - + - + all runtime; build; native; contentfiles; analyzers; buildtransitive @@ -313,8 +313,8 @@ - - + + diff --git a/CollapseLauncher/packages.lock.json b/CollapseLauncher/packages.lock.json index 7afb0b0a28..8895a94d6c 100644 --- a/CollapseLauncher/packages.lock.json +++ b/CollapseLauncher/packages.lock.json @@ -117,17 +117,17 @@ }, "Microsoft.Extensions.DependencyInjection.Abstractions": { "type": "Direct", - "requested": "[10.0.11, )", - "resolved": "10.0.11", - "contentHash": "/a1aJz4m7ylhEDf25ugQChLQoN5XwoGjWw/BoR/ZWWKsO1v4DdJElS1uyngahz4B/eOzjFk1KNTkarRLE5wsIg==" + "requested": "[10.0.12, )", + "resolved": "10.0.12", + "contentHash": "9/qymSh7hVDMGTGwrLz8MRp5zRyXy9adGDOs4HwRdnLil3oZGYuWeZjbmHgCQ9BL1qBroVfgUK3U/nb61617Cw==" }, "Microsoft.Extensions.Logging.Abstractions": { "type": "Direct", - "requested": "[10.0.11, )", - "resolved": "10.0.11", - "contentHash": "Ljd0Uxoq5XpScD2Bg0nM/r3mwx7Ao5Uq24eo2ARxbGvqJ7Zht6rt2cJtwVRH4Cv+1ZVMdXz6TB43KbpmsxRrvQ==", + "requested": "[10.0.12, )", + "resolved": "10.0.12", + "contentHash": "+24lC4plfbEDNfLAdTV/SWKS7dW+16X4HdydO3R++134kSNTzcbYA4KpR1Hdh6uWisB8Za3AzwyOn+K+NxWIug==", "dependencies": { - "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.11" + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.12" } }, "Microsoft.Graphics.Win2D": { @@ -141,9 +141,9 @@ }, "Microsoft.NET.ILLink.Tasks": { "type": "Direct", - "requested": "[10.0.11, )", - "resolved": "10.0.11", - "contentHash": "IBf7lbovvjGWVWXZX5cJ/cO0WXbId0Zq4BuSeT94mGZuOAP66oMeH9PTBZ9Jpp3Jb6jtK0qm/NyUbPRo1gC/wQ==" + "requested": "[10.0.12, )", + "resolved": "10.0.12", + "contentHash": "xi+BDjFpW+Sb+MHFHaH6Y/gV9I8BluFwRXc1QyCdoZbIK26eNiBeFuMTe/FMwc33G1wdHCyDg7CVTmb8OdQrMQ==" }, "Microsoft.Web.WebView2": { "type": "Direct", @@ -231,15 +231,15 @@ }, "System.CommandLine": { "type": "Direct", - "requested": "[2.0.11, )", - "resolved": "2.0.11", - "contentHash": "Pmg3/T0M37ZS3ovwPHtfBNTCdqNpnxo+tnZMg8JDHVR0qD8KwcPUKlheEiZysb1En8IyH7yDoa9QDq97Ny/vhQ==" + "requested": "[2.0.12, )", + "resolved": "2.0.12", + "contentHash": "FpW672e6qqrTd1EfKqsvUtFZaJWlTlszUR1o8tdakXhkzKbnEZVndtpfd4K3fUPCFcIs9UmcswMyYGJP1zp/rg==" }, "System.Security.Cryptography.ProtectedData": { "type": "Direct", - "requested": "[10.0.11, )", - "resolved": "10.0.11", - "contentHash": "PNoxCTPb+Tlux+GJyq4c89ddYdpioVSqfGx8pqOF6shCSKwUNNctXhQtRkCICjbJmGrJJsW8NY52kVYO/b8mlQ==" + "requested": "[10.0.12, )", + "resolved": "10.0.12", + "contentHash": "wO0W8VDMeRJZakRySOmemAnrBZMwEFRlbfBtATxUiLf2I3IhSRnCp1Vt157NWptdE4IJ7eftd5n8q9LtaKHFTQ==" }, "ThisAssembly.Constants": { "type": "Direct", @@ -316,40 +316,40 @@ }, "Microsoft.Extensions.DependencyInjection": { "type": "Transitive", - "resolved": "10.0.11", - "contentHash": "PSmotV19c7E3lKed++uYo1kSiXFI+uTl37CBSrhq+CfLC3FCHjG7R91+xPnNehQfHS1b0Tzo/CCLPWH3qaEheg==", + "resolved": "10.0.12", + "contentHash": "lXyK2O5GoYvfxW8eCFcD16JFbcoSTM1sJkAM0UHS1jZyl9NYMW64Tqm6OQFT0IDBjZi+xHt95/Zg+nxZhGFhZg==", "dependencies": { - "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.11" + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.12" } }, "Microsoft.Extensions.Logging": { "type": "Transitive", - "resolved": "10.0.11", - "contentHash": "nUOJwgFkSiLHiVGFpU22pIJtuWYewuSYQ3JVuP/gdK8ASMT807Px+TYQiRWs6uSsOmoyFTaVCwKXTasczV6BpA==", + "resolved": "10.0.12", + "contentHash": "6I46fTPfgYkrjRYfRXbho9WOvOelTnNjWuZws/hzGHDASH1LEJeA4VKK9k3wJvido8o7jJSB5WkMTonX7HM1bA==", "dependencies": { - "Microsoft.Extensions.DependencyInjection": "10.0.11", - "Microsoft.Extensions.Logging.Abstractions": "10.0.11", - "Microsoft.Extensions.Options": "10.0.11" + "Microsoft.Extensions.DependencyInjection": "10.0.12", + "Microsoft.Extensions.Logging.Abstractions": "10.0.12", + "Microsoft.Extensions.Options": "10.0.12" } }, "Microsoft.Extensions.Options": { "type": "Transitive", - "resolved": "10.0.11", - "contentHash": "eY1GAKcTfD2maP27J84X9IovT3yjHJ2dVDzPmDg6/XqYvt3jMzJhtfQCLjG9pVsZGAd+8DQ2QrjaDcs2+VQLGw==", + "resolved": "10.0.12", + "contentHash": "TDYD33TSRpXKZWlmTXNlj5kCihxatmv2Ec1u6C+bMYLphCS7PoSLE9Pjd/nunDoE7yETk+LLKjVJX78HYtWjpA==", "dependencies": { - "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.11", - "Microsoft.Extensions.Primitives": "10.0.11" + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.12", + "Microsoft.Extensions.Primitives": "10.0.12" } }, "Microsoft.Extensions.Primitives": { "type": "Transitive", - "resolved": "10.0.11", - "contentHash": "SXcz+kF+4Oo9b1+55zntpJFYfwb1jw66ioxptyNOOTDc8g2FHnBFWjZpsWfCvZIhzr0x+4e2trVTs4OKwQfBtw==" + "resolved": "10.0.12", + "contentHash": "dYfCLR52UA+3DL7C4I/pvSaRPkNqxrUAQmbFL2u0zvYKKzqgrFCJl08Df+F1aYc8leu9JvpC9bsURUdpExcBXQ==" }, "Microsoft.Win32.SystemEvents": { "type": "Transitive", - "resolved": "10.0.11", - "contentHash": "WXGazuzGf3eX24z1qgiGwP2df3t2T28QeZvMjlRqdjNuewQyLfbOb6rEXmk6Xs++gOQClmw90pkjPd9j/hNWDw==" + "resolved": "10.0.12", + "contentHash": "kdj5cqOwgZCMltEhQ4+WVUoFkMHG08EJfy1MDyS9H+FBg66vTTFyX8YcNl8IQh5wRQv79P+iduQgDGY8ucBR+w==" }, "Microsoft.Windows.AI.MachineLearning": { "type": "Transitive", @@ -471,16 +471,16 @@ }, "System.Drawing.Common": { "type": "Transitive", - "resolved": "10.0.11", - "contentHash": "Schxw0FzXMAwa+If/oVxqjrmmiyUbw9KUAeqJOPnjsdAzkmUZXSZWNRLHZye2boFYlbMRkeBY6dba5hs8Mu4OQ==", + "resolved": "10.0.12", + "contentHash": "FBUxiucg2gsnyItbpGgyXI5u8FYLcqdLKS1XJkHHTj3RK1c6kpsJr/Y9i4edUNF7LqCYFrHqpHEvpt8/lNBe9w==", "dependencies": { - "Microsoft.Win32.SystemEvents": "10.0.11" + "Microsoft.Win32.SystemEvents": "10.0.12" } }, "System.IO.Hashing": { "type": "Transitive", - "resolved": "10.0.11", - "contentHash": "OzKDcIRkeNJeC8qAsbn8yJXnfTLP1dtkWILe+T56Gf/z+IkAASi7sMqLqJQat08j5z/mRN5xVtoAwbkMNMoBUQ==" + "resolved": "10.0.12", + "contentHash": "jDix4bBMYnpZdSPcnY+KDV6ik3SRMzpMKby/bZl/XUwIiflwRNAFZ0oOl61R/pSaveIJ8t1gs2BUlrGsPs/bcg==" }, "System.Numerics.Tensors": { "type": "Transitive", @@ -495,26 +495,26 @@ "colorthief": { "type": "Project", "dependencies": { - "System.Drawing.Common": "[10.0.11, )" + "System.Drawing.Common": "[10.0.12, )" } }, "discordrpc": { "type": "Project", "dependencies": { - "Microsoft.Extensions.Logging": "[10.0.11, )" + "Microsoft.Extensions.Logging": "[10.0.12, )" } }, "h.generatedicons.system.drawing": { "type": "Project", "dependencies": { - "System.Drawing.Common": "[10.0.11, )" + "System.Drawing.Common": "[10.0.12, )" } }, "h.notifyicon": { "type": "Project", "dependencies": { "H.GeneratedIcons.System.Drawing": "[1.0.0, )", - "Microsoft.Extensions.Logging": "[10.0.11, )" + "Microsoft.Extensions.Logging": "[10.0.12, )" } }, "h.notifyicon.winui": { @@ -539,7 +539,7 @@ "Google.Protobuf": "[3.36.1, )", "Hi3Helper.Http": "[2.0.1, )", "Hi3Helper.Win32": "[1.0.0, )", - "System.IO.Hashing": "[10.0.11, )" + "System.IO.Hashing": "[10.0.12, )" } }, "hi3helper.http": { @@ -555,7 +555,7 @@ "hi3helper.plugin.core": { "type": "Project", "dependencies": { - "Microsoft.Extensions.Logging.Abstractions": "[10.0.11, )" + "Microsoft.Extensions.Logging.Abstractions": "[10.0.12, )" } }, "hi3helper.simpleziparchivereader": { @@ -567,20 +567,20 @@ "Google.Protobuf": "[3.36.1, )", "Hi3Helper.ZstdNet": "[1.6.7, )", "SharpHPatchZ": "[3.1.0, )", - "System.IO.Hashing": "[10.0.11, )" + "System.IO.Hashing": "[10.0.12, )" } }, "hi3helper.win32": { "type": "Project", "dependencies": { - "Microsoft.Extensions.Logging.Abstractions": "[10.0.11, )" + "Microsoft.Extensions.Logging.Abstractions": "[10.0.12, )" } }, "hi3helper.win32.winrt": { "type": "Project", "dependencies": { "Hi3Helper.Win32": "[1.0.0, )", - "Microsoft.Extensions.Logging.Abstractions": "[10.0.11, )", + "Microsoft.Extensions.Logging.Abstractions": "[10.0.12, )", "Microsoft.Windows.CsWinRT": "[2.3.1, )" } }, @@ -608,7 +608,7 @@ "innosetuphelper": { "type": "Project", "dependencies": { - "System.IO.Hashing": "[10.0.11, )" + "System.IO.Hashing": "[10.0.12, )" } }, "SettingsControls": { @@ -689,8 +689,8 @@ }, "Microsoft.Win32.SystemEvents": { "type": "Transitive", - "resolved": "10.0.11", - "contentHash": "WXGazuzGf3eX24z1qgiGwP2df3t2T28QeZvMjlRqdjNuewQyLfbOb6rEXmk6Xs++gOQClmw90pkjPd9j/hNWDw==" + "resolved": "10.0.12", + "contentHash": "kdj5cqOwgZCMltEhQ4+WVUoFkMHG08EJfy1MDyS9H+FBg66vTTFyX8YcNl8IQh5wRQv79P+iduQgDGY8ucBR+w==" }, "Microsoft.Windows.AI.MachineLearning": { "type": "Transitive", diff --git a/ColorThief b/ColorThief index b568f2973a..5a04db74a5 160000 --- a/ColorThief +++ b/ColorThief @@ -1 +1 @@ -Subproject commit b568f2973aa050c4a5665362da665c71945da2bb +Subproject commit 5a04db74a50573469e48d8ffae4ece35f8707308 diff --git a/H.NotifyIcon b/H.NotifyIcon index 8cadbd2a7a..7422382b4d 160000 --- a/H.NotifyIcon +++ b/H.NotifyIcon @@ -1 +1 @@ -Subproject commit 8cadbd2a7a5d06473083ec59174de0ca04427c15 +Subproject commit 7422382b4da5a738f84b400b31d23df1c1c0c319 diff --git a/Hi3Helper.Core/packages.lock.json b/Hi3Helper.Core/packages.lock.json index ab2e2a562d..32a4d25acc 100644 --- a/Hi3Helper.Core/packages.lock.json +++ b/Hi3Helper.Core/packages.lock.json @@ -4,9 +4,9 @@ "net10.0-windows10.0.26100": { "Microsoft.NET.ILLink.Tasks": { "type": "Direct", - "requested": "[10.0.11, )", - "resolved": "10.0.11", - "contentHash": "IBf7lbovvjGWVWXZX5cJ/cO0WXbId0Zq4BuSeT94mGZuOAP66oMeH9PTBZ9Jpp3Jb6jtK0qm/NyUbPRo1gC/wQ==" + "requested": "[10.0.12, )", + "resolved": "10.0.12", + "contentHash": "xi+BDjFpW+Sb+MHFHaH6Y/gV9I8BluFwRXc1QyCdoZbIK26eNiBeFuMTe/FMwc33G1wdHCyDg7CVTmb8OdQrMQ==" }, "Microsoft.Windows.CsWinRT": { "type": "Direct", @@ -27,21 +27,21 @@ }, "Microsoft.Extensions.DependencyInjection.Abstractions": { "type": "Transitive", - "resolved": "10.0.11", - "contentHash": "/a1aJz4m7ylhEDf25ugQChLQoN5XwoGjWw/BoR/ZWWKsO1v4DdJElS1uyngahz4B/eOzjFk1KNTkarRLE5wsIg==" + "resolved": "10.0.12", + "contentHash": "9/qymSh7hVDMGTGwrLz8MRp5zRyXy9adGDOs4HwRdnLil3oZGYuWeZjbmHgCQ9BL1qBroVfgUK3U/nb61617Cw==" }, "Microsoft.Extensions.Logging.Abstractions": { "type": "Transitive", - "resolved": "10.0.11", - "contentHash": "Ljd0Uxoq5XpScD2Bg0nM/r3mwx7Ao5Uq24eo2ARxbGvqJ7Zht6rt2cJtwVRH4Cv+1ZVMdXz6TB43KbpmsxRrvQ==", + "resolved": "10.0.12", + "contentHash": "+24lC4plfbEDNfLAdTV/SWKS7dW+16X4HdydO3R++134kSNTzcbYA4KpR1Hdh6uWisB8Za3AzwyOn+K+NxWIug==", "dependencies": { - "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.11" + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.12" } }, "System.IO.Hashing": { "type": "Transitive", - "resolved": "10.0.11", - "contentHash": "OzKDcIRkeNJeC8qAsbn8yJXnfTLP1dtkWILe+T56Gf/z+IkAASi7sMqLqJQat08j5z/mRN5xVtoAwbkMNMoBUQ==" + "resolved": "10.0.12", + "contentHash": "jDix4bBMYnpZdSPcnY+KDV6ik3SRMzpMKby/bZl/XUwIiflwRNAFZ0oOl61R/pSaveIJ8t1gs2BUlrGsPs/bcg==" }, "hi3helper.enctool": { "type": "Project", @@ -49,7 +49,7 @@ "Google.Protobuf": "[3.36.1, )", "Hi3Helper.Http": "[2.0.1, )", "Hi3Helper.Win32": "[1.0.0, )", - "System.IO.Hashing": "[10.0.11, )" + "System.IO.Hashing": "[10.0.12, )" } }, "hi3helper.http": { @@ -58,7 +58,7 @@ "hi3helper.win32": { "type": "Project", "dependencies": { - "Microsoft.Extensions.Logging.Abstractions": "[10.0.11, )" + "Microsoft.Extensions.Logging.Abstractions": "[10.0.12, )" } } } diff --git a/Hi3Helper.EncTool b/Hi3Helper.EncTool index 47eeba101e..98d8b57866 160000 --- a/Hi3Helper.EncTool +++ b/Hi3Helper.EncTool @@ -1 +1 @@ -Subproject commit 47eeba101eb7a34d8eb94251ad022ad7215f1d04 +Subproject commit 98d8b57866ea969fbaf029c4491864af9b7332d8 diff --git a/Hi3Helper.EncTool.Test/packages.lock.json b/Hi3Helper.EncTool.Test/packages.lock.json index 993187baa8..188fe2b983 100644 --- a/Hi3Helper.EncTool.Test/packages.lock.json +++ b/Hi3Helper.EncTool.Test/packages.lock.json @@ -4,9 +4,9 @@ "net10.0": { "Microsoft.NET.ILLink.Tasks": { "type": "Direct", - "requested": "[10.0.11, )", - "resolved": "10.0.11", - "contentHash": "IBf7lbovvjGWVWXZX5cJ/cO0WXbId0Zq4BuSeT94mGZuOAP66oMeH9PTBZ9Jpp3Jb6jtK0qm/NyUbPRo1gC/wQ==" + "requested": "[10.0.12, )", + "resolved": "10.0.12", + "contentHash": "xi+BDjFpW+Sb+MHFHaH6Y/gV9I8BluFwRXc1QyCdoZbIK26eNiBeFuMTe/FMwc33G1wdHCyDg7CVTmb8OdQrMQ==" } } } diff --git a/Hi3Helper.Plugin.Core b/Hi3Helper.Plugin.Core index 3613d20b40..55d05702cc 160000 --- a/Hi3Helper.Plugin.Core +++ b/Hi3Helper.Plugin.Core @@ -1 +1 @@ -Subproject commit 3613d20b405626e694f78793ac0d5f1626a37ff1 +Subproject commit 55d05702ccc96eedb041c8d66f0bb3940b1ce08f diff --git a/Hi3Helper.SharpDiscordRPC b/Hi3Helper.SharpDiscordRPC index e80e57c3e7..a2d1cb7c6d 160000 --- a/Hi3Helper.SharpDiscordRPC +++ b/Hi3Helper.SharpDiscordRPC @@ -1 +1 @@ -Subproject commit e80e57c3e758bc7a28f9c69fb266019e977d1a1a +Subproject commit a2d1cb7c6d4d39660bc557bec7296aafb6d48ad3 diff --git a/Hi3Helper.Sophon b/Hi3Helper.Sophon index ea21b18838..9189e990e2 160000 --- a/Hi3Helper.Sophon +++ b/Hi3Helper.Sophon @@ -1 +1 @@ -Subproject commit ea21b18838579add755fe1edd709b2f3daf0fb7c +Subproject commit 9189e990e2d8ef6a9ee5b3dfd77b41e1874f9cac diff --git a/Hi3Helper.Win32 b/Hi3Helper.Win32 index ff564bec95..5ac3f62614 160000 --- a/Hi3Helper.Win32 +++ b/Hi3Helper.Win32 @@ -1 +1 @@ -Subproject commit ff564bec95894ef8da5e4dfb4f08e3057080cdfc +Subproject commit 5ac3f626146f2c9ddc29b4d289b6854156e3b9d2 diff --git a/ImageEx b/ImageEx index 4027757a48..f129c5ef97 160000 --- a/ImageEx +++ b/ImageEx @@ -1 +1 @@ -Subproject commit 4027757a4890e68fe1d32306cee95310a19d231e +Subproject commit f129c5ef97209c9f343291b09d06639f692e97eb diff --git a/InnoSetupHelper/InnoSetupHelper.csproj b/InnoSetupHelper/InnoSetupHelper.csproj index cef6b630a3..3ee2949c18 100644 --- a/InnoSetupHelper/InnoSetupHelper.csproj +++ b/InnoSetupHelper/InnoSetupHelper.csproj @@ -19,7 +19,7 @@ - + \ No newline at end of file diff --git a/InnoSetupHelper/packages.lock.json b/InnoSetupHelper/packages.lock.json index 13ea477f7f..1f9ccd3b26 100644 --- a/InnoSetupHelper/packages.lock.json +++ b/InnoSetupHelper/packages.lock.json @@ -4,15 +4,15 @@ "net10.0": { "Microsoft.NET.ILLink.Tasks": { "type": "Direct", - "requested": "[10.0.11, )", - "resolved": "10.0.11", - "contentHash": "IBf7lbovvjGWVWXZX5cJ/cO0WXbId0Zq4BuSeT94mGZuOAP66oMeH9PTBZ9Jpp3Jb6jtK0qm/NyUbPRo1gC/wQ==" + "requested": "[10.0.12, )", + "resolved": "10.0.12", + "contentHash": "xi+BDjFpW+Sb+MHFHaH6Y/gV9I8BluFwRXc1QyCdoZbIK26eNiBeFuMTe/FMwc33G1wdHCyDg7CVTmb8OdQrMQ==" }, "System.IO.Hashing": { "type": "Direct", - "requested": "[10.0.11, )", - "resolved": "10.0.11", - "contentHash": "OzKDcIRkeNJeC8qAsbn8yJXnfTLP1dtkWILe+T56Gf/z+IkAASi7sMqLqJQat08j5z/mRN5xVtoAwbkMNMoBUQ==" + "requested": "[10.0.12, )", + "resolved": "10.0.12", + "contentHash": "jDix4bBMYnpZdSPcnY+KDV6ik3SRMzpMKby/bZl/XUwIiflwRNAFZ0oOl61R/pSaveIJ8t1gs2BUlrGsPs/bcg==" } } } diff --git a/global.json b/global.json index 5a41a19fd4..582607ec2c 100644 --- a/global.json +++ b/global.json @@ -1,6 +1,6 @@ { "sdk": { - "version": "10.0.400", + "version": "10.0.401", "rollForward": "latestPatch", "allowPrerelease": true } From ba558bb1cfbf43cd402f1ebc4ccfdf9af84632aa Mon Sep 17 00:00:00 2001 From: Kemal Setya Adhi Date: Fri, 11 Sep 2026 03:09:20 +0700 Subject: [PATCH 20/50] Use Built-in ZstandardStream on .NET 11 build --- .../Classes/Helper/Metadata/DataCooker.cs | 48 +++++++++++-------- CollapseLauncher/Program.cs | 2 + 2 files changed, 30 insertions(+), 20 deletions(-) diff --git a/CollapseLauncher/Classes/Helper/Metadata/DataCooker.cs b/CollapseLauncher/Classes/Helper/Metadata/DataCooker.cs index bd519bfc2c..ba50fd1f71 100644 --- a/CollapseLauncher/Classes/Helper/Metadata/DataCooker.cs +++ b/CollapseLauncher/Classes/Helper/Metadata/DataCooker.cs @@ -4,6 +4,7 @@ using System.Buffers.Text; using System.IO; using System.IO.Compression; +using System.Runtime.CompilerServices; using System.Runtime.InteropServices; using System.Security.Cryptography; using System.Text; @@ -12,7 +13,11 @@ // ReSharper disable IdentifierTypo // ReSharper disable UnusedMember.Global // ReSharper disable StringLiteralTypo +#if NET11_0_OR_GREATER +using ZstdDecompressStream = System.IO.Compression.ZstandardStream; +#else using ZstdDecompressStream = ZstdNet.DecompressionStream; +#endif #pragma warning disable IDE0130 #nullable enable @@ -179,10 +184,10 @@ internal static void ServeV3Data(ReadOnlySpan data, throw new FormatException($"Decompression format is not supported! ({compressionType})"); } - #if DEBUG +#if DEBUG Logger.LogWriteLine($"[DataCooker::ServeV3Data()] Loaded ServeV3 data [IsPooled: {isDecryptPoolUsed}][TCompress: {compressionType} | IsEncrypt: {isUseEncryption}][CompSize: {compressedSize} | UncompSize: {decompressedSize}]", LogType.Debug, true); - #endif +#endif } finally { @@ -214,28 +219,31 @@ private static int DecompressDataFromBrotli(Span outData, int compressedSi private static unsafe int DecompressDataFromZstd(Span outData, int decompressedSize, ReadOnlySpan dataRawBuffer) { - fixed (byte* inputBuffer = &dataRawBuffer[0]) - fixed (byte* outputBuffer = &outData[0]) - { - int decompressedWritten = 0; + byte* inputBuffer = (byte*)Unsafe.AsPointer(ref MemoryMarshal.GetReference(dataRawBuffer)); + byte* outputBuffer = (byte*)Unsafe.AsPointer(ref MemoryMarshal.GetReference(outData)); + int decompressedWritten = 0; - byte[] buffer = new byte[4 << 10]; + Span buffer = stackalloc byte[1 << 10]; - using UnmanagedMemoryStream inputStream = new(inputBuffer, dataRawBuffer.Length); - using UnmanagedMemoryStream outputStream = new(outputBuffer, outData.Length); - using ZstdDecompressStream decompStream = new(inputStream); + using UnmanagedMemoryStream inputStream = new(inputBuffer, dataRawBuffer.Length); + using UnmanagedMemoryStream outputStream = new(outputBuffer, outData.Length); - int read; - while ((read = decompStream.Read(buffer)) > 0) - { - outputStream.Write(buffer, 0, read); - decompressedWritten += read; - } +#if NET11_0_OR_GREATER + using ZstdDecompressStream decompStream = new(inputStream, CompressionMode.Decompress); +#else + using ZstdDecompressStream decompStream = new(inputStream); +#endif - return decompressedSize != decompressedWritten - ? throw new DataMisalignedException("Decompressed data is misaligned!") - : decompressedWritten; - } + int read; + while ((read = decompStream.Read(buffer)) > 0) + { + outputStream.Write(buffer[..read]); + decompressedWritten += read; + } + + return decompressedSize != decompressedWritten + ? throw new DataMisalignedException("Decompressed data is misaligned!") + : decompressedWritten; } } } \ No newline at end of file diff --git a/CollapseLauncher/Program.cs b/CollapseLauncher/Program.cs index 9ab411f59e..aecb3f733e 100644 --- a/CollapseLauncher/Program.cs +++ b/CollapseLauncher/Program.cs @@ -319,10 +319,12 @@ private static void InitCriticalModules() * Module: Libzstd */ +#if !NET11_0_OR_GREATER // Basically, the Libzstd's DLL will be checked if they exist on Non-AOT build. // But due to AOT build uses Static Library in favor of Shared ones (that comes // with .dll files), the check will be ignored. ZstdNet.DllUtils.IsIgnoreMissingLibrary = true; +#endif /* --------------------------------------------------------------------------------------------- * Module: Velopack From f23232e11d38254cc3f99657d7a7f93bb49bf057 Mon Sep 17 00:00:00 2001 From: Kemal Setya Adhi Date: Fri, 11 Sep 2026 04:50:05 +0700 Subject: [PATCH 21/50] Fix ZZZ GSP changes not applied --- .../BaseClass/MagicNodeBaseValues.cs | 22 ++--- .../Zenless/FileClass/GeneralData.cs | 86 ++++++------------- .../Zenless/JsonProperties/Properties.cs | 4 +- .../GameSettings/Zenless/Settings.cs | 8 +- .../ZenlessGameSettingsContext.cs | 11 ++- 5 files changed, 53 insertions(+), 78 deletions(-) diff --git a/CollapseLauncher/Classes/GameManagement/GameSettings/BaseClass/MagicNodeBaseValues.cs b/CollapseLauncher/Classes/GameManagement/GameSettings/BaseClass/MagicNodeBaseValues.cs index 2dc0372ada..32e6f1e1e7 100644 --- a/CollapseLauncher/Classes/GameManagement/GameSettings/BaseClass/MagicNodeBaseValues.cs +++ b/CollapseLauncher/Classes/GameManagement/GameSettings/BaseClass/MagicNodeBaseValues.cs @@ -28,13 +28,6 @@ public enum JsonEnumStoreType internal static class MagicNodeBaseValuesExt { - // ReSharper disable once UnusedMember.Local - private static readonly JsonSerializerOptions JsonSerializerOpts = new() - { - AllowTrailingCommas = true, - ReadCommentHandling = JsonCommentHandling.Skip - }; - private static JsonObject EnsureCreatedObject(this JsonNode? node, string keyName) { // If the node is empty, then create a new instance of it @@ -336,7 +329,11 @@ internal class MagicNodeBaseValues : NotifyPropertyChanged, IGameSettingsValu private SettingsGameVersionManager GameVersionManager { get; set; } [JsonIgnore] - protected JsonNode? SettingsJsonNode { get; private set; } + protected JsonNode? SettingsJsonNode + { + get; + private set; + } [JsonIgnore] public IGameSettings ParentGameSettings => null!; @@ -406,15 +403,14 @@ public void Save() { // Get the file and dir path string filePath = GameVersionManager.ConfigFilePath; - string? fileDirPath = Path.GetDirectoryName(filePath); - - // Create the dir if not exist - if (string.IsNullOrEmpty(fileDirPath) && !Directory.Exists(fileDirPath)) - Directory.CreateDirectory(fileDirPath!); // Write into the file string jsonString = SettingsJsonNode.SerializeJsonNode(TypeInfo, false, true); Sleepy.WriteString(filePath, jsonString, Magic); + +#if DEBUG + Logger.LogWriteLine($"Serialized data:\r\n{jsonString}", LogType.Debug, true); +#endif } public override bool Equals(object? obj) diff --git a/CollapseLauncher/Classes/GameManagement/GameSettings/Zenless/FileClass/GeneralData.cs b/CollapseLauncher/Classes/GameManagement/GameSettings/Zenless/FileClass/GeneralData.cs index 67a9075499..a424ba10f6 100644 --- a/CollapseLauncher/Classes/GameManagement/GameSettings/Zenless/FileClass/GeneralData.cs +++ b/CollapseLauncher/Classes/GameManagement/GameSettings/Zenless/FileClass/GeneralData.cs @@ -3,7 +3,6 @@ using CollapseLauncher.GameSettings.Zenless.JsonProperties; using Hi3Helper; using System; -using System.Diagnostics.CodeAnalysis; using System.Text.Json.Nodes; using System.Text.Json.Serialization; using System.Text.Json.Serialization.Metadata; @@ -20,34 +19,10 @@ namespace CollapseLauncher.GameSettings.Zenless { [GeneratedBindableCustomProperty] - internal sealed partial class GeneralData : MagicNodeBaseValues, IDisposable + internal sealed partial class GeneralData : MagicNodeBaseValues { - #region Disposer - - ~GeneralData() - { - _systemSettingDataMap = null; - _keyboardBindingMap = null; - _mouseBindingMap = null; - _gamepadBindingMap = null; - - GC.Collect(); - } - - public void Dispose() - { - GC.SuppressFinalize(this); - } - - #endregion - #region Node Based Properties - private JsonNode? _systemSettingDataMap; - private JsonNode? _keyboardBindingMap; - private JsonNode? _mouseBindingMap; - private JsonNode? _gamepadBindingMap; - [JsonPropertyName("SystemSettingDataMap")] [JsonIgnore] // We ignore this one from getting serialized to default JSON value public JsonNode SystemSettingDataMap @@ -55,8 +30,8 @@ public JsonNode SystemSettingDataMap // Cache the SystemSettingDataMap inside the parent SettingsJsonNode // and ensure that the node for SystemSettingDataMap exists. If not exist, // create a new one (via GetAsJsonNode()). - get => _systemSettingDataMap ??= SettingsJsonNode.GetAsJsonNode("SystemSettingDataMap"); - set => _systemSettingDataMap?.SetAsJsonNode("SystemSettingDataMap", value); + get => field ??= SettingsJsonNode.GetAsJsonNode("SystemSettingDataMap"); + set => field?.SetAsJsonNode("SystemSettingDataMap", value); } [JsonPropertyName("KeyboardBindingMap")] @@ -66,8 +41,8 @@ public JsonNode KeyboardBindingMap // Cache the KeyboardBindingMap inside the parent SettingsJsonNode // and ensure that the node for KeyboardBindingMap exists. If not exist, // create a new one (via GetAsJsonNode()). - get => _keyboardBindingMap ??= SettingsJsonNode.GetAsJsonNode("KeyboardBindingMap"); - set => _keyboardBindingMap?.SetAsJsonNode("KeyboardBindingMap", value); + get => field ??= SettingsJsonNode.GetAsJsonNode("KeyboardBindingMap"); + set => field?.SetAsJsonNode("KeyboardBindingMap", value); } [JsonPropertyName("MouseBindingMap")] @@ -77,8 +52,8 @@ public JsonNode MouseBindingMap // Cache the MouseBindingMap inside the parent SettingsJsonNode // and ensure that the node for MouseBindingMap exists. If not exist, // create a new one (via GetAsJsonNode()). - get => _mouseBindingMap ??= SettingsJsonNode.GetAsJsonNode("MouseBindingMap"); - set => _mouseBindingMap?.SetAsJsonNode("MouseBindingMap", value); + get => field ??= SettingsJsonNode.GetAsJsonNode("MouseBindingMap"); + set => field?.SetAsJsonNode("MouseBindingMap", value); } [JsonPropertyName("GamepadBindingMap")] @@ -88,26 +63,23 @@ public JsonNode GamepadBindingMap // Cache the GamepadBindingMap inside the parent SettingsJsonNode // and ensure that the node for GamepadBindingMap exists. If not exist, // create a new one (via GetAsJsonNode()). - get => _gamepadBindingMap ??= SettingsJsonNode.GetAsJsonNode("GamepadBindingMap"); - set => _gamepadBindingMap?.SetAsJsonNode("GamepadBindingMap", value); + get => field ??= SettingsJsonNode.GetAsJsonNode("GamepadBindingMap"); + set => field?.SetAsJsonNode("GamepadBindingMap", value); } [JsonPropertyName("PlayerPrefs_StringContainer")] - [JsonIgnore] - [field: AllowNull, MaybeNull] // We ignore this one from getting serialized to default JSON value + [JsonIgnore] // We ignore this one from getting serialized to default JSON value public JsonNode PlayerPrefsStringContainer { // Cache the PlayerPrefsStringContainer inside the parent SettingsJsonNode // and ensure that the node for PlayerPrefsStringContainer exists. If not exist, // create a new one (via GetAsJsonNode()). - get => field ??= - SettingsJsonNode.GetAsJsonNode("PlayerPrefs_StringContainer"); + get => field ??= SettingsJsonNode.GetAsJsonNode("PlayerPrefs_StringContainer"); set => field?.SetAsJsonNode("PlayerPrefs_StringContainer", value); } [JsonPropertyName("PlayerPrefs_IntContainer")] - [JsonIgnore] - [field: AllowNull, MaybeNull] // We ignore this one from getting serialized to default JSON value + [JsonIgnore] // We ignore this one from getting serialized to default JSON value public JsonNode PlayerPrefsIntContainer { // Cache the PlayerPrefsIntContainer inside the parent SettingsJsonNode @@ -118,15 +90,13 @@ public JsonNode PlayerPrefsIntContainer } [JsonPropertyName("PlayerPrefs_FloatContainer")] - [JsonIgnore] - [field: AllowNull, MaybeNull] // We ignore this one from getting serialized to default JSON value + [JsonIgnore] // We ignore this one from getting serialized to default JSON value public JsonNode PlayerPrefsFloatContainer { // Cache the PlayerPrefsFloatContainer inside the parent SettingsJsonNode // and ensure that the node for PlayerPrefsFloatContainer exists. If not exist, // create a new one (via GetAsJsonNode()). - get => field ??= - SettingsJsonNode.GetAsJsonNode("PlayerPrefs_FloatContainer"); + get => field ??= SettingsJsonNode.GetAsJsonNode("PlayerPrefs_FloatContainer"); set => field?.SetAsJsonNode("PlayerPrefs_FloatContainer", value); } @@ -617,22 +587,22 @@ public static GeneralData Load() public new static GeneralData LoadWithMagic(byte[] magic, SettingsGameVersionManager versionManager, JsonTypeInfo typeInfo) { - var returnVal = MagicNodeBaseValues.LoadWithMagic(magic, versionManager, typeInfo); + GeneralData returnVal = MagicNodeBaseValues.LoadWithMagic(magic, versionManager, typeInfo); #if DEBUG - const bool isPrintDebug = true; - if (isPrintDebug) - { - Logger.LogWriteLine($"Zenless GeneralData parsed value:\r\n\t" + - $"FPS : {returnVal.Fps}\r\n\t" + - $"VSync : {returnVal.VSync}\r\n\t" + - $"RenRes: {returnVal.RenderResolution}\r\n\t" + - $"AA : {returnVal.AntiAliasing}\r\n\t" + - $"Shadow: {returnVal.ShadowQuality}\r\n\t" + - $"CharQ : {returnVal.CharacterQuality}\r\n\t" + - $"RelfQ : {returnVal.ReflectionQuality}\r\n\t", - LogType.Debug, true); - } + const bool isPrintDebug = true; + if (isPrintDebug) + { + Logger.LogWriteLine($"Zenless GeneralData parsed value:\r\n\t" + + $"FPS : {returnVal.Fps}\r\n\t" + + $"VSync : {returnVal.VSync}\r\n\t" + + $"RenRes: {returnVal.RenderResolution}\r\n\t" + + $"AA : {returnVal.AntiAliasing}\r\n\t" + + $"Shadow: {returnVal.ShadowQuality}\r\n\t" + + $"CharQ : {returnVal.CharacterQuality}\r\n\t" + + $"RelfQ : {returnVal.ReflectionQuality}\r\n\t", + LogType.Debug, true); + } #endif return returnVal; diff --git a/CollapseLauncher/Classes/GameManagement/GameSettings/Zenless/JsonProperties/Properties.cs b/CollapseLauncher/Classes/GameManagement/GameSettings/Zenless/JsonProperties/Properties.cs index 1c9ff0bf2f..79379a3d0f 100644 --- a/CollapseLauncher/Classes/GameManagement/GameSettings/Zenless/JsonProperties/Properties.cs +++ b/CollapseLauncher/Classes/GameManagement/GameSettings/Zenless/JsonProperties/Properties.cs @@ -28,7 +28,7 @@ public readonly struct SystemSettingLocalData public void SetDataEnum(TDataEnum value, JsonEnumStoreType enumStoreType = JsonEnumStoreType.AsNumber) where TDataEnum : struct, Enum => _node.SetNodeValueEnum("Data", value, enumStoreType); - public SystemSettingLocalData([NotNull] JsonNode node, TData defaultData = default, int defaultVersion = 1) + public SystemSettingLocalData([NotNull] JsonNode node, TData defaultData = default, int defaultVersion = 0) { ArgumentNullException.ThrowIfNull(node); _node = node; @@ -44,7 +44,7 @@ public SystemSettingLocalData([NotNull] JsonNode node, TData defaultData = defau public static class SystemSettingLocalDataExt { public static SystemSettingLocalData AsSystemSettingLocalData( - [NotNull] this JsonNode? node, string keyName, TData defaultData = default, int defaultVersion = 1) + [NotNull] this JsonNode? node, string keyName, TData defaultData = default, int defaultVersion = 0) where TData : struct { ArgumentNullException.ThrowIfNull(node); diff --git a/CollapseLauncher/Classes/GameManagement/GameSettings/Zenless/Settings.cs b/CollapseLauncher/Classes/GameManagement/GameSettings/Zenless/Settings.cs index 9718db6c2e..edebfc692c 100644 --- a/CollapseLauncher/Classes/GameManagement/GameSettings/Zenless/Settings.cs +++ b/CollapseLauncher/Classes/GameManagement/GameSettings/Zenless/Settings.cs @@ -39,7 +39,12 @@ private byte[] MagicReDo #endregion #region Properties - public GeneralData GeneralData { get; private set; } + + public GeneralData GeneralData + { + get; + private set; + } #endregion public ZenlessSettings(IGameVersion gameVersionManager) : base(gameVersionManager) @@ -57,7 +62,6 @@ public sealed override void InitializeSettings() base.InitializeSettings(); SettingsScreen = ScreenManager.Load(this); - GeneralData?.Dispose(); GeneralData = GeneralData.LoadWithMagic( MagicReDo, SettingsGameVersionManager.Create(GameVersionManager, ZZZSettingsConfigFile, "GENERAL_DATA.bin"), diff --git a/CollapseLauncher/XAMLs/MainApp/Pages/GameSettingsPages/ZenlessGameSettingsContext.cs b/CollapseLauncher/XAMLs/MainApp/Pages/GameSettingsPages/ZenlessGameSettingsContext.cs index 5f65404940..970cb16b6e 100644 --- a/CollapseLauncher/XAMLs/MainApp/Pages/GameSettingsPages/ZenlessGameSettingsContext.cs +++ b/CollapseLauncher/XAMLs/MainApp/Pages/GameSettingsPages/ZenlessGameSettingsContext.cs @@ -7,11 +7,16 @@ namespace CollapseLauncher.Pages; [GeneratedBindableCustomProperty] -internal partial class ZenlessGameSettingsContext(ZenlessSettings settings) : NotifyPropertyChanged +internal partial class ZenlessGameSettingsContext : NotifyPropertyChanged { - public ZenlessSettings Settings { get; init; } = settings; + internal ZenlessGameSettingsContext(ZenlessSettings settings) + { + Settings = settings; + } + + public ZenlessSettings Settings { get; } - public GeneralData GenericData { get; init; } = settings.GeneralData; + public GeneralData GenericData => Settings.GeneralData; public LocalUiLayoutPlatform LocalUILayoutPlatform { From 7bed549f0427f2b439cef3049da58de8d368da8d Mon Sep 17 00:00:00 2001 From: Kemal Setya Adhi Date: Fri, 11 Sep 2026 04:59:51 +0700 Subject: [PATCH 22/50] [ZZZ GSP] Fix Sleepy header assertion and write The function should now correctly serialize/deserialize the BinaryFormatter Header as previous implementation was based on a guess and trial&error work. Also efficiently Read/Write with more reduced memory footprint (even though, I know. It's not really a big deal) --- .../GameSettings/Zenless/Sleepy.cs | 401 +++++++++--------- 1 file changed, 193 insertions(+), 208 deletions(-) diff --git a/CollapseLauncher/Classes/GameManagement/GameSettings/Zenless/Sleepy.cs b/CollapseLauncher/Classes/GameManagement/GameSettings/Zenless/Sleepy.cs index 87c4309354..32031197cd 100644 --- a/CollapseLauncher/Classes/GameManagement/GameSettings/Zenless/Sleepy.cs +++ b/CollapseLauncher/Classes/GameManagement/GameSettings/Zenless/Sleepy.cs @@ -1,6 +1,3 @@ -// ReSharper disable CommentTypo -// ReSharper disable UnusedMember.Local -// ReSharper disable UnusedVariable /* * Initial Implementation Credit by: @Shatyuka */ @@ -10,16 +7,19 @@ using System.Buffers; using System.IO; using System.Runtime.CompilerServices; +using System.Runtime.InteropServices; using System.Text; +// ReSharper disable RedundantUnsafeContext +// ReSharper disable UnusedMember.Local // ReSharper disable IdentifierTypo namespace CollapseLauncher.GameSettings.Zenless; #nullable enable -internal static class Sleepy +internal static unsafe class Sleepy { // https://github.com/dotnet/runtime/blob/a7efcd9ca9255dc9faa8b4a2761cdfdb62619610/src/libraries/System.Runtime.Serialization.Formatters/src/System/Runtime/Serialization/Formatters/Binary/BinaryEnums.cs#L7C1-L32C6 - private enum BinaryHeaderEnum + private enum BinaryHeaderEnum : byte { SerializedStreamHeader = 0, Object = 1, @@ -44,47 +44,12 @@ private enum BinaryHeaderEnum CrossAppDomainAssembly = 20, MethodCall = 21, MethodReturn = 22, - BinaryReference = -1 - } - - // https://github.com/dotnet/runtime/blob/a7efcd9ca9255dc9faa8b4a2761cdfdb62619610/src/libraries/System.Runtime.Serialization.Formatters/src/System/Runtime/Serialization/Formatters/Binary/BinaryEnums.cs#L35 - private enum BinaryTypeEnum - { - Primitive = 0, - String = 1, - Object = 2, - ObjectUrt = 3, - ObjectUser = 4, - ObjectArray = 5, - StringArray = 6, - PrimitiveArray = 7 - } - - // https://github.com/dotnet/runtime/blob/a7efcd9ca9255dc9faa8b4a2761cdfdb62619610/src/libraries/System.Runtime.Serialization.Formatters/src/System/Runtime/Serialization/Formatters/Binary/BinaryEnums.cs#L47 - private enum BinaryArrayTypeEnum - { - Single = 0, - Jagged = 1, - Rectangular = 2, - SingleOffset = 3, - JaggedOffset = 4, - RectangularOffset = 5 - } - - // https://github.com/dotnet/runtime/blob/a7efcd9ca9255dc9faa8b4a2761cdfdb62619610/src/libraries/System.Runtime.Serialization.Formatters/src/System/Runtime/Serialization/Formatters/Binary/BinaryEnums.cs#L99 - private enum InternalArrayTypeE - { - Empty = 0, - Single = 1, - Jagged = 2, - Rectangular = 3, - Base64 = 4 } internal static string ReadString(string filePath, ReadOnlySpan magic) { // Get the FileInfo - FileInfo fileInfo = new FileInfo(filePath).EnsureNoReadOnly(out bool isExist); + FileInfo fileInfo = new FileInfo(filePath).StripAlternateDataStream().EnsureNoReadOnly(out bool isExist); if (!isExist) throw new FileNotFoundException("[Sleepy::ReadString] File does not exist!"); @@ -93,76 +58,52 @@ internal static string ReadString(string filePath, ReadOnlySpan magic) return ReadString(stream, magic); } - internal static unsafe string ReadString(Stream stream, ReadOnlySpan magic) + internal static string ReadString(Stream stream, ReadOnlySpan magic) { // Stream assertion if (!stream.CanRead) throw new ArgumentException("[Sleepy::ReadString] Stream must be readable!", nameof(stream)); // Assign the reader - using BinaryReader reader = new BinaryReader(stream, Encoding.UTF8, true); + using BinaryReader reader = new(stream, Encoding.UTF8, true); - // Emulate and Assert the BinaryFormatter header info - reader.EmulateSleepyBinaryFormatterHeaderAssertion(); + // Emulate and Assert the BinaryFormatter header + reader.EmulateReadAssert(); // Get the data length - int length = reader.GetBinaryFormatterDataLength(); - int magicLength = magic.Length; + int length = reader.Read7BitEncodedInt(); // Alloc temporary buffers - char[] bufferChars = ArrayPool.Shared.Rent(length); + Span evil = stackalloc bool[magic.Length]; + byte[] evilBuffer = ArrayPool.Shared.Rent(length); + char[] unevilBuffer = ArrayPool.Shared.Rent(length); - // Do the do - CreateEvil(magic, out bool[] evil, out int evilsCount); - fixed (bool* evp = &evil[0]) - fixed (char* bp = &bufferChars[0]) - { - try - { - // Do the do (pt. 2) - int j = InternalDecode(magic, evp, reader, length, magicLength, bp); - - // Emulate and Assert the BinaryFormatter footer - reader.EmulateSleepyBinaryFormatterFooterAssertion(); - - // Return - return new string(bp, 0, j); - } - finally - { - // Return and clear the buffer, to only returns the return string. - ArrayPool.Shared.Return(bufferChars, true); - } - } - } + // Read evil data to evil buffer >:) + reader.BaseStream.ReadExactly(evilBuffer, 0, length); - private static unsafe int InternalDecode(ReadOnlySpan magic, bool* evil, BinaryReader reader, int length, int magicLength, char* bp) - { - bool eepy = false; + try + { + // Do the do + CreateEvil(magic, evil); - int j = 0; - int i = 0; + // Do the do (pt. 2) + int j = InternalRead(magic, + evil, + evilBuffer.AsSpan(0, length), + unevilBuffer.AsSpan(0, length)); - amimir: - var n = i % magicLength; - byte c = reader.ReadByte(); - byte ch = (byte)(c ^ magic[n]); + // Emulate and Assert the BinaryFormatter footer + reader.EmulateReadAssertMessageEnd(); - if (*(evil + n)) - { - eepy = ch != 0; + // Return + return new string(unevilBuffer, 0, j); } - else + finally { - if (eepy) - { - ch += 0x40; - eepy = false; - } - *(bp + j++) = (char)ch; + // Return and clear the buffer, to only returns the return string. + evil.Clear(); + ArrayPool.Shared.Return(evilBuffer, true); + ArrayPool.Shared.Return(unevilBuffer, true); } - - if (++i < length) goto amimir; - return j; } internal static void WriteString(string filePath, ReadOnlySpan content, ReadOnlySpan magic) @@ -182,7 +123,7 @@ internal static void WriteString(string filePath, ReadOnlySpan content, Re WriteString(stream, content, magic); } - internal static unsafe void WriteString(Stream stream, ReadOnlySpan content, ReadOnlySpan magic) + internal static void WriteString(Stream stream, ReadOnlySpan content, ReadOnlySpan magic) { // Stream assertion if (!stream.CanWrite) throw new ArgumentException("[Sleepy::WriteString] Stream must be writable!", nameof(stream)); @@ -191,178 +132,222 @@ internal static unsafe void WriteString(Stream stream, ReadOnlySpan conten if (magic.Length == 0) throw new ArgumentException("[Sleepy::WriteString] Magic cannot be empty!", nameof(magic)); // Assign the writer - using BinaryWriter writer = new BinaryWriter(stream, Encoding.UTF8, true); + using BinaryWriter writer = new(stream, Encoding.UTF8, true); // Emulate to write the BinaryFormatter header - writer.EmulateSleepyBinaryFormatterHeaderWrite(); + writer.EmulateWrite(); // Do the do - int contentLen = content.Length; - int bufferLen = contentLen * 2; + int contentLen = content.Length; + int bufferLen = Encoding.UTF8.GetMaxByteCount(contentLen); // Alloc temporary buffers - byte[] contentBytes = ArrayPool.Shared.Rent(bufferLen); - byte[] encodedBytes = ArrayPool.Shared.Rent(bufferLen); + Span evil = stackalloc bool[magic.Length]; + byte[] evilBuffer = ArrayPool.Shared.Rent(bufferLen); + byte[] unevilBuffer = ArrayPool.Shared.Rent(bufferLen); - // Do the do - CreateEvil(magic, out bool[] evil, out int evilsCount); - - fixed (char* cp = &content[0]) - fixed (byte* bp = &contentBytes[0]) - fixed (byte* ep = &encodedBytes[0]) - fixed (bool* evp = &evil[0]) - { - try - { - // Get the string bytes - _ = Encoding.UTF8.GetBytes(cp, contentLen, bp, bufferLen); - - // Do the do (pt. 2) - int h = InternalWrite(magic, contentLen, bp, ep, evp); - - writer.Write7BitEncodedInt(h); - writer.BaseStream.Write(encodedBytes, 0, h); - writer.EmulateSleepyBinaryFormatterFooterWrite(); - } - finally - { - // Return and clear the buffer. - ArrayPool.Shared.Return(contentBytes, true); - ArrayPool.Shared.Return(encodedBytes, true); - } - } + try + { + // Encode content to unevil UTF-8 buffer + int unevilBufferLen = Encoding.UTF8.GetBytes(content, unevilBuffer); + + // Do the do + CreateEvil(magic, evil); + + // Do the do (pt. 2) + int h = InternalWrite(magic, + unevilBuffer.AsSpan(0, unevilBufferLen), + evil, + evilBuffer); + + writer.Write7BitEncodedInt(h); + writer.BaseStream.Write(evilBuffer, 0, h); + writer.EmulateWriteMessageEnd(); + } + finally + { + // Return and clear the buffer. + evil.Clear(); + ArrayPool.Shared.Return(evilBuffer, true); + ArrayPool.Shared.Return(unevilBuffer, true); + } + } + + private static int InternalRead( + ReadOnlySpan magic, + scoped ReadOnlySpan evil, + ReadOnlySpan evilBuffer, + Span unevilBuffer) + { + bool eepy = false; + + int j = 0; + int i = 0; + + amimir: + int n = i % magic.Length; + byte c = evilBuffer[i]; + byte ch = (byte)(c ^ magic[n]); + + if (evil[n]) + { + eepy = ch != 0; + } + else + { + if (eepy) + { + ch += 0x40; + eepy = false; + } + unevilBuffer[j++] = (char)ch; + } + + if (++i < evilBuffer.Length) goto amimir; + return j; } - private static unsafe int InternalWrite(ReadOnlySpan magic, int contentLen, byte* bp, byte* ep, bool* evil) + private static int InternalWrite( + ReadOnlySpan magic, + ReadOnlySpan unevilBuffer, + scoped ReadOnlySpan evil, + Span evilBuffer) { int h = 0; int i = 0; int j = 0; - amimir: + amimir: int n = i % magic.Length; - byte ch = *(bp + j); - if (*(evil + n)) + byte ch = unevilBuffer[j]; + if (evil[n]) { byte eepy = 0; - if (*(bp + j) >= 0x40) + if (unevilBuffer[j] >= 0x40) { ch -= 0x40; eepy = 1; } - *(ep + h++) = (byte)(eepy ^ magic[n]); + evilBuffer[h++] = (byte)(eepy ^ magic[n]); n = ++i % magic.Length; } - *(ep + h++) = (byte)(ch ^ magic[n]); + evilBuffer[h++] = (byte)(ch ^ magic[n]); ++i; ++j; - if (j < contentLen) goto amimir; + if (j < unevilBuffer.Length) goto amimir; return h; } - private static void CreateEvil(ReadOnlySpan magic, out bool[] evilist, out int evilsCount) + private static void CreateEvil(ReadOnlySpan magic, scoped Span evilist) { int magicLength = magic.Length; int i = 0; - evilist = new bool[magicLength]; - evilsCount = 0; - evilist: + + evilist: int n = i % magicLength; evilist[i] = (magic[n] & 0xC0) == 0xC0; - if (evilist[i]) ++evilsCount; + if (++i < magicLength) goto evilist; } - private static void EmulateSleepyBinaryFormatterHeaderAssertion(this BinaryReader reader) + extension(BinaryReader reader) { - // Do assert [class] -> [string object] - // START! - // Check if the first byte is SerializedStreamHeader - reader.LogAssertInfoByteEnum(BinaryHeaderEnum.SerializedStreamHeader); + private void EmulateReadAssert() + { + // Check if the record type is a SerializedStreamHeader + reader.ReadAssertEnum(BinaryHeaderEnum.SerializedStreamHeader); - // Check if the type is an Object - reader.LogAssertInfoInt32Enum(BinaryHeaderEnum.Object); + // Check if Root object ID == 1 + reader.ReadAssert(1); - // Check if the type is a BinaryReference - reader.LogAssertInfoInt32Enum(BinaryHeaderEnum.BinaryReference); + // Check if No header object is required + reader.ReadAssert(-1); - // Check if the BinaryReference type is a String - reader.LogAssertInfoInt32Enum(BinaryTypeEnum.String); + // Check if the major version is 1 + reader.ReadAssert(1); - // Check for the binary array type and check if it's Single - reader.LogAssertInfoInt32Enum(BinaryArrayTypeEnum.Single); + // Check if the minor version is 0 + reader.ReadAssert(0); - // Check for the binary type and check if it's StringArray (UTF-8) - reader.LogAssertInfoByteEnum(BinaryTypeEnum.StringArray); + // Check if the record type is an ObjectString + reader.ReadAssertEnum(BinaryHeaderEnum.ObjectString); - // Check for the internal array type and check if it's Single - reader.LogAssertInfoInt32Enum(InternalArrayTypeE.Single); - } + // Check if Root object ID == 1 + reader.ReadAssert(1); + } - // Do assert [class] -> [EOF mark] - // START! - private static void EmulateSleepyBinaryFormatterFooterAssertion(this BinaryReader reader) => - reader.LogAssertInfoByteEnum(BinaryHeaderEnum.MessageEnd); + private void EmulateReadAssertMessageEnd() => + reader.ReadAssertEnum(BinaryHeaderEnum.MessageEnd); - private static void EmulateSleepyBinaryFormatterHeaderWrite(this BinaryWriter writer) - { - // Emulate to write Sleepy BinaryFormatter header information - writer.WriteEnumAsByte(BinaryHeaderEnum.SerializedStreamHeader); - writer.WriteEnumAsInt32(BinaryHeaderEnum.Object); - writer.WriteEnumAsInt32(BinaryHeaderEnum.BinaryReference); - writer.WriteEnumAsInt32(BinaryTypeEnum.String); - writer.WriteEnumAsInt32(BinaryArrayTypeEnum.Single); - writer.WriteEnumAsByte(BinaryTypeEnum.StringArray); - writer.WriteEnumAsInt32(InternalArrayTypeE.Single); - } + [SkipLocalsInit] + private void ReadAssertEnum(T assertWith) + where T : unmanaged, Enum + { + Span buffer = stackalloc byte[sizeof(T)]; + _ = reader.BaseStream.Read(buffer); - // Emulate to write Sleepy BinaryFormatter footer EOF - private static void EmulateSleepyBinaryFormatterFooterWrite(this BinaryWriter writer) => - writer.WriteEnumAsByte(BinaryHeaderEnum.MessageEnd); + ref T thisEnum = ref MemoryMarshal.AsRef(buffer); + if (IsEqual(ref thisEnum, ref assertWith)) + return; - private static void WriteEnumAsByte(this BinaryWriter writer, T headerEnum) - where T : struct, Enum - { - int enumValue = Unsafe.As(ref headerEnum); - writer.Write((byte)enumValue); - } + string? assertHeaderEnumValueName = Enum.GetName(assertWith); + string? comparedHeaderEnumValueName = Enum.GetName(thisEnum); - private static void WriteEnumAsInt32(this BinaryWriter writer, T headerEnum) - where T : struct, Enum - { - int enumValue = Unsafe.As(ref headerEnum); - writer.Write(enumValue); - } + throw new InvalidDataException($"[Sleepy::LogAssertInfo] BinaryFormatter header is not valid at stream pos: {reader.BaseStream.Position - sizeof(T):x8}. Expecting object enum: {assertHeaderEnumValueName} but getting: {comparedHeaderEnumValueName} instead!"); + } - private static void LogAssertInfoByteEnum(this BinaryReader stream, T assertHeaderEnum) - where T : struct, Enum - { - int currentInt = stream.ReadByte(); - LogAssertInfo(stream, ref assertHeaderEnum, ref currentInt); - } + [SkipLocalsInit] + private void ReadAssert(T assertWith) + where T : unmanaged + { + Span buffer = stackalloc byte[sizeof(T)]; + _ = reader.BaseStream.Read(buffer); - private static void LogAssertInfoInt32Enum(this BinaryReader stream, T assertHeaderEnum) - where T : struct, Enum - { - int currentInt = stream.ReadInt32(); - LogAssertInfo(stream, ref assertHeaderEnum, ref currentInt); + ref T thisEnum = ref MemoryMarshal.AsRef(buffer); + if (IsEqual(ref thisEnum, ref assertWith)) + return; + + throw new InvalidDataException($"[Sleepy::LogAssertInfo] BinaryFormatter header is not valid at stream pos: {reader.BaseStream.Position:x8}. Expecting value: {assertWith} but getting: {thisEnum} instead!"); + } } - private static void LogAssertInfo(BinaryReader reader, ref T assertHeaderEnum, ref int currentInt) - where T : struct, Enum + + extension(BinaryWriter writer) { - int intAssertCasted = Unsafe.As(ref assertHeaderEnum); - if (intAssertCasted != currentInt) + private void EmulateWrite() { - string? assertHeaderEnumValueName = Enum.GetName(assertHeaderEnum); - T comparedEnumCasted = Unsafe.As(ref currentInt); - string? comparedHeaderEnumValueName = Enum.GetName(comparedEnumCasted); + // Emulate to write Sleepy BinaryFormatter header information + writer.Write(BinaryHeaderEnum.SerializedStreamHeader); + writer.Write(1); + writer.Write(-1); + writer.Write(1); + writer.Write(0); + writer.Write(BinaryHeaderEnum.ObjectString); + writer.Write(1); + } + + // Emulate to write Sleepy BinaryFormatter footer EOF + private void EmulateWriteMessageEnd() => + writer.Write(BinaryHeaderEnum.MessageEnd); - throw new InvalidDataException($"[Sleepy::LogAssertInfo] BinaryFormatter header is not valid at stream pos: {reader.BaseStream.Position:x8}. Expecting object enum: {assertHeaderEnumValueName} but getting: {comparedHeaderEnumValueName} instead!"); + private void Write(T value) + where T : unmanaged + { + ReadOnlySpan buffer = MemoryMarshal.AsBytes(new ReadOnlySpan(ref value)); + writer.BaseStream.Write(buffer); } } - private static int GetBinaryFormatterDataLength(this BinaryReader reader) => reader.Read7BitEncodedInt(); + private static bool IsEqual(ref T from, ref T to) + where T : unmanaged + => sizeof(T) switch + { + 1 => Unsafe.As(ref from) == Unsafe.As(ref to), + 2 => Unsafe.As(ref from) == Unsafe.As(ref to), + 4 => Unsafe.As(ref from) == Unsafe.As(ref to), + 8 => Unsafe.As(ref from) == Unsafe.As(ref to), + _ => MemoryMarshal.AsBytes(new Span(ref from)).SequenceEqual(MemoryMarshal.AsBytes(new Span(ref to))) + }; } \ No newline at end of file From 3a4ba92c50009c5178c4b935a61c093c2450e5ab Mon Sep 17 00:00:00 2001 From: Kemal Setya Adhi Date: Fri, 11 Sep 2026 05:10:05 +0700 Subject: [PATCH 23/50] [ZZZ GSP] Fix resolution selector This fix, however, isn't yet tested on any monitor other than 16:9 aspect ratio --- .../ZenlessGameSettingsPage.xaml.cs | 35 ++++++------------- 1 file changed, 10 insertions(+), 25 deletions(-) diff --git a/CollapseLauncher/XAMLs/MainApp/Pages/GameSettingsPages/ZenlessGameSettingsPage.xaml.cs b/CollapseLauncher/XAMLs/MainApp/Pages/GameSettingsPages/ZenlessGameSettingsPage.xaml.cs index 238d03b259..4beb9099a6 100644 --- a/CollapseLauncher/XAMLs/MainApp/Pages/GameSettingsPages/ZenlessGameSettingsPage.xaml.cs +++ b/CollapseLauncher/XAMLs/MainApp/Pages/GameSettingsPages/ZenlessGameSettingsPage.xaml.cs @@ -131,8 +131,6 @@ .. Directory.EnumerateFiles(sourceDirPath, "*", SearchOption.AllDirectories).Sel ]; } - private Size SizeProp { get; set; } - private void InitializeSettings(object sender, RoutedEventArgs e) { try @@ -141,15 +139,13 @@ private void InitializeSettings(object sender, RoutedEventArgs e) ImageBackgroundManager.Shared.ForegroundOpacity = 0d; ImageBackgroundManager.Shared.SmokeOpacity = 1d; - SizeProp = ScreenProp.CurrentResolution; - // Get the native resolution first Size nativeResSize = GetNativeDefaultResolution(); string nativeResString = string.Format(Locale.Current.Lang?._GameSettingsPage?.Graphics_ResPrefixFullscreen ?? "", nativeResSize.Width, nativeResSize.Height) + $" [{Locale.Current.Lang?._Misc?.Default}]"; // Then get the rest of the list List resFullscreen = GetResPairs_Fullscreen(nativeResSize); - List resWindowed = GetResPairs_Windowed(); + List resWindowed = GetResPairs_Windowed(nativeResSize); // Add the index of fullscreen and windowed resolution booleans ScreenResolutionIsFullscreenIdx.Add(true); @@ -159,10 +155,10 @@ private void InitializeSettings(object sender, RoutedEventArgs e) // Add native resolution string, other fullscreen resolutions, and windowed resolutions List resolutionList = [ - nativeResString + nativeResString, + .. resFullscreen, + .. resWindowed ]; - resolutionList.AddRange(resFullscreen); - resolutionList.AddRange(resWindowed); GameResolutionSelector.ItemsSource = resolutionList; _isAllowResolutionIndexChanged = true; // Unlock resolution change @@ -213,9 +209,9 @@ private static Size GetNativeDefaultResolution() currentAcceptedRes.MaxBy(x => (x.Width, x.Height)); } - private List GetResPairs_Fullscreen(Size defaultResolution) + private static List GetResPairs_Fullscreen(Size defaultResolution) { - double nativeAspRatio = (double)SizeProp.Width / SizeProp.Height; + double nativeAspRatio = (double)defaultResolution.Width / defaultResolution.Height; List acH = AcceptableHeight; int acceptedMaxHeight = ScreenProp.GetMaxHeight(); @@ -223,8 +219,7 @@ private List GetResPairs_Fullscreen(Size defaultResolution) //acH.RemoveAll(h => h > 1600); // Get the resolution pairs and initialize default resolution index - List resPairs = []; - int indexOfDefaultRes = -1; + List resPairs = []; // ReSharper disable once LoopCanBeConvertedToQuery // ReSharper disable once ForCanBeConvertedToForeach @@ -234,26 +229,16 @@ private List GetResPairs_Fullscreen(Size defaultResolution) int h = acH[i]; int w = (int)Math.Round(h * nativeAspRatio); - // If the resolution is the same as default, set the index - if (h == defaultResolution.Height && w == defaultResolution.Width) - indexOfDefaultRes = i; - // Add the resolution pair to the list resPairs.Add(string.Format(Locale.Current.Lang?._GameSettingsPage?.Graphics_ResPrefixFullscreen ?? "", w, h)); } - // If the index of default resolution is found, remove it from the list - if (indexOfDefaultRes != -1) - { - resPairs.RemoveAt(indexOfDefaultRes); - } - return resPairs; } - private List GetResPairs_Windowed() + private static List GetResPairs_Windowed(Size defaultResolution) { - double nativeAspRatio = (double)SizeProp.Width / SizeProp.Height; + double nativeAspRatio = (double)defaultResolution.Width / defaultResolution.Height; const double wideRatio = (double)16 / 9; const double ulWideRatio = (double)21 / 9; List acH = AcceptableHeight; @@ -265,7 +250,7 @@ private List GetResPairs_Windowed() // If res is 21:9 then add proper native to the list if (Math.Abs(nativeAspRatio - ulWideRatio) < 0.01) - resPairs.Add($"{SizeProp.Width}x{SizeProp.Height}"); + resPairs.Add($"{defaultResolution.Width}x{defaultResolution.Height}"); // ReSharper disable once LoopCanBeConvertedToQuery // ReSharper disable once ForCanBeConvertedToForeach From c1d2f4958e04777f8454d3af36d528788c7249d1 Mon Sep 17 00:00:00 2001 From: Kemal Setya Adhi Date: Fri, 11 Sep 2026 05:26:33 +0700 Subject: [PATCH 24/50] [Homepage] Fix Save Background Context Menu not working --- CollapseLauncher/XAMLs/MainApp/Pages/HomePage.xaml | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/CollapseLauncher/XAMLs/MainApp/Pages/HomePage.xaml b/CollapseLauncher/XAMLs/MainApp/Pages/HomePage.xaml index a15274498c..8d96657ad8 100644 --- a/CollapseLauncher/XAMLs/MainApp/Pages/HomePage.xaml +++ b/CollapseLauncher/XAMLs/MainApp/Pages/HomePage.xaml @@ -46,11 +46,15 @@ + true + false @@ -58,6 +62,8 @@ From 452c6dfb46189071e1ddc105e71131339b25beda Mon Sep 17 00:00:00 2001 From: Kemal Setya Adhi Date: Sun, 13 Sep 2026 00:19:58 +0700 Subject: [PATCH 25/50] [ZZZ GSP] Simplify Sleepy assertion --- .../GameSettings/Zenless/Sleepy.cs | 44 +++++++------------ 1 file changed, 15 insertions(+), 29 deletions(-) diff --git a/CollapseLauncher/Classes/GameManagement/GameSettings/Zenless/Sleepy.cs b/CollapseLauncher/Classes/GameManagement/GameSettings/Zenless/Sleepy.cs index 32031197cd..07e050c15c 100644 --- a/CollapseLauncher/Classes/GameManagement/GameSettings/Zenless/Sleepy.cs +++ b/CollapseLauncher/Classes/GameManagement/GameSettings/Zenless/Sleepy.cs @@ -58,6 +58,7 @@ internal static string ReadString(string filePath, ReadOnlySpan magic) return ReadString(stream, magic); } + [SkipLocalsInit] internal static string ReadString(Stream stream, ReadOnlySpan magic) { // Stream assertion @@ -123,6 +124,7 @@ internal static void WriteString(string filePath, ReadOnlySpan content, Re WriteString(stream, content, magic); } + [SkipLocalsInit] internal static void WriteString(Stream stream, ReadOnlySpan content, ReadOnlySpan magic) { // Stream assertion @@ -156,9 +158,9 @@ internal static void WriteString(Stream stream, ReadOnlySpan content, Read // Do the do (pt. 2) int h = InternalWrite(magic, - unevilBuffer.AsSpan(0, unevilBufferLen), evil, - evilBuffer); + evilBuffer, + unevilBuffer.AsSpan(0, unevilBufferLen)); writer.Write7BitEncodedInt(h); writer.BaseStream.Write(evilBuffer, 0, h); @@ -209,9 +211,9 @@ private static int InternalRead( private static int InternalWrite( ReadOnlySpan magic, - ReadOnlySpan unevilBuffer, scoped ReadOnlySpan evil, - Span evilBuffer) + Span evilBuffer, + ReadOnlySpan unevilBuffer) { int h = 0; int i = 0; @@ -257,7 +259,7 @@ private static void CreateEvil(ReadOnlySpan magic, scoped Span evili private void EmulateReadAssert() { // Check if the record type is a SerializedStreamHeader - reader.ReadAssertEnum(BinaryHeaderEnum.SerializedStreamHeader); + reader.ReadAssert(BinaryHeaderEnum.SerializedStreamHeader); // Check if Root object ID == 1 reader.ReadAssert(1); @@ -272,31 +274,14 @@ private void EmulateReadAssert() reader.ReadAssert(0); // Check if the record type is an ObjectString - reader.ReadAssertEnum(BinaryHeaderEnum.ObjectString); + reader.ReadAssert(BinaryHeaderEnum.ObjectString); // Check if Root object ID == 1 reader.ReadAssert(1); } private void EmulateReadAssertMessageEnd() => - reader.ReadAssertEnum(BinaryHeaderEnum.MessageEnd); - - [SkipLocalsInit] - private void ReadAssertEnum(T assertWith) - where T : unmanaged, Enum - { - Span buffer = stackalloc byte[sizeof(T)]; - _ = reader.BaseStream.Read(buffer); - - ref T thisEnum = ref MemoryMarshal.AsRef(buffer); - if (IsEqual(ref thisEnum, ref assertWith)) - return; - - string? assertHeaderEnumValueName = Enum.GetName(assertWith); - string? comparedHeaderEnumValueName = Enum.GetName(thisEnum); - - throw new InvalidDataException($"[Sleepy::LogAssertInfo] BinaryFormatter header is not valid at stream pos: {reader.BaseStream.Position - sizeof(T):x8}. Expecting object enum: {assertHeaderEnumValueName} but getting: {comparedHeaderEnumValueName} instead!"); - } + reader.ReadAssert(BinaryHeaderEnum.MessageEnd); [SkipLocalsInit] private void ReadAssert(T assertWith) @@ -344,10 +329,11 @@ private static bool IsEqual(ref T from, ref T to) where T : unmanaged => sizeof(T) switch { - 1 => Unsafe.As(ref from) == Unsafe.As(ref to), - 2 => Unsafe.As(ref from) == Unsafe.As(ref to), - 4 => Unsafe.As(ref from) == Unsafe.As(ref to), - 8 => Unsafe.As(ref from) == Unsafe.As(ref to), - _ => MemoryMarshal.AsBytes(new Span(ref from)).SequenceEqual(MemoryMarshal.AsBytes(new Span(ref to))) + 1 => Unsafe.As(ref from) == Unsafe.As(ref to), + 2 => Unsafe.As(ref from) == Unsafe.As(ref to), + 4 => Unsafe.As(ref from) == Unsafe.As(ref to), + 8 => Unsafe.As(ref from) == Unsafe.As(ref to), + 16 => Unsafe.As(ref from) == Unsafe.As(ref to), + _ => MemoryMarshal.AsBytes(new Span(ref from)).SequenceEqual(MemoryMarshal.AsBytes(new Span(ref to))) }; } \ No newline at end of file From f3ddf6acb3e00350c75df23d066edc5c906e964e Mon Sep 17 00:00:00 2001 From: Kemal Setya Adhi Date: Sun, 13 Sep 2026 00:20:31 +0700 Subject: [PATCH 26/50] Fix Zip-based installation doesn't work on chunk-based files --- .../HypLauncherGameResourcePackageApi.cs | 5 +- .../Classes/Helper/PatternMatcher.cs | 7 +- .../Base/GameInstallPackage.cs | 244 +++++++----------- .../Base/InstallManagerBase.cs | 77 +++++- .../Classes/Interfaces/Class/ProgressBase.cs | 4 +- 5 files changed, 172 insertions(+), 165 deletions(-) diff --git a/CollapseLauncher/Classes/Helper/LauncherApiLoader/HoYoPlay/HypLauncherGameResourcePackageApi.cs b/CollapseLauncher/Classes/Helper/LauncherApiLoader/HoYoPlay/HypLauncherGameResourcePackageApi.cs index 049bba709a..c4f8c505de 100644 --- a/CollapseLauncher/Classes/Helper/LauncherApiLoader/HoYoPlay/HypLauncherGameResourcePackageApi.cs +++ b/CollapseLauncher/Classes/Helper/LauncherApiLoader/HoYoPlay/HypLauncherGameResourcePackageApi.cs @@ -69,10 +69,7 @@ public class HypPackageData public byte[]? PackageMD5Hash { get; init; } [JsonIgnore] - public string? PackageMD5HashString - { - get => field ??= HexTool.BytesToHexUnsafe(PackageMD5Hash); - } + public string? PackageMD5HashString => field ??= HexTool.BytesToHexUnsafe(PackageMD5Hash); [JsonPropertyName("size")] [JsonNumberHandling(JsonNumberHandling.AllowReadingFromString)] diff --git a/CollapseLauncher/Classes/Helper/PatternMatcher.cs b/CollapseLauncher/Classes/Helper/PatternMatcher.cs index cad29ade89..f653f6784e 100644 --- a/CollapseLauncher/Classes/Helper/PatternMatcher.cs +++ b/CollapseLauncher/Classes/Helper/PatternMatcher.cs @@ -8,7 +8,7 @@ namespace CollapseLauncher.Helper { - public static class PatternMatcher + public static partial class PatternMatcher { /// /// Determines whether the specified input string matches the given pattern. @@ -132,5 +132,10 @@ public static string MergeRegexPattern(params ReadOnlySpan regexPatterns return builder.ToString(); } + + [GeneratedRegex(@"\.[0-9][0-9][0-9]$", RegexOptions.NonBacktracking)] + public static partial Regex MatchChunkFilePath(); + + public static bool IsChunkedFilePath(this string filePath) => MatchChunkFilePath().IsMatch(filePath); } } \ No newline at end of file diff --git a/CollapseLauncher/Classes/InstallManagement/Base/GameInstallPackage.cs b/CollapseLauncher/Classes/InstallManagement/Base/GameInstallPackage.cs index 1e2447e69d..f016f73bb2 100644 --- a/CollapseLauncher/Classes/InstallManagement/Base/GameInstallPackage.cs +++ b/CollapseLauncher/Classes/InstallManagement/Base/GameInstallPackage.cs @@ -3,11 +3,11 @@ using Hi3Helper; using Hi3Helper.Data; using Hi3Helper.EncTool; -using Hi3Helper.Http.Legacy; using Hi3Helper.Plugin.Core.Management; using Hi3Helper.Preset; using Hi3Helper.SentryHelper; using System; +using System.Collections.Generic; using System.Diagnostics.CodeAnalysis; using System.IO; using System.Linq; @@ -20,21 +20,23 @@ namespace CollapseLauncher.InstallManager internal class GameInstallPackage : IAssetIndexSummary { #region Properties - public string URL { get; private set; } - public string DecompressedURL { get; set; } - public string Name { get; } - public string PathOutput { get; } - public GameInstallPackageType PackageType { get; init; } - public long Size { get; set; } - public long SizeRequired { get; } - public long SizeDownloaded { get; set; } - public GameVersion Version { get; set; } - public byte[] Hash { get; } - public string HashString { get => HexTool.BytesToHexUnsafe(Hash); } - public string LanguageID { get; init; } - public string RunCommand { get; } - public string PluginId { get; } - public bool IsUseLegacyDownloader { get; set; } + public string URL { get; private set; } + public string DecompressedURL { get; set; } + public string Name { get; init; } + public string PathOutput { get; init; } + public GameInstallPackageType PackageType { get; init; } + public long Size { get; set; } + public long SizeRequired { get; init; } + public long SizeDownloaded { get; set; } + public GameVersion Version { get; set; } + public byte[] Hash { get; init; } + public string HashString { get => field ??= HexTool.BytesToHexUnsafe(Hash); } + public string LanguageID { get; init; } + public string RunCommand { get; init; } + public string PluginId { get; init; } + public bool IsUseLegacyDownloader { get; set; } + public List ChunkList { get; set; } = []; + public object SourceObject { get; set; } #endregion public GameInstallPackage(HypChannelSdkData packageProperty, @@ -45,6 +47,8 @@ public GameInstallPackage(HypChannelSdkData packageProperty, ArgumentNullException.ThrowIfNull(packageProperty.SdkPackageDetail); ArgumentException.ThrowIfNullOrEmpty(pathOutput); + SourceObject = packageProperty; + PluginId = "sdk"; RunCommand = packageProperty.SdkPackageDetail.PackageRunCommand; Version = packageProperty.Version; @@ -77,6 +81,8 @@ public GameInstallPackage(HypPluginPackageInfo packageProperty, ArgumentNullException.ThrowIfNull(packageProperty.PluginPackage); ArgumentException.ThrowIfNullOrEmpty(pathOutput); + SourceObject = packageProperty; + PluginId = packageProperty.PluginId; RunCommand = packageProperty.PluginPackage.PackageRunCommand; Version = packageProperty.Version; @@ -106,6 +112,8 @@ public GameInstallPackage(HypPackageData packageProperty, string uncompressedUrl = null, GameVersion version = default) { + SourceObject = packageProperty; + if (packageProperty == null || pathOutput == null) throw new NullReferenceException(); if (packageProperty.FilePath != null) @@ -132,177 +140,123 @@ public GameInstallPackage(HypPackageData packageProperty, } } - public bool IsReadStreamExist(int count) - { - if (PathOutput == null) return false; - // Check if the single file exist or not - FileInfo fileInfo = new FileInfo(PathOutput); - if (fileInfo.Exists) - return true; + private GameInstallPackage() { } - // Check for the chunk files - return Enumerable.Range(0, count).All(chunkID => + public GameInstallPackage Clone() + { + return SourceObject switch { - // Get the hash number - long id = Http.GetHashNumber(count, chunkID); - // Append the hash number to the path - string pathLegacy = $"{PathOutput}.{id}"; - string path = PathOutput + $".{chunkID + 1:000}"; - // Get the file info - FileInfo fileInfoLegacy = new FileInfo(pathLegacy); - FileInfo fileInfoLocal = new FileInfo(path); - // Check if the file exist - return fileInfoLegacy.Exists || fileInfoLocal.Exists; - }); + HypChannelSdkData asSdkPackage => new GameInstallPackage(asSdkPackage, Path.GetDirectoryName(PathOutput), uncompressedUrl: DecompressedURL), + HypPluginPackageInfo asPluginPackage => new GameInstallPackage(asPluginPackage, Path.GetDirectoryName(PathOutput), uncompressedUrl: DecompressedURL), + HypPackageData asPackage => new GameInstallPackage(asPackage, Path.GetDirectoryName(PathOutput), uncompressedUrl: DecompressedURL), + _ => new GameInstallPackage + { + URL = URL, + DecompressedURL = DecompressedURL, + Name = Name, + PathOutput = Path.GetDirectoryName(PathOutput), + PackageType = PackageType, + Size = Size, + SizeRequired = SizeRequired, + SizeDownloaded = SizeDownloaded, + Version = Version, + Hash = Hash, + LanguageID = LanguageID, + RunCommand = RunCommand, + PluginId = PluginId, + IsUseLegacyDownloader = IsUseLegacyDownloader, + ChunkList = ChunkList, + SourceObject = SourceObject + } + }; } - public Stream GetReadStream(int count) + public bool IsReadStreamExist() { - // Get the file info of the single file - FileInfo fileInfo = new FileInfo(PathOutput!).ResolveSymlink().StripAlternateDataStream(); - // Check if the file exist and the length is equal to the size - if (fileInfo.Exists && fileInfo.Length == Size) + return ChunkList.Count == 0 + ? File.Exists(PathOutput) + : ChunkList.All(x => File.Exists(x.PathOutput)); + } + + public Stream GetReadStream() + { + if (ChunkList.Count == 0) { + FileInfo fileInfo = new FileInfo(PathOutput!) + .ResolveSymlink() + .StripAlternateDataStream() + .EnsureNoReadOnly(); // Return the stream for read return fileInfo.Open(new FileStreamOptions { - Access = FileAccess.Read, + Access = FileAccess.Read, BufferSize = 4 << 10, - Mode = FileMode.Open, - Options = FileOptions.None, - Share = FileShare.Read + Mode = FileMode.Open, + Options = FileOptions.None, + Share = FileShare.Read }); } - // If the single file doesn't exist, then try getting chunk stream - return GetCombinedStreamFromPackageAsset(count); - } - - public long GetStreamLength(int count) - { - // Get the file info of the single file - FileInfo fileInfo = new FileInfo(PathOutput!); - // Check if the file exist and the length is equal to the size - if (fileInfo.Exists && fileInfo.Length == Size) + var streams = new FileStream[ChunkList.Count]; + for (int i = 0; i < ChunkList.Count; i++) { - // Return the stream for read - return fileInfo.Length; - } + GameInstallPackage chunk = ChunkList[i]; + FileInfo fileInfo = new FileInfo(chunk.PathOutput) + .ResolveSymlink() + .StripAlternateDataStream() + .EnsureNoReadOnly(); - // If the single file doesn't exist, then try getting chunk stream - return GetCombinedLengthFromPackageAsset(count); - } - - private CombinedStream GetCombinedStreamFromPackageAsset(int count) - { - // Set the array - FileStream[] streamList = new FileStream[count]; - // Enumerate the ID - for (int i = 0; i < streamList.Length; i++) - { - // Get the hash ID - long id = Http.GetHashNumber(count, i); - // Append hash ID to the path - string path = PathOutput + $".{i + 1:000}"; - string pathLegacy = $"{PathOutput}.{id}"; - // Get the file info and check if the file exist - FileInfo fileInfo = new FileInfo(path); - FileInfo fileInfoLegacy = new FileInfo(pathLegacy); - if (!fileInfo.Exists && !fileInfoLegacy.Exists) + if (!fileInfo.Exists) { - // If not found, then throw - throw new FileNotFoundException($"File chunk doesn't exist in this path! -> {path}"); + throw new FileNotFoundException($"File: {chunk.PathOutput} is missing and cannot be merged!"); } - // Allocate to the array and open the stream - FileStreamOptions opt = new FileStreamOptions + streams[i] = fileInfo.Open(new FileStreamOptions { Access = FileAccess.Read, BufferSize = 4 << 10, Mode = FileMode.Open, Options = FileOptions.None, Share = FileShare.Read - }; - if (fileInfo.Exists) - streamList[i] = fileInfo.Open(opt); - else if (fileInfoLegacy.Exists) - streamList[i] = fileInfoLegacy.Open(opt); + }); } - // Assign the array and initiate it as a combined stream - return new CombinedStream(streamList); + return new CombinedStream(streams); } - private long GetCombinedLengthFromPackageAsset(int count) + public long GetStreamLength() { - // Initialize length - long length = 0; - // Enumerate the ID - for (int i = 0; i < count; i++) + if (ChunkList.Count != 0) { - // Get the hash ID - long id = Http.GetHashNumber(count, i); - // Append hash ID to the path - string path = PathOutput + $".{i + 1:000}"; - string pathLegacy = $"{PathOutput}.{id}"; - // Get the file info and check if the file exist - FileInfo fileInfo = new FileInfo(path); - FileInfo fileInfoLegacy = new FileInfo(pathLegacy); - switch (fileInfo.Exists) + return ChunkList.Sum(static x => { - case false when !fileInfoLegacy.Exists: - continue; - // Add length to the existing one - case true: - length += fileInfo.Length; - break; - default: - { - if (fileInfoLegacy.Exists) - length += fileInfoLegacy.Length; - break; - } - } - - // Then go back to the loop routine - // ReSharper disable once RedundantJumpStatement - continue; + FileInfo fileInfo = new(x.PathOutput); + return fileInfo.Exists ? fileInfo.Length : 0; + }); } - // Return the length - return length; + FileInfo fileInfo = new(PathOutput); + return fileInfo.Exists ? fileInfo.Length : 0; } - public void DeleteFile(int count) + public void DeleteFile() { string lastFile = PathOutput; try { - FileInfo fileInfo = new FileInfo(PathOutput!); - if (fileInfo.Exists && fileInfo.Length == Size) + if (ChunkList.Count == 0) { - fileInfo.Delete(); + FileInfo fileInfo = new(PathOutput); + fileInfo.TryDeleteFile(true); + return; } - for (int i = 0; i < count; i++) + foreach (GameInstallPackage chunk in ChunkList) { - long id = Http.GetHashNumber(count, i); - string path = PathOutput + $".{i + 1:000}"; - string pathLegacy = $"{PathOutput}.{id}"; - bool isUseLegacy = File.Exists(pathLegacy); - - lastFile = isUseLegacy ? pathLegacy : path; - fileInfo = new FileInfo(path); - FileInfo fileInfoLegacy = new FileInfo(pathLegacy); - if (fileInfo.Exists) - { - fileInfo.Delete(); - } - - if (fileInfoLegacy.Exists) - { - fileInfoLegacy.Delete(); - } + lastFile = chunk.PathOutput; + FileInfo fileInfo = new(chunk.PathOutput); + fileInfo.TryDeleteFile(true); + return; } } catch (Exception ex) @@ -313,7 +267,7 @@ public void DeleteFile(int count) } public string PrintSummary() => $"File [T: {PackageType}]: {URL}\t{ConverterTool.SummarizeSizeSimple(Size)} ({Size} bytes)"; - public long GetAssetSize() => Size; + public long GetAssetSize() => ChunkList.Count > 0 ? ChunkList.Sum(x => x.Size) : Size; public string GetRemoteURL() => URL; public void SetRemoteURL(string url) => URL = url; } diff --git a/CollapseLauncher/Classes/InstallManagement/Base/InstallManagerBase.cs b/CollapseLauncher/Classes/InstallManagement/Base/InstallManagerBase.cs index 560767b58b..671af8af42 100644 --- a/CollapseLauncher/Classes/InstallManagement/Base/InstallManagerBase.cs +++ b/CollapseLauncher/Classes/InstallManagement/Base/InstallManagerBase.cs @@ -678,7 +678,7 @@ private async ValueTask RunPackageVerificationRoutine(GameInstallPackage as ProgressPerFileSizeCurrent = 0; byte[] hashLocal; - await using (Stream fs = asset.GetReadStream(DownloadThreadCount)!) + await using (Stream fs = asset.GetReadStream()) { // Reset the per file size ProgressPerFileSizeTotal = fs.Length; @@ -742,6 +742,9 @@ protected virtual async Task StartPackageInstallationInner(List IsPreloadCompleted(CancellationToken token) await GetPackagesRemoteSize(AssetIndex, token); long totalPackageSize = AssetIndex.Sum(x => x.Size); - // Get the sum of the total size of the single or segmented packages - return AssetIndex.Sum(asset => asset.IsReadStreamExist(DownloadThreadCount) ? - // If yes, then return the size of the single stream - asset.GetStreamLength(DownloadThreadCount) : - // If neither of both exist, then return 0 - 0) == totalPackageSize; // Then compare if the total package size is equal - - // Note: - // x.GetReadStream() will check if the single package/zip exist. - // So checking the fully downloaded single package is unnecessary. + // Get the sum of the total size of the single or segmented packages. + // Then compare if the total package size is equal. + return AssetIndex.Sum(asset => asset.GetStreamLength()) == totalPackageSize; } public async ValueTask MoveGameLocation() @@ -2983,9 +2979,9 @@ private async ValueTask RunPackageDownloadRoutine(Http httpClient, // If the file exist or package size is unmatched, // then start downloading - long legacyExistingPackageFileSize = package.GetStreamLength(DownloadThreadCount); + long legacyExistingPackageFileSize = package.GetStreamLength(); long existingPackageFileSize = package.SizeDownloaded > legacyExistingPackageFileSize ? package.SizeDownloaded : legacyExistingPackageFileSize; - bool isExistingPackageFileExist = package.IsReadStreamExist(DownloadThreadCount); + bool isExistingPackageFileExist = package.IsReadStreamExist(); if (!isExistingPackageFileExist || existingPackageFileSize != package.Size) @@ -3288,6 +3284,61 @@ protected virtual GameInstallFileInfo GetGameInstallFileInfo() #endregion + #region Private Methods + + private static List MergeChunkedPackage(List packages) + { + List dedupList = []; + + // Clone the list first. + foreach (GameInstallPackage package in packages) + { + string filePath = package.PathOutput; + string fileExtension = Path.GetExtension(filePath); + + // Add the first chunk + if (fileExtension.StartsWith(".001")) + { + dedupList.Add(package.Clone()); + continue; + } + + // Ignore other chunks + if (fileExtension.IsChunkedFilePath()) + { + continue; + } + + // Add other non-chunk file + dedupList.Add(package.Clone()); + } + + // Start adding up the chunk files. + foreach (GameInstallPackage dedupPackage in dedupList) + { + string filePath = dedupPackage.PathOutput; + string filePathNoChunkExt = Path.Combine(Path.GetDirectoryName(filePath) ?? "", Path.GetFileNameWithoutExtension(filePath)); + string fileExtension = Path.GetExtension(filePath); + + if (!fileExtension.StartsWith(".001")) + { + continue; + } + + // Select the chunked file by order only + foreach (GameInstallPackage package in packages + .Where(x => x.PathOutput.StartsWith(filePathNoChunkExt)) + .OrderBy(x => x.PathOutput)) + { + dedupPackage.ChunkList.Add(package.Clone()); + } + } + + return dedupList; + } + + #endregion + #region Event Methods protected void UpdateProgressBase() diff --git a/CollapseLauncher/Classes/Interfaces/Class/ProgressBase.cs b/CollapseLauncher/Classes/Interfaces/Class/ProgressBase.cs index f344db39fc..9ca682bfa8 100644 --- a/CollapseLauncher/Classes/Interfaces/Class/ProgressBase.cs +++ b/CollapseLauncher/Classes/Interfaces/Class/ProgressBase.cs @@ -1443,12 +1443,12 @@ protected virtual long GetSingleOrSegmentedUncompressedSize(GameInstallPackage a protected virtual Stream GetSingleOrSegmentedDownloadStream(GameInstallPackage asset) { - return asset.GetReadStream(DownloadThreadCount); + return asset.GetReadStream(); } protected virtual void DeleteSingleOrSegmentedDownloadStream(GameInstallPackage asset) { - asset.DeleteFile(DownloadThreadCount); + asset.DeleteFile(); } From aca0cb5d2fc5555fda57842532dd5af44d75d00f Mon Sep 17 00:00:00 2001 From: Kemal Setya Adhi Date: Sun, 13 Sep 2026 00:21:06 +0700 Subject: [PATCH 27/50] Update Hi3Helper.SimpleZipArchiveReader --- Hi3Helper.SimpleZipArchiveReader | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Hi3Helper.SimpleZipArchiveReader b/Hi3Helper.SimpleZipArchiveReader index 2abcc93d83..345d805765 160000 --- a/Hi3Helper.SimpleZipArchiveReader +++ b/Hi3Helper.SimpleZipArchiveReader @@ -1 +1 @@ -Subproject commit 2abcc93d837fd53b8893988e3eab85b89356f74c +Subproject commit 345d8057652890010e7e3600849d91c0acddfcf0 From 7f562b60493184985d4e762ce556c29c725e8eeb Mon Sep 17 00:00:00 2001 From: Kemal Setya Adhi Date: Sun, 13 Sep 2026 18:26:15 +0700 Subject: [PATCH 28/50] Update lock --- Hi3Helper.CommunityToolkit/ImageCropper/packages.lock.json | 6 +++--- .../SettingsControls/packages.lock.json | 6 +++--- Hi3Helper.Http | 2 +- SevenZipExtractor | 2 +- 4 files changed, 8 insertions(+), 8 deletions(-) diff --git a/Hi3Helper.CommunityToolkit/ImageCropper/packages.lock.json b/Hi3Helper.CommunityToolkit/ImageCropper/packages.lock.json index b0e130e081..5a97df22ee 100644 --- a/Hi3Helper.CommunityToolkit/ImageCropper/packages.lock.json +++ b/Hi3Helper.CommunityToolkit/ImageCropper/packages.lock.json @@ -41,9 +41,9 @@ }, "Microsoft.NET.ILLink.Tasks": { "type": "Direct", - "requested": "[10.0.11, )", - "resolved": "10.0.11", - "contentHash": "IBf7lbovvjGWVWXZX5cJ/cO0WXbId0Zq4BuSeT94mGZuOAP66oMeH9PTBZ9Jpp3Jb6jtK0qm/NyUbPRo1gC/wQ==" + "requested": "[10.0.12, )", + "resolved": "10.0.12", + "contentHash": "xi+BDjFpW+Sb+MHFHaH6Y/gV9I8BluFwRXc1QyCdoZbIK26eNiBeFuMTe/FMwc33G1wdHCyDg7CVTmb8OdQrMQ==" }, "Microsoft.Windows.CsWinRT": { "type": "Direct", diff --git a/Hi3Helper.CommunityToolkit/SettingsControls/packages.lock.json b/Hi3Helper.CommunityToolkit/SettingsControls/packages.lock.json index 03109e68b1..04b55167f4 100644 --- a/Hi3Helper.CommunityToolkit/SettingsControls/packages.lock.json +++ b/Hi3Helper.CommunityToolkit/SettingsControls/packages.lock.json @@ -20,9 +20,9 @@ }, "Microsoft.NET.ILLink.Tasks": { "type": "Direct", - "requested": "[10.0.11, )", - "resolved": "10.0.11", - "contentHash": "IBf7lbovvjGWVWXZX5cJ/cO0WXbId0Zq4BuSeT94mGZuOAP66oMeH9PTBZ9Jpp3Jb6jtK0qm/NyUbPRo1gC/wQ==" + "requested": "[10.0.12, )", + "resolved": "10.0.12", + "contentHash": "xi+BDjFpW+Sb+MHFHaH6Y/gV9I8BluFwRXc1QyCdoZbIK26eNiBeFuMTe/FMwc33G1wdHCyDg7CVTmb8OdQrMQ==" }, "Microsoft.Windows.CsWinRT": { "type": "Direct", diff --git a/Hi3Helper.Http b/Hi3Helper.Http index 2d4c209d4d..887674ee63 160000 --- a/Hi3Helper.Http +++ b/Hi3Helper.Http @@ -1 +1 @@ -Subproject commit 2d4c209d4d462ee94a5e6a13e3c45abcfbca848e +Subproject commit 887674ee6365c4907d4b45061b26857726d9df74 diff --git a/SevenZipExtractor b/SevenZipExtractor index 5e0f4de32a..eb15009af2 160000 --- a/SevenZipExtractor +++ b/SevenZipExtractor @@ -1 +1 @@ -Subproject commit 5e0f4de32a5da3ae441fdd5c95d3951ef703480e +Subproject commit eb15009af23925d56453415d75e51e3192160a44 From 10956dd68149427b158262a2cbe021404fcaabb0 Mon Sep 17 00:00:00 2001 From: Kemal Setya Adhi Date: Mon, 14 Sep 2026 01:30:42 +0700 Subject: [PATCH 29/50] [TaskSched] Move to NativeAOT friendly marshalling --- .../Classes/Helper/TaskSchedulerHelper.cs | 381 ++++++------------ CollapseLauncher/CollapseLauncher.csproj | 6 +- CollapseLauncher/packages.lock.json | 6 + Hi3Helper.TaskScheduler/FodyWeavers.xml | 3 - Hi3Helper.TaskScheduler/FodyWeavers.xsd | 186 --------- .../Hi3Helper.TaskScheduler.csproj | 30 +- Hi3Helper.TaskScheduler/Program.cs | 231 ----------- .../PublishProfiles/AsRelease.pubxml | 15 - .../Properties/launchSettings.json | 8 - Hi3Helper.TaskScheduler/TaskSchedulerUtil.cs | 283 +++++++++++++ Hi3Helper.TaskScheduler/packages.lock.json | 99 +---- Hi3Helper.Win32 | 2 +- 12 files changed, 429 insertions(+), 821 deletions(-) delete mode 100644 Hi3Helper.TaskScheduler/FodyWeavers.xml delete mode 100644 Hi3Helper.TaskScheduler/FodyWeavers.xsd delete mode 100644 Hi3Helper.TaskScheduler/Program.cs delete mode 100644 Hi3Helper.TaskScheduler/Properties/PublishProfiles/AsRelease.pubxml delete mode 100644 Hi3Helper.TaskScheduler/Properties/launchSettings.json create mode 100644 Hi3Helper.TaskScheduler/TaskSchedulerUtil.cs diff --git a/CollapseLauncher/Classes/Helper/TaskSchedulerHelper.cs b/CollapseLauncher/Classes/Helper/TaskSchedulerHelper.cs index 188feb099b..5ccb57b83f 100644 --- a/CollapseLauncher/Classes/Helper/TaskSchedulerHelper.cs +++ b/CollapseLauncher/Classes/Helper/TaskSchedulerHelper.cs @@ -2,296 +2,149 @@ using Hi3Helper; using Hi3Helper.SentryHelper; using Hi3Helper.Shared.Region; +using Hi3Helper.TaskScheduler; using Hi3Helper.Win32.ShellLinkCOM; using System; using System.Diagnostics; using System.IO; -using System.Text; // ReSharper disable CommentTypo // ReSharper disable StringLiteralTypo // ReSharper disable GrammarMistakeInComment #nullable enable -namespace CollapseLauncher.Helper -{ - internal static class TaskSchedulerHelper - { - private const string CollapseStartupTaskName = "CollapseLauncherStartupTask"; - - private static bool _isInitialized; - private static bool _cachedIsOnTrayEnabled; - private static bool _cachedIsEnabled; - - internal static bool IsOnTrayEnabled() - { - if (!_isInitialized) - InvokeGetStatusCommand(); - - return _cachedIsOnTrayEnabled; - } - - internal static bool IsEnabled() - { - if (!_isInitialized) - InvokeGetStatusCommand(); - - return _cachedIsEnabled; - } - - private static void InvokeGetStatusCommand() - { - // Build the argument and mode to set - var argumentBuilder = new StringBuilder(); - argumentBuilder.Append("IsEnabled"); - - // Append task name and stub path - AppendTaskNameAndPathArgument(argumentBuilder); - - // Store argument builder as string - var argumentString = argumentBuilder.ToString(); - - // Invoke command and get return code - var returnCode = GetInvokeCommandReturnCode(argumentString); +namespace CollapseLauncher.Helper; - (_cachedIsEnabled, _cachedIsOnTrayEnabled) = returnCode switch - { - // -1 means task is disabled with tray enabled - -1 => (false, true), - // 0 means task is disabled with tray disabled - 0 => (false, false), - // 1 means task is enabled with tray disabled - 1 => (true, false), - // 2 means task is enabled with tray enabled - 2 => (true, true), - // Otherwise, return both disabled (due to failure) - _ => (false, false) - }; - - // Print init determination - CheckInitDetermination(returnCode); - } +internal static class TaskSchedulerHelper +{ + private static readonly string StubLocation = VelopackLocatorExtension.FindCollapseStubPath(); + private const string CollapseStartupTaskName = "CollapseLauncherStartupTask"; - private static void CheckInitDetermination(int returnCode) - { - // If the return code is within range, then set as initialized - if (returnCode is > -2 and < 3) - { - // Set as initialized - _isInitialized = true; - } - // Otherwise, log the return code - else - { - string reason = returnCode switch - { - int.MaxValue => "ARGUMENT_INVALID", - int.MinValue => "UNHANDLED_ERROR", - short.MaxValue => "INTERNALINVOKE_ERROR", - short.MinValue => "APPLET_NOTFOUND", - _ => $"UNKNOWN_{returnCode}" - }; - Logger.LogWriteLine($"Error while getting task status from applet with reason: {reason}", LogType.Error, true); - } - } + private static bool _cachedIsOnTrayEnabled; + private static bool _cachedIsEnabled; - internal static void ToggleTrayEnabled(bool isEnabled) - { - _cachedIsOnTrayEnabled = isEnabled; - InvokeToggleCommand(); - } + internal static bool IsOnTrayEnabled() + { + IsEnabled(); + return _cachedIsOnTrayEnabled; + } - internal static void ToggleEnabled(bool isEnabled) - { - _cachedIsEnabled = isEnabled; - InvokeToggleCommand(); - } + internal static bool IsEnabled() + { + int returnCode = TaskSchedulerUtil.IsEnabled(CollapseStartupTaskName, StubLocation); - private static void InvokeToggleCommand() + (_cachedIsEnabled, _cachedIsOnTrayEnabled) = returnCode switch { - // Build the argument and mode to set - StringBuilder argumentBuilder = new StringBuilder(); - argumentBuilder.Append(_cachedIsEnabled ? "Enable" : "Disable"); - - // Append argument whether to toggle the tray or not - if (_cachedIsOnTrayEnabled) - argumentBuilder.Append("ToTray"); - - // Append task name and stub path - AppendTaskNameAndPathArgument(argumentBuilder); - - // Store argument builder as string - string argumentString = argumentBuilder.ToString(); - - // Invoke applet - int returnCode = GetInvokeCommandReturnCode(argumentString); - - // Print init determination - CheckInitDetermination(returnCode); - } + // -1 means task is disabled with tray enabled + -1 => (false, true), + // 0 means task is disabled with tray disabled + 0 => (false, false), + // 1 means task is enabled with tray disabled + 1 => (true, false), + // 2 means task is enabled with tray enabled + 2 => (true, true), + // Otherwise, return both disabled (due to failure) + _ => (false, false) + }; + + return _cachedIsEnabled; + } - private static void AppendTaskNameAndPathArgument(StringBuilder argumentBuilder) - { - // Get current stub or main executable path - string currentExecPath = VelopackLocatorExtension.FindCollapseStubPath(); + internal static void ToggleTrayEnabled(bool isEnabled) + { + _cachedIsOnTrayEnabled = isEnabled; + TaskSchedulerUtil.ToggleTask(_cachedIsEnabled, _cachedIsOnTrayEnabled, CollapseStartupTaskName, StubLocation); + } - // Build argument to the task name - argumentBuilder.Append(" \""); - argumentBuilder.Append(CollapseStartupTaskName); - argumentBuilder.Append('"'); + internal static void ToggleEnabled(bool isEnabled) + { + _cachedIsEnabled = isEnabled; + TaskSchedulerUtil.ToggleTask(_cachedIsEnabled, _cachedIsOnTrayEnabled, CollapseStartupTaskName, StubLocation); + } - // Build argument to the executable path - argumentBuilder.Append(" \""); - argumentBuilder.Append(currentExecPath); - argumentBuilder.Append('"'); - } + internal static void RecreateIconShortcuts() + { + // Get icons paths + (string iconLocationStartMenu, string iconLocationDesktop) + = GetIconLocationPaths( + out _, + out string? appDescription, + out string? executablePath, + out string? workingDirPath); + + // Create shell link instance and save the shortcut under Desktop and User's Start menu + CreateShortcut(iconLocationStartMenu, appDescription, executablePath, workingDirPath); + CreateShortcut(iconLocationDesktop, appDescription, executablePath, workingDirPath); + } - internal static void RecreateIconShortcuts() - { - // Get icons paths - (string iconLocationStartMenu, string iconLocationDesktop) - = GetIconLocationPaths( - out _, - out string? appDescription, - out string? executablePath, - out string? workingDirPath); + private static void CreateShortcut( + string iconLocation, + string? appDescription, + string? executablePath, + string? workingDirPath) + { + // Try create icon location directory + string iconLocationDir = Path.GetDirectoryName(iconLocation) ?? ""; - // Create shell link instance and save the shortcut under Desktop and User's Start menu - CreateShortcut(iconLocationStartMenu, appDescription, executablePath, workingDirPath); - CreateShortcut(iconLocationDesktop, appDescription, executablePath, workingDirPath); - } + // Try create directory + Directory.CreateDirectory(iconLocationDir); + + // Create ShellLink instance + ShellLink shellLink = new(); - private static void CreateShortcut( - string iconLocation, - string? appDescription, - string? executablePath, - string? workingDirPath) + // If existing icon exist, try open it + try { - // Try create icon location directory - string iconLocationDir = Path.GetDirectoryName(iconLocation) ?? ""; - - // Try create directory - Directory.CreateDirectory(iconLocationDir); - - // Create ShellLink instance - ShellLink shellLink = new(); - - // If existing icon exist, try open it - try - { - if (File.Exists(iconLocation)) - shellLink.Open(iconLocation); - } - catch (Exception ex) - { - string msg = $"An error occurred while opening existing icon file at: {iconLocation}"; - SentryHelper.ExceptionHandler(new Exception(msg, ex)); - Logger.LogWriteLine(msg + $"\r\n{ex}", LogType.Error, true); - } - - // Set params on the shortcut instance - shellLink.IconIndex = 0; - shellLink.IconPath = executablePath ?? ""; - shellLink.DisplayMode = LinkDisplayMode.edmNormal; - shellLink.WorkingDirectory = workingDirPath ?? ""; - shellLink.Target = executablePath ?? ""; - shellLink.Description = appDescription ?? ""; - - // Save the icons - shellLink.Save(iconLocation); + if (File.Exists(iconLocation)) + shellLink.Open(iconLocation); } - - internal static (string IconStartMenu, string IconDesktop) GetIconLocationPaths( - out string? appProductName, - out string? appDescription, - out string? executablePath, - out string? workingDirPath) + catch (Exception ex) { - // Get current executable path as its target. - executablePath = LauncherConfig.AppExecutablePath; - workingDirPath = Path.GetDirectoryName(executablePath); - - // Get exe's description - FileVersionInfo currentExecVersionInfo = FileVersionInfo.GetVersionInfo(executablePath); - appDescription = currentExecVersionInfo.FileDescription ?? ""; - - // Get paths - appProductName = currentExecVersionInfo.ProductName; - string shortcutFilename = appProductName + ".lnk"; - string startMenuLocation = Environment.GetFolderPath(Environment.SpecialFolder.CommonStartMenu); - string desktopLocation = Environment.GetFolderPath(Environment.SpecialFolder.DesktopDirectory); - string iconLocationStartMenu = Path.Combine( - startMenuLocation, - "Programs", - currentExecVersionInfo.CompanyName ?? "", - shortcutFilename); - string iconLocationDesktop = Path.Combine( - desktopLocation, - shortcutFilename); - - return (iconLocationStartMenu, iconLocationDesktop); + string msg = $"An error occurred while opening existing icon file at: {iconLocation}"; + SentryHelper.ExceptionHandler(new Exception(msg, ex)); + Logger.LogWriteLine(msg + $"\r\n{ex}", LogType.Error, true); } + + // Set params on the shortcut instance + shellLink.IconIndex = 0; + shellLink.IconPath = executablePath ?? ""; + shellLink.DisplayMode = LinkDisplayMode.edmNormal; + shellLink.WorkingDirectory = workingDirPath ?? ""; + shellLink.Target = executablePath ?? ""; + shellLink.Description = appDescription ?? ""; + + // Save the icons + shellLink.Save(iconLocation); + } - private static int GetInvokeCommandReturnCode(string argument) - { - const string retValMark = "RETURNVAL_"; - - // Get the applet path and check if the file exist - string appletPath = Path.Combine(LauncherConfig.AppExecutableDir, "Lib", "win-x64", "Hi3Helper.TaskScheduler.exe"); - if (!File.Exists(appletPath)) - { - Logger.LogWriteLine($"Task Scheduler Applet does not exist in this path: {appletPath}", LogType.Error, true); - return short.MinValue; - } - - // Try to make process instance for the applet - using Process process = new Process(); - process.StartInfo = new ProcessStartInfo - { - FileName = appletPath, - Arguments = argument, - UseShellExecute = false, - RedirectStandardOutput = true, - CreateNoWindow = true - }; - -#if DEBUG - Logger.LogWriteLine("[TaskSchedulerHelper] Running TaskSchedulerHelper with command:\r\n" + appletPath + " " + argument, LogType.Debug, true); -#endif - - int lastErrCode = short.MaxValue; - try - { - // Start the applet and wait until it exit. - process.Start(); - while (process.StandardOutput.ReadLine() is {} consoleStdOut) - { - Logger.LogWriteLine("[TaskScheduler] " + consoleStdOut, LogType.Debug, true); - - // Parse if it has RETURNVAL_ - if (!consoleStdOut.StartsWith(retValMark)) - { - continue; - } - - ReadOnlySpan span = consoleStdOut.AsSpan(retValMark.Length); - if (int.TryParse(span, null, out int resultReturnCode)) - { - lastErrCode = resultReturnCode; - } - } - process.WaitForExit(); - } - catch (Exception ex) - { - // If error happened, then return. - SentryHelper.ExceptionHandler(ex, SentryHelper.ExceptionType.UnhandledOther); - Logger.LogWriteLine($"An error has occurred while invoking Task Scheduler applet!\r\n{ex}", LogType.Error, true); - return short.MaxValue; - } - - // Get return code - return lastErrCode; - } + internal static (string IconStartMenu, string IconDesktop) GetIconLocationPaths( + out string? appProductName, + out string? appDescription, + out string? executablePath, + out string? workingDirPath) + { + // Get current executable path as its target. + executablePath = LauncherConfig.AppExecutablePath; + workingDirPath = Path.GetDirectoryName(executablePath); + + // Get exe's description + FileVersionInfo currentExecVersionInfo = FileVersionInfo.GetVersionInfo(executablePath); + appDescription = currentExecVersionInfo.FileDescription ?? ""; + + // Get paths + appProductName = currentExecVersionInfo.ProductName; + string shortcutFilename = appProductName + ".lnk"; + string startMenuLocation = Environment.GetFolderPath(Environment.SpecialFolder.CommonStartMenu); + string desktopLocation = Environment.GetFolderPath(Environment.SpecialFolder.DesktopDirectory); + string iconLocationStartMenu = Path.Combine( + startMenuLocation, + "Programs", + currentExecVersionInfo.CompanyName ?? "", + shortcutFilename); + string iconLocationDesktop = Path.Combine( + desktopLocation, + shortcutFilename); + + return (iconLocationStartMenu, iconLocationDesktop); } } diff --git a/CollapseLauncher/CollapseLauncher.csproj b/CollapseLauncher/CollapseLauncher.csproj index 7648badc8a..ea5db1cc8f 100644 --- a/CollapseLauncher/CollapseLauncher.csproj +++ b/CollapseLauncher/CollapseLauncher.csproj @@ -323,6 +323,7 @@ + @@ -477,10 +478,6 @@ --> - - - - @@ -494,7 +491,6 @@ - diff --git a/CollapseLauncher/packages.lock.json b/CollapseLauncher/packages.lock.json index 8895a94d6c..b6f9d5dc82 100644 --- a/CollapseLauncher/packages.lock.json +++ b/CollapseLauncher/packages.lock.json @@ -570,6 +570,12 @@ "System.IO.Hashing": "[10.0.12, )" } }, + "hi3helper.taskscheduler": { + "type": "Project", + "dependencies": { + "Hi3Helper.Win32": "[1.0.0, )" + } + }, "hi3helper.win32": { "type": "Project", "dependencies": { diff --git a/Hi3Helper.TaskScheduler/FodyWeavers.xml b/Hi3Helper.TaskScheduler/FodyWeavers.xml deleted file mode 100644 index 5029e70602..0000000000 --- a/Hi3Helper.TaskScheduler/FodyWeavers.xml +++ /dev/null @@ -1,3 +0,0 @@ - - - \ No newline at end of file diff --git a/Hi3Helper.TaskScheduler/FodyWeavers.xsd b/Hi3Helper.TaskScheduler/FodyWeavers.xsd deleted file mode 100644 index dbeb102093..0000000000 --- a/Hi3Helper.TaskScheduler/FodyWeavers.xsd +++ /dev/null @@ -1,186 +0,0 @@ - - - - - - - - - - - - A list of assembly names to exclude from the default action of "embed all Copy Local references", delimited with line breaks - - - - - A list of assembly names to include from the default action of "embed all Copy Local references", delimited with line breaks. - - - - - A list of runtime assembly names to exclude from the default action of "embed all Copy Local references", delimited with line breaks - - - - - A list of runtime assembly names to include from the default action of "embed all Copy Local references", delimited with line breaks. - - - - - A list of runtimes to exclude from the default action of "embed all Copy Local references", delimited with line breaks - - - - - A list of runtimes names to include from the default action of "embed all Copy Local references", delimited with line breaks. - - - - - Obsolete, use UnmanagedWinX86Assemblies instead - - - - - A list of unmanaged X86 (32 bit) assembly names to include, delimited with line breaks. - - - - - Obsolete, use UnmanagedWinX64Assemblies instead. - - - - - A list of unmanaged X64 (64 bit) assembly names to include, delimited with line breaks. - - - - - A list of unmanaged Arm64 (64 bit) assembly names to include, delimited with line breaks. - - - - - The order of preloaded assemblies, delimited with line breaks. - - - - - - This will copy embedded files to disk before loading them into memory. This is helpful for some scenarios that expected an assembly to be loaded from a physical file. - - - - - Controls if .pdbs for reference assemblies are also embedded. - - - - - Controls if runtime assemblies are also embedded. - - - - - Controls whether the runtime assemblies are embedded with their full path or only with their assembly name. - - - - - Embedded assemblies are compressed by default, and uncompressed when they are loaded. You can turn compression off with this option. - - - - - As part of Costura, embedded assemblies are no longer included as part of the build. This cleanup can be turned off. - - - - - The attach method no longer subscribes to the `AppDomain.AssemblyResolve` (.NET 4.x) and `AssemblyLoadContext.Resolving` (.NET 6.0+) events. - - - - - Costura by default will load as part of the module initialization. This flag disables that behavior. Make sure you call CosturaUtility.Initialize() somewhere in your code. - - - - - Costura will by default use assemblies with a name like 'resources.dll' as a satellite resource and prepend the output path. This flag disables that behavior. - - - - - A list of assembly names to exclude from the default action of "embed all Copy Local references", delimited with | - - - - - A list of assembly names to include from the default action of "embed all Copy Local references", delimited with |. - - - - - A list of runtime assembly names to exclude from the default action of "embed all Copy Local references", delimited with | - - - - - A list of runtime assembly names to include from the default action of "embed all Copy Local references", delimited with |. - - - - - Obsolete, use UnmanagedWinX86Assemblies instead - - - - - A list of unmanaged X86 (32 bit) assembly names to include, delimited with |. - - - - - Obsolete, use UnmanagedWinX64Assemblies instead - - - - - A list of unmanaged X64 (64 bit) assembly names to include, delimited with |. - - - - - A list of unmanaged Arm64 (64 bit) assembly names to include, delimited with |. - - - - - The order of preloaded assemblies, delimited with |. - - - - - - - - 'true' to run assembly verification (PEVerify) on the target assembly after all weavers have been executed. - - - - - A comma-separated list of error codes that can be safely ignored in assembly verification. - - - - - 'false' to turn off automatic generation of the XML Schema file. - - - - - \ No newline at end of file diff --git a/Hi3Helper.TaskScheduler/Hi3Helper.TaskScheduler.csproj b/Hi3Helper.TaskScheduler/Hi3Helper.TaskScheduler.csproj index 0e90bc2cf1..e4aba6a518 100644 --- a/Hi3Helper.TaskScheduler/Hi3Helper.TaskScheduler.csproj +++ b/Hi3Helper.TaskScheduler/Hi3Helper.TaskScheduler.csproj @@ -1,37 +1,27 @@ - + - Exe - netframework462 + net10.0 disable x64 x64 enable en portable - Task Scheduler Shell - Task Scheduler Shell - Collapse Launcher's Task Scheduler Shell - Collapse Launcher's Task Scheduler Shell + Task Scheduler Utility + Task Scheduler Utility + Collapse Launcher's Task Scheduler Utility + Collapse Launcher's Task Scheduler Utility Collapse Launcher Team $(Company). neon-nyan, Cry0, bagusnl, shatyuka, gablm. Copyright 2022-2026 $(Company) - 8.0 - 1.0.2 + latest + 2.0.0 + true - - all - runtime; build; native; contentfiles; analyzers; buildtransitive; compile - - - all - runtime; build; native; contentfiles; analyzers; buildtransitive; compile - - - - + diff --git a/Hi3Helper.TaskScheduler/Program.cs b/Hi3Helper.TaskScheduler/Program.cs deleted file mode 100644 index fee5215065..0000000000 --- a/Hi3Helper.TaskScheduler/Program.cs +++ /dev/null @@ -1,231 +0,0 @@ -using Microsoft.Win32.TaskScheduler; -using System; -using System.Diagnostics; -using System.IO; -using System.Linq; -using System.Text.RegularExpressions; -// ReSharper disable once IdentifierTypo -// ReSharper disable StringLiteralTypo - -using TaskSched = Microsoft.Win32.TaskScheduler.Task; - -namespace Hi3Helper.TaskScheduler -{ - public static class ReturnExtension - { - public static int ReturnValAsConsole(this int returnVal) - { - Console.WriteLine($"RETURNVAL_{returnVal}"); - return returnVal; - } - } - - public class Program - { - private static int PrintUsage() - { - ProcessModule? processModule = Process.GetCurrentProcess().MainModule; - if (processModule == null) - { - return int.MaxValue; - } - - string executableName = Path.GetFileNameWithoutExtension(processModule.FileName); - Console.WriteLine($"Usage:\r\n{executableName} [IsEnabled] \"Scheduler name\" \"Executable path\""); - Console.WriteLine($"{executableName} [Enable] \"Scheduler name\" \"Executable path\""); - Console.WriteLine($"{executableName} [EnableToTray] \"Scheduler name\" \"Executable path\""); - Console.WriteLine($"{executableName} [Disable] \"Scheduler name\" \"Executable path\""); - Console.WriteLine($"{executableName} [DisableToTray] \"Scheduler name\" \"Executable path\""); - - return int.MaxValue; - } - - internal static int Main(string[] args) - { - try - { - if (args.Length < 3) - return PrintUsage().ReturnValAsConsole(); - - string action = args[0].ToLower(); - // ReSharper disable once IdentifierTypo - string schedName = args[1]; - string execPath = args[2]; - - switch (action) - { - case "isenabled": - return IsEnabled(schedName, execPath).ReturnValAsConsole(); - case "enable": - ToggleTask(true, false, schedName, execPath); - break; - case "enabletotray": - ToggleTask(true, true, schedName, execPath); - break; - case "disable": - ToggleTask(false, false, schedName, execPath); - break; - case "disabletotray": - ToggleTask(false, true, schedName, execPath); - break; - default: - return PrintUsage().ReturnValAsConsole(); - } - - WriteConsole($"Operation: {action} \"{schedName}\" \"{execPath}\" has been executed!"); - } - catch (Exception ex) - { - WriteConsole($"An unexpected error has occurred!\r\n{ex}"); - return int.MinValue.ReturnValAsConsole(); - } - - return 0.ReturnValAsConsole(); - } - - private static TaskSched Create(TaskService taskService, string scheduleName, string execPath) - { - using TaskDefinition taskDefinition = TaskService.Instance.NewTask(); - taskDefinition.RegistrationInfo.Author = "CollapseLauncher"; - taskDefinition.RegistrationInfo.Description = "Run Collapse Launcher automatically when computer starts"; - taskDefinition.Principal.LogonType = TaskLogonType.InteractiveToken; - taskDefinition.Principal.RunLevel = TaskRunLevel.Highest; - taskDefinition.Settings.Enabled = false; - taskDefinition.Triggers.Add(new LogonTrigger()); - taskDefinition.Actions.Add(new ExecAction(execPath)); - - TaskSched task = taskService.RootFolder.RegisterTaskDefinition(scheduleName, taskDefinition); - WriteConsole("New task schedule has been created!"); - return task; - } - - private static void TryDelete(TaskService taskService, string scheduleName) - { - // Try to get the tasks - TaskSched[] tasks = taskService.FindAllTasks(new Regex(scheduleName, RegexOptions.Compiled, TimeSpan.FromSeconds(5)), false); - - // If null, then ignore - if (tasks == null || tasks.Length == 0) - { - WriteConsole($"None of the existing task: {scheduleName} exist but trying to delete, ignoring!"); - return; - } - - // Remove possible tasks - foreach (TaskSched task in tasks) - { - using (task) - { - WriteConsole($"Deleting redundant task: {task.Name}"); - taskService.RootFolder.DeleteTask(task.Name); - } - } - } - - private static TaskSched? GetExistingTask(TaskService taskService, string scheduleName, string execPath) - { - // Try to get the tasks - TaskSched[] tasks = taskService.FindAllTasks(new Regex(scheduleName, RegexOptions.Compiled, TimeSpan.FromSeconds(5)), false); - - // Try to get the first task - TaskSched? task = tasks? - .FirstOrDefault(x => - x.Name.Equals(scheduleName, StringComparison.OrdinalIgnoreCase)); - - // Return null as empty - if (task == null) - { - return null; - } - - // Get actionPath - string? actionPath = task.Definition.Actions.FirstOrDefault()?.ToString(); - - // If actionPath is null, then return null as empty - if (string.IsNullOrEmpty(actionPath)) - { - return null; - } - - // if actionPath isn't matched, then replace with current executable path - if (!(!actionPath?.StartsWith(execPath, StringComparison.OrdinalIgnoreCase) ?? false)) - { - return task; - } - - // Check if the last action path runs on tray - // ReSharper disable once ConstantConditionalAccessQualifier - bool isLastHasTray = actionPath?.EndsWith("tray", StringComparison.OrdinalIgnoreCase) ?? false; - - // Register changes - task.Definition.Actions.Clear(); - task.Definition.Actions.Add(new ExecAction(execPath, isLastHasTray ? "tray" : null)); - task.RegisterChanges(); - - // If the task matches, then return the task - return task; - } - - private static int IsEnabled(string scheduleName, string execPath) - { - using TaskService taskService = new TaskService(); - // Get the task - TaskSched? task = GetExistingTask(taskService, scheduleName, execPath); - - // If the task is null, return 0 - if (task == null) - { - return 0; - } - - // If the task is not null, then do further check - - // Check if it's enabled with tray - bool isOnTray = task.Definition.Actions.FirstOrDefault()?.ToString().EndsWith("tray", StringComparison.OrdinalIgnoreCase) ?? false; - - // If the task definition is enabled, then return 1 (true) or 2 (true with tray) - if (task.Definition.Settings.Enabled) - return isOnTray ? 2 : 1; - - // Otherwise, if the task exist but not enabled, then return 0 (false) or -1 (false with tray) - return isOnTray ? -1 : 0; - } - - private static void ToggleTask(bool isEnabled, bool isStartupToTray, string scheduleName, string execPath) - { - using TaskService taskService = new TaskService(); - // Try get existing task - TaskSched? task = GetExistingTask(taskService, scheduleName, execPath); - - try - { - // If the task is null due to its non-existence or - // there are some unmatched tasks, then try to recreate the task - // by try deleting and create a new one. - if (task == null) - { - TryDelete(taskService, scheduleName); - task = Create(taskService, scheduleName, execPath); - } - - // Try clear the existing actions and set the new one - task.Definition.Actions.Clear(); - task.Definition.Actions.Add(new ExecAction(execPath, isStartupToTray ? "tray" : null)); - task.Definition.Settings.Enabled = isEnabled; - } - finally - { - // Register the changes if it's not null. - if (task != null) - { - task.RegisterChanges(); - task.Dispose(); - WriteConsole($"ToggledStatus: isEnabled -> {isEnabled} & isStartupToTray -> {isStartupToTray}"); - } - } - } - - private static void WriteConsole(string message) => - Console.WriteLine(message); - } -} diff --git a/Hi3Helper.TaskScheduler/Properties/PublishProfiles/AsRelease.pubxml b/Hi3Helper.TaskScheduler/Properties/PublishProfiles/AsRelease.pubxml deleted file mode 100644 index 7d3019a676..0000000000 --- a/Hi3Helper.TaskScheduler/Properties/PublishProfiles/AsRelease.pubxml +++ /dev/null @@ -1,15 +0,0 @@ - - - - - Release - x64 - bin\publish - FileSystem - <_TargetId>Folder - netframework462 - win-x64 - - \ No newline at end of file diff --git a/Hi3Helper.TaskScheduler/Properties/launchSettings.json b/Hi3Helper.TaskScheduler/Properties/launchSettings.json deleted file mode 100644 index 119938de0b..0000000000 --- a/Hi3Helper.TaskScheduler/Properties/launchSettings.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "profiles": { - "Hi3Helper.TaskScheduler": { - "commandName": "Project", - "commandLineArgs": "RecreateIcons \"E:\\myGit\\Collapse\\CollapseLauncher\\bin\\x64\\Debug\\net10.0-windows10.0.26100.0\\win-x64\\CollapseLauncher.exe\"" - } - } -} \ No newline at end of file diff --git a/Hi3Helper.TaskScheduler/TaskSchedulerUtil.cs b/Hi3Helper.TaskScheduler/TaskSchedulerUtil.cs new file mode 100644 index 0000000000..4facd39a0b --- /dev/null +++ b/Hi3Helper.TaskScheduler/TaskSchedulerUtil.cs @@ -0,0 +1,283 @@ +using Hi3Helper.Win32.ManagedTools; +using Hi3Helper.Win32.Native.Enums; +using Hi3Helper.Win32.Native.Interfaces.TaskScheduler; +using Hi3Helper.Win32.Native.Interfaces.TaskScheduler.Action; +using Hi3Helper.Win32.Native.Interfaces.TaskScheduler.Trigger; +using Hi3Helper.Win32.Native.Structs; +using System; +using System.Diagnostics.CodeAnalysis; +using System.Runtime.CompilerServices; +// ReSharper disable once IdentifierTypo +// ReSharper disable StringLiteralTypo + +namespace Hi3Helper.TaskScheduler; + +public static class TaskSchedulerUtil +{ + private const string RootFolder = "\\"; + + private static ITaskService? _taskService; + + public static int IsEnabled(string scheduleName, string execPath) + { + EnsureTaskServiceConnected(); + + IRegisteredTask? existingTask = GetExistingTask(_taskService, scheduleName, execPath, out IExecAction? execAction); + if (existingTask == null) // If the task is null, return 0 + { + return 0; + } + + if (execAction == null) throw new NullReferenceException("Cannot get an existing IExecAction instance"); + + existingTask.Definition(out ITaskDefinition? definition); + if (definition == null) throw new NullReferenceException("Cannot get an existing ITaskDefinition instance"); + + definition.GetSettings(out ITaskSettings? settings); + if (settings == null) throw new NullReferenceException("Cannot get an existing ITaskSettings instance"); + + // Get existing argument and enable state + settings.GetEnabled(out VARIANT_BOOL isEnabled); + execAction.GetArguments(out string? argument); + + // Check if it's enabled with tray + bool isOnTray = argument?.Equals("tray", StringComparison.OrdinalIgnoreCase) ?? false; + + // If the task definition is enabled, then return 1 (true) or 2 (true with tray) + if (isEnabled) + return isOnTray ? 2 : 1; + + // Otherwise, if the task exist but not enabled, then return 0 (false) or -1 (false with tray) + return isOnTray ? -1 : 0; + } + + public static void ToggleTask(bool isEnabled, bool isStartupToTray, string scheduleName, string execPath) + { + EnsureTaskServiceConnected(); + + IRegisteredTask? registeredTask = GetExistingTask(_taskService, scheduleName, execPath, out IExecAction? execAction); + + // Create new if existing task doesn't exist and remove a redundant one + if (registeredTask == null) + { + TryDeleteRedundant(_taskService, scheduleName); + registeredTask = Create(_taskService, scheduleName, execPath, out execAction); + } + + registeredTask.Definition(out ITaskDefinition? definition); + if (definition == null) throw new NullReferenceException("Cannot get an existing ITaskDefinition instance"); + + definition.GetSettings(out ITaskSettings? settings); + if (settings == null) throw new NullReferenceException("Cannot get an existing ITaskSettings instance"); + + definition.GetActions(out IActionCollection? actionCollections); + if (actionCollections == null) throw new NullReferenceException("Cannot get an existing IActionCollection instance"); + + // Clear all collections and re-create the IExecAction + actionCollections.Clear(); + actionCollections.Create(TASK_ACTION_TYPE.TASK_ACTION_EXEC, out IAction? action); + if (action == null) throw new NullReferenceException("Cannot re-create an IAction instance"); + if (!ComMarshal.TryCastComObjectAs(action, + out execAction, + out Exception? ex)) + { + throw ex; + } + + // Set IExecAction path and arguments, then set the toggle + execAction.SetPath(execPath); + execAction.SetArguments(isStartupToTray ? "tray" : null); + settings.SetEnabled(isEnabled); + + _taskService.GetFolder(RootFolder, out ITaskFolder? rootFolder); + if (rootFolder == null) throw new NullReferenceException("Cannot get an existing Root Folder ITaskFolder instance"); + rootFolder.RegisterTaskDefinition(scheduleName, definition, (int)TASK_CREATION.CreateOrUpdate, null, null, TASK_LOGON_TYPE.TASK_LOGON_INTERACTIVE_TOKEN, null, out _); + } + + [MemberNotNull(nameof(_taskService))] + private static void EnsureTaskServiceConnected() + { + if (_taskService != null) + { + return; + } + + if (!ComMarshal.TryCreateComObject(new Guid(TaskSchedulerIIDConst.CLSID_TaskScheduler), + CLSCTX.CLSCTX_INPROC_SERVER, + out ITaskService? taskServiceAot, + out Exception? ex)) + { + throw ex; + } + + // Try to connect to Task Scheduler service. + taskServiceAot.Connect(null, null, null, null); + _taskService = taskServiceAot; + } + + private static IRegisteredTask Create(ITaskService taskService, string scheduleName, string execPath, out IExecAction? execAction, string folder = RootFolder) + { + // Create a new ITaskDefinition + taskService.NewTask(0, out ITaskDefinition? taskDefinition); + if (taskDefinition == null) throw new NullReferenceException("Cannot create a ITaskDefinition instance"); + + // Try to create a new IRegistrationInfo instance + taskDefinition.GetRegistrationInfo(out IRegistrationInfo? registrationInfo); + if (registrationInfo == null) throw new NullReferenceException("Cannot create a IRegistrationInfo instance"); + + // -- Set author and description + registrationInfo.SetAuthor("CollapseLauncher"); + registrationInfo.SetDescription("Run Collapse Launcher automatically when computer starts"); + + // Try to create a new IPrincipalInfo instance + taskDefinition.GetPrincipal(out IPrincipal? principal); + if (principal == null) throw new NullReferenceException("Cannot create a IPrincipal instance"); + + // -- Set logon type and run level + principal.LogonType(TASK_LOGON_TYPE.TASK_LOGON_INTERACTIVE_TOKEN); + principal.RunLevel(TASK_RUNLEVEL_TYPE.TASK_RUNLEVEL_HIGHEST); + + // Try to create a new ITaskSettings instance + taskDefinition.GetSettings(out ITaskSettings? settings); + if (settings == null) throw new NullReferenceException("Cannot create a ITaskSettings instance"); + + // -- Set the task to enable + settings.SetEnabled(true); + + // Try to create a TASK_TRIGGER_LOGON + taskDefinition.GetTriggers(out ITriggerCollection? triggers); + if (triggers == null) throw new NullReferenceException("Cannot create a ITriggerCollection instance"); + triggers.Create(TASK_TRIGGER_TYPE2.TASK_TRIGGER_LOGON, out _); + + // Try to create a IExecAction + taskDefinition.GetActions(out IActionCollection? actions); + if (actions == null) throw new NullReferenceException("Cannot create a IActionCollection instance"); + actions.Create(TASK_ACTION_TYPE.TASK_ACTION_EXEC, out IAction? action); + if (!ComMarshal.TryCastComObjectAs(action ?? throw new NullReferenceException("Cannot create a IAction instance"), + out execAction, + out Exception? ex)) + { + throw ex; + } + + // Set path and register to the service + execAction.SetPath(execPath); + taskService.GetFolder(folder, out ITaskFolder? taskFolder); + if (taskFolder == null) throw new NullReferenceException($"Cannot open the {folder} instance"); + + taskFolder.RegisterTaskDefinition(scheduleName, taskDefinition, (int)TASK_CREATION.CreateOrUpdate, null, null, TASK_LOGON_TYPE.TASK_LOGON_INTERACTIVE_TOKEN, null, out IRegisteredTask? task); + if (task == null) throw new NullReferenceException("Cannot register the IRegisteredTask instance"); + return task; + } + + private static void TryDeleteRedundant(ITaskService taskService, string scheduleName, ITaskFolder? currentTaskFolder = null) + { + if (currentTaskFolder == null) + { + taskService.GetFolder(RootFolder, out currentTaskFolder); + if (currentTaskFolder == null) + { + return; + } + } + + int tasksCount = 0; + currentTaskFolder.GetTasks(1, out IRegisteredTaskCollection? tasksCollection); + tasksCollection?.Count(out tasksCount); + if (tasksCollection != null && tasksCount > 0) + { + for (int i = 0; i < tasksCount; i++) + { + tasksCollection.Item(i + 1, out IRegisteredTask? task); + if (task == null) continue; + + task.Name(out string? name); + if (!(name?.Equals(scheduleName, StringComparison.OrdinalIgnoreCase) ?? false)) continue; + + currentTaskFolder.DeleteTask(name, 0); + } + } + + currentTaskFolder.GetFolders(0, out ITaskFolderCollection? folders); + if (folders == null) return; + + folders.Count(out int folderCount); + if (folderCount == 0) + { + return; + } + + for (int i = 0; i < folderCount; i++) + { + folders.Item(i + 1, out ITaskFolder? nextFolder); + if (nextFolder == null) continue; + + TryDeleteRedundant(taskService, scheduleName, nextFolder); + } + } + + private static IRegisteredTask? GetExistingTask(ITaskService taskService, string scheduleName, string execPath, out IExecAction? execAction, string folder = RootFolder) + { + Unsafe.SkipInit(out ITaskFolder? taskFolder); + Unsafe.SkipInit(out IRegisteredTaskCollection? tasks); + Unsafe.SkipInit(out execAction); + + taskService.GetFolder(folder, out taskFolder); + taskFolder?.GetTasks(1, out tasks); + + int taskCount = 0; + tasks?.Count(out taskCount); + + for (int i = 0; i < taskCount; i++) + { + Unsafe.SkipInit(out IRegisteredTask? task); + tasks?.Item(i + 1, out task); + + string? taskName = null; + task?.Name(out taskName); + if (!scheduleName.Equals(taskName) || + !IsExecutableActionEquals(task, execPath, out execAction)) + { + continue; + } + + return task; + } + + return null; + + static bool IsExecutableActionEquals(IRegisteredTask? task, string execPath, out IExecAction? execAction) + { + Unsafe.SkipInit(out ITaskDefinition? definition); + Unsafe.SkipInit(out IActionCollection? actions); + Unsafe.SkipInit(out execAction); + task?.Definition(out definition); + definition?.GetActions(out actions); + + Unsafe.SkipInit(out int actionsCount); + actions?.Count(out actionsCount); + for (int i = 0; i < actionsCount; i++) + { + Unsafe.SkipInit(out IAction? action); + actions?.Item(i + 1, out action); + + if (action == null) continue; + + if (!ComMarshal.TryCastComObjectAs(action, + out execAction, + out Exception? ex)) + { + throw ex; + } + + execAction.GetPath(out string? path); + if (execPath.Equals(path, StringComparison.OrdinalIgnoreCase)) + { + return true; + } + } + + return false; + } + } +} diff --git a/Hi3Helper.TaskScheduler/packages.lock.json b/Hi3Helper.TaskScheduler/packages.lock.json index dd14c88fc8..0849386bcb 100644 --- a/Hi3Helper.TaskScheduler/packages.lock.json +++ b/Hi3Helper.TaskScheduler/packages.lock.json @@ -1,101 +1,24 @@ { "version": 1, "dependencies": { - ".NETFramework,Version=v4.6.2": { - "Costura.Fody": { - "type": "Direct", - "requested": "[6.2.0, )", - "resolved": "6.2.0", - "contentHash": "jaX+A6sw6pI7lkLDTvxN7zU8Ja/a50N2xPKF6RYy0J6QF62wVg+hJMXJph2K8XarDaAlxQ82EaKv3fAvUNyRpg==", - "dependencies": { - "Fody": "6.9.3" - } - }, - "Fody": { - "type": "Direct", - "requested": "[6.9.3, )", - "resolved": "6.9.3", - "contentHash": "1CUGgFdyECDKgi5HaUBhdv6k+VG9Iy4OCforGfHyar3xQXAJypZkzymgKtWj/4SPd6nSG0Qi7NH71qHrDSZLaA==" - }, - "System.Net.Http": { - "type": "Direct", - "requested": "[4.3.4, )", - "resolved": "4.3.4", - "contentHash": "aOa2d51SEbmM+H+Csw7yJOuNZoHkrP2XnAurye5HWYgGVVU54YZDvsLUYRv6h18X3sPnjNCANmN7ZhIPiqMcjA==", - "dependencies": { - "System.Security.Cryptography.X509Certificates": "4.3.0" - } - }, - "System.Text.RegularExpressions": { - "type": "Direct", - "requested": "[4.3.1, )", - "resolved": "4.3.1", - "contentHash": "N0kNRrWe4+nXOWlpLT4LAY5brb8caNFlUuIRpraCVMDLYutKkol1aV079rQjLuSxKMJT2SpBQsYX9xbcTMmzwg==" - }, - "TaskScheduler": { - "type": "Direct", - "requested": "[2.12.2, )", - "resolved": "2.12.2", - "contentHash": "glpAb3VrwfdAofp6PIyAzL0ZeTV7XUJ8muu0oZoTeyU5jtk2sMJ6QAMRRuFbovcaj+SBJiEUGklxIWOqQoxshA==" - }, - "System.Security.Cryptography.Algorithms": { - "type": "Transitive", - "resolved": "4.3.0", - "contentHash": "W1kd2Y8mYSCgc3ULTAZ0hOP2dSdG5YauTb1089T0/kRcN2MpSAW1izOFROrJgxSlMn3ArsgHXagigyi+ibhevg==", - "dependencies": { - "System.Security.Cryptography.Primitives": "4.3.0" - } - }, - "System.Security.Cryptography.Encoding": { + "net10.0": { + "Microsoft.Extensions.DependencyInjection.Abstractions": { "type": "Transitive", - "resolved": "4.3.0", - "contentHash": "1DEWjZZly9ae9C79vFwqaO5kaOlI5q+3/55ohmq/7dpDyDfc8lYe7YVxJUZ5MF/NtbkRjwFRo14yM4OEo9EmDw==" + "resolved": "10.0.12", + "contentHash": "9/qymSh7hVDMGTGwrLz8MRp5zRyXy9adGDOs4HwRdnLil3oZGYuWeZjbmHgCQ9BL1qBroVfgUK3U/nb61617Cw==" }, - "System.Security.Cryptography.Primitives": { + "Microsoft.Extensions.Logging.Abstractions": { "type": "Transitive", - "resolved": "4.3.0", - "contentHash": "7bDIyVFNL/xKeFHjhobUAQqSpJq9YTOpbEs6mR233Et01STBMXNAc/V+BM6dwYGc95gVh/Zf+iVXWzj3mE8DWg==" - }, - "System.Security.Cryptography.X509Certificates": { - "type": "Transitive", - "resolved": "4.3.0", - "contentHash": "t2Tmu6Y2NtJ2um0RtcuhP7ZdNNxXEgUm2JeoA/0NvlMjAhKCnM1NX07TDl3244mVp3QU6LPEhT3HTtH1uF7IYw==", - "dependencies": { - "System.Security.Cryptography.Algorithms": "4.3.0", - "System.Security.Cryptography.Encoding": "4.3.0" - } - } - }, - ".NETFramework,Version=v4.6.2/win-x64": { - "System.Net.Http": { - "type": "Direct", - "requested": "[4.3.4, )", - "resolved": "4.3.4", - "contentHash": "aOa2d51SEbmM+H+Csw7yJOuNZoHkrP2XnAurye5HWYgGVVU54YZDvsLUYRv6h18X3sPnjNCANmN7ZhIPiqMcjA==", - "dependencies": { - "System.Security.Cryptography.X509Certificates": "4.3.0" - } - }, - "System.Security.Cryptography.Algorithms": { - "type": "Transitive", - "resolved": "4.3.0", - "contentHash": "W1kd2Y8mYSCgc3ULTAZ0hOP2dSdG5YauTb1089T0/kRcN2MpSAW1izOFROrJgxSlMn3ArsgHXagigyi+ibhevg==", + "resolved": "10.0.12", + "contentHash": "+24lC4plfbEDNfLAdTV/SWKS7dW+16X4HdydO3R++134kSNTzcbYA4KpR1Hdh6uWisB8Za3AzwyOn+K+NxWIug==", "dependencies": { - "System.Security.Cryptography.Primitives": "4.3.0" + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.12" } }, - "System.Security.Cryptography.Encoding": { - "type": "Transitive", - "resolved": "4.3.0", - "contentHash": "1DEWjZZly9ae9C79vFwqaO5kaOlI5q+3/55ohmq/7dpDyDfc8lYe7YVxJUZ5MF/NtbkRjwFRo14yM4OEo9EmDw==" - }, - "System.Security.Cryptography.X509Certificates": { - "type": "Transitive", - "resolved": "4.3.0", - "contentHash": "t2Tmu6Y2NtJ2um0RtcuhP7ZdNNxXEgUm2JeoA/0NvlMjAhKCnM1NX07TDl3244mVp3QU6LPEhT3HTtH1uF7IYw==", + "hi3helper.win32": { + "type": "Project", "dependencies": { - "System.Security.Cryptography.Algorithms": "4.3.0", - "System.Security.Cryptography.Encoding": "4.3.0" + "Microsoft.Extensions.Logging.Abstractions": "[10.0.12, )" } } } diff --git a/Hi3Helper.Win32 b/Hi3Helper.Win32 index 5ac3f62614..c061d517ca 160000 --- a/Hi3Helper.Win32 +++ b/Hi3Helper.Win32 @@ -1 +1 @@ -Subproject commit 5ac3f626146f2c9ddc29b4d289b6854156e3b9d2 +Subproject commit c061d517ca757ebaf8ceeca955aac1b73ef41087 From 4522c1e2648e23a6c63fd36bb9b2591e02e8660d Mon Sep 17 00:00:00 2001 From: Kemal Setya Adhi Date: Mon, 14 Sep 2026 01:33:34 +0700 Subject: [PATCH 30/50] [TaskSched] Caught exception if happen --- .../Classes/Helper/TaskSchedulerHelper.cs | 61 +++++++++++++------ 1 file changed, 43 insertions(+), 18 deletions(-) diff --git a/CollapseLauncher/Classes/Helper/TaskSchedulerHelper.cs b/CollapseLauncher/Classes/Helper/TaskSchedulerHelper.cs index 5ccb57b83f..8eb5ac137f 100644 --- a/CollapseLauncher/Classes/Helper/TaskSchedulerHelper.cs +++ b/CollapseLauncher/Classes/Helper/TaskSchedulerHelper.cs @@ -31,35 +31,60 @@ internal static bool IsOnTrayEnabled() internal static bool IsEnabled() { - int returnCode = TaskSchedulerUtil.IsEnabled(CollapseStartupTaskName, StubLocation); - - (_cachedIsEnabled, _cachedIsOnTrayEnabled) = returnCode switch + try + { + int returnCode = TaskSchedulerUtil.IsEnabled(CollapseStartupTaskName, StubLocation); + (_cachedIsEnabled, _cachedIsOnTrayEnabled) = returnCode switch + { + // -1 means task is disabled with tray enabled + -1 => (false, true), + // 0 means task is disabled with tray disabled + 0 => (false, false), + // 1 means task is enabled with tray disabled + 1 => (true, false), + // 2 means task is enabled with tray enabled + 2 => (true, true), + // Otherwise, return both disabled (due to failure) + _ => (false, false) + }; + + return _cachedIsEnabled; + } + catch (Exception ex) { - // -1 means task is disabled with tray enabled - -1 => (false, true), - // 0 means task is disabled with tray disabled - 0 => (false, false), - // 1 means task is enabled with tray disabled - 1 => (true, false), - // 2 means task is enabled with tray enabled - 2 => (true, true), - // Otherwise, return both disabled (due to failure) - _ => (false, false) - }; - - return _cachedIsEnabled; + Logger.LogWriteLine($"An error occurred while trying to check TaskSchedulerUtil.IsEnabled\r\n{ex}", + LogType.Error, + true); + SentryHelper.ExceptionHandler(ex); + return false; + } } internal static void ToggleTrayEnabled(bool isEnabled) { _cachedIsOnTrayEnabled = isEnabled; - TaskSchedulerUtil.ToggleTask(_cachedIsEnabled, _cachedIsOnTrayEnabled, CollapseStartupTaskName, StubLocation); + ToggleCore(); } internal static void ToggleEnabled(bool isEnabled) { _cachedIsEnabled = isEnabled; - TaskSchedulerUtil.ToggleTask(_cachedIsEnabled, _cachedIsOnTrayEnabled, CollapseStartupTaskName, StubLocation); + ToggleCore(); + } + + private static void ToggleCore() + { + try + { + TaskSchedulerUtil.ToggleTask(_cachedIsEnabled, _cachedIsOnTrayEnabled, CollapseStartupTaskName, StubLocation); + } + catch (Exception ex) + { + Logger.LogWriteLine($"An error occurred while trying to toggle Task Scheduler Task using TaskSchedulerUtil.ToggleTask\r\n{ex}", + LogType.Error, + true); + SentryHelper.ExceptionHandler(ex); + } } internal static void RecreateIconShortcuts() From 7217bd9b3976923c5416b7656191ac49179e6230 Mon Sep 17 00:00:00 2001 From: Kemal Setya Adhi Date: Mon, 14 Sep 2026 22:43:16 +0700 Subject: [PATCH 31/50] Defer UI Element while minimized + Fix BG Minimize/Restore event By telling WinUI to disable its visibility on the parent element, this helps to flush and temporarily pause element rendering. Calling CanvasDevice.Trim() also telling the Shared DXGI device used by Win2D that the app is going to be in paused state, helping to reduce the VRAM Allocation even further. Thanks to these approach, on the benchmarked device (in R7 9800X3D \w SMT Disabled), the app reduces its idle CPU usage from 5% to nearly 0% most of the time (except when it temporarily spikes due to .NET GC taking place). Also, the VRAM usage reduced from around ~300 MB to only 26.2 MB at idle after one minute (after CanvasDevice.Trim() has been triggered). --- .../Classes/Helper/WindowUtility.cs | 26 +++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/CollapseLauncher/Classes/Helper/WindowUtility.cs b/CollapseLauncher/Classes/Helper/WindowUtility.cs index 4a09ee5824..ba55d6d872 100644 --- a/CollapseLauncher/Classes/Helper/WindowUtility.cs +++ b/CollapseLauncher/Classes/Helper/WindowUtility.cs @@ -14,6 +14,7 @@ using Hi3Helper.Win32.WinRT.ToastCOM; using Hi3Helper.Win32.WinRT.ToastCOM.Notification; using Microsoft.Extensions.Logging; +using Microsoft.Graphics.Canvas; using Microsoft.Graphics.Display; using Microsoft.UI; using Microsoft.UI.Composition.SystemBackdrops; @@ -598,12 +599,16 @@ private static nint MainWndProc(nint hwnd, uint msg, nuint wParam, nint lParam) { ImageBackgroundManager.Shared.SetWindowMinimizeEvent(); InnerLauncherConfig.m_homePage?.StopCarouselSlideshow(); + ToggleDeferVisibility(Visibility.Collapsed); + CanvasDevice sharedDevice = CanvasDevice.GetSharedDevice(); + sharedDevice.Trim(); break; } case SC_RESTORE: { ImageBackgroundManager.Shared.SetWindowRestoreEvent(); InnerLauncherConfig.m_homePage?.StartCarouselSlideshow(); + ToggleDeferVisibility(Visibility.Visible); break; } } @@ -614,11 +619,15 @@ private static nint MainWndProc(nint hwnd, uint msg, nuint wParam, nint lParam) { if (wParam == 0) { + ImageBackgroundManager.Shared.SetWindowMinimizeEvent(); InnerLauncherConfig.m_homePage?.StopCarouselSlideshow(); + ToggleDeferVisibility(Visibility.Collapsed); } else { + ImageBackgroundManager.Shared.SetWindowRestoreEvent(); InnerLauncherConfig.m_homePage?.StartCarouselSlideshow(); + ToggleDeferVisibility(Visibility.Visible); } break; } @@ -709,8 +718,25 @@ private static nint MainWndProc(nint hwnd, uint msg, nuint wParam, nint lParam) } return PInvoke.CallWindowProc(_oldMainWndProcPtr, hwnd, msg, wParam, lParam); + + static void ToggleDeferVisibility(Visibility visibility) + { + if (CurrentWindow.IsObjectDisposed() || + CurrentWindow is not { } currentWindow) + { + return; + } + + ref SystemBackdrop? lastBackdrop = + ref CollectionsMarshal.GetValueRefOrAddDefault(_windowBackdrops, currentWindow.GetHashCode(), out _); + + currentWindow.SystemBackdrop = visibility == Visibility.Collapsed ? null : lastBackdrop; + currentWindow.Content.Visibility = visibility; + } } + private static readonly Dictionary _windowBackdrops = []; + #endregion #region Titlebar Methods From e578000378e69f17fe4f87eca7768100ebb429d7 Mon Sep 17 00:00:00 2001 From: Kemal Setya Adhi Date: Mon, 14 Sep 2026 23:51:42 +0700 Subject: [PATCH 32/50] Run background GC in loop Max. Run in 300 seconds --- .../Classes/Helper/WindowUtility.cs | 70 +++++++++++++++++-- 1 file changed, 65 insertions(+), 5 deletions(-) diff --git a/CollapseLauncher/Classes/Helper/WindowUtility.cs b/CollapseLauncher/Classes/Helper/WindowUtility.cs index ba55d6d872..af1981e3ed 100644 --- a/CollapseLauncher/Classes/Helper/WindowUtility.cs +++ b/CollapseLauncher/Classes/Helper/WindowUtility.cs @@ -26,8 +26,11 @@ using System; using System.Collections.Generic; using System.IO; +using System.Runtime; using System.Runtime.CompilerServices; using System.Runtime.InteropServices; +using System.Threading; +using System.Threading.Tasks; using Windows.Graphics; using Windows.UI; using WinRT.Interop; @@ -599,9 +602,7 @@ private static nint MainWndProc(nint hwnd, uint msg, nuint wParam, nint lParam) { ImageBackgroundManager.Shared.SetWindowMinimizeEvent(); InnerLauncherConfig.m_homePage?.StopCarouselSlideshow(); - ToggleDeferVisibility(Visibility.Collapsed); - CanvasDevice sharedDevice = CanvasDevice.GetSharedDevice(); - sharedDevice.Trim(); + ToggleDeferVisibility(Visibility.Collapsed, FlushSharedCanvasDevice); break; } case SC_RESTORE: @@ -621,7 +622,7 @@ private static nint MainWndProc(nint hwnd, uint msg, nuint wParam, nint lParam) { ImageBackgroundManager.Shared.SetWindowMinimizeEvent(); InnerLauncherConfig.m_homePage?.StopCarouselSlideshow(); - ToggleDeferVisibility(Visibility.Collapsed); + ToggleDeferVisibility(Visibility.Collapsed, FlushSharedCanvasDevice); } else { @@ -719,7 +720,13 @@ private static nint MainWndProc(nint hwnd, uint msg, nuint wParam, nint lParam) return PInvoke.CallWindowProc(_oldMainWndProcPtr, hwnd, msg, wParam, lParam); - static void ToggleDeferVisibility(Visibility visibility) + static void FlushSharedCanvasDevice() + { + CanvasDevice sharedDevice = CanvasDevice.GetSharedDevice(); + sharedDevice.Trim(); + } + + static void ToggleDeferVisibility(Visibility visibility, Action? runActionBeforeGC = null) { if (CurrentWindow.IsObjectDisposed() || CurrentWindow is not { } currentWindow) @@ -732,10 +739,63 @@ static void ToggleDeferVisibility(Visibility visibility) currentWindow.SystemBackdrop = visibility == Visibility.Collapsed ? null : lastBackdrop; currentWindow.Content.Visibility = visibility; + + CancellationTokenSource newCts = new(); + CancellationTokenSource? oldCts = Interlocked.Exchange(ref _gcJobMinimizedCts, newCts); + oldCts?.Cancel(); + oldCts?.Dispose(); + + if (visibility == Visibility.Collapsed) + { + // Run GC collection task in the background for 300 seconds approx. + // This however shouldn't bother any functionality of the launcher as the task will be cancelled + // immediately as the token is renewed and cancelled. + StartAggressiveGCCollectTask(10, 30, runActionBeforeGC, newCts.Token); + } + } + + static async void StartAggressiveGCCollectTask(double delayIntervalSec, int attempt, Action? runActionBeforeGC = null, CancellationToken token = default) + { + try + { + int attemptT = attempt; + while (--attempt >= 0) + { + if (token.IsCancellationRequested) + { + return; + } + + await Task.Delay(TimeSpan.FromSeconds(delayIntervalSec), token); + runActionBeforeGC?.Invoke(); + + GC.Collect(GC.MaxGeneration, + GCCollectionMode.Forced, + blocking: true, + compacting: true); + + GC.WaitForPendingFinalizers(); + } + + Logger.LogWriteLine($"[StartAggressiveGCCollectTask] Background GC Collection has been finished executing in: {attemptT * delayIntervalSec} seconds", + LogType.Info, + true); + } + catch (OperationCanceledException) + { + Logger.LogWriteLine("[StartAggressiveGCCollectTask] Background GC Collection Task was cancelled.", + LogType.Warning, + true); + } + catch (Exception ex) + { + Logger.LogWriteLine($"[StartAggressiveGCCollectTask] {ex}", LogType.Error, true); + } } } private static readonly Dictionary _windowBackdrops = []; + private static CancellationTokenSource? _gcJobMinimizedCts; #endregion From f184704f8bfa216bf9ca63e510557d721fe52eb8 Mon Sep 17 00:00:00 2001 From: Kemal Setya Adhi Date: Tue, 15 Sep 2026 20:35:16 +0700 Subject: [PATCH 33/50] Change ProgressBar with basic Border for carousel ticker --- .../PanelSlideshow.Templates.cs | 6 ++-- .../CustomControls/PanelSlideshow.Timer.cs | 12 ++++--- .../Theme/CustomControls/PanelSlideshow.xaml | 34 ++++++++++++------- 3 files changed, 32 insertions(+), 20 deletions(-) diff --git a/CollapseLauncher/XAMLs/Theme/CustomControls/PanelSlideshow.Templates.cs b/CollapseLauncher/XAMLs/Theme/CustomControls/PanelSlideshow.Templates.cs index f0239c769c..5021128f97 100644 --- a/CollapseLauncher/XAMLs/Theme/CustomControls/PanelSlideshow.Templates.cs +++ b/CollapseLauncher/XAMLs/Theme/CustomControls/PanelSlideshow.Templates.cs @@ -12,7 +12,7 @@ namespace CollapseLauncher.XAMLs.Theme.CustomControls; [TemplatePart(Name = TemplateNamePresenterGrid, Type = typeof(Grid))] [TemplatePart(Name = TemplateNamePreviousButton, Type = typeof(Button))] [TemplatePart(Name = TemplateNameNextButton, Type = typeof(Button))] -[TemplatePart(Name = TemplateNameCountdownProgressBar, Type = typeof(ProgressBar))] +[TemplatePart(Name = TemplateNameCountdownProgressBar, Type = typeof(Border))] [TemplatePart(Name = TemplateNamePreviousButtonShadow, Type = typeof(AttachedDropShadow))] [TemplatePart(Name = TemplateNameNextButtonShadow, Type = typeof(AttachedDropShadow))] @@ -52,7 +52,7 @@ public partial class PanelSlideshow private Button _nextButton = null!; private AttachedDropShadow _nextButtonShadow = null!; private Grid _nextButtonGrid = null!; - private ProgressBar _countdownProgressBar = null!; + private Border _countdownProgressBar = null!; private bool _isTemplateLoaded; @@ -75,7 +75,7 @@ protected override void OnApplyTemplate() _previousButtonShadow = this.GetTemplateChild(TemplateNamePreviousButtonShadow); _nextButton = this.GetTemplateChild