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..9c3764d42b --- /dev/null +++ b/CollapseLauncher/Classes/DiscordPresence/DiscordRpcManager.cs @@ -0,0 +1,422 @@ +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 _lastPresenceId = LauncherConfig.AppDiscordApplicationID; + private DiscordActivityType _lastActivityStatus; + + private readonly EventWaitHandle _isReadyWaitHandle = new(false, EventResetMode.ManualReset); + + public bool IsGameStatusEnabled + { + get => LauncherConfig.GetAppConfigValue("EnableDiscordGameStatus"); + set + { + LauncherConfig.SetAndSaveConfigValue("EnableDiscordGameStatus", value); + SetActivity(_lastActivityStatus); // Refresh activity status to the last one + } + } + + public bool IsShowOnIdle + { + get => LauncherConfig.GetAppConfigValue("EnableDiscordIdleStatus"); + set + { + LauncherConfig.SetAndSaveConfigValue("EnableDiscordIdleStatus", value); + SetActivity(_lastActivityStatus); // Refresh activity status to the last one + } + } + + 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(_lastActivityStatus); + } + } + } + + 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 + if (IsEnabled) + { + 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 presences + 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 = _lastPresenceId == 0 + ? LauncherConfig.AppDiscordApplicationID + : _lastPresenceId; + + // 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 _lastPresenceId, 0); + return; + } + + ulong presenceId = GetDiscordPresenceId(config); + Interlocked.Exchange(ref _lastPresenceId, 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 _lastActivityStatus, type); + + // Prevent from exhausting the Presence channel if not enabled. + if (!IsEnabled) return; + + // 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) + { + 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 => 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), + _ => IsShowOnIdle ? new RichPresence + { + Details = Locale.Current.Lang?._Misc?.DiscordRP_Default, + Assets = new Assets + { + LargeImageKey = PresenceBuilder.DefaultLauncherLogo, + LargeImageText = PresenceBuilder.DefaultLauncherLogoTooltip + }, + Timestamps = null! + } : 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); + + 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, Timestamps? timestamps = null) + { + PresetConfig? presetConfig = manager._currentPresetConfig; + TryGetGameIconsAndTranslatedNames(presetConfig, + out string? largeIconUrl, + out string? largeIconTooltip, + out string? smallIconUrl, + out string? smallIconTooltip, + 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 = timestamps + }; + } + + 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? 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; + string? 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/Extension/UIElementExtensions.cs b/CollapseLauncher/Classes/Extension/UIElementExtensions.cs index 5fe490be70..88e3c5a1b4 100644 --- a/CollapseLauncher/Classes/Extension/UIElementExtensions.cs +++ b/CollapseLauncher/Classes/Extension/UIElementExtensions.cs @@ -57,7 +57,11 @@ internal static T BindNavigationViewItemText(this T element, object? localeOb internal static T BindTooltipToLocale(this T element, object? localeObjBinding, string localePropertyName, IValueConverter? converter = null, object? converterParameter = null) where T : DependencyObject { - TextBlock tooltipTextBlock = new(); + TextBlock tooltipTextBlock = new() + { + TextWrapping = TextWrapping.Wrap, + TextTrimming = TextTrimming.CharacterEllipsis + }; tooltipTextBlock.BindProperty(TextBlock.TextProperty, localeObjBinding, localePropertyName, @@ -1306,5 +1310,14 @@ internal static T Create(Action? setAttributeDelegate = null) return element; } + + internal static void UpdateLayoutAndBinding(this ComboBox comboBox) + { + comboBox.UpdateLayout(); + object? lastSelectedItem = comboBox.SelectedItem; + comboBox.SelectedItem = null; + comboBox.SelectedItem = lastSelectedItem; + comboBox.UpdateLayout(); + } } } diff --git a/CollapseLauncher/Classes/GameManagement/GameSettings/BaseClass/ImportExportBase.cs b/CollapseLauncher/Classes/GameManagement/GameSettings/BaseClass/ImportExportBase.cs index e907634086..50b1e78d8a 100644 --- a/CollapseLauncher/Classes/GameManagement/GameSettings/BaseClass/ImportExportBase.cs +++ b/CollapseLauncher/Classes/GameManagement/GameSettings/BaseClass/ImportExportBase.cs @@ -1,6 +1,9 @@ using CollapseLauncher.Helper; +using CollapseLauncher.Helper.Database; +using CollapseLauncher.Helper.Metadata; using CollapseLauncher.Interfaces; using Hi3Helper; +using Hi3Helper.Data; using Hi3Helper.EncTool; using Hi3Helper.UABT; using Hi3Helper.UABT.Binary; @@ -70,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); @@ -190,11 +193,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 +536,70 @@ 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; + } + + 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/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/Classes/GameManagement/GameSettings/Zenless/Sleepy.cs b/CollapseLauncher/Classes/GameManagement/GameSettings/Zenless/Sleepy.cs index 87c4309354..07e050c15c 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,53 @@ internal static string ReadString(string filePath, ReadOnlySpan magic) return ReadString(stream, magic); } - internal static unsafe string ReadString(Stream stream, ReadOnlySpan magic) + [SkipLocalsInit] + 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 +124,8 @@ internal static void WriteString(string filePath, ReadOnlySpan content, Re WriteString(stream, content, magic); } - internal static unsafe void WriteString(Stream stream, ReadOnlySpan content, ReadOnlySpan magic) + [SkipLocalsInit] + 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 +134,206 @@ 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, + evil, + evilBuffer, + unevilBuffer.AsSpan(0, unevilBufferLen)); + + 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, + scoped ReadOnlySpan evil, + Span evilBuffer, + ReadOnlySpan unevilBuffer) { 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.ReadAssert(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.ReadAssert(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.ReadAssert(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 ReadAssert(T assertWith) + where T : unmanaged + { + 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); + 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 WriteEnumAsInt32(this BinaryWriter writer, T headerEnum) - where T : struct, Enum - { - int enumValue = Unsafe.As(ref headerEnum); - writer.Write(enumValue); - } - private static void LogAssertInfoByteEnum(this BinaryReader stream, T assertHeaderEnum) - where T : struct, Enum + extension(BinaryWriter writer) { - int currentInt = stream.ReadByte(); - LogAssertInfo(stream, ref assertHeaderEnum, ref currentInt); - } + private void EmulateWrite() + { + // 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); + } - private static void LogAssertInfoInt32Enum(this BinaryReader stream, T assertHeaderEnum) - where T : struct, Enum - { - int currentInt = stream.ReadInt32(); - LogAssertInfo(stream, ref assertHeaderEnum, ref currentInt); - } + // Emulate to write Sleepy BinaryFormatter footer EOF + private void EmulateWriteMessageEnd() => + writer.Write(BinaryHeaderEnum.MessageEnd); - private static void LogAssertInfo(BinaryReader reader, ref T assertHeaderEnum, ref int currentInt) - where T : struct, Enum - { - int intAssertCasted = Unsafe.As(ref assertHeaderEnum); - if (intAssertCasted != currentInt) + private void Write(T value) + where T : unmanaged { - string? assertHeaderEnumValueName = Enum.GetName(assertHeaderEnum); - T comparedEnumCasted = Unsafe.As(ref currentInt); - string? comparedHeaderEnumValueName = Enum.GetName(comparedEnumCasted); - - 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!"); + 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), + 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 diff --git a/CollapseLauncher/Classes/GameManagement/ImageBackground/ImageBackgroundManager.CodecDetect.cs b/CollapseLauncher/Classes/GameManagement/ImageBackground/ImageBackgroundManager.CodecDetect.cs index da68852c83..f7e23c164a 100644 --- a/CollapseLauncher/Classes/GameManagement/ImageBackground/ImageBackgroundManager.CodecDetect.cs +++ b/CollapseLauncher/Classes/GameManagement/ImageBackground/ImageBackgroundManager.CodecDetect.cs @@ -18,13 +18,13 @@ public partial class ImageBackgroundManager private async ValueTask<(bool IsSupported, bool IsVideo)> CheckCodecOrSpawnDialog(Uri? fileUri) { - // -- Cancel if null - if (fileUri == null) + // -- Cancel if null or URI is not a local file + if (fileUri == null || !fileUri.IsFile) { return (false, false); } - string filePath = fileUri.IsFile ? fileUri.LocalPath : fileUri.ToString(); + string filePath = fileUri.LocalPath; // -- Check for supported extension first if (!IsMediaFileExtensionSupported(filePath)) diff --git a/CollapseLauncher/Classes/GameManagement/ImageBackground/ImageBackgroundManager.ImageCropperAndConvert.cs b/CollapseLauncher/Classes/GameManagement/ImageBackground/ImageBackgroundManager.ImageCropperAndConvert.cs index dca17f19f6..3cb1b575be 100644 --- a/CollapseLauncher/Classes/GameManagement/ImageBackground/ImageBackgroundManager.ImageCropperAndConvert.cs +++ b/CollapseLauncher/Classes/GameManagement/ImageBackground/ImageBackgroundManager.ImageCropperAndConvert.cs @@ -478,7 +478,7 @@ private static bool IsWebpExtendedFormat(Stream stream, out ColorProfileMode col /// Get native or redirected decoded image path. /// /// If the codec of the image is not supported. - internal static async Task<(string, ImageExternalCodecType)> GetNativeOrDecodedImagePath(string filePath, CancellationToken token) + internal static async ValueTask<(string, ImageExternalCodecType)> GetNativeOrDecodedImagePath(string filePath, CancellationToken token) { // Try to get decoded temporary file. If it exists, return the file path. if (TryGetDecodedTemporaryFile(filePath, out string decodedFilePath)) @@ -510,7 +510,7 @@ private static bool IsWebpExtendedFormat(Stream stream, out ColorProfileMode col /// Get native or redirected decoded image path. /// /// If the codec of the image is not supported. - internal static async Task<(Uri, ImageExternalCodecType)> GetNativeOrDecodedImagePath( + internal static async ValueTask<(Uri, ImageExternalCodecType)> GetNativeOrDecodedImagePath( Uri filePath, CancellationToken token) { (string, ImageExternalCodecType) result = await GetNativeOrDecodedImagePath(filePath.ToString(), token); diff --git a/CollapseLauncher/Classes/GameManagement/ImageBackground/ImageBackgroundManager.Loaders.cs b/CollapseLauncher/Classes/GameManagement/ImageBackground/ImageBackgroundManager.Loaders.cs index 3441f50acd..a5fe627358 100644 --- a/CollapseLauncher/Classes/GameManagement/ImageBackground/ImageBackgroundManager.Loaders.cs +++ b/CollapseLauncher/Classes/GameManagement/ImageBackground/ImageBackgroundManager.Loaders.cs @@ -246,14 +246,6 @@ private async Task LoadImageAtIndexCore(int index, bool forceLoadToStatic, Cance return; } - // -- Get upscaled image file if Waifu2X is enabled - if (GlobalIsWaifu2XEnabled) - { - downloadedOverlayUri = await TryGetScaledWaifu2XImagePath(downloadedOverlayUri, token).ConfigureAwait(false); - downloadedBackgroundUri = await TryGetScaledWaifu2XImagePath(downloadedBackgroundUri, token).ConfigureAwait(false); - downloadedBackgroundStaticUri = await TryGetScaledWaifu2XImagePath(downloadedBackgroundStaticUri, token).ConfigureAwait(false); - } - token.ThrowIfCancellationRequested(); // -- Check for codec support (Also spawn dialog to install either native WIC/MediaFoundation decoder or using Ffmpeg decoder) @@ -263,6 +255,14 @@ private async Task LoadImageAtIndexCore(int index, bool forceLoadToStatic, Cance return; } + // -- Get upscaled image file if Waifu2X is enabled + if (GlobalIsWaifu2XEnabled) + { + downloadedOverlayUri = await TryGetScaledWaifu2XImagePath(downloadedOverlayUri, token).ConfigureAwait(false); + downloadedBackgroundUri = await TryGetScaledWaifu2XImagePath(downloadedBackgroundUri, token).ConfigureAwait(false); + downloadedBackgroundStaticUri = await TryGetScaledWaifu2XImagePath(downloadedBackgroundStaticUri, token).ConfigureAwait(false); + } + // Try to force loading static image if requested. if (forceLoadToStatic && downloadedBackgroundStaticUri != null) { @@ -362,7 +362,7 @@ private void SpawnImageLayer(Uri? overlayFilePath, return; } - if (CurrentBackgroundElement is LayeredBackgroundImage existingLayer && + if (CurrentBackgroundElement is { } existingLayer && IsSameLocalFile(existingLayer.BackgroundSource, backgroundFilePath) && IsSameLocalFile(existingLayer.BackgroundStaticSource, backgroundStaticFilePath)) { @@ -376,7 +376,7 @@ private void SpawnImageLayer(Uri? overlayFilePath, private static bool IsSameLocalFile(object? currentSource, Uri? newFilePath) { if (newFilePath == null) return currentSource == null; - string? newPath = newFilePath.IsFile ? newFilePath.LocalPath : newFilePath.OriginalString; + string newPath = newFilePath.IsFile ? newFilePath.LocalPath : newFilePath.OriginalString; string? currentPath = currentSource switch { Uri uri => uri.IsFile ? uri.LocalPath : uri.OriginalString, @@ -389,7 +389,7 @@ private static bool IsSameLocalFile(object? currentSource, Uri? newFilePath) private async Task RestoreSavedAccent(string cachedBgKey, Uri? fallbackSourceUri = null) { string? savedHex = LauncherConfig.GetAppConfigValue($"{cachedBgKey}-AccentColor").ToString(); - if (!string.IsNullOrEmpty(savedHex) && savedHex!.Length >= 6 && ThemeRootElement != null) + if (!string.IsNullOrEmpty(savedHex) && savedHex.Length >= 6 && ThemeRootElement != null) { if (TryParseHexColor(savedHex, out Color accentColor)) { @@ -414,7 +414,7 @@ private async Task RestoreSavedAccent(string cachedBgKey, Uri? fallbackSourceUri } } - private static bool TryParseHexColor(string hex, out Color color) + private static bool TryParseHexColor(ReadOnlySpan hex, out Color color) { color = default; if (hex.Length < 6) return false; @@ -445,7 +445,8 @@ private LayeredBackgroundImage CreateLayerElement(Uri? overlayFilePath, }; if (!CurrentIsEnableCustomImage && - !GlobalIsEnableCustomImage) + !GlobalIsEnableCustomImage && + backgroundStaticFilePath != null) { layerElement.BindProperty(LayeredBackgroundImage.IsVideoAutoplayProperty, this, @@ -644,7 +645,7 @@ private async Task GetMediaAccentColor(object? context) Color color = await ColorPaletteUtility.GetMediaAccentColorFromAsync(asUri, useFfmpegForVideo) .ConfigureAwait(false); - if (color == default(Color)) return; + if (color == default) return; string hex = $"{color.R:X2}{color.G:X2}{color.B:X2}"; if (!string.IsNullOrEmpty(configKey)) diff --git a/CollapseLauncher/Classes/GameManagement/ImageBackground/ImageBackgroundManager.StreamAndPath.cs b/CollapseLauncher/Classes/GameManagement/ImageBackground/ImageBackgroundManager.StreamAndPath.cs index ec50a11353..be30af72a9 100644 --- a/CollapseLauncher/Classes/GameManagement/ImageBackground/ImageBackgroundManager.StreamAndPath.cs +++ b/CollapseLauncher/Classes/GameManagement/ImageBackground/ImageBackgroundManager.StreamAndPath.cs @@ -36,14 +36,14 @@ public partial class ImageBackgroundManager #endregion - private static Task OpenStreamFromFileOrUrl(string? filePath, CancellationToken token) + private static ValueTask OpenStreamFromFileOrUrl(string? filePath, CancellationToken token) { return !Uri.TryCreate(filePath, UriKind.Absolute, out Uri? uri) ? throw new InvalidOperationException($"File path or URL is misformed! {filePath}") : OpenStreamFromFileOrUrl(uri, token); } - private static async Task OpenStreamFromFileOrUrl(Uri uri, CancellationToken token) + private static async ValueTask OpenStreamFromFileOrUrl(Uri uri, CancellationToken token) { if (uri.IsFile) { @@ -63,7 +63,7 @@ private static async Task OpenStreamFromFileOrUrl(Uri uri, Cancellat } HttpClient sharedClient = FallbackCDNUtil.GetGlobalHttpClient(true); - status = await sharedClient.GetCachedUrlStatus(uri, token); + status = await sharedClient.GetCachedUrlStatus(uri, token).ConfigureAwait(false); status.EnsureSuccessStatusCode(); if (status.FileSize == 0) @@ -82,9 +82,14 @@ private static async Task OpenStreamFromFileOrUrl(Uri uri, Cancellat downloadedFilePath.Open(FileMode.Create, FileAccess.ReadWrite, FileShare.ReadWrite); using HttpResponseMessage responseMessage = - await sharedClient.GetAsync(uri, HttpCompletionOption.ResponseHeadersRead, token); - await using Stream responseStream = await responseMessage.Content.ReadAsStreamAsync(token); - await responseStream.CopyToAsync(downloadedFileStream, token); + await sharedClient.GetAsync(uri, HttpCompletionOption.ResponseHeadersRead, token) + .ConfigureAwait(false); + await using Stream responseStream = + await responseMessage.Content + .ReadAsStreamAsync(token) + .ConfigureAwait(false); + await responseStream.CopyToAsync(downloadedFileStream, token) + .ConfigureAwait(false); // Write stamp for future cache metadata ReadOnlySpan stampData = AsSpan(in status); @@ -97,13 +102,13 @@ static unsafe ReadOnlySpan AsSpan(in T data) where T : unmanaged => new(Unsafe.AsPointer(in data), sizeof(T)); } - internal static async Task GetLocalOrDownloadedFilePath(Uri uri, CancellationToken token) + internal static async ValueTask GetLocalOrDownloadedFilePath(Uri uri, CancellationToken token) { await using FileStream stream = await OpenStreamFromFileOrUrl(uri, token); return new Uri(stream.Name); } - internal static async Task GetLocalOrDownloadedFilePath(string path, CancellationToken token) + internal static async ValueTask GetLocalOrDownloadedFilePath(string path, CancellationToken token) { Uri uri = await GetLocalOrDownloadedFilePath(new Uri(path), token); return uri.IsFile diff --git a/CollapseLauncher/Classes/Helper/Background/ColorPaletteUtility.cs b/CollapseLauncher/Classes/Helper/Background/ColorPaletteUtility.cs index a6f698043d..6f4c85439c 100644 --- a/CollapseLauncher/Classes/Helper/Background/ColorPaletteUtility.cs +++ b/CollapseLauncher/Classes/Helper/Background/ColorPaletteUtility.cs @@ -62,7 +62,7 @@ private static byte[] GetSharedBufferOrResizeTo(int size) } } - public static async Task GetMediaAccentColorFromAsync( + public static async ValueTask GetMediaAccentColorFromAsync( Uri uri, bool useFfmpegForVideo, CancellationToken token = default) @@ -336,7 +336,7 @@ static unsafe FileStream CreateSvgTempStream(int width, int height, Span b } static unsafe ref T AsRef(Span span) - => ref Unsafe.AsRef(Unsafe.AsPointer(ref MemoryMarshal.AsRef(span))); + => ref Unsafe.As(ref MemoryMarshal.AsRef(span)); } catch (Exception ex) { diff --git a/CollapseLauncher/Classes/Helper/Database/DBHandler.cs b/CollapseLauncher/Classes/Helper/Database/DBHandler.cs index 69b5bb6e6e..e5f5e467fc 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; @@ -29,7 +30,7 @@ public static bool? IsEnabled field = value; DbConfig.DbEnabled = value ?? false; - _isFirstInit = true; // Force first init + IsInitialized = false; // Force first init if (!(value ?? false)) Dispose(); // Dispose instance if user disabled database function globally } } @@ -45,11 +46,11 @@ public static string? Uri } set { - if (value != field) _isFirstInit = true; // Force first init if value changed + if (value != field) IsInitialized = false; // Force first init if value changed field = value; DbConfig.DbUrl = value; - _isFirstInit = true; + IsInitialized = false; } } @@ -65,11 +66,11 @@ public static string? Token } set { - if (value != field) _isFirstInit = true; // Force first init if value changed + if (value != field) IsInitialized = false; // Force first init if value changed field = value; DbConfig.DbToken = value; - _isFirstInit = true; + IsInitialized = false; } } @@ -93,7 +94,7 @@ public static string? UserId } set { - if (value != field) _isFirstInit = true; // Force first init if value changed + if (value != field) IsInitialized = false; // Force first init if value changed field = value; DbConfig.UserGuid = value; @@ -101,17 +102,18 @@ public static string? UserId if (string.IsNullOrWhiteSpace(value)) { _userIdHash = null; - _isFirstInit = true; + IsInitialized = false; return; } var byteUidH = System.IO.Hashing.XxHash64.Hash(Encoding.ASCII.GetBytes(value)); _userIdHash = Convert.ToHexStringLower(byteUidH); - _isFirstInit = true; + IsInitialized = false; } } + + public static bool IsInitialized { get; private set; } = false; - private static bool _isFirstInit = true; #endregion [DebuggerBrowsable(DebuggerBrowsableState.Never)] @@ -156,14 +158,18 @@ public static async Task Init(bool redirectThrow = false, bool bypassEnableFlag opts.AuthToken = Token; }); - if (_isFirstInit) + if (!IsInitialized) { LogWriteLine("[DbHandler::Init] Initializing database system..."); // Ensure table exist at first initialization await _database .Execute($"CREATE TABLE IF NOT EXISTS \"uid-{_userIdHash}\" (Id INTEGER PRIMARY KEY AUTOINCREMENT, 'key' TEXT UNIQUE NOT NULL, 'value' TEXT)"); - _isFirstInit = false; + + await + _database + .Execute($"CREATE TABLE IF NOT EXISTS \"uid-{_userIdHash}-blob\" (Id INTEGER PRIMARY KEY AUTOINCREMENT, 'key' TEXT UNIQUE NOT NULL, 'value' BLOB)"); + IsInitialized = true; } else LogWriteLine("[DbHandler::Init] Reinitializing database system..."); } @@ -218,7 +224,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 @@ -229,7 +235,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 @@ -237,8 +243,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; } @@ -278,24 +292,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; } @@ -338,7 +371,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 @@ -347,22 +380,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 } @@ -386,16 +450,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 diff --git a/CollapseLauncher/Classes/Helper/Image/Waifu2X.cs b/CollapseLauncher/Classes/Helper/Image/Waifu2X.cs index cb4cbf279b..0fe47486db 100644 --- a/CollapseLauncher/Classes/Helper/Image/Waifu2X.cs +++ b/CollapseLauncher/Classes/Helper/Image/Waifu2X.cs @@ -219,7 +219,7 @@ public static Waifu2XStatus VulkanTest() Logger.LogWriteLine("D3DMappingLayers package detected. Fallback to CPU mode.", LogType.Warning, true); return Waifu2XStatus.D3DMappingLayers; } - var status = Waifu2XPInvoke.waifu2x_self_test(0); + Waifu2XStatus status = Waifu2XPInvoke.waifu2x_self_test(0); switch (status) { case Waifu2XStatus.CpuMode: @@ -248,7 +248,7 @@ public static Waifu2XStatus VulkanTest() return ReturnAsFailedDllInit(ex); } - Waifu2XStatus ReturnAsFailedDllInit(T ex) + static Waifu2XStatus ReturnAsFailedDllInit(T ex) where T : Exception { Logger.LogWriteLine($"Cannot load Waifu2X as the library failed to load!\r\n{ex}", LogType.Error, true); 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/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/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/Helper/TaskSchedulerHelper.cs b/CollapseLauncher/Classes/Helper/TaskSchedulerHelper.cs index 188feb099b..8eb5ac137f 100644 --- a/CollapseLauncher/Classes/Helper/TaskSchedulerHelper.cs +++ b/CollapseLauncher/Classes/Helper/TaskSchedulerHelper.cs @@ -2,58 +2,38 @@ 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(); +namespace CollapseLauncher.Helper; - return _cachedIsOnTrayEnabled; - } +internal static class TaskSchedulerHelper +{ + private static readonly string StubLocation = VelopackLocatorExtension.FindCollapseStubPath(); + private const string CollapseStartupTaskName = "CollapseLauncherStartupTask"; - internal static bool IsEnabled() - { - if (!_isInitialized) - InvokeGetStatusCommand(); + private static bool _cachedIsOnTrayEnabled; + private static bool _cachedIsEnabled; - return _cachedIsEnabled; - } + internal static bool IsOnTrayEnabled() + { + IsEnabled(); + return _cachedIsOnTrayEnabled; + } - private static void InvokeGetStatusCommand() + internal static bool IsEnabled() + { + try { - // 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); - + int returnCode = TaskSchedulerUtil.IsEnabled(CollapseStartupTaskName, StubLocation); (_cachedIsEnabled, _cachedIsOnTrayEnabled) = returnCode switch { // -1 means task is disabled with tray enabled @@ -65,233 +45,131 @@ private static void InvokeGetStatusCommand() // 2 means task is enabled with tray enabled 2 => (true, true), // Otherwise, return both disabled (due to failure) - _ => (false, false) + _ => (false, false) }; - // Print init determination - CheckInitDetermination(returnCode); - } - - 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); - } - } - - internal static void ToggleTrayEnabled(bool isEnabled) - { - _cachedIsOnTrayEnabled = isEnabled; - InvokeToggleCommand(); + return _cachedIsEnabled; } - - internal static void ToggleEnabled(bool isEnabled) + catch (Exception ex) { - _cachedIsEnabled = isEnabled; - InvokeToggleCommand(); + Logger.LogWriteLine($"An error occurred while trying to check TaskSchedulerUtil.IsEnabled\r\n{ex}", + LogType.Error, + true); + SentryHelper.ExceptionHandler(ex); + return false; } + } - private static void InvokeToggleCommand() - { - // 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); + internal static void ToggleTrayEnabled(bool isEnabled) + { + _cachedIsOnTrayEnabled = isEnabled; + ToggleCore(); + } - // Print init determination - CheckInitDetermination(returnCode); - } + internal static void ToggleEnabled(bool isEnabled) + { + _cachedIsEnabled = isEnabled; + ToggleCore(); + } - private static void AppendTaskNameAndPathArgument(StringBuilder argumentBuilder) + private static void ToggleCore() + { + try { - // Get current stub or main executable path - string currentExecPath = VelopackLocatorExtension.FindCollapseStubPath(); - - // Build argument to the task name - argumentBuilder.Append(" \""); - argumentBuilder.Append(CollapseStartupTaskName); - argumentBuilder.Append('"'); - - // Build argument to the executable path - argumentBuilder.Append(" \""); - argumentBuilder.Append(currentExecPath); - argumentBuilder.Append('"'); + TaskSchedulerUtil.ToggleTask(_cachedIsEnabled, _cachedIsOnTrayEnabled, CollapseStartupTaskName, StubLocation); } - - internal static void RecreateIconShortcuts() + catch (Exception ex) { - // 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); + Logger.LogWriteLine($"An error occurred while trying to toggle Task Scheduler Task using TaskSchedulerUtil.ToggleTask\r\n{ex}", + LogType.Error, + true); + SentryHelper.ExceptionHandler(ex); } + } - private static void CreateShortcut( - string iconLocation, - string? appDescription, - string? executablePath, - string? workingDirPath) - { - // Try create icon location directory - string iconLocationDir = Path.GetDirectoryName(iconLocation) ?? ""; - - // Try create directory - Directory.CreateDirectory(iconLocationDir); - - // Create ShellLink instance - ShellLink shellLink = new(); + 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); + } - // 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 ?? ""; + private static void CreateShortcut( + string iconLocation, + string? appDescription, + string? executablePath, + string? workingDirPath) + { + // Try create icon location directory + string iconLocationDir = Path.GetDirectoryName(iconLocation) ?? ""; - // Save the icons - shellLink.Save(iconLocation); - } + // Try create directory + Directory.CreateDirectory(iconLocationDir); + + // Create ShellLink instance + ShellLink shellLink = new(); - internal static (string IconStartMenu, string IconDesktop) GetIconLocationPaths( - out string? appProductName, - out string? appDescription, - out string? executablePath, - out string? workingDirPath) + // If existing icon exist, try open it + try { - // 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); + if (File.Exists(iconLocation)) + shellLink.Open(iconLocation); } - - private static int GetInvokeCommandReturnCode(string argument) + catch (Exception ex) { - 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; + 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); + } + + 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/Classes/Helper/WindowUtility.cs b/CollapseLauncher/Classes/Helper/WindowUtility.cs index 4a09ee5824..af1981e3ed 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; @@ -25,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; @@ -598,12 +602,14 @@ private static nint MainWndProc(nint hwnd, uint msg, nuint wParam, nint lParam) { ImageBackgroundManager.Shared.SetWindowMinimizeEvent(); InnerLauncherConfig.m_homePage?.StopCarouselSlideshow(); + ToggleDeferVisibility(Visibility.Collapsed, FlushSharedCanvasDevice); break; } case SC_RESTORE: { ImageBackgroundManager.Shared.SetWindowRestoreEvent(); InnerLauncherConfig.m_homePage?.StartCarouselSlideshow(); + ToggleDeferVisibility(Visibility.Visible); break; } } @@ -614,11 +620,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, FlushSharedCanvasDevice); } else { + ImageBackgroundManager.Shared.SetWindowRestoreEvent(); InnerLauncherConfig.m_homePage?.StartCarouselSlideshow(); + ToggleDeferVisibility(Visibility.Visible); } break; } @@ -709,8 +719,84 @@ private static nint MainWndProc(nint hwnd, uint msg, nuint wParam, nint lParam) } return PInvoke.CallWindowProc(_oldMainWndProcPtr, hwnd, msg, wParam, lParam); + + static void FlushSharedCanvasDevice() + { + CanvasDevice sharedDevice = CanvasDevice.GetSharedDevice(); + sharedDevice.Trim(); + } + + static void ToggleDeferVisibility(Visibility visibility, Action? runActionBeforeGC = null) + { + 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; + + 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 #region Titlebar Methods 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 094de6a0e1..671af8af42 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; @@ -158,7 +159,6 @@ public InstallManagerBase( $"{Path.GetFileNameWithoutExtension(gameVersionManager.GamePreset.GameExecutableName)}_Data\\Persistent"; _gameStreamingAssetsFolderBasePath = $"{Path.GetFileNameWithoutExtension(gameVersionManager.GamePreset.GameExecutableName)}_Data\\StreamingAssets"; - UpdateCompletenessStatus(CompletenessStatus.Idle); } protected void ResetToken() @@ -315,15 +315,13 @@ 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); + using HDiffInfo hdiffInfo = await HPatch.CreateInstanceAsync(patchProperty.PatchPath, Token!.Token); + Exception? resultException = await HPatch.PatchAsync(hdiffInfo, patchProperty.PatchPath, ingredientPath, + previousPath, PatchOptions.BigBuffer, DeltaPatchProgress, + token: Token.Token); + + if (resultException != null) + throw resultException; // Remove ingredient folder Directory.Delete(ingredientPath, true); @@ -346,11 +344,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( @@ -685,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; @@ -749,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() @@ -1348,38 +1337,44 @@ 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 + Exception? resultException = await HPatch.PatchAsync(hdiffInfo, + patchFileInfo.FullName, + sourceFileInfo.FullName, + targetFileInfo.FullName, + PatchOptions.BigBuffer, + EventListener_PatchEvent, + 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) @@ -1442,7 +1437,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) @@ -1457,169 +1452,158 @@ protected virtual async Task ApplyHDiffMap() ProgressAllCountTotal = 1; ProgressAllCountFound = hDiffMapEntries.Count; - HDiffPatch.LogVerbosity = Verbosity.Verbose; - EventListener.LoggerEvent += EventListener_PatchLogEvent; - EventListener.PatchEvent += 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, + EventListener_PatchEvent, + 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(); @@ -1641,10 +1625,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, @@ -1727,17 +1707,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()) { @@ -1754,32 +1729,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 = []; @@ -1816,7 +1765,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); @@ -1840,6 +1789,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 @@ -3000,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) @@ -3233,7 +3212,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 +3223,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 +3240,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 +3251,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; } @@ -3305,58 +3284,85 @@ protected virtual GameInstallFileInfo GetGameInstallFileInfo() #endregion - #region Event Methods + #region Private Methods - protected void UpdateProgressBase() + private static List MergeChunkedPackage(List packages) { - base.UpdateProgress(); - } + List dedupList = []; - protected void DeltaPatchCheckProgress(object? sender, PatchEvent e) - { - if (!CheckIfNeedRefreshStopwatch()) + // Clone the list first. + foreach (GameInstallPackage package in packages) { - return; + 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()); } - lock (Progress) + // Start adding up the chunk files. + foreach (GameInstallPackage dedupPackage in dedupList) { - Progress.ProgressAllPercentage = e.ProgressPercentage; - Progress.ProgressAllTimeLeft = e.TimeLeft; - Progress.ProgressAllSpeed = e.Speed; - Progress.ProgressAllSizeTotal = e.TotalSizeToBePatched; - Progress.ProgressAllSizeCurrent = e.CurrentSizePatched; + 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()); + } } - Status.IsProgressAllIndetermined = false; - UpdateProgressBase(); - UpdateStatus(); + return dedupList; + } + + #endregion + + #region Event Methods + + protected void UpdateProgressBase() + { + base.UpdateProgress(); } - protected void DeltaPatchCheckLogEvent(object? sender, LoggerEvent e) + private void DeltaPatchProgress(long totalWritten, long totalSize, int currentlyWritten) { - 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)) + double speed = CalculateSpeed(currentlyWritten); + if (!CheckIfNeedRefreshStopwatch()) { return; } - LogType type = e.LogLevel switch - { - Verbosity.Verbose => LogType.Debug, - Verbosity.Debug => LogType.Debug, - _ => LogType.Default - }; + 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; - LogWriteLine(e.Message, type, true); + Status.IsProgressAllIndetermined = false; + UpdateProgressBase(); + UpdateStatus(); } protected void DeltaPatchCheckProgress(object? sender, TotalPerFileProgress e) 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(); } diff --git a/CollapseLauncher/Classes/Interfaces/IGameSettingsUniversal.cs b/CollapseLauncher/Classes/Interfaces/IGameSettingsUniversal.cs index 46281787a5..0d5be1c1b9 100644 --- a/CollapseLauncher/Classes/Interfaces/IGameSettingsUniversal.cs +++ b/CollapseLauncher/Classes/Interfaces/IGameSettingsUniversal.cs @@ -22,7 +22,12 @@ public interface IGameSettingsExportable RegistryKey? RegistryRoot { get; } RegistryKey? RefreshRegistryRoot(); - Task ImportSettings(string? gameBasePath = null); - Task ExportSettings(bool isCompressed = true, string? parentPathToImport = null, string[]? relativePathToImport = 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/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/Properties/WindowSizeProp/WindowSizeProp.cs b/CollapseLauncher/Classes/Properties/WindowSizeProp/WindowSizeProp.cs index 3851f3d258..2071ff6009 100644 --- a/CollapseLauncher/Classes/Properties/WindowSizeProp/WindowSizeProp.cs +++ b/CollapseLauncher/Classes/Properties/WindowSizeProp/WindowSizeProp.cs @@ -33,12 +33,12 @@ Dictionary WindowSizeProfiles PostEventPanelScaleFactor = 1.35f, SidePanel1Width = new GridLength(340, GridUnitType.Pixel), EventPostCarouselBounds = new Size(340, 158), - PostPanelBounds = new Size(340, 84), + PostPanelBounds = new Size(340, 100), PostPanelBottomMargin = new Thickness(0, 0, 0, 20), PostPanelPaimonHeight = 110, PostPanelPaimonMargin = new Thickness(0, -48, -56, 0), PostPanelPaimonInnerMargin = new Thickness(0, 0, 0, 0), - PostPanelPaimonTextMargin = new Thickness(0, 0, 0, 18), + PostPanelPaimonTextMargin = new Thickness(0, 0, 0, 24), PostPanelPaimonTextSize = 11, BannerIconHeight = 40, BannerIconHeightHYP = 40, @@ -59,12 +59,12 @@ Dictionary WindowSizeProfiles PostEventPanelScaleFactor = 1.25f, SidePanel1Width = new GridLength(280, GridUnitType.Pixel), EventPostCarouselBounds = new Size(280, 130), - PostPanelBounds = new Size(280, 82), + PostPanelBounds = new Size(280, 80), PostPanelBottomMargin = new Thickness(0, 0, 0, 12), PostPanelPaimonHeight = 110, PostPanelPaimonMargin = new Thickness(0, -48, -56, 0), PostPanelPaimonInnerMargin = new Thickness(0, 0, 0, 0), - PostPanelPaimonTextMargin = new Thickness(0, 0, 0, 18), + PostPanelPaimonTextMargin = new Thickness(0, 0, 0, 20), PostPanelPaimonTextSize = 11, BannerIconHeight = 32, BannerIconHeightHYP = 32, 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/CollapseLauncher.csproj b/CollapseLauncher/CollapseLauncher.csproj index f66aa6163e..ea5db1cc8f 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 @@ -274,10 +274,10 @@ - + - - + + all runtime; build; native; contentfiles; analyzers; buildtransitive @@ -303,7 +303,7 @@ - + @@ -313,8 +313,8 @@ - - + + @@ -323,6 +323,7 @@ + @@ -477,10 +478,6 @@ --> - - - - @@ -494,7 +491,6 @@ - diff --git a/CollapseLauncher/Program.cs b/CollapseLauncher/Program.cs index 9ab411f59e..998d8f1422 100644 --- a/CollapseLauncher/Program.cs +++ b/CollapseLauncher/Program.cs @@ -1,6 +1,7 @@ using CollapseLauncher.Extension; using CollapseLauncher.Helper; using CollapseLauncher.Helper.Database; +using CollapseLauncher.Helper.Image; using CollapseLauncher.Helper.InternalPInvoke; using CollapseLauncher.Helper.Update; using Hi3Helper; @@ -181,7 +182,6 @@ private static void InitializeExperimentalWinUIFeatures() // https://github.com/sundaramramaswamy/microsoft-ui-xaml/blob/069fbc9683b3b07df5549961e00251439a6916cd/specs/XamlOptionalChanges/XamlOptionalChanges-Spec.md#xamlchangeid-enum EnableXamlOpts(XamlChangeId.DefaultStyleOptimizations, XamlChangeId.DeferContextFlyoutInit, - XamlChangeId.IconNoGridOptimization, XamlChangeId.OptimizeApplyStyles); return; @@ -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 @@ -371,6 +373,11 @@ private static async void InitOtherSdkAsync() * Module: MagicScaler External Codecs for Image Decoding */ InitMagicScalerExternalCodecs(); + + /* --------------------------------------------------------------------------------------------- + * Module: Waifu2X (Start device test in the background and cache it) + */ + ImageLoaderHelper.EnsureWaifu2X(); } catch (Exception ex) { 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/GameSettingsPageBase.cs b/CollapseLauncher/XAMLs/MainApp/Pages/GameSettingsPages/GameSettingsPageBase.cs index 548c2d905e..13d45482e3 100644 --- a/CollapseLauncher/XAMLs/MainApp/Pages/GameSettingsPages/GameSettingsPageBase.cs +++ b/CollapseLauncher/XAMLs/MainApp/Pages/GameSettingsPages/GameSettingsPageBase.cs @@ -1,10 +1,12 @@ -using CollapseLauncher.Extension; +using CollapseLauncher.Helper.Database; +using CollapseLauncher.Extension; using CollapseLauncher.Helper; using CollapseLauncher.Interfaces; using CollapseLauncher.RegistryUtils; using CollapseLauncher.Statics; using Hi3Helper; using Hi3Helper.SentryHelper; +using Microsoft.UI.Text; using Microsoft.UI.Xaml; using Microsoft.UI.Xaml.Controls; using Microsoft.UI.Xaml.Data; @@ -94,7 +96,9 @@ protected GameSettingsPageBase(IGameSettings? settings, RegistryKey? gameRegistr VerticalAlignment = VerticalAlignment.Center, Style = UIElementExtensions.GetApplicationResource + \ No newline at end of file diff --git a/CollapseLauncher/XAMLs/Theme/Button/TransparentDropDownButton.xaml b/CollapseLauncher/XAMLs/Theme/Button/TransparentDropDownButton.xaml new file mode 100644 index 0000000000..644baad54d --- /dev/null +++ b/CollapseLauncher/XAMLs/Theme/Button/TransparentDropDownButton.xaml @@ -0,0 +1,73 @@ + + + \ No newline at end of file diff --git a/CollapseLauncher/XAMLs/Theme/CustomControls/LayeredBackgroundImage.Events.FrameInitializer.cs b/CollapseLauncher/XAMLs/Theme/CustomControls/LayeredBackgroundImage.Events.FrameInitializer.cs index a8b1a5a523..ccbe668d6a 100644 --- a/CollapseLauncher/XAMLs/Theme/CustomControls/LayeredBackgroundImage.Events.FrameInitializer.cs +++ b/CollapseLauncher/XAMLs/Theme/CustomControls/LayeredBackgroundImage.Events.FrameInitializer.cs @@ -114,12 +114,15 @@ private unsafe void InitializeRenderTarget() { if (_functionTableBeginDraw == null! || _functionTableDrawImage == null! || + _functionTableCopyFrameToVideoSurface == null! || _functionTableDispose == null!) { SwapChainPanelHelper.GetDirectNativeDelegateForDrawRoutine(_canvasImageSourceNativePtr, _canvasRenderTargetNativePtr, + _videoPlayerPtr, out _functionTableBeginDraw, out _functionTableDrawImage, + out _functionTableCopyFrameToVideoSurface, out _functionTableDispose, in _canvasRenderSize); } @@ -128,9 +131,10 @@ private unsafe void InitializeRenderTarget() { Interlocked.Exchange(ref _useSafeFrameRenderer, true); // Fallback - _functionTableBeginDraw = null; - _functionTableDrawImage = null; - _functionTableDispose = null; + _functionTableBeginDraw = null; + _functionTableDrawImage = null; + _functionTableCopyFrameToVideoSurface = null; + _functionTableDispose = null; Logger.LogWriteLine($"[LayeredBackgroundImage::InitializeRenderTarget] Failed to initialize fast-unsafe method for frame rendering. Fallback to safe renderer.\r\n{e}", LogType.Error, true); @@ -256,8 +260,9 @@ private void NullifyMediaPlayerNativePointers() { // -- Note to myself @neon-nyan: // Release IMediaPlayer5 reference first, then dispose the whole MediaPlayer. - // This is necessary as we just cast the _videoPlayer object (as IWinRTObject, then took its direct pointer) into IMediaPlayer5. - // If not released, the reference on the IWinRTObject will not be zeroed, causing leak. + // This is necessary as we just cast/QueryInterface the _videoPlayer object + // (as IWinRTObject, then took its direct pointer) into IMediaPlayer5. If not + // released, the reference on the IWinRTObject will not be zeroed, causing leak. if (_videoPlayerPtr != nint.Zero) Marshal.Release(Interlocked.Exchange(ref _videoPlayerPtr, nint.Zero)); } @@ -265,8 +270,9 @@ private void NullifyRenderTargetNativePointers() { // -- Note to myself @neon-nyan: // Release IDirect3DSurface reference first, then dispose the whole CanvasRenderTarget. - // This is necessary as we just cast the _canvasRenderTargetNativePtr (which is obtained from IWinRTObject's direct pointer) into IDirect3DSurface. - // If not released, the reference on the IWinRTObject will not be zeroed, causing leak. + // This is necessary as we just cast/QueryInterface the _canvasRenderTargetNativePtr (which + // is obtained from IWinRTObject's direct pointer) into IDirect3DSurface. If not released, + // the reference on the IWinRTObject will not be zeroed, causing leak. if (_canvasRenderTargetAsSurfacePtr != nint.Zero) Marshal.Release(Interlocked.Exchange(ref _canvasRenderTargetAsSurfacePtr, nint.Zero)); // -- Nullify IWinRTObject direct pointers. diff --git a/CollapseLauncher/XAMLs/Theme/CustomControls/LayeredBackgroundImage.Events.FrameRenderer.cs b/CollapseLauncher/XAMLs/Theme/CustomControls/LayeredBackgroundImage.Events.FrameRenderer.cs index 38850acee0..6159befcd1 100644 --- a/CollapseLauncher/XAMLs/Theme/CustomControls/LayeredBackgroundImage.Events.FrameRenderer.cs +++ b/CollapseLauncher/XAMLs/Theme/CustomControls/LayeredBackgroundImage.Events.FrameRenderer.cs @@ -50,6 +50,7 @@ private static ref readonly Guid IMediaPlayer5_IID private static unsafe delegate* unmanaged[Stdcall] _functionTableBeginDraw; private static unsafe delegate* unmanaged[Stdcall] _functionTableDrawImage; + private static unsafe delegate* unmanaged[Stdcall] _functionTableCopyFrameToVideoSurface; private static unsafe delegate* unmanaged[Stdcall] _functionTableDispose; #endregion @@ -108,9 +109,7 @@ private unsafe void VideoPlayer_VideoFrameAvailableUnsafe(MediaPlayer sender, ob return; } - SwapChainPanelHelper.MediaPlayerCopyFrameUnsafe(_videoPlayerPtr, - _canvasRenderTargetAsSurfacePtr); - + _functionTableCopyFrameToVideoSurface(_videoPlayerPtr, _canvasRenderTargetAsSurfacePtr); drawingSessionPpv = SwapChainPanelHelper .CanvasSessionDrawUnsafe(_canvasImageSourceNativePtr, _canvasRenderTargetNativePtr, diff --git a/CollapseLauncher/XAMLs/Theme/CustomControls/PanelSlideshow.Converters.cs b/CollapseLauncher/XAMLs/Theme/CustomControls/PanelSlideshow.Converters.cs index d02d5cc98a..6330312954 100644 --- a/CollapseLauncher/XAMLs/Theme/CustomControls/PanelSlideshow.Converters.cs +++ b/CollapseLauncher/XAMLs/Theme/CustomControls/PanelSlideshow.Converters.cs @@ -1,6 +1,7 @@ #nullable enable using Hi3Helper.Data; using Microsoft.UI.Xaml; +using Microsoft.UI.Xaml.Controls; using Microsoft.UI.Xaml.Data; using System; @@ -25,6 +26,24 @@ public object Convert(object value, Type targetType, object parameter, string la return (radius.BottomLeft + radius.BottomRight + radius.TopLeft + radius.TopRight) / 4d; } + public object ConvertBack(object value, Type targetType, object parameter, string language) + { + throw new NotImplementedException(); + } +} + +internal partial class ReverseProgressBarValue : IValueConverter +{ + public object Convert(object value, Type targetType, object parameter, string language) + { + double doubleValue = value.TryGetDouble(); + return parameter switch + { + PanelSlideshow slideshow => slideshow.SlideshowDuration - doubleValue, + _ => 0d + }; + } + public object ConvertBack(object value, Type targetType, object parameter, string language) { throw new NotImplementedException(); diff --git a/CollapseLauncher/XAMLs/Theme/CustomControls/PanelSlideshow.Templates.cs b/CollapseLauncher/XAMLs/Theme/CustomControls/PanelSlideshow.Templates.cs index f0239c769c..2de0e460dd 100644 --- a/CollapseLauncher/XAMLs/Theme/CustomControls/PanelSlideshow.Templates.cs +++ b/CollapseLauncher/XAMLs/Theme/CustomControls/PanelSlideshow.Templates.cs @@ -8,13 +8,14 @@ namespace CollapseLauncher.XAMLs.Theme.CustomControls; -[TemplatePart(Name = TemplateNameRootGrid, Type = typeof(Grid))] -[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 = TemplateNamePreviousButtonShadow, Type = typeof(AttachedDropShadow))] -[TemplatePart(Name = TemplateNameNextButtonShadow, Type = typeof(AttachedDropShadow))] +[TemplatePart(Name = TemplateNameRootGrid, Type = typeof(Grid))] +[TemplatePart(Name = TemplateNamePresenterGrid, Type = typeof(Grid))] +[TemplatePart(Name = TemplateNamePreviousButton, Type = typeof(Button))] +[TemplatePart(Name = TemplateNameNextButton, Type = typeof(Button))] +[TemplatePart(Name = TemplateNameCountdownProgressBar, Type = typeof(ProgressRing))] +[TemplatePart(Name = TemplateNameCountdownProgressBarTickerText, Type = typeof(TextBlock))] +[TemplatePart(Name = TemplateNamePreviousButtonShadow, Type = typeof(AttachedDropShadow))] +[TemplatePart(Name = TemplateNameNextButtonShadow, Type = typeof(AttachedDropShadow))] [TemplateVisualState(GroupName = StateGroupNameCommon, Name = StateNameNormal)] [TemplateVisualState(GroupName = StateGroupNameCommon, Name = StateNamePointerOver)] @@ -25,13 +26,14 @@ public partial class PanelSlideshow { #region Constants - private const string TemplateNameRootGrid = "RootGrid"; - private const string TemplateNamePresenterGrid = "PresenterGrid"; - private const string TemplateNamePreviousButton = "PreviousButton"; - private const string TemplateNamePreviousButtonShadow = "PreviousButtonShadow"; - private const string TemplateNameNextButton = "NextButton"; - private const string TemplateNameNextButtonShadow = "NextButtonShadow"; - private const string TemplateNameCountdownProgressBar = "CountdownProgressBar"; + private const string TemplateNameRootGrid = "RootGrid"; + private const string TemplateNamePresenterGrid = "PresenterGrid"; + private const string TemplateNamePreviousButton = "PreviousButton"; + private const string TemplateNamePreviousButtonShadow = "PreviousButtonShadow"; + private const string TemplateNameNextButton = "NextButton"; + private const string TemplateNameNextButtonShadow = "NextButtonShadow"; + private const string TemplateNameCountdownProgressBar = "CountdownProgressBar"; + private const string TemplateNameCountdownProgressBarTickerText = "CountdownProgressBarTickerText"; private const string StateGroupNameCommon = "CommonStates"; private const string StateNameNormal = "Normal"; @@ -45,14 +47,15 @@ public partial class PanelSlideshow #region Fields - private Grid _presenterGrid = null!; - private Button _previousButton = null!; - private AttachedDropShadow _previousButtonShadow = null!; - private Grid _previousButtonGrid = null!; - private Button _nextButton = null!; - private AttachedDropShadow _nextButtonShadow = null!; - private Grid _nextButtonGrid = null!; - private ProgressBar _countdownProgressBar = null!; + private Grid _presenterGrid = null!; + private Button _previousButton = null!; + private AttachedDropShadow _previousButtonShadow = null!; + private Grid _previousButtonGrid = null!; + private Button _nextButton = null!; + private AttachedDropShadow _nextButtonShadow = null!; + private Grid _nextButtonGrid = null!; + private ProgressRing _countdownProgressBar = null!; + private TextBlock _countdownProgressBarTickerText = null!; private bool _isTemplateLoaded; @@ -70,12 +73,13 @@ protected override void OnApplyTemplate() return; } - _presenterGrid = this.GetTemplateChild(TemplateNamePresenterGrid); - _previousButton = this.GetTemplateChild