diff --git a/PD2Launcherv2/App.xaml b/PD2Launcherv2/App.xaml index 3ea55f7d..b2a42ae6 100644 --- a/PD2Launcherv2/App.xaml +++ b/PD2Launcherv2/App.xaml @@ -10,17 +10,13 @@ - - - pack://application:,,,/Resources/Fonts/exocet-blizzard-heavy.otf#Exocet Blizzard OT Light Bold + pack://application:,,,/Resources/Fonts/exocet-blizzard-heavy.ttf#Exocet Blizzard OT Light Bold pack://application:,,,/Resources/Fonts/exocet-blizzard-light.ttf#Exocet Blizzard OT Light Regular pack://application:,,,/Resources/Fonts/exocet-blizzard-medium.ttf#Exocet Blizzard OT Medium Regular diff --git a/PD2Launcherv2/App.xaml.cs b/PD2Launcherv2/App.xaml.cs index eb007f6a..90adfe7b 100644 --- a/PD2Launcherv2/App.xaml.cs +++ b/PD2Launcherv2/App.xaml.cs @@ -10,6 +10,13 @@ using System.Net.Http; using System.Text; using System.Windows; +using PD2Launcherv2.Utils; +using PD2Shared.GameFileUpdate; +using PD2Shared.Logging; +using static PD2Shared.Logging.LoggingStatic; +using PD2Shared.Utils; + +[assembly: System.Runtime.CompilerServices.RuntimeCompatibilityAttribute(WrapNonExceptionThrows = true)] namespace PD2Launcherv2 { @@ -19,6 +26,8 @@ namespace PD2Launcherv2 /// public partial class App : Application { + private readonly Stopwatch _bootStopwatch = Stopwatch.StartNew(); + /// /// Holds the service provider for dependency injection. /// @@ -58,7 +67,7 @@ private static void ConfigureServices(ServiceCollection services) services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); - services.AddSingleton(); + services.AddSingleton(); services.AddSingleton(provider => new FileUpdateHelpers( provider.GetRequiredService())); services.AddTransient(); @@ -98,7 +107,7 @@ protected override async void OnStartup(StartupEventArgs e) try { - if (Process.GetProcessesByName("Game").Any()) + if (LaunchGameHelpers.IsGameRunning) { Debug.WriteLine("Game already running."); return; @@ -110,8 +119,8 @@ protected override async void OnStartup(StartupEventArgs e) await filterHelpers.CheckAndUpdateFilterAsync(selected); } - var launcherArgs = localStorage.LoadSection(StorageKey.LauncherArgs); - if (!launcherArgs.disableAutoUpdate) + var launcherOptions = localStorage.LoadSection(StorageKey.LauncherOptions); + if (!launcherOptions.DisableAutoUpdate) { try { @@ -136,23 +145,107 @@ protected override async void OnStartup(StartupEventArgs e) return; } + // This is a bit meh, but let's keep the convention and make this case-insensitive + var createConsole = e.Args.Any(arg => arg.Equals("--console", StringComparison.OrdinalIgnoreCase)); + + // This is not expected to throw + Logging.SetUp(createConsole); + + if (Wine.IsRunningUnderWine) + { + if (Wine.Version != null) + { + string wineVersionStr = Wine.Version.ToString(); + + if (Wine.BuildId != null) + { + wineVersionStr += $" ({Wine.BuildId})"; + } + + if (Wine.OsName != null) + { + wineVersionStr += $" on {Wine.OsName}"; + + if (Wine.OsRelease != null) + { + wineVersionStr += $" {Wine.OsRelease}"; + } + } + + L.CallerInformation($"Running under Wine {wineVersionStr}"); + } + else + { + L.CallerInformation($"Running under an undetermined Wine version"); + } + } + + L.CallerInformation($"Using up to {Environment.ProcessorCount} concurrent task(s)"); + + if (!SanityChecks.Run()) + { + L.CallerInformation($"Sanity checks failed and user declined to continue."); + + this.Shutdown(1); + return; + } + // Normal UI mode base.OnStartup(e); var mainWindow = _serviceProvider.GetService(); + + if (mainWindow != null) + { + mainWindow.ContentRendered += MainWindow_ContentRendered; + } + mainWindow?.Show(); } + private void MainWindow_ContentRendered(object? sender, EventArgs e) + { + ((MainWindow)sender!).ContentRendered -= MainWindow_ContentRendered; + + L.CallerDebug($"Time till {nameof(MainWindow)} rendered: {_bootStopwatch.Elapsed}."); + } + + protected override void OnExit(ExitEventArgs e) + { + Logging.ShutDown(e.ApplicationExitCode); + + base.OnExit(e); + } + // Handle non-UI thread exceptions private void CurrentDomain_UnhandledException(object sender, UnhandledExceptionEventArgs e) { - LogException(e.ExceptionObject as Exception); + // Blindly cast the object to Exception thanks to RuntimeCompatibilityAttribute (https://learn.microsoft.com/en-us/dotnet/api/system.unhandledexceptioneventargs.exceptionobject#remarks) + var ex = (Exception)e.ExceptionObject; + + L.Fatal(ex, "Unhandled exception"); + + this.Dispatcher.Invoke(() => + { + MsgBox.Exception(ex, "Unhandled exception:"); + + // Unlike DispatcherUnhandledException, WER will still kick in. + // However, due to reliance on task asynchronous programming model, it is very unlikely that this handler will ever be used. + this.Shutdown(1); + }); } // Handle UI thread exceptions private void App_DispatcherUnhandledException(object sender, System.Windows.Threading.DispatcherUnhandledExceptionEventArgs e) { - LogException(e.Exception); + var ex = e.Exception; + + L.Fatal(ex, "Unhandled exception"); + MsgBox.Exception(ex, "Unhandled exception:"); + e.Handled = true; // Prevent application from crashing + + // ...yet shut it down on our own terms now, knowing that we have prevented Windows Error Reporting from kicking in. + this.Shutdown(1); } private void CleanUpTempStorageFiles() @@ -174,47 +267,5 @@ private void CleanUpTempStorageFiles() } } } - - private void LogException(Exception ex) - { - if (ex == null) return; - - string logPath = Path.Combine(AppDomain.CurrentDomain.BaseDirectory); - - string logFile = Path.Combine(logPath, $"pd2launcher_error__{DateTime.Now:yyyy-MM-dd_HH-mm-ss}.txt"); - - // Use StackTrace to get more detailed info about where the exception occurred - var stackTrace = new StackTrace(ex, true); - var frame = stackTrace.GetFrames()?.FirstOrDefault(); - var method = frame?.GetMethod(); - var declaringType = method?.DeclaringType; - var methodName = method?.Name; - - // Prepare the log entry - var sb = new StringBuilder(); - sb.AppendLine("=============================================================================="); - sb.AppendLine($"Timestamp: {DateTime.Now}"); - sb.AppendLine("Exception Class:"); - sb.AppendLine(declaringType?.FullName ?? "N/A"); - sb.AppendLine("Exception Method:"); - sb.AppendLine($"{methodName ?? "N/A"}"); - sb.AppendLine("Exception Message:"); - sb.AppendLine(ex.Message); - sb.AppendLine("Stack Trace:"); - sb.AppendLine(ex.StackTrace); - - // Include inner exception details if available - if (ex.InnerException != null) - { - sb.AppendLine("Inner Exception Message:"); - sb.AppendLine(ex.InnerException.Message); - sb.AppendLine("Inner Exception Stack Trace:"); - sb.AppendLine(ex.InnerException.StackTrace); - } - sb.AppendLine("=============================================================================="); - - // Append the log entry to the file - File.AppendAllText(logFile, sb.ToString()); - } } } \ No newline at end of file diff --git a/PD2Launcherv2/Constants.cs b/PD2Launcherv2/Constants.cs deleted file mode 100644 index 0585acc7..00000000 --- a/PD2Launcherv2/Constants.cs +++ /dev/null @@ -1,145 +0,0 @@ -using PD2Shared.Models; - -namespace ProjectDiablo2Launcherv2 -{ - public static class Constants - { - public static readonly List excludedFiles = new List - { - "D2.LNG", "BnetLog.txt", "ProjectDiablo.cfg", "ddraw.ini", "default.filter", "loot.filter", "UI.ini", "d2gl.yaml", - }; - - public static class Ddraw - { - public const string IniFileName = "ddraw.ini"; - - public static DdrawOptions DefaultDdrawOptions => new() - { - Fullscreen = true, - MaxFps = "60", - Shader = "Shaders\\xbr\\xbr-lv2-noblend.glsl", - PosX = -32000, - PosY = -32000, - Renderer = "opengl", - SaveSettings = "1", - Resizeable = true, - MaxGameTicks = "-2", - HandleMouse = true, - Hook = "4", - MinFps = "0", - SingleCpu = true - }; - } - - public static class LocalStorage - { - public const string LauncherArgsKey = "LauncherArgs"; - public const string DdrawOptionsKey = "DdrawOptions"; - public const string FileUpdateModelKey = "FileUpdateModel"; - public const string AuthorAndFilterKey = "AuthorAndFilterKey"; - } - - public static List MaxFpsPickerItems() - { - return new List - { - new DisplayValuePair { DisplayValue = "60 (default)", ActualValue = "60" }, - new DisplayValuePair { DisplayValue = "-1 (screen rate)", ActualValue = "-1" }, - new DisplayValuePair { DisplayValue = "unlimited", ActualValue = "0" }, - new DisplayValuePair { DisplayValue = "n = cap", ActualValue = "n" } - }; - } - - public static List ModePickerItems() - { - return new List - { - new DisplayValuePair { DisplayValue = "Fullscreen (default)", ActualValue = "fullscreen" }, - new DisplayValuePair { DisplayValue = "Windowed", ActualValue = "windowed" }, - new DisplayValuePair { DisplayValue = "Borderless", ActualValue = "borderless" } - }; - } - - public static List MaxGameTicksPickerItems() - { - return new List - { - new DisplayValuePair { DisplayValue = "-2 Default Refresh Rate", ActualValue = "-2" }, - new DisplayValuePair { DisplayValue = "-1 (disable)", ActualValue = "-1" }, - new DisplayValuePair { DisplayValue = "0 emulate 60hz", ActualValue = "0" }, - new DisplayValuePair { DisplayValue = "60", ActualValue = "60" }, - new DisplayValuePair { DisplayValue = "30", ActualValue = "30" }, - new DisplayValuePair { DisplayValue = "25", ActualValue = "25" }, - new DisplayValuePair { DisplayValue = "20", ActualValue = "20" }, - new DisplayValuePair { DisplayValue = "15", ActualValue = "15" } - }; - } - - public static List SaveWindowPositionPickerItems() - { - return new List - { - new DisplayValuePair { DisplayValue = "global (default)", ActualValue = "1" }, - new DisplayValuePair { DisplayValue = "game specific", ActualValue = "2" }, - new DisplayValuePair { DisplayValue = "disable", ActualValue = "0" } - }; - } - - public static List RendererPickerItems() - { - return new List - { - new DisplayValuePair { DisplayValue = "OpenGL (default)", ActualValue = "opengl" }, - new DisplayValuePair { DisplayValue = "Auto", ActualValue = "auto" }, - new DisplayValuePair { DisplayValue = "direct3d9", ActualValue = "direct3d9" }, - new DisplayValuePair { DisplayValue = "GDI", ActualValue = "gdi" } - }; - } - - public static List HookPickerItems() - { - return new List - { - new DisplayValuePair { DisplayValue = "all modules (default)", ActualValue = "4" }, - new DisplayValuePair { DisplayValue = "IAT hooking", ActualValue = "1" }, - new DisplayValuePair { DisplayValue = "microsoft detours", ActualValue = "2" }, - new DisplayValuePair { DisplayValue = "IAT+detours", ActualValue = "3" }, - new DisplayValuePair { DisplayValue = "disable", ActualValue = "0" } - }; - } - - public static List MinFpsPickerItems() - { - return new List - { - new DisplayValuePair { DisplayValue = "0 (default)", ActualValue = "0" }, - new DisplayValuePair { DisplayValue = "-1 (use max fps)", ActualValue = "-1" }, - new DisplayValuePair { DisplayValue = "5", ActualValue = "5" }, - new DisplayValuePair { DisplayValue = "10", ActualValue = "10" } - }; - } - - public static List ShaderPickerItems() - { - return new List - { - new DisplayValuePair { DisplayValue = "xbr-lv2-noblend (default)", ActualValue = "Shaders\\xbr\\xbr-lv2-noblend.glsl" }, - new DisplayValuePair { DisplayValue = "xbr-lv2", ActualValue = "Shaders\\xbr-lv2.glsl" }, - new DisplayValuePair { DisplayValue = "xbr-lv3", ActualValue = "Shaders\\xbr\\xbr-lv3.glsl" }, - new DisplayValuePair { DisplayValue = "xbrz-freescale", ActualValue = "Shaders\\xbrz-freescale.glsl" }, - new DisplayValuePair { DisplayValue = "4xbrz", ActualValue = "Shaders\\xbrz\\4xbrz.glsl" }, - new DisplayValuePair { DisplayValue = "5xbrz", ActualValue = "Shaders\\xbrz\\5xbrz.glsl" }, - new DisplayValuePair { DisplayValue = "6xbrz", ActualValue = "Shaders\\xbrz\\6xbrz.glsl" }, - new DisplayValuePair { DisplayValue = "aa-shader-4.0", ActualValue = "Shaders\\anti-aliasing\\aa-shader-4.0.glsl" }, - new DisplayValuePair { DisplayValue = "advanced-aa", ActualValue = "Shaders\\anti-aliasing\\advanced-aa.glsl" }, - new DisplayValuePair { DisplayValue = "reverse-aa", ActualValue = "Shaders\\anti-aliasing\\reverse-aa.glsl" }, - new DisplayValuePair { DisplayValue = "bilinear", ActualValue = "Shaders\\bilinear.glsl" }, - new DisplayValuePair { DisplayValue = "bright", ActualValue = "Shaders\\bright.glsl" }, - new DisplayValuePair { DisplayValue = "crt-lottes-fast", ActualValue = "Shaders\\crt-lottes-fast.glsl" }, - new DisplayValuePair { DisplayValue = "cubic", ActualValue = "Shaders\\cubic.glsl" }, - new DisplayValuePair { DisplayValue = "scanline", ActualValue = "Shaders\\scanline.glsl" }, - new DisplayValuePair { DisplayValue = "simple-sharp-bilinear", ActualValue = "Shaders\\simple-sharp-bilinear.glsl" } - }; - } - } -} \ No newline at end of file diff --git a/PD2Launcherv2/CustomControl/BlankButtonWithText.cs b/PD2Launcherv2/CustomControl/BlankButtonWithText.cs new file mode 100644 index 00000000..a62c4912 --- /dev/null +++ b/PD2Launcherv2/CustomControl/BlankButtonWithText.cs @@ -0,0 +1,44 @@ +using System.Windows; +using System.Windows.Controls; + +namespace PD2Launcherv2.CustomControl +{ + public class BlankButtonWithText : Button + { + public enum ButtonKindEnum + { + Normal, + SplitTop, + SplitBottom + } + + static BlankButtonWithText() + { + DefaultStyleKeyProperty.OverrideMetadata(typeof(BlankButtonWithText), new FrameworkPropertyMetadata(typeof(BlankButtonWithText))); + } + + public static readonly DependencyProperty TextProperty = DependencyProperty.Register( + "Text", + typeof(string), + typeof(BlankButtonWithText), + new PropertyMetadata("...")); + + public static readonly DependencyProperty ButtonKindProperty = DependencyProperty.Register( + "ButtonKind", + typeof(ButtonKindEnum), + typeof(BlankButtonWithText), + new PropertyMetadata(ButtonKindEnum.Normal)); + + public string Text + { + get => (string)GetValue(TextProperty); + set => SetValue(TextProperty, value); + } + + public ButtonKindEnum ButtonKind + { + get => (ButtonKindEnum)GetValue(ButtonKindProperty); + set => SetValue(ButtonKindProperty, value); + } + } +} diff --git a/PD2Launcherv2/CustomControl/CustomImageButton.cs b/PD2Launcherv2/CustomControl/CustomImageButton.cs index 74e543f8..1bb06df4 100644 --- a/PD2Launcherv2/CustomControl/CustomImageButton.cs +++ b/PD2Launcherv2/CustomControl/CustomImageButton.cs @@ -1,11 +1,36 @@ using System.Windows; using System.Windows.Controls; using System.Windows.Media; +using System.Windows.Media.Imaging; namespace PD2Launcherv2.CustomControl { public class CustomImageButton : Button { + private static RenderTargetBitmap _missingImage = null!; + private static BitmapSource MissingImage + { + get + { + if (_missingImage == null) + { + Rect imageSize = new(new Size(100, 100)); + + _missingImage = new RenderTargetBitmap((int)imageSize.Width, (int)imageSize.Height, 96, 96, PixelFormats.Pbgra32); + + DrawingVisual visual = new(); + using (DrawingContext context = visual.RenderOpen()) + { + context.DrawRectangle(Brushes.Magenta, pen: null, imageSize); + } + + _missingImage.Render(visual); + } + + return _missingImage; + } + } + static CustomImageButton() { DefaultStyleKeyProperty.OverrideMetadata(typeof(CustomImageButton), new FrameworkPropertyMetadata(typeof(CustomImageButton))); @@ -23,6 +48,12 @@ static CustomImageButton() typeof(CustomImageButton), new PropertyMetadata(default(ImageSource))); + public static readonly DependencyProperty DisabledImageSourceProperty = DependencyProperty.Register( + "DisabledImageSource", + typeof(ImageSource), + typeof(CustomImageButton), + new PropertyMetadata(MissingImage)); + public ImageSource NormalImageSource { get => (ImageSource)GetValue(NormalImageSourceProperty); @@ -34,5 +65,11 @@ public ImageSource PressedImageSource get => (ImageSource)GetValue(PressedImageSourceProperty); set => SetValue(PressedImageSourceProperty, value); } + + public ImageSource DisabledImageSource + { + get => (ImageSource)GetValue(DisabledImageSourceProperty); + set => SetValue(DisabledImageSourceProperty, value); + } } } \ No newline at end of file diff --git a/PD2Launcherv2/Helpers/DDrawHelpers.cs b/PD2Launcherv2/Helpers/DDrawHelpers.cs index 34b5700e..95a6bbbb 100644 --- a/PD2Launcherv2/Helpers/DDrawHelpers.cs +++ b/PD2Launcherv2/Helpers/DDrawHelpers.cs @@ -1,8 +1,8 @@  using MadMilkman.Ini; +using PD2Shared; using PD2Shared.Interfaces; using PD2Shared.Models; -using ProjectDiablo2Launcherv2; using System.Diagnostics; using System.IO; diff --git a/PD2Launcherv2/MainWindow.xaml b/PD2Launcherv2/MainWindow.xaml index 9d00b23c..b8a758d2 100644 --- a/PD2Launcherv2/MainWindow.xaml +++ b/PD2Launcherv2/MainWindow.xaml @@ -2,19 +2,31 @@ xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:local="clr-namespace:PD2Launcherv2.CustomControl" - Title="Project Diablo 2 Launcher" Height="600" Width="800" - WindowStyle="None" AllowsTransparency="True" Background="Transparent"> + xmlns:d="http://schemas.microsoft.com/expression/blend/2008" + xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" + mc:Ignorable="d" + Style="{StaticResource PixelPerfectFrameworkElement}" + Title="Project Diablo 2 Launcher" + WindowStyle="None" AllowsTransparency="True" Background="Transparent" + ResizeMode="NoResize" + SizeToContent="WidthAndHeight" + Closing="Window_Closing" + KeyDown="Window_KeyDown" + KeyUp="Window_KeyUp" + IsKeyboardFocusWithinChanged="Window_IsKeyboardFocusWithinChanged" + Activated="Window_Activated" + Icon="pack://application:,,,/Resources/Icons/icon.ico"> - @@ -26,49 +38,50 @@ - - - - + + + Home + - + + - - - - + + + Trade + - + - - - - - - + + + Reddit + + + - - - - + + + Twitter + - + - - - - - - + + + Discord + + + - - - - + + + Wiki + @@ -126,75 +139,127 @@ + Click="MinimizeButton_Click" + TabIndex="6" /> + DisabledImageSource="pack://application:,,,/Resources/Images/close_disabled.png" + Click="CloseButton_Click" + TabIndex="7"/> - - - - - + - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - + - + - + @@ -205,28 +270,42 @@ FontFamily="{StaticResource BlizzLight}"/> - - + + + Source="pack://application:,,,/Resources/Images/btn_beta.jpg" + Visibility="{Binding BetaVisibility}" + HorizontalAlignment="Center" + VerticalAlignment="Center"/> + + - + Margin="5,0,0,0" + NormalImageSource="pack://application:,,,/Resources/Images/btn_switch.jpg" + PressedImageSource="pack://application:,,,/Resources/Images/btn_switch_pressed.jpg" + DisabledImageSource="pack://application:,,,/Resources/Images/btn_switch_disabled.png" + Command="{Binding OpenAboutCommand}" + HorizontalAlignment="Center" + VerticalAlignment="Center" + TabIndex="5"/> - @@ -236,14 +315,19 @@ + + + + + + + - + diff --git a/PD2Launcherv2/MainWindow.xaml.cs b/PD2Launcherv2/MainWindow.xaml.cs index 13c4a325..98db9ece 100644 --- a/PD2Launcherv2/MainWindow.xaml.cs +++ b/PD2Launcherv2/MainWindow.xaml.cs @@ -9,15 +9,22 @@ using PD2Launcherv2.Views; using System.ComponentModel; using System.Diagnostics; +using System.Diagnostics.CodeAnalysis; using System.Globalization; using System.Net.Http; using System.Windows; using System.Windows.Controls; using System.Windows.Input; -using System.Windows.Media.Imaging; +using System.Windows.Media; using System.Windows.Navigation; using System.Windows.Threading; using System.IO; +using PD2Launcherv2.Utils; +using PD2Launcherv2.Utils.Gl; +using PD2Shared.GameFileUpdate; +using PD2Shared.Logging; +using static PD2Shared.Logging.LoggingStatic; +using PD2Shared.Utils; namespace PD2Launcherv2 { @@ -26,6 +33,16 @@ namespace PD2Launcherv2 /// . public partial class MainWindow : Window, INotifyPropertyChanged { + private enum KeyComboDown + { + Play, + + Update, + Restore, + Download, + Reset + } + public event PropertyChangedEventHandler PropertyChanged; private readonly ILocalStorage _localStorage; private readonly FileUpdateHelpers _fileUpdateHelpers; @@ -33,7 +50,45 @@ public partial class MainWindow : Window, INotifyPropertyChanged private readonly LaunchGameHelpers _launchGameHelpers; private readonly NewsHelpers _newsHelpers; private readonly DDrawHelpers _dDrawHelpers; - private readonly GameFileUpdateHelpers _gameFileUpdater; + private readonly GameFileUpdater _gameFileUpdater; + + private CancellationTokenSource? _currentCts = null; + private bool _cancellingAllowed = false; + private bool _closePending = false; + private bool _closePendingAllowClose = false; + + private bool _isOffline; + + private KeyComboDown _keyComboDown = KeyComboDown.Play; + + private readonly ProgressCookie _progressCookie = new(); + + TextBlock? _progressTotalText = null; + TextBlock? _progressFileCountText = null; + TextBlock? _progressBytesText = null; + TextBlock? _progressBytesPerSecText = null; + int _progressBytesPrecision; + + private string _playButtonText; + private bool _playButtonTextLocked; + private bool _progressErrorShown; + + private readonly Brush NormalTextBrush; + private readonly Brush ErrorTextBrush; + + private bool _suppressRendererChangedMessages = false; + + // Auto-close + private static readonly TimeSpan AutoCloseTimeSpan = TimeSpan.FromSeconds(10); + + private readonly object _autoCloseLock = new(); + private bool _autoCloseActive = false; + private Process? _autoCloseGameProcess = null; + private readonly EventWaitHandle _autoCloseThreadInterrupt = new(initialState: false, EventResetMode.ManualReset); + private Thread? _autoCloseThread = null; + private readonly Stopwatch _autoCloseProgressUpdateStopwatch = new(); + private DispatcherTimer? _autoCloseProgressUpdateTimer = null; + private bool _isBeta; public bool IsBeta { @@ -93,6 +148,65 @@ public Visibility CustomVisibility } } + private bool _forceSoftwareRenderer; + public bool ForceSoftwareRenderer + { + get => _forceSoftwareRenderer; + set + { + if (_forceSoftwareRenderer != value) + { + _forceSoftwareRenderer = value; + System.Windows.Media.RenderOptions.ProcessRenderMode = _forceSoftwareRenderer ? System.Windows.Interop.RenderMode.SoftwareOnly : System.Windows.Interop.RenderMode.Default; + OnPropertyChanged(nameof(ForceSoftwareRenderer)); + } + } + } + + private bool _useHttp2; + public bool UseHttp2 + { + get => _useHttp2; + set + { + if (_useHttp2 != value) + { + _useHttp2 = value; + OnPropertyChanged(nameof(UseHttp2)); + } + } + } + + private bool _isDisableUpdates; + public bool IsDisableUpdates + { + get => _isDisableUpdates; + set + { + if (_isDisableUpdates != value) + { + _isDisableUpdates = value; + UpdatesNotificationVisibility = value ? Visibility.Visible : Visibility.Collapsed; + OnPropertyChanged(nameof(IsDisableUpdates)); + } + } + } + + private bool _autoCloseAfterLaunch; + public bool AutoCloseAfterLaunch + { + get => _autoCloseAfterLaunch; + set + { + if (_autoCloseAfterLaunch != value) + { + _autoCloseAfterLaunch = value; + AutoCloseCheckBox.IsChecked = _autoCloseAfterLaunch; + OnPropertyChanged(nameof(AutoCloseAfterLaunch)); + } + } + } + private Visibility _updatesNotificationVisibility = Visibility.Collapsed; public Visibility UpdatesNotificationVisibility { @@ -108,7 +222,6 @@ public Visibility UpdatesNotificationVisibility } public List NewsItems { get; set; } - public bool IsDisableUpdates { get; private set; } public ICommand OpenOptionsCommand { get; private set; } public ICommand OpenLootCommand { get; private set; } public ICommand OpenAboutCommand { get; private set; } @@ -117,6 +230,9 @@ public MainWindow() { InitializeComponent(); + NormalTextBrush = (Brush)FindResource("GoldLighterBrush"); + ErrorTextBrush = (Brush)FindResource("RedLighterBrush"); + OpenOptionsCommand = new RelayCommand(ShowOptionsView); OpenLootCommand = new RelayCommand(ShowLootView); OpenAboutCommand = new RelayCommand(ShowAboutView); @@ -127,16 +243,24 @@ public MainWindow() _filterHelpers = (FilterHelpers)App.ServiceProvider.GetService(typeof(FilterHelpers)); _launchGameHelpers = (LaunchGameHelpers)App.ServiceProvider.GetService(typeof(LaunchGameHelpers)); _newsHelpers = (NewsHelpers)App.ServiceProvider.GetService(typeof(NewsHelpers)); - _gameFileUpdater = (GameFileUpdateHelpers)App.ServiceProvider.GetService(typeof(GameFileUpdateHelpers)); + _gameFileUpdater = (GameFileUpdater)App.ServiceProvider.GetService(typeof(GameFileUpdater)); LoadAndUpdateDDrawOptions(); - InitWindow(); - EnsureWindowIsVisible(); Loaded += MainWindow_Loaded; LoadConfiguration(); + LoadOptions(); + + CheckGlCtxAndPrompt( + // This property is incredibly ambiguous + usesD2gl: _localStorage.LoadSection(StorageKey.LauncherArgs).graphics == false, + // This is quite horrible and should be made into an enum + cncDdrawUsesOgl: _localStorage.LoadSection(StorageKey.DdrawOptions).Renderer == "opengl" + ); // Registering to receive NavigationMessage Messenger.Default.Register(this, OnNavigationMessageReceived); Messenger.Default.Register(this, OnConfigurationChanged); + Messenger.Default.Register(this, OnLauncherOptionsChanged); + Messenger.Default.Register(this, OnRendererChanged); DataContext = this; this.Closed += MainWindow_Closed; @@ -155,6 +279,24 @@ public MainWindow() _localStorage.Update(StorageKey.FileUpdateModel, storeUpdate); } + this.Title = MsgBox.DefaultDialogTitle; + this.VersionText.Text = PD2Shared.Constants.VersionString; + UseFileCountProgressMapping(); + ResetUI(); + ToggleOffline(show: false); + + if (Wine.IsRunningUnderWine) + { + WineLogo16Image.ToolTip = Wine.Version != null ? $"Wine {Wine.Version} detected" : "Undetermined Wine version"; + } + else + { + WineLogo16Image.Visibility = Visibility.Hidden; + } + + // Auto-close + AutoCloseResetProgress(); + InputManager.Current.PostProcessInput += AutoClosePostProcessInput; // Don't try to update launcher in debug mode // TEST @@ -165,6 +307,7 @@ public MainWindow() CheckForUpdates(); #endif } + private void OnNavigationMessageReceived(NavigationMessage message) { Overlay.Visibility = Visibility.Collapsed; @@ -200,7 +343,7 @@ private async void CheckForUpdates() { Dispatcher.Invoke(() => { - DownloadProgressBar.Value = value * 100; + DownloadProgressBar.Value = value * DownloadProgressBar.Maximum; if (DownloadProgressBar.Visibility != Visibility.Visible) { DownloadProgressBar.Visibility = Visibility.Visible; @@ -235,43 +378,282 @@ private void BackgroundImage_MouseLeftButtonDown(object sender, MouseButtonEvent private async void PlayButton_Click(object sender, RoutedEventArgs e) { Debug.WriteLine("PlayButton_Click start"); + + L.Separator(); + L.CallerInformation($"Clicked on '{PlayButton.Text}'"); + + // Store this early on to allow releasing the keys immediately upon clicking the button + KeyComboDown keyComboDown = _keyComboDown; + + if (keyComboDown == KeyComboDown.Play) + { + if (LaunchGameHelpers.IsGameRunning) + { + L.CallerWarning("Attempted to start the game while another instance is already running."); + + MsgBox.Warn("Another instance of the game is already running."); + return; + } + } + + UpdateMode updateMode; + bool noFilterUpdate; + bool noLaunch; + + switch (keyComboDown) + { + case KeyComboDown.Play: + updateMode = UpdateMode.Normal; + noFilterUpdate = false; + noLaunch = false; + break; + + case KeyComboDown.Update: + updateMode = UpdateMode.Normal; + noFilterUpdate = false; + noLaunch = true; + break; + + case KeyComboDown.Restore: + updateMode = UpdateMode.Restore; + noFilterUpdate = true; + noLaunch = true; + break; + + case KeyComboDown.Download: + updateMode = UpdateMode.Download; + noFilterUpdate = true; + noLaunch = true; + break; + + case KeyComboDown.Reset: + updateMode = UpdateMode.Reset; + noFilterUpdate = true; + noLaunch = true; + break; + + // Only switch expressions can benefit from "exhaustive switch" + default: + throw new InvalidEnumArgumentException(); + } + UpdateUIForOperationStart(); try { - if (Process.GetProcessesByName("Game").Any()) + bool workOffline = IsDisableUpdates && !noLaunch; + bool proceed = false; + + { + Exception? caughtEx = null; + + using (_currentCts = new CancellationTokenSource()) + { + try + { + CancelButton.IsEnabled = true; + CancelButton.Visibility = Visibility.Visible; + _cancellingAllowed = true; + + L.Separator(); + + await _gameFileUpdater.UpdateAsync( + workOffline, + updateMode, + UseHttp2, + _localStorage.LoadSection(StorageKey.FileUpdateModel), + new ProgressWithCookie(_progressCookie, UpdateProgressValues), + new ProgressWithCookie(_progressCookie, UpdatePlayButtonText), + new ProgressWithCookie(_progressCookie, ToggleOffline), + new ProgressWithCookie(_progressCookie, ToggleProgressErrorIndicator), + _currentCts.Token); + } + catch (OperationCanceledException ex) when (ex.CancellationToken == _currentCts.Token) + { + // A user-requested cancellation -- just bail + L.CallerWarning("Canceled."); + return; + } + catch (DownloadException ex) + { + // These contain AggregateException and are vile to log + // Since all contained inner exceptions must have been logged already -- don't log them here + L.CallerError($"{nameof(DownloadException)} caught: '{ex.Message}'"); + + caughtEx = ex; + } + catch (FatalGameFileUpdateException ex) + { + // These will be handled below + L.CallerError($"{nameof(FatalGameFileUpdateException)} caught: '{ex.Message}'"); + + caughtEx = ex; + } + catch (Exception ex) + { + L.CallerError(ex, $"{nameof(GameFileUpdater.UpdateAsync)}() threw"); + + caughtEx = ex; + } + finally + { + _cancellingAllowed = false; + CancelButton.Visibility = Visibility.Hidden; + + _currentCts = null; + + if (_closePending) + { + _closePendingAllowClose = true; + this.Close(); + } + } + } + + if (caughtEx == null) + { + proceed = true; + } + else + { + void HandleFatalGameFileUpdateException(string cause, string effect) + { + const string ActionMsg = "\nRefusing to launch the game."; + const string OfflineActionMsg = "\nAttempt to launch the game anyway?"; + + if (noLaunch) + { + MsgBox.Exception( + caughtEx.InnerException, + cause); + } + else + { + if (!workOffline) + { + MsgBox.Exception( + caughtEx.InnerException, + string.Join('\n', cause, effect, ActionMsg)); + } + else + { + if (MsgBox.Exception( + caughtEx.InnerException, + string.Join('\n', cause, effect, OfflineActionMsg), + MessageBoxImage.Warning, + MessageBoxButton.YesNo, + MessageBoxResult.No) == MessageBoxResult.Yes) + { + proceed = true; + } + } + } + } + + if (caughtEx is OfflineInvalidManifest) + { + HandleFatalGameFileUpdateException( + cause: updateMode == UpdateMode.Reset ? + // Manifest gets cleared during Reset + "Failed to retrieve metadata." : + "Failed to retrieve metadata and there is no local manifest to work with.", + effect: "Game files could not be validated and the integrity of the game cannot be guaranteed." + ); + } + else if (caughtEx is InvalidMetadataRetrieved) + { + HandleFatalGameFileUpdateException( + cause: "Retrieved metadata is invalid.", + effect: "Game files could not be validated and the integrity of the game cannot be guaranteed." + ); + } + else if (caughtEx is OfflineNeedsDownload) + { + HandleFatalGameFileUpdateException( + cause: "Game files failed validation and cannot be re-downloaded.", + effect: "The integrity of the game cannot be guaranteed." + ); + } + else + { + // Is this still needed? + if (caughtEx is HttpRequestException) + { + ToggleOffline(show: true); + } + + MsgBox.Exception(caughtEx); + } + } + } + + if (!proceed) { - MessageBox.Show("Game is already running."); return; } - var selectedAuthorAndFilter = _localStorage.LoadSection(StorageKey.SelectedAuthorAndFilter); - if (selectedAuthorAndFilter?.selectedFilter != null) + // Clear progress indicator at this point + UpdateProgressValues(new ProgressValues().Clear().Extract()); + + if (!noFilterUpdate) { - bool isUpdated = await _filterHelpers.CheckAndUpdateFilterAsync(selectedAuthorAndFilter); + // Make this step obey IsDisableUpdates and also bail in case of _isOffline not to produce more errors + if (!workOffline && !_isOffline) + { + var selectedAuthorAndFilter = _localStorage.LoadSection(StorageKey.SelectedAuthorAndFilter); + if (selectedAuthorAndFilter?.selectedFilter != null) + { + UpdatePlayButtonText("Updating filter..."); + + try + { + await _filterHelpers.CheckAndUpdateFilterAsync(selectedAuthorAndFilter); + } + catch (Exception ex) + { + L.CallerError(ex, $"{nameof(FilterHelpers.CheckAndUpdateFilterAsync)}() threw"); + MsgBox.Exception(ex, "Failed to update the filter:"); + + return; + } + } + } + } + + if (noLaunch) + { + return; } - LauncherArgs launcherArgs = _localStorage.LoadSection(StorageKey.LauncherArgs); - if (!launcherArgs.disableAutoUpdate) { + UpdatePlayButtonText("Launching..."); + + bool useAutoClose = AutoCloseAfterLaunch; + Process gameProcess; + try { - await _gameFileUpdater.UpdateFromShaMetadataAsync(_localStorage, new Progress(UpdateProgress), () => { }); - Debug.WriteLine("made it out of the update check"); - await _fileUpdateHelpers.SyncFilesFromEnvToRoot(_localStorage); + gameProcess = _launchGameHelpers.LaunchGame(_localStorage, useAutoClose ? AutoCloseGameProcessExited : null); + } + catch (Exception ex) + { + L.CallerError(ex, $"{nameof(_launchGameHelpers.LaunchGame)}() threw"); + MsgBox.Exception(ex, "Failed to launch the game:"); + + return; } - catch (HttpRequestException ex) + + if (useAutoClose) { - Debug.WriteLine($"Update failed: {ex.Message}. Proceeding in offline mode."); - MessageBox.Show("Could not check for updates. Proceeding in offline mode.", "Offline Mode", MessageBoxButton.OK, MessageBoxImage.Information); + AutoCloseBegin(gameProcess); } + else + { + gameProcess.Dispose(); + } + + await Task.Delay(TimeSpan.FromSeconds(1.5)); } - _launchGameHelpers.LaunchGame(_localStorage); - } - catch (Exception ex) - { - Debug.WriteLine($"Exception occurred during PlayButton_Click: {ex.Message}"); - ShowErrorMessage($"An error occurred: {ex.Message}"); } finally { @@ -281,45 +663,310 @@ private async void PlayButton_Click(object sender, RoutedEventArgs e) } } + private bool Cancel() + { + if (!_cancellingAllowed) + { + return false; + } + + _cancellingAllowed = false; + CancelButton.IsEnabled = false; + + L.CallerWarning("Cancellation requested!"); + _currentCts!.Cancel(throwOnFirstException: true); + + return true; + } + + private void CancelButton_Click(object sender, RoutedEventArgs e) + { + Cancel(); + } + + private void Window_Closing(object sender, CancelEventArgs e) + { + if (_closePending) + { + e.Cancel = !_closePendingAllowClose; + return; + } + + if (this.Cancel()) + { + e.Cancel = true; + + _closePending = true; + } + + if (AutoCloseAbort()) + { + L.CallerDebug($"Auto-close aborted due to window closing."); + } + } + + private void CheckKeys(KeyboardDevice kd) + { + _keyComboDown = kd.Modifiers switch + { + // Pressing Alt+Space will pop up system menu. Similarly, pressing Alt alone can focus it (even with WindowStyle.None). + // Therefore, handling Alt alone isn't great (without disabling system menu first, but that's too invasive). + + ModifierKeys.Control | ModifierKeys.Shift | ModifierKeys.Alt => KeyComboDown.Reset, + ModifierKeys.Control | ModifierKeys.Shift => KeyComboDown.Download, + ModifierKeys.Shift => KeyComboDown.Restore, + ModifierKeys.Control => KeyComboDown.Update, + _ => KeyComboDown.Play, + }; + } + + private void Window_KeyDown(object sender, KeyEventArgs e) + { + CheckKeys(e.KeyboardDevice); + RefreshPlayButtonText(); + } + + private void Window_KeyUp(object sender, KeyEventArgs e) + { + CheckKeys(e.KeyboardDevice); + RefreshPlayButtonText(); + } + + private void Window_IsKeyboardFocusWithinChanged(object sender, DependencyPropertyChangedEventArgs e) + { + if (!IsKeyboardFocusWithin) + { + _keyComboDown = KeyComboDown.Play; + } + + RefreshPlayButtonText(); + } + + private void Window_Activated(object sender, EventArgs e) + { + if (AutoCloseAbort()) + { + L.CallerDebug($"Auto-close aborted due to window activation."); + } + + // Attempt to refocus when closing a modal dialog to get keyboard focus back + if (!this.IsKeyboardFocusWithin) + { + this.Focus(); + } + } + private void UpdateUIForOperationStart() { - try + Mouse.OverrideCursor = Cursors.AppStarting; + + _playButtonTextLocked = true; + UpdatePlayButtonText("Updating..."); + PlayButton.IsEnabled = false; + + UpdateProgressValues(new ProgressValues().Clear().Extract()); + DownloadProgressBar.Visibility = Visibility.Visible; + AboutButton.IsEnabled = false; + OptionsButton.IsEnabled = false; + LootButton.IsEnabled = false; + } + + [MemberNotNull(nameof(_playButtonText))] + private void ResetUI() + { + _progressCookie.Advance(); + + LootButton.IsEnabled = true; + OptionsButton.IsEnabled = true; + AboutButton.IsEnabled = true; + CancelButton.Visibility = Visibility.Hidden; + CancelButton.IsEnabled = true; + DownloadProgressBar.Visibility = Visibility.Hidden; + UpdateProgressValues(new ProgressValues().Clear().Extract()); + ToggleProgressErrorIndicator(false); + + PlayButton.IsEnabled = true; + _playButtonTextLocked = false; + UpdatePlayButtonText("Play"); + + Mouse.OverrideCursor = null; + } + + private void UseFileCountProgressMapping() + { + UpdateProgressValues(new ProgressValues().Clear().Extract()); + _progressBytesPrecision = 2; + + _progressTotalText = null; + _progressFileCountText = ProgressLargeText; + _progressBytesText = ProgressSmallText1; + _progressBytesPerSecText = ProgressSmallText2; + } + + private void UseTotalProgressMapping() + { + UpdateProgressValues(new ProgressValues().Clear().Extract()); + _progressBytesPrecision = 0; + + _progressTotalText = ProgressLargeText; + _progressFileCountText = ProgressSmallText2; + _progressBytesText = ProgressSmallText1; + _progressBytesPerSecText = null; + } + + private void UpdateProgressValues(ProgressValues.IData progressData) + { + if (progressData.TotalSet) UpdateTotalProgress(progressData.Total); + if (progressData.FileCountSet) UpdateFileCountProgress(progressData.FileCount); + if (progressData.BytesSet) UpdateBytesProgress(progressData.Bytes); + if (progressData.BytesPerSecSet) UpdateBytesPerSecProgress(progressData.BytesPerSec); + } + + private void UpdateTotalProgress(double? progress) + { + if (progress == null) { - var updatingImageUri = new Uri("pack://application:,,,/Resources/Images/updating_disabled.jpg"); - PlayButton.NormalImageSource = new BitmapImage(updatingImageUri); - DownloadProgressBar.Visibility = Visibility.Visible; DownloadProgressBar.Value = 0; } - catch (UriFormatException ex) + else { - Debug.WriteLine($"URI format exception: {ex.Message}"); + DownloadProgressBar.Value = progress.Value * DownloadProgressBar.Maximum; + } + + if (_progressTotalText == null) + { + return; + } + + if (progress == null) + { + _progressTotalText.Visibility = Visibility.Hidden; + } + else + { + _progressTotalText.Text = $"{progress * 100:N1}%"; + _progressTotalText.Visibility = Visibility.Visible; } } - private void ResetUI() + private void UpdateFileCountProgress(ProgressValues.FileCountProgress? progress) { - // Code to reset the Play button and hide the progress bar - Dispatcher.Invoke(() => + if (_progressFileCountText == null) { - try - { - var playImageUri = new Uri("pack://application:,,,/Resources/Images/play.jpg"); - PlayButton.NormalImageSource = new BitmapImage(playImageUri); - } - catch (UriFormatException ex) - { - Debug.WriteLine($"URI format exception: {ex.Message}"); - } - DownloadProgressBar.Visibility = Visibility.Hidden; - }); + return; + } + + if (progress == null) + { + _progressFileCountText.Visibility = Visibility.Hidden; + return; + } + + _progressFileCountText.Text = $"{progress.Current:N0}/{progress.Total:N0}"; + _progressFileCountText.Visibility = Visibility.Visible; } - private void UpdateProgress(double value) + private void UpdateBytesProgress(ProgressValues.BytesProgress? progress) { - Dispatcher.Invoke(() => + if (_progressBytesText == null) { - DownloadProgressBar.Value = value * 100; - }); + return; + } + + if (progress == null) + { + _progressBytesText.Visibility = Visibility.Hidden; + return; + } + + var currentStr = Formatting.FormatSizeInMiB(progress.Current, appendUnits: progress.Total == null, _progressBytesPrecision); + var slashStr = progress.Total == null ? "" : "/"; + var totalStr = progress.Total == null ? "" : Formatting.FormatSizeInMiB(progress.Total.Value, appendUnits: true, _progressBytesPrecision); + + _progressBytesText.Text = $"{currentStr}{slashStr}{totalStr}"; + _progressBytesText.Visibility = Visibility.Visible; + } + + private void UpdateBytesPerSecProgress(ProgressValues.BytesPerSecProgress? progress) + { + if (_progressBytesPerSecText == null) + { + return; + } + + if (progress == null) + { + _progressBytesPerSecText.Visibility = Visibility.Hidden; + return; + } + + _progressBytesPerSecText.Text = $"({Formatting.FormatThroughputInMiB(progress.Bytes, progress.ElapsedMilliseconds)})"; + _progressBytesPerSecText.Visibility = Visibility.Visible; + } + + [MemberNotNull(nameof(_playButtonText))] + private void UpdatePlayButtonText(string text) + { + _playButtonText = text; + + RefreshPlayButtonText(); + } + + private void ToggleOffline(bool show) + { + _isOffline = show; + + OfflineIndicatorImage.Visibility = _isOffline ? Visibility.Visible : Visibility.Hidden; + } + + private static string GetTextForKeyComboDown(KeyComboDown keyComboDown) + { + switch (keyComboDown) + { + case KeyComboDown.Play: + return null!; + + case KeyComboDown.Update: + return "Update"; + case KeyComboDown.Restore: + return "Restore"; + case KeyComboDown.Download: + return "Download"; + case KeyComboDown.Reset: + return "Reset"; + + // Only switch expressions can benefit from "exhaustive switch" + default: + throw new InvalidEnumArgumentException(); + } + } + + private void RefreshPlayButtonText() + { + if (_playButtonTextLocked) + { + PlayButton.Text = _playButtonText; + } + else + { + PlayButton.Text = GetTextForKeyComboDown(_keyComboDown) ?? _playButtonText; + } + } + + private void ToggleProgressErrorIndicator(bool show) + { + if (_progressErrorShown == show) + { + return; + } + + _progressErrorShown = show; + + var brush = show ? ErrorTextBrush : NormalTextBrush; + + ProgressLargeText.Foreground = brush; + ProgressSmallText1.Foreground = brush; + ProgressSmallText2.Foreground = brush; } private void onDownloadComplete() @@ -342,7 +989,20 @@ private void ShowOptionsView() { ClearNavigationStack(); Overlay.Visibility = Visibility.Visible; - MainFrame.Navigate(new OptionsView()); + + try + { + // This is a nasty workaround for OptionsViewModel's excessive and reentrant event firing during initialization + // caused by not differentiating between properties being set programmatically and interactively. + _suppressRendererChangedMessages = true; + + MainFrame.Navigate(new OptionsView()); + MainFrame.Focus(); + } + finally + { + _suppressRendererChangedMessages = false; + } } private void ShowLootView() @@ -350,6 +1010,7 @@ private void ShowLootView() ClearNavigationStack(); Overlay.Visibility = Visibility.Visible; MainFrame.Navigate(new FiltersView()); + MainFrame.Focus(); } private void ShowAboutView() @@ -357,6 +1018,7 @@ private void ShowAboutView() ClearNavigationStack(); Overlay.Visibility = Visibility.Visible; MainFrame.Navigate(new AboutView()); + MainFrame.Focus(); } private void Hyperlink_RequestNavigate(object sender, RequestNavigateEventArgs e) @@ -382,7 +1044,6 @@ private void DonateButton_Click(object sender, RoutedEventArgs e) private void CloseButton_Click(object sender, RoutedEventArgs e) { - MainWindow_Closed(sender, e); this.Close(); } @@ -402,13 +1063,18 @@ private void TextBlock_MouseLeftButtonUp(object sender, MouseButtonEventArgs e) private void LoadConfiguration() { var fileUpdateModel = _localStorage.LoadSection(StorageKey.FileUpdateModel); - var launcherArgs = _localStorage.LoadSection(StorageKey.LauncherArgs); IsBeta = fileUpdateModel?.FilePath == "Beta"; IsCustom = fileUpdateModel?.FilePath == "Custom"; - IsDisableUpdates = launcherArgs?.disableAutoUpdate == true; + } - // Use property to control visibility - UpdatesNotificationVisibility = IsDisableUpdates ? Visibility.Visible : Visibility.Collapsed; + private void LoadOptions() + { + LauncherOptions launcherOptions = _localStorage.LoadSection(StorageKey.LauncherOptions); + + ForceSoftwareRenderer = launcherOptions.ForceSoftwareRenderer; + UseHttp2 = launcherOptions.UseHttp2; + IsDisableUpdates = launcherOptions.DisableAutoUpdate; + AutoCloseAfterLaunch = launcherOptions.AutoCloseAfterLaunch; } private void OnConfigurationChanged(ConfigurationChangeMessage message) @@ -417,12 +1083,79 @@ private void OnConfigurationChanged(ConfigurationChangeMessage message) OnPropertyChanged(nameof(IsBeta)); IsCustom = message.IsCustom; OnPropertyChanged(nameof(IsCustom)); - // Use property to control visibility - UpdatesNotificationVisibility = message.IsDisableUpdates ? Visibility.Visible : Visibility.Collapsed; + } + + private void OnLauncherOptionsChanged(LauncherOptionsChangeMessage message) + { + ForceSoftwareRenderer = message.ForceSoftwareRenderer; + OnPropertyChanged(nameof(ForceSoftwareRenderer)); + UseHttp2 = message.UseHttp2; + OnPropertyChanged(nameof(UseHttp2)); + IsDisableUpdates = message.DisableAutoUpdate; + OnPropertyChanged(nameof(IsDisableUpdates)); + } + + private void OnRendererChanged(RendererChangeMessage message) + { + if (_suppressRendererChangedMessages) + { + return; + } + + CheckGlCtxAndPrompt(message.UseD2GL, message.CncDdrawUsesOGL); + } + + private static void CheckGlCtxAndPrompt(bool usesD2gl, bool cncDdrawUsesOgl) + { + if (!usesD2gl && !cncDdrawUsesOgl) + { + return; + } + + if (usesD2gl) + { + if (GlTest.BestCtx.GlCtxInfo == null && GlTest.BestCtx.StageReached.IndicatesGlFailure()) + { + MsgBox.Exception( + GlTest.BestCtx.Exception, + "Selected D2GL renderer wrapper might not work:", + MessageBoxImage.Warning + ); + } + else if(GlTest.BestCtx.GlCtxInfo != null) + { + // D2GL requires a 3.3 context at minimum. Might as well crash with Access Violation otherwise. + // It effectively asks for 3.3 Core profile, but any 3.3+ should be fine. + if (GlTest.BestCtx.GlCtxInfo.Version < new Version(3, 3)) + { + MsgBox.Warn( + "Selected D2GL renderer wrapper might not work.\n" + + "D2GL requires at least a 3.3 context.\n" + + "\n" + + "Best GL context created:\n" + + "\n" + + GlTest.BestCtx.GlCtxInfo.ToString() + ); + } + } + } + else + { + if (GlTest.BestCtx.GlCtxInfo == null && GlTest.BestCtx.StageReached.IndicatesGlFailure()) + { + MsgBox.Exception( + GlTest.BestCtx.Exception, + "Selected cnc-ddraw renderer wrapper (set to OpenGL renderer) might not work:", + MessageBoxImage.Warning + ); + } + } } private async void MainWindow_Loaded(object sender, RoutedEventArgs e) { + RestoreWindowPosition(); + await InitializeAsync(); } @@ -496,11 +1229,22 @@ private void MainWindow_Closed(object sender, EventArgs e) Debug.WriteLine($"\n\n Saving window position: Left = {this.Left}, Top = {this.Top} \n\n"); _localStorage.Update(StorageKey.WindowPosition, windowPosition); + + // Usage of these local values instead of LocalStorage directly might lead to discrepancy + _localStorage.Update(StorageKey.LauncherOptions, new LauncherOptions + { + ForceSoftwareRenderer = this.ForceSoftwareRenderer, + UseHttp2 = this.UseHttp2, + DisableAutoUpdate = this.IsDisableUpdates, + AutoCloseAfterLaunch = this.AutoCloseAfterLaunch + }); } - private void EnsureWindowIsVisible() + private void RestoreWindowPosition() { - var windowPosition = _localStorage.LoadSection(StorageKey.WindowPosition); + WindowPositionModel windowPosition = _localStorage.LoadSection(StorageKey.WindowPosition); + + Debug.WriteLine($"\n\n Loaded window position: Left = {windowPosition.Left}, Top = {windowPosition.Top} \n\n"); // Check if the window is out of bounds bool isOutOfBounds = @@ -509,25 +1253,7 @@ private void EnsureWindowIsVisible() windowPosition.Left > SystemParameters.VirtualScreenLeft + SystemParameters.VirtualScreenWidth || windowPosition.Top > SystemParameters.VirtualScreenTop + SystemParameters.VirtualScreenHeight; - if (windowPosition == null || isOutOfBounds) - { - CenterWindowOnScreen(); - } - else - { - // Restore the window to its last saved position - this.Left = windowPosition.Left; - this.Top = windowPosition.Top; - } - } - - private void InitWindow() - { - var windowPosition = _localStorage.LoadSection(StorageKey.WindowPosition); - - Debug.WriteLine($"\n\n Loaded window position: Left = {windowPosition?.Left}, Top = {windowPosition?.Top} \n\n"); - - if (windowPosition == null || (windowPosition.Left == 0 && windowPosition.Top == 0)) + if (windowPosition == null || isOutOfBounds || (windowPosition.Left == 0 && windowPosition.Top == 0)) { CenterWindowOnScreen(); } @@ -545,9 +1271,70 @@ private void ShowErrorMessage(string message) public void InitializeDefaultSettings(ILocalStorage localStorage) { + // Since LauncherOptions has been recently added, expect existing config files (aka LocalStorage) + // to be missing that section. Since the whole LocalStorage implementation is a bit sketchy and the below + // initialization doesn't even work -- perform only this specific manual step for now. + // + // Keep in mind that LocalStorage.Update() will still rotate and rewrite the entire file. *sigh* + if (_localStorage.LoadSectionIfExists(StorageKey.LauncherOptions) == null) + { + bool localStorageUpdated = false; + + if (Wine.IsRunningUnderWine) + { + if (MsgBox.Info( + "It appears to be the first time the launcher has been run.\n" + + "Additionally, it's running under Wine.\n" + + "\n" + + "Would you like to set up launcher options for maximum Wine compatibility?\n" + + "(This can be re-adjusted in the Options menu at any time).", + MessageBoxButton.YesNo, + MessageBoxResult.Yes) == MessageBoxResult.Yes) + { + localStorage.Update(StorageKey.LauncherOptions, new LauncherOptions() + { + ForceSoftwareRenderer = true, + // HTTP/2 performance in Wine is currently subpar + UseHttp2 = false + }); + + localStorageUpdated = true; + } + + if (MsgBox.Info( + "Would you like to apply PD2-specific Wine configuration?\n" + + "(This can be re-adjusted in the Options menu at any time).", + MessageBoxButton.YesNo, + MessageBoxResult.Yes) == MessageBoxResult.Yes) + { + try + { + Wine.ApplyWineConfiguration(); + } + catch (Wine.WineException ex) + { + L.CallerError(ex.InnerException, ex.Message); + MsgBox.Exception(ex, "Failed to apply Wine configuration:"); + } + catch (Exception ex) + { + L.CallerError(ex, "Failed to apply Wine configuration."); + MsgBox.Exception(ex, "Failed to apply Wine configuration:"); + } + } + } + + if (!localStorageUpdated) + { + localStorage.Update(StorageKey.LauncherOptions, new LauncherOptions()); + } + } + + // None of the below logic works as expected _localStorage.InitializeIfNotExists(StorageKey.FileUpdateModel, new FileUpdateModel()); _localStorage.InitializeIfNotExists(StorageKey.DdrawOptions, new DdrawOptions()); _localStorage.InitializeIfNotExists(StorageKey.LauncherArgs, new LauncherArgs()); + _localStorage.InitializeIfNotExists(StorageKey.LauncherOptions, new LauncherOptions()); _localStorage.InitializeIfNotExists(StorageKey.SelectedAuthorAndFilter, new SelectedAuthorAndFilter()); _localStorage.InitializeIfNotExists(StorageKey.Pd2AuthorList, new Pd2AuthorList()); _localStorage.InitializeIfNotExists(StorageKey.News, new News()); @@ -662,7 +1449,8 @@ await _fileUpdateHelpers.GetCloudFileMetadataAsync( catch (Exception ex) { Debug.WriteLine($"Unhandled exception: {ex}"); - UpdatesNotificationVisibility = Visibility.Visible; + + ToggleOffline(show: true); onDownloadComplete?.Invoke(); return; } @@ -826,5 +1614,173 @@ public static void ShowTopmostMessageBox(string message, string title) topmostWindow.Show(); } + + private async void GoToLogButton_Click(object sender, RoutedEventArgs e) + { + try + { + await Shell.OpenFolderAndSelectItemsAsync(Logging.LogDirPath, Logging.LogFileName); + } + catch (Exception ex) + { + L.CallerWarning(ex, $"{nameof(Shell.OpenFolderAndSelectItemsAsync)}() threw"); + MsgBox.Exception(ex, "Failed to navigate to the log file:", MessageBoxImage.Warning); + } + } + + private void AutoCloseBegin(Process gameProcess) + { + lock (_autoCloseLock) + { + if (_autoCloseActive) + { + return; + } + + if (gameProcess.HasExited) + { + // If the process has already terminated -- bail + return; + } + + _autoCloseGameProcess = gameProcess; + + _autoCloseProgressUpdateStopwatch.Restart(); + _autoCloseProgressUpdateTimer = new DispatcherTimer( + TimeSpan.FromMilliseconds(UpdateThrottle.DefaultIntervalMilliseconds), + DispatcherPriority.Render, + AutoCloseProgressUpdateTimer, + this.Dispatcher + ); + _autoCloseProgressUpdateTimer.Start(); + + _autoCloseThreadInterrupt.Reset(); + _autoCloseThread = new Thread(AutoCloseThread); + _autoCloseThread.Start(); + + _autoCloseActive = true; + + L.CallerDebug($"Auto-closing in {AutoCloseTimeSpan}..."); + } + } + + private bool AutoCloseAbort(bool joinThread = true) + { + Thread? autoCloseThread = null; + + lock (_autoCloseLock) + { + if (!_autoCloseActive) + { + return false; + } + + _autoCloseThreadInterrupt.Set(); + if (joinThread) + { + autoCloseThread = _autoCloseThread; + } + _autoCloseThread = null; + + _autoCloseProgressUpdateTimer!.Stop(); + _autoCloseProgressUpdateTimer = null; + + this.Dispatcher.Invoke(AutoCloseResetProgress); + + _autoCloseGameProcess!.Exited -= AutoCloseGameProcessExited; + _autoCloseGameProcess = null; + + _autoCloseActive = false; + } + + autoCloseThread?.Join(); + + return true; + } + + private void AutoCloseResetProgress() + { + AutoCloseProgressBar.Value = 0; + } + + private void AutoCloseGameProcessExited(object? sender, EventArgs e) + { + // This will be called from a thread pool + + if (!AutoCloseAbort()) + { + // If not active yet -- just bail. + // AutoCloseBegin() will also bail if the process has terminated already. + return; + } + + if (sender is Process process) + { + L.CallerDebug($"Auto-close aborted due to game process terminating with {process.ExitCode} exit code."); + + process.Dispose(); + } + else + { + L.CallerDebug($"Auto-close aborted due to game process terminating."); + } + + this.Dispatcher.Invoke(() => + { + // Try to bring the window into foreground + this.Topmost = true; + this.Activate(); + this.Topmost = false; + }); + } + + private void AutoClosePostProcessInput(object sender, ProcessInputEventArgs e) + { + if (e.StagingItem.Input is MouseButtonEventArgs m) + { + // Minimize number of different events being handled here + if (m.ButtonState == MouseButtonState.Pressed && m.RoutedEvent.RoutingStrategy == RoutingStrategy.Tunnel) + { + if (AutoCloseAbort()) + { + L.CallerDebug($"Auto-close aborted due to mouse down event."); + } + } + } + else if (e.StagingItem.Input is KeyEventArgs k) + { + // Minimize number of different events being handled here + if (k.IsDown && !k.IsRepeat && k.RoutedEvent.RoutingStrategy == RoutingStrategy.Tunnel) + { + if (AutoCloseAbort()) + { + L.CallerDebug($"Auto-close aborted due to key down event."); + } + } + } + } + + private void AutoCloseThread() + { + if (_autoCloseThreadInterrupt.WaitOne(AutoCloseTimeSpan)) + { + return; + } + else + { + if (AutoCloseAbort(joinThread: false)) + { + L.CallerDebug("Auto-closing..."); + this.Dispatcher.Invoke(this.Close); + } + } + } + + private void AutoCloseProgressUpdateTimer(object? sender, EventArgs e) + { + double progress = Math.Max(0, AutoCloseTimeSpan.Ticks - _autoCloseProgressUpdateStopwatch.Elapsed.Ticks) / (double)AutoCloseTimeSpan.Ticks; + + AutoCloseProgressBar.Value = progress * AutoCloseProgressBar.Maximum; + } } } \ No newline at end of file diff --git a/PD2Launcherv2/Messages/ConfigurationChangeMessage.cs b/PD2Launcherv2/Messages/ConfigurationChangeMessage.cs index 518d8a1e..5eb308fc 100644 --- a/PD2Launcherv2/Messages/ConfigurationChangeMessage.cs +++ b/PD2Launcherv2/Messages/ConfigurationChangeMessage.cs @@ -5,6 +5,5 @@ public class ConfigurationChangeMessage { public bool IsBeta { get; set; } public bool IsCustom { get; set; } - public bool IsDisableUpdates { get; set; } } } \ No newline at end of file diff --git a/PD2Launcherv2/Messages/LauncherOptionsChangeMessage.cs b/PD2Launcherv2/Messages/LauncherOptionsChangeMessage.cs new file mode 100644 index 00000000..a8aef6cd --- /dev/null +++ b/PD2Launcherv2/Messages/LauncherOptionsChangeMessage.cs @@ -0,0 +1,9 @@ +namespace PD2Launcherv2.Messages +{ + public class LauncherOptionsChangeMessage + { + public bool ForceSoftwareRenderer { get; init; } + public bool UseHttp2 { get; init; } + public bool DisableAutoUpdate { get; init; } + } +} diff --git a/PD2Launcherv2/Messages/RendererChangeMessage.cs b/PD2Launcherv2/Messages/RendererChangeMessage.cs new file mode 100644 index 00000000..39be55b2 --- /dev/null +++ b/PD2Launcherv2/Messages/RendererChangeMessage.cs @@ -0,0 +1,9 @@ +namespace PD2Launcherv2.Messages +{ + public class RendererChangeMessage + { + // This should really be a common enum and not a bool + public bool UseD2GL { get; init; } + public bool CncDdrawUsesOGL { get; init; } + } +} diff --git a/PD2Launcherv2/PD2Launcherv2.csproj b/PD2Launcherv2/PD2Launcherv2.csproj index 4ac15617..9e9ecab7 100644 --- a/PD2Launcherv2/PD2Launcherv2.csproj +++ b/PD2Launcherv2/PD2Launcherv2.csproj @@ -23,8 +23,16 @@ true + + full + + + + embedded + + - + @@ -32,33 +40,38 @@ + + + + + + + + + + + - - - - - - - - + - + + @@ -66,10 +79,6 @@ - - - - @@ -82,7 +91,7 @@ - + @@ -90,35 +99,36 @@ + + + + + + + + + + + - - - - - - - - + - - - - - + + diff --git a/PD2Launcherv2/Resources/Fonts/exocet-blizzard-heavy.otf b/PD2Launcherv2/Resources/Fonts/exocet-blizzard-heavy.ttf similarity index 100% rename from PD2Launcherv2/Resources/Fonts/exocet-blizzard-heavy.otf rename to PD2Launcherv2/Resources/Fonts/exocet-blizzard-heavy.ttf diff --git a/PD2Launcherv2/Resources/Images/bg1.jpg b/PD2Launcherv2/Resources/Images/bg1.jpg index 786a82f1..9474768d 100644 Binary files a/PD2Launcherv2/Resources/Images/bg1.jpg and b/PD2Launcherv2/Resources/Images/bg1.jpg differ diff --git a/PD2Launcherv2/Resources/Images/bg2.jpg b/PD2Launcherv2/Resources/Images/bg2.jpg index a353bb4e..bcabf371 100644 Binary files a/PD2Launcherv2/Resources/Images/bg2.jpg and b/PD2Launcherv2/Resources/Images/bg2.jpg differ diff --git a/PD2Launcherv2/Resources/Images/bg_plain.jpg b/PD2Launcherv2/Resources/Images/bg_plain.jpg index 8092e75f..e810128b 100644 Binary files a/PD2Launcherv2/Resources/Images/bg_plain.jpg and b/PD2Launcherv2/Resources/Images/bg_plain.jpg differ diff --git a/PD2Launcherv2/Resources/Images/blank.png b/PD2Launcherv2/Resources/Images/blank.png new file mode 100644 index 00000000..7dd1f7b5 Binary files /dev/null and b/PD2Launcherv2/Resources/Images/blank.png differ diff --git a/PD2Launcherv2/Resources/Images/blank_disabled.png b/PD2Launcherv2/Resources/Images/blank_disabled.png new file mode 100644 index 00000000..77834287 Binary files /dev/null and b/PD2Launcherv2/Resources/Images/blank_disabled.png differ diff --git a/PD2Launcherv2/Resources/Images/blank_pressed.png b/PD2Launcherv2/Resources/Images/blank_pressed.png new file mode 100644 index 00000000..b13031b4 Binary files /dev/null and b/PD2Launcherv2/Resources/Images/blank_pressed.png differ diff --git a/PD2Launcherv2/Resources/Images/blank_split_bottom.png b/PD2Launcherv2/Resources/Images/blank_split_bottom.png new file mode 100644 index 00000000..3278e457 Binary files /dev/null and b/PD2Launcherv2/Resources/Images/blank_split_bottom.png differ diff --git a/PD2Launcherv2/Resources/Images/blank_split_bottom_disabled.png b/PD2Launcherv2/Resources/Images/blank_split_bottom_disabled.png new file mode 100644 index 00000000..522b57b4 Binary files /dev/null and b/PD2Launcherv2/Resources/Images/blank_split_bottom_disabled.png differ diff --git a/PD2Launcherv2/Resources/Images/blank_split_bottom_pressed.png b/PD2Launcherv2/Resources/Images/blank_split_bottom_pressed.png new file mode 100644 index 00000000..d7d55d94 Binary files /dev/null and b/PD2Launcherv2/Resources/Images/blank_split_bottom_pressed.png differ diff --git a/PD2Launcherv2/Resources/Images/blank_split_top.png b/PD2Launcherv2/Resources/Images/blank_split_top.png new file mode 100644 index 00000000..d885704b Binary files /dev/null and b/PD2Launcherv2/Resources/Images/blank_split_top.png differ diff --git a/PD2Launcherv2/Resources/Images/blank_split_top_disabled.png b/PD2Launcherv2/Resources/Images/blank_split_top_disabled.png new file mode 100644 index 00000000..44a5872e Binary files /dev/null and b/PD2Launcherv2/Resources/Images/blank_split_top_disabled.png differ diff --git a/PD2Launcherv2/Resources/Images/blank_split_top_pressed.png b/PD2Launcherv2/Resources/Images/blank_split_top_pressed.png new file mode 100644 index 00000000..8afa86ba Binary files /dev/null and b/PD2Launcherv2/Resources/Images/blank_split_top_pressed.png differ diff --git a/PD2Launcherv2/Resources/Images/btn_beta.jpg b/PD2Launcherv2/Resources/Images/btn_beta.jpg index 2515c188..43e39fd3 100644 Binary files a/PD2Launcherv2/Resources/Images/btn_beta.jpg and b/PD2Launcherv2/Resources/Images/btn_beta.jpg differ diff --git a/PD2Launcherv2/Resources/Images/btn_live.jpg b/PD2Launcherv2/Resources/Images/btn_live.jpg index ffcde2c6..ae1bbbb3 100644 Binary files a/PD2Launcherv2/Resources/Images/btn_live.jpg and b/PD2Launcherv2/Resources/Images/btn_live.jpg differ diff --git a/PD2Launcherv2/Resources/Images/btn_more.jpg b/PD2Launcherv2/Resources/Images/btn_more.jpg index 4a502f24..e2d465b6 100644 Binary files a/PD2Launcherv2/Resources/Images/btn_more.jpg and b/PD2Launcherv2/Resources/Images/btn_more.jpg differ diff --git a/PD2Launcherv2/Resources/Images/btn_more_pressed.jpg b/PD2Launcherv2/Resources/Images/btn_more_pressed.jpg index 085a5260..974b2348 100644 Binary files a/PD2Launcherv2/Resources/Images/btn_more_pressed.jpg and b/PD2Launcherv2/Resources/Images/btn_more_pressed.jpg differ diff --git a/PD2Launcherv2/Resources/Images/btn_no_updates3.jpg b/PD2Launcherv2/Resources/Images/btn_no_updates3.jpg index 94aab60f..95c2b64a 100644 Binary files a/PD2Launcherv2/Resources/Images/btn_no_updates3.jpg and b/PD2Launcherv2/Resources/Images/btn_no_updates3.jpg differ diff --git a/PD2Launcherv2/Resources/Images/btn_switch.jpg b/PD2Launcherv2/Resources/Images/btn_switch.jpg index dac743fa..425406ee 100644 Binary files a/PD2Launcherv2/Resources/Images/btn_switch.jpg and b/PD2Launcherv2/Resources/Images/btn_switch.jpg differ diff --git a/PD2Launcherv2/Resources/Images/btn_switch_disabled.png b/PD2Launcherv2/Resources/Images/btn_switch_disabled.png new file mode 100644 index 00000000..ce8006c2 Binary files /dev/null and b/PD2Launcherv2/Resources/Images/btn_switch_disabled.png differ diff --git a/PD2Launcherv2/Resources/Images/btn_switch_pressed.jpg b/PD2Launcherv2/Resources/Images/btn_switch_pressed.jpg index 56eb9412..f00732ae 100644 Binary files a/PD2Launcherv2/Resources/Images/btn_switch_pressed.jpg and b/PD2Launcherv2/Resources/Images/btn_switch_pressed.jpg differ diff --git a/PD2Launcherv2/Resources/Images/checkbox_checked.png b/PD2Launcherv2/Resources/Images/checkbox_checked.png index 8ffff529..0d4493d8 100644 Binary files a/PD2Launcherv2/Resources/Images/checkbox_checked.png and b/PD2Launcherv2/Resources/Images/checkbox_checked.png differ diff --git a/PD2Launcherv2/Resources/Images/checkbox_unchecked.png b/PD2Launcherv2/Resources/Images/checkbox_unchecked.png index ed801a3d..cf1990ed 100644 Binary files a/PD2Launcherv2/Resources/Images/checkbox_unchecked.png and b/PD2Launcherv2/Resources/Images/checkbox_unchecked.png differ diff --git a/PD2Launcherv2/Resources/Images/close.jpg b/PD2Launcherv2/Resources/Images/close.jpg index 9a7add3f..1a2bc53b 100644 Binary files a/PD2Launcherv2/Resources/Images/close.jpg and b/PD2Launcherv2/Resources/Images/close.jpg differ diff --git a/PD2Launcherv2/Resources/Images/close_disabled.png b/PD2Launcherv2/Resources/Images/close_disabled.png new file mode 100644 index 00000000..e9479d5d Binary files /dev/null and b/PD2Launcherv2/Resources/Images/close_disabled.png differ diff --git a/PD2Launcherv2/Resources/Images/close_pressed.jpg b/PD2Launcherv2/Resources/Images/close_pressed.jpg index a26d6aa3..3fa701e7 100644 Binary files a/PD2Launcherv2/Resources/Images/close_pressed.jpg and b/PD2Launcherv2/Resources/Images/close_pressed.jpg differ diff --git a/PD2Launcherv2/Resources/Images/custom_notif.jpg b/PD2Launcherv2/Resources/Images/custom_notif.jpg index 100282f4..f2260cdc 100644 Binary files a/PD2Launcherv2/Resources/Images/custom_notif.jpg and b/PD2Launcherv2/Resources/Images/custom_notif.jpg differ diff --git a/PD2Launcherv2/Resources/Images/donate.jpg b/PD2Launcherv2/Resources/Images/donate.jpg deleted file mode 100644 index 092af618..00000000 Binary files a/PD2Launcherv2/Resources/Images/donate.jpg and /dev/null differ diff --git a/PD2Launcherv2/Resources/Images/donate_pressed.jpg b/PD2Launcherv2/Resources/Images/donate_pressed.jpg deleted file mode 100644 index 0b4d4996..00000000 Binary files a/PD2Launcherv2/Resources/Images/donate_pressed.jpg and /dev/null differ diff --git a/PD2Launcherv2/Resources/Images/empty_button.png b/PD2Launcherv2/Resources/Images/empty_button.png index ce584199..616ca2eb 100644 Binary files a/PD2Launcherv2/Resources/Images/empty_button.png and b/PD2Launcherv2/Resources/Images/empty_button.png differ diff --git a/PD2Launcherv2/Resources/Images/logo.gif b/PD2Launcherv2/Resources/Images/logo.gif index b2e2af11..b74faef2 100644 Binary files a/PD2Launcherv2/Resources/Images/logo.gif and b/PD2Launcherv2/Resources/Images/logo.gif differ diff --git a/PD2Launcherv2/Resources/Images/loot.jpg b/PD2Launcherv2/Resources/Images/loot.jpg deleted file mode 100644 index aa0bf543..00000000 Binary files a/PD2Launcherv2/Resources/Images/loot.jpg and /dev/null differ diff --git a/PD2Launcherv2/Resources/Images/loot_pressed.jpg b/PD2Launcherv2/Resources/Images/loot_pressed.jpg deleted file mode 100644 index 10ad0349..00000000 Binary files a/PD2Launcherv2/Resources/Images/loot_pressed.jpg and /dev/null differ diff --git a/PD2Launcherv2/Resources/Images/minimize.jpg b/PD2Launcherv2/Resources/Images/minimize.jpg index 3e730488..fa2df52c 100644 Binary files a/PD2Launcherv2/Resources/Images/minimize.jpg and b/PD2Launcherv2/Resources/Images/minimize.jpg differ diff --git a/PD2Launcherv2/Resources/Images/minimize_pressed.jpg b/PD2Launcherv2/Resources/Images/minimize_pressed.jpg index 752c879b..d76aad2f 100644 Binary files a/PD2Launcherv2/Resources/Images/minimize_pressed.jpg and b/PD2Launcherv2/Resources/Images/minimize_pressed.jpg differ diff --git a/PD2Launcherv2/Resources/Images/offline24.png b/PD2Launcherv2/Resources/Images/offline24.png new file mode 100644 index 00000000..926dc9a0 Binary files /dev/null and b/PD2Launcherv2/Resources/Images/offline24.png differ diff --git a/PD2Launcherv2/Resources/Images/options.jpg b/PD2Launcherv2/Resources/Images/options.jpg deleted file mode 100644 index ba5ba8c4..00000000 Binary files a/PD2Launcherv2/Resources/Images/options.jpg and /dev/null differ diff --git a/PD2Launcherv2/Resources/Images/options_pressed.jpg b/PD2Launcherv2/Resources/Images/options_pressed.jpg deleted file mode 100644 index 1cd3f28d..00000000 Binary files a/PD2Launcherv2/Resources/Images/options_pressed.jpg and /dev/null differ diff --git a/PD2Launcherv2/Resources/Images/play.jpg b/PD2Launcherv2/Resources/Images/play.jpg deleted file mode 100644 index 7d8d4b46..00000000 Binary files a/PD2Launcherv2/Resources/Images/play.jpg and /dev/null differ diff --git a/PD2Launcherv2/Resources/Images/play_pressed.jpg b/PD2Launcherv2/Resources/Images/play_pressed.jpg deleted file mode 100644 index 0e1ab20f..00000000 Binary files a/PD2Launcherv2/Resources/Images/play_pressed.jpg and /dev/null differ diff --git a/PD2Launcherv2/Resources/Images/thin_next.jpg b/PD2Launcherv2/Resources/Images/thin_next.jpg index 9bad28b7..9949ae3b 100644 Binary files a/PD2Launcherv2/Resources/Images/thin_next.jpg and b/PD2Launcherv2/Resources/Images/thin_next.jpg differ diff --git a/PD2Launcherv2/Resources/Images/thin_prev.jpg b/PD2Launcherv2/Resources/Images/thin_prev.jpg index 98728437..16645843 100644 Binary files a/PD2Launcherv2/Resources/Images/thin_prev.jpg and b/PD2Launcherv2/Resources/Images/thin_prev.jpg differ diff --git a/PD2Launcherv2/Resources/Images/updating_disabled.jpg b/PD2Launcherv2/Resources/Images/updating_disabled.jpg deleted file mode 100644 index 2f1344df..00000000 Binary files a/PD2Launcherv2/Resources/Images/updating_disabled.jpg and /dev/null differ diff --git a/PD2Launcherv2/Resources/Images/winelogo16.png b/PD2Launcherv2/Resources/Images/winelogo16.png new file mode 100644 index 00000000..8d4fce56 Binary files /dev/null and b/PD2Launcherv2/Resources/Images/winelogo16.png differ diff --git a/PD2Launcherv2/Resources/Images/winelogo32.png b/PD2Launcherv2/Resources/Images/winelogo32.png new file mode 100644 index 00000000..450a1111 Binary files /dev/null and b/PD2Launcherv2/Resources/Images/winelogo32.png differ diff --git a/PD2Launcherv2/Resources/Styles/Colors.xaml b/PD2Launcherv2/Resources/Styles/Colors.xaml index 54690ae6..7e8b2172 100644 --- a/PD2Launcherv2/Resources/Styles/Colors.xaml +++ b/PD2Launcherv2/Resources/Styles/Colors.xaml @@ -5,10 +5,16 @@ #FFA78E65 #FFB59E76 #FF947A56 + #FFA7656D + #FFB5767D + #FF945661 #007759 + + + #998363 #b39a74 diff --git a/PD2Launcherv2/Resources/Styles/ControlStyles.xaml b/PD2Launcherv2/Resources/Styles/ControlStyles.xaml index 00bb549f..4663ef10 100644 --- a/PD2Launcherv2/Resources/Styles/ControlStyles.xaml +++ b/PD2Launcherv2/Resources/Styles/ControlStyles.xaml @@ -1,14 +1,17 @@ + xmlns:local="clr-namespace:PD2Launcherv2.CustomControl" + xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" + xmlns:comment="https://whatever/comment" + mc:Ignorable="comment"> - - + + + + + + + + + + - + + @@ -429,7 +651,7 @@ - + @@ -438,10 +660,10 @@ - + - + @@ -457,14 +679,14 @@ diff --git a/PD2Launcherv2/SanityChecks.cs b/PD2Launcherv2/SanityChecks.cs new file mode 100644 index 00000000..c64583cb --- /dev/null +++ b/PD2Launcherv2/SanityChecks.cs @@ -0,0 +1,152 @@ +using System.Text; +using System.Windows; +using PD2Launcherv2.Utils; +using PD2Launcherv2.Utils.Gl; +using PD2Shared.Logging; +using static PD2Shared.Logging.LoggingStatic; +using PD2Shared.Utils; + +namespace PD2Launcherv2 +{ + internal static class SanityChecks + { + private static bool FailurePrompt(string message) + { + if (MsgBox.Warn( + message + "\n" + + "\n" + + "Continue regardless?", + MessageBoxButton.YesNo, + MessageBoxResult.No) == MessageBoxResult.No) + { + return false; + } + + L.CallerWarning("User ignored sanity check failure."); + return true; + } + + private static bool CheckGameDirPathEncoding() + { + string gameDirPath = Env.GetCwd(); + + var encodingAltNames = new string[] { Env.AnsiEncoding.WebName, Env.AnsiEncoding.BodyName, Env.AnsiEncoding.HeaderName } + .Where(n => !string.IsNullOrEmpty(n)) + .Distinct() + .Select(n => $"'{n}'"); + string encodingDisplayName = $"'{Env.AnsiEncoding.EncodingName}' (aka {string.Join(", ", encodingAltNames)})"; + + string roundTrip = Env.AnsiEncoding.GetString(Encoding.Convert(Encoding.Unicode, Env.AnsiEncoding, Encoding.Unicode.GetBytes(gameDirPath))); + + if (roundTrip != gameDirPath) + { + L.CallerWarning($"Game directory '{gameDirPath}' cannot be represented in system-default ANSI encoding: {encodingDisplayName}."); + + if (!FailurePrompt( + $"Game directory '{gameDirPath}' cannot be represented in system-default ANSI encoding: {encodingDisplayName}.\n" + + "\n" + + $"Problematic characters: '{new string(gameDirPath.Except(roundTrip).Distinct().ToArray())}'.\n" + + "\n" + + "This will likely cause PD2 to crash.")) + { + return false; + } + } + else + { + L.CallerInformation($"Game directory '{gameDirPath}' can be represented in system-default ANSI encoding: {encodingDisplayName}."); + } + + return true; + } + + private static bool CheckIfDirectoriesWritable() + { + { + string dirPath = Env.ProcessDirPath; + + var ex = Env.CheckIfDirectoryIsWritable(dirPath); + + if (ex != null) + { + L.CallerWarning(ex, $"Launcher directory '{dirPath}' is not writable."); + + if (!FailurePrompt( + $"Launcher directory '{dirPath}' is not writable.\n" + + "\n" + + "This can lead to unexpected issues.")) + { + return false; + } + } + else + { + L.CallerInformation($"Launcher directory '{dirPath}' is writable."); + } + } + + { + string dirPath = Env.GetCwd(); + + var ex = Env.CheckIfDirectoryIsWritable(dirPath); + + if (ex != null) + { + L.CallerWarning(ex, $"Launcher working directory '{dirPath}' is not writable."); + + if (!FailurePrompt( + $"Launcher working directory '{dirPath}' is not writable.\n" + + "\n" + + "This can lead to unexpected issues.")) + { + return false; + } + } + else + { + L.CallerInformation($"Launcher working directory '{dirPath}' is writable."); + } + } + + return true; + } + + private static bool TestGlContext() + { + using LoggedRoutine loggedRoutine = new(); + + // Force creating GL contexts + _ = GlTest.BestCtx; + + // This is too early to complain about any issues with GL. + // Evaluate GlTest.BestCtx in light of current launcher options in MainWindow. + return true; + } + + public static bool Run() + { + List> sanityChecks = new() + { + () => CheckGameDirPathEncoding(), + () => CheckIfDirectoriesWritable(), + () => TestGlContext() + }; + + using LoggedScope loggedScope = new($"Running {sanityChecks.Count} sanity check(s)..."); + + for (int i = 0; i < sanityChecks.Count; ++i) + { + var check = sanityChecks[i]; + + L.CallerInformation($"> {i + 1}/{sanityChecks.Count}"); + + if (!check()) + { + return false; + } + } + + return true; + } + } +} diff --git a/PD2Launcherv2/Utils/Gl/GlCtxInfo.cs b/PD2Launcherv2/Utils/Gl/GlCtxInfo.cs new file mode 100644 index 00000000..cc184223 --- /dev/null +++ b/PD2Launcherv2/Utils/Gl/GlCtxInfo.cs @@ -0,0 +1,68 @@ +namespace PD2Launcherv2.Utils.Gl +{ + public class GlCtxInfo + { + public GlCtxInfo( + Version? version, + string glVendor, + string glRenderer, + string glVersion, + string glShadingLanguageVersion, + + bool? isForwardCompatible, + bool? isDebug, + bool? isRobust, + bool? isNoError, + + bool? isCore, + bool? isCompatibility + ) + { + Version = version; + + GlVendor = glVendor; + GlRenderer = glRenderer; + GlVersion = glVersion; + GlShadingLanguageVersion = glShadingLanguageVersion; + + IsForwardCompatible = isForwardCompatible; + IsDebug = isDebug; + IsRobust = isRobust; + IsNoError = isNoError; + + IsCore = isCore; + IsCompatibility = isCompatibility; + } + + public Version? Version { get; } + + public string GlVendor { get; } + public string GlRenderer { get; } + public string GlVersion { get; } + public string GlShadingLanguageVersion { get; } + + public bool? IsForwardCompatible { get; } + public bool? IsDebug { get; } + public bool? IsRobust { get; } + public bool? IsNoError { get; } + + public bool? IsCore { get; } + public bool? IsCompatibility { get; } + + public string[] ToLines() + { + return new string[] { + $"{(Version?.ToString() ?? "Unknown version")}{(IsCore == true ? " Core" : IsCompatibility == true ? " Compatibility" : string.Empty)}{(IsForwardCompatible == true ? " Forward-compatible" : string.Empty)}", + $"GL_VERSION: {GlVersion}", + $"GL_RENDERER: {GlRenderer}", + $"GL_VENDOR: {GlVendor}", + $"GL_SHADING_LANGUAGE_VERSION: {GlShadingLanguageVersion}" + }; + } + + public override string ToString() + { + return string.Join('\n', ToLines()); + } + } +} diff --git a/PD2Launcherv2/Utils/Gl/GlTest.cs b/PD2Launcherv2/Utils/Gl/GlTest.cs new file mode 100644 index 00000000..396bb190 --- /dev/null +++ b/PD2Launcherv2/Utils/Gl/GlTest.cs @@ -0,0 +1,387 @@ +using System.Runtime.InteropServices; +using PD2Launcherv2.Utils.Gl.Internal; +using PD2Shared.Logging; +using static PD2Shared.Logging.LoggingStatic; +using PD2Shared.Utils; + +namespace PD2Launcherv2.Utils.Gl +{ + public static class GlTest + { + // Tracks GetBestContext() progress to determine if an occurring failure deems GL driver unusable + public enum BestContextStage + { + None, + + // Exceptions thrown here indicate GL failure (see: BestContextStageEx.IndicatesGlFailure()) + ForceLoadingOpenGLDll, + + WindowCreation, + + // Exceptions thrown here indicate GL failure (see: BestContextStageEx.IndicatesGlFailure()) + PixelFormatSelection, + InitialCtxCreation, + GotInitialCtx, + BestCtxCreation, + + GotBestCtx, + LeftWithInitialCtx + } + + public class BestCtxInfo + { + public GlCtxInfo? GlCtxInfo { get; init; } + public Exception? Exception { get; init; } + public BestContextStage StageReached { get; init; } + } + + private static BestCtxInfo? _bestContext = null; + public static BestCtxInfo BestCtx + { + get + { + if (_bestContext == null) + { + BestContextStage stageReached = BestContextStage.None; + + try + { + _bestContext = new() + { + GlCtxInfo = GetBestContext(out stageReached), + Exception = null, + StageReached = stageReached + }; + + if (stageReached < BestContextStage.GotBestCtx) + { + L.CallerWarning($"{nameof(GetBestContext)}() reached '{stageReached}'"); + } + } + catch (Exception ex) + { + L.CallerWarning(ex, $"{nameof(GetBestContext)}() failed after reaching '{stageReached}'"); + + _bestContext = new() + { + GlCtxInfo = null, + Exception = ex, + StageReached = stageReached + }; + } + } + + return _bestContext; + } + } + + private static GlCtxInfo GetBestContext(out BestContextStage stage) + { + ushort classAtom = 0; + IntPtr hwnd = IntPtr.Zero; + IntPtr hdc = IntPtr.Zero; + IntPtr hCtx = IntPtr.Zero; + + try + { + stage = BestContextStage.ForceLoadingOpenGLDll; + + // Force load opengl.dll to trigger any of its static initializers. + // Otherwise, WinAPI-provided WGL might misbehave if this isn't done before setting pixel format. + // + // This might as well throw an EntryPointNotFoundException exception here. + _ = OpenGLDll.wglGetCurrentContext(); + + stage = BestContextStage.WindowCreation; + + // Window creation loosely based on OpenGL.Net (https://github.com/luca-piccioni/OpenGL.Net/blob/master/OpenGL.Net/DeviceContextWGL.cs#L205) + + DllImports.WNDCLASSEX windowClass = new("Hidden GL ctx window") + { + style = DllImports.ClassStyles.CS_OWNDC + }; + + classAtom = DllImports.RegisterClassEx(windowClass); + + if (classAtom == 0) + { + throw Win32.GetLastException(nameof(DllImports.RegisterClassEx)); + } + + hwnd = DllImports.CreateWindowEx( + dwExStyle: 0, + windowClass.lpszClassName, + lpWindowName: string.Empty, + dwStyle: 0, + x: 0, + y: 0, + nWidth: 0, + nHeight: 0, + hWndParent: IntPtr.Zero, + hMenu: IntPtr.Zero, + windowClass.hInstance, + lpParam: IntPtr.Zero + ); + + if (hwnd == IntPtr.Zero) + { + throw Win32.GetLastException(nameof(DllImports.CreateWindowEx)); + } + + hdc = DllImports.GetDC(hwnd); + + if (hdc == IntPtr.Zero) + { + throw new Exception($"{nameof(DllImports.GetDC)}() failed."); + } + + stage = BestContextStage.PixelFormatSelection; + + // Pick the most sane pixel format (similar to D2GL) (https://github.com/bayaraa/d2gl/blob/master/d2gl/src/graphic/context.cpp#L37) + DllImports.PIXELFORMATDESCRIPTOR pfd = new() + { + dwFlags = 0 + | DllImports.PfdFlags.PFD_DRAW_TO_WINDOW + | DllImports.PfdFlags.PFD_DOUBLEBUFFER + | DllImports.PfdFlags.PFD_SUPPORT_OPENGL + | DllImports.PfdFlags.PFD_GENERIC_ACCELERATED, + iPixelType = DllImports.PfdPixelType.PFD_TYPE_RGBA, + cColorBits = 32, + cDepthBits = 24, + cStencilBits = 8, + iLayerType = DllImports.PfdLayerType.PFD_MAIN_PLANE, + }; + + int pixelFormat = DllImports.ChoosePixelFormat(hdc, pfd); + + if (pixelFormat == 0) + { + throw Win32.GetLastException(nameof(DllImports.ChoosePixelFormat)); + } + + if (DllImports.DescribePixelFormat(hdc, pixelFormat, pfd) == 0) + { + throw Win32.GetLastException(nameof(DllImports.DescribePixelFormat)); + } + + L.CallerInformation($"Chosen pixel format: {pfd.Describe()} ({pixelFormat})"); + + if (!DllImports.SetPixelFormat(hdc, pixelFormat, pfd)) + { + throw Win32.GetLastException(nameof(DllImports.SetPixelFormat)); + } + + stage = BestContextStage.InitialCtxCreation; + + hCtx = OpenGLDll.wglCreateContext(hdc); + + if (hCtx == IntPtr.Zero) + { + throw Win32.GetLastException(nameof(OpenGLDll.wglCreateContext)); + } + + using Ctx initCtx = new(ref hCtx, hdc); + + // Preemptively load all GL functions explicitly. + // If this fails, not even getting GlCtxInfo is possible, which would be VERY unlikely, but still... + try + { + initCtx.LoadGlFunctions(); + } + catch (EntryPointNotFoundException) + { + L.CallerError("Unable to load required GL functions for the initial context."); + + throw; + } + + L.CallerInformation("Initial GL context:"); + foreach (var line in initCtx.Info.ToLines()) + { + L.CallerInformation($"> {line}"); + } + + stage = BestContextStage.GotInitialCtx; + + // Preemptively load all WGL functions explicitly. + // If this fails, it's impossible to create a better context and might as well bail. + try + { + initCtx.LoadWglFunctions(); + } + catch (EntryPointNotFoundException ex) + { + L.CallerError(ex, "Unable to load required WGL functions for the initial context."); + L.CallerWarning("No better context can be created. Using initial GL context as best context."); + + return initCtx.Info; + } + + if (!(initCtx.Info.Version >= new Version(3, 2) || initCtx.WglExtensions.Contains("WGL_ARB_create_context_profile"))) + { + L.CallerWarning("Initial GL context is neither >=3.2, nor supports WGL_ARB_create_context_profile."); + L.CallerWarning("No better context can be created. Using initial GL context as best context."); + + return initCtx.Info; + } + + stage = BestContextStage.BestCtxCreation; + + // Test creating contexts D2GL would normally request (https://github.com/bayaraa/d2gl/blob/master/d2gl/src/graphic/context.cpp#L63) + foreach (Version version in new Version[] { + new(4, 6), + new(4, 5), + new(4, 4), + new(4, 3), + new(4, 2), + new(4, 1), + new(4, 0), + new(3, 3), + }) + { + L.CallerDebug($"Attempting to create a {version} Core context..."); + + initCtx.MakeCurrent(); + hCtx = initCtx.wglCreateContextAttribsARB( + hdc, + WglContextAttribs.WGL_CONTEXT_MAJOR_VERSION_ARB, version.Major, + WglContextAttribs.WGL_CONTEXT_MINOR_VERSION_ARB, version.Minor, + WglContextAttribs.WGL_CONTEXT_PROFILE_MASK_ARB, WglContextAttribs.WGL_CONTEXT_CORE_PROFILE_BIT_ARB, + // According to: https://wikis.khronos.org/opengl/OpenGL_Context#Forward_compatibility + // requesting forward compatibility for contexts >= 3.3 makes little sense... + // Still, keep this attribute for parity with D2GL. + WglContextAttribs.WGL_CONTEXT_FLAGS_ARB, WglContextAttribs.WGL_CONTEXT_FORWARD_COMPATIBLE_BIT_ARB + ); + + if (hCtx != IntPtr.Zero) + { + using Ctx bestCtx = new(ref hCtx, hdc); + + // Preemptively load all GL functions explicitly. + // If this fails, GlCtxInfo won't work, so might as well ignore this context. + try + { + bestCtx.LoadGlFunctions(); + } + catch (EntryPointNotFoundException ex) + { + L.CallerError(ex, $"Unable to load required GL functions for this {version} context. Skipping context."); + continue; + } + + var parsedVersion = bestCtx.Info.Version; + + if (parsedVersion == null) + { + L.CallerWarning($"Unable to determine the exact version of this {version} context:"); + + foreach (var line in bestCtx.Info.ToLines()) + { + L.CallerWarning($"> {line}"); + } + L.CallerWarning("Skipping context."); + + continue; + } + else if (parsedVersion != version && parsedVersion != new Version(version.Major, version.Minor, 0)) + { + L.CallerWarning($"This {version} context is in fact {parsedVersion}. Skipping context."); + + continue; + } + + stage = BestContextStage.GotBestCtx; + + L.CallerInformation("Best GL context:"); + foreach (var line in bestCtx.Info.ToLines()) + { + L.CallerInformation($"> {line}"); + } + + return bestCtx.Info; + } + else + { + var lastError = Marshal.GetLastWin32Error(); + + string mainMsg = $"Failed to create {version} Core context"; + + // wglCreateContextAttribsARB() fails yet GetLastError() reports ERROR_SUCCESS. Weird, but not impossible. + if (lastError == Win32.ERROR_SUCCESS) + { + L.CallerWarning(mainMsg); + } + else + { + var asLastGLerror = (LastGLerror)lastError; + + if (Enum.GetValues().Contains(asLastGLerror)) + { + L.CallerWarning($"{mainMsg}: {asLastGLerror}"); + } + else + { + L.CallerWarning($"{mainMsg}: {Win32.GetLastErrorMessage(lastError)}."); + } + } + } + } + + // At this point, the initial context is the best we got. + stage = BestContextStage.LeftWithInitialCtx; + + L.CallerWarning("No better context could be created. Using initial GL context as best context."); + return initCtx.Info; + } + finally + { + if (hCtx != IntPtr.Zero) + { + if (!OpenGLDll.wglDeleteContext(hCtx)) + { + L.CallerError(Win32.GetLastErrorMessage(nameof(OpenGLDll.wglDeleteContext))); + } + } + + if (hdc != IntPtr.Zero) + { + if (!DllImports.ReleaseDC(hwnd, hdc)) + { + L.CallerError($"{nameof(DllImports.ReleaseDC)}() failed."); + } + } + + if (hwnd != IntPtr.Zero) + { + if (!DllImports.DestroyWindow(hwnd)) + { + L.CallerError(Win32.GetLastErrorMessage(nameof(DllImports.DestroyWindow))); + } + } + + if (classAtom != 0) + { + if (!DllImports.UnregisterClass(classAtom, DllImports.HThisInstance)) + { + L.CallerError(Win32.GetLastErrorMessage(nameof(DllImports.UnregisterClass))); + } + } + } + } + } + + // Extension methods: + + public static class BestContextStageEx + { + public static bool IndicatesGlFailure(this GlTest.BestContextStage stage) + { + return false + || stage == GlTest.BestContextStage.ForceLoadingOpenGLDll + || stage == GlTest.BestContextStage.PixelFormatSelection + || stage == GlTest.BestContextStage.InitialCtxCreation + || stage == GlTest.BestContextStage.GotInitialCtx + || stage == GlTest.BestContextStage.BestCtxCreation; + } + } +} diff --git a/PD2Launcherv2/Utils/Gl/Internal/Ctx.cs b/PD2Launcherv2/Utils/Gl/Internal/Ctx.cs new file mode 100644 index 00000000..c903f822 --- /dev/null +++ b/PD2Launcherv2/Utils/Gl/Internal/Ctx.cs @@ -0,0 +1,369 @@ +using System.Collections.Immutable; +using System.Reflection; +using System.Runtime.InteropServices; +using System.Text.RegularExpressions; +using PD2Shared.Logging; +using static PD2Shared.Logging.LoggingStatic; +using PD2Shared.Utils; + +namespace PD2Launcherv2.Utils.Gl.Internal +{ + internal class Ctx : IDisposable + { + // A regex to parse the GL_VERSION string + // (https://wikis.khronos.org/opengl/OpenGL_Context#Context_information_queries) + // (https://wikis.khronos.org/opengl/GLAPI/glGetString#Description) + private static readonly Regex GlVersionRegex = new(@"^(\d+\.\d+(?:\.\d+)?)", RegexOptions.Compiled); + + private static IntPtr _hOpenGLDll = IntPtr.Zero; + + private readonly CtxDelegates _delegates = new(); + private bool _wglFunctionsLoaded = false; + private bool _glFunctionsLoaded = false; + + private bool _disposed = false; + private readonly IntPtr _hRenderCtx; + private readonly IntPtr _hdc; + + public Ctx(ref IntPtr hRenderCtx, IntPtr hdc) + { + if (hRenderCtx == IntPtr.Zero) + { + throw new ArgumentException($"'{nameof(hRenderCtx)}' is invalid.", nameof(hRenderCtx)); + } + + _hdc = hdc; + + // Take ownership of the handle + _hRenderCtx = hRenderCtx; + hRenderCtx = IntPtr.Zero; + } + + // Context operations + + private GlCtxInfo? _info = null; + public GlCtxInfo Info + { + get + { + if (_info == null) + { + LoadGlFunctions(); + + // Since it is unknown whether this is a >=3.0 context, don't use glGetIntegerv() with GL_MAJOR_VERSION and GL_MINOR_VERSION. + // Just resort to parsing GL_VERSION in a classic fashion. + + FlushGlError(); + + string? glVersionString = glGetString(GLenum.GL_VERSION); + Version? version = null; + + if (glVersionString == null) + { + L.CallerError($"{nameof(glGetString)}({GLenum.GL_VERSION}) failed with {glGetError()}."); + } + else + { + var regexMatch = GlVersionRegex.Match(glVersionString); + + if (!regexMatch.Success || !Version.TryParse(regexMatch.ValueSpan, out version)) + { + L.CallerWarning($"Failed to parse version number from GL_VERSION string: '{glVersionString}'."); + } + } + + // Additional context information (https://wikis.khronos.org/opengl/OpenGL_Context#Context_flags) + + bool? isForwardCompatible = null; + bool? isDebug = null; + bool? isRobust = null; + bool? isNoError = null; + + bool? isCore = null; + bool? isCompatibility = null; + + if (version != null) + { + if (version >= new Version(3, 0)) + { + if (glGetIntegerv(GLenum.GL_CONTEXT_FLAGS, out int flags) == GLenumError.GL_NO_ERROR) + { + var flagsAsGLenum = (GLenum)flags; + + isForwardCompatible = flagsAsGLenum.HasFlag(GLenum.GL_CONTEXT_FLAG_FORWARD_COMPATIBLE_BIT); + isDebug = flagsAsGLenum.HasFlag(GLenum.GL_CONTEXT_FLAG_DEBUG_BIT); + isRobust = flagsAsGLenum.HasFlag(GLenum.GL_CONTEXT_FLAG_ROBUST_ACCESS_BIT); + isNoError = flagsAsGLenum.HasFlag(GLenum.GL_CONTEXT_FLAG_NO_ERROR_BIT); + } + else + { + L.CallerError($"{nameof(glGetIntegerv)}({GLenum.GL_CONTEXT_FLAGS}) failed."); + } + } + + if (version >= new Version(3, 2)) + { + if (glGetIntegerv(GLenum.GL_CONTEXT_PROFILE_MASK, out int mask) == GLenumError.GL_NO_ERROR) + { + var maskAsGLenum = (GLenum)mask; + + isCore = maskAsGLenum.HasFlag(GLenum.GL_CONTEXT_CORE_PROFILE_BIT); + isCompatibility = maskAsGLenum.HasFlag(GLenum.GL_CONTEXT_COMPATIBILITY_PROFILE_BIT); + } + else + { + L.CallerError($"{nameof(glGetIntegerv)}({GLenum.GL_CONTEXT_PROFILE_MASK}) failed."); + } + } + } + + string glGetStringOrDefault(GLenum name) + { + string? res = glGetString(name); + + if (res == null) + { + L.CallerError($"{nameof(glGetString)}({name}) failed with {glGetError()}."); + + return string.Empty; + } + + return res; + } + + _info = new GlCtxInfo( + version: version, + glVendor: glGetStringOrDefault(GLenum.GL_VENDOR), + glRenderer: glGetStringOrDefault(GLenum.GL_RENDERER), + glVersion: glVersionString ?? string.Empty, + glShadingLanguageVersion: glGetStringOrDefault(GLenum.GL_SHADING_LANGUAGE_VERSION), + + isForwardCompatible: isForwardCompatible, + isDebug: isDebug, + isRobust: isRobust, + isNoError: isNoError, + + isCore: isCore, + isCompatibility: isCompatibility + ); + } + + return _info; + } + } + + public void MakeCurrent() + { + if (!OpenGLDll.wglMakeCurrent(_hdc, _hRenderCtx)) + { + throw Win32.GetLastException(nameof(OpenGLDll.wglMakeCurrent)); + } + } + + // Function/extension loading + + private void LoadFunctions(Func predicate) + { + // A sane approach to this would be to use an established utility library. + // + // However, given how narrow the scope is, (with no need to specify/resolve extension and GL version relationships + // except for WGL_ARB_create_context_profile which doesn't even specify additional entry points) -- attempt to + // load this handful of functions manually. + + MakeCurrent(); + + SortedSet failedFunctions = new(); + + foreach (var f in _delegates.GetType().GetFields(BindingFlags.NonPublic | BindingFlags.Instance) + .Where(f => f.FieldType.IsSubclassOf(typeof(Delegate))) + .Where(f => predicate(f)) + ) + { + // Strip the leading '_' + string funcName = f.Name[1..]; + + IntPtr funcPtr = OpenGLDll.wglGetProcAddress(funcName); + + // https://wikis.khronos.org/opengl/Load_OpenGL_Functions#Windows + if (funcPtr == IntPtr.Zero || + funcPtr == (IntPtr)1 || + funcPtr == (IntPtr)2 || + funcPtr == (IntPtr)3 || + funcPtr == (IntPtr)(-1) + ) + { + // It is not uncommon for the call to not return a valid function pointer yet GetLastError() to return ERROR_SUCCESS + if (Marshal.GetLastWin32Error() != Win32.ERROR_SUCCESS) + { + L.CallerError(Win32.GetLastErrorMessage(nameof(OpenGLDll.wglGetProcAddress), funcName)); + } + + // Attempt to use native GetProcAddress() instead (https://wikis.khronos.org/opengl/Load_OpenGL_Functions#Windows) + + if (_hOpenGLDll == IntPtr.Zero) + { + _hOpenGLDll = DllImports.LoadLibrary(OpenGLDll.LibraryName); + + if (_hOpenGLDll == IntPtr.Zero) + { + throw Win32.GetLastException(nameof(DllImports.LoadLibrary), OpenGLDll.LibraryName); + } + } + + funcPtr = DllImports.GetProcAddress(_hOpenGLDll, funcName); + + if (funcPtr != IntPtr.Zero) + { + L.CallerWarning($"{funcName}() resolved via {nameof(DllImports.GetProcAddress)}()"); + } + else if (Marshal.GetLastWin32Error() != Win32.ERROR_SUCCESS) + { + L.CallerError(Win32.GetLastErrorMessage(nameof(DllImports.GetProcAddress), funcName)); + } + } + + if (funcPtr != IntPtr.Zero) + { + f.SetValue(_delegates, Marshal.GetDelegateForFunctionPointer(funcPtr, f.FieldType)); + } + else + { + failedFunctions.Add(funcName); + L.CallerError($"{funcName}() could not be resolved"); + } + } + + if (failedFunctions.Any()) + { + throw new EntryPointNotFoundException($"Failed to resolve {failedFunctions.Count} function(s): {string.Join("; ", failedFunctions)}"); + } + } + + public void LoadWglFunctions() + { + if (_wglFunctionsLoaded) + { + return; + } + + _wglFunctionsLoaded = true; + + LoadFunctions(fieldInfo => fieldInfo.Name.StartsWith("_wgl")); + } + + public void LoadGlFunctions() + { + if (_glFunctionsLoaded) + { + return; + } + + _glFunctionsLoaded = true; + + LoadFunctions(fieldInfo => fieldInfo.Name.StartsWith("_gl")); + } + + private ImmutableHashSet? _wglExtensions = null; + public ImmutableHashSet WglExtensions + { + get + { + if (_wglExtensions == null) + { + LoadWglFunctions(); + + string? extensionsString = wglGetExtensionsStringARB(_hdc) ?? throw Win32.GetLastException(nameof(wglGetExtensionsStringARB)); + + _wglExtensions = ImmutableHashSet.Create(extensionsString.Split()); + } + + return _wglExtensions; + } + } + + // OpenGL core profile and ARB extension interfaces + + public GLenumError glGetError() + { + return _delegates.glGetError(); + } + + public GLenumError glGetIntegerv(GLenum pname, out int data) + { + _delegates.glGetIntegerv(pname, out data); + + return glGetError(); + } + + public string? glGetString(GLenum name) + { + return Marshal.PtrToStringAnsi(_delegates.glGetString(name)); + } + + // WGL_ARB_create_context + + public IntPtr wglCreateContextAttribsARB(IntPtr hdc, params object[] attribList) + { + // Assume every attribList element is convertible to int. + // Additionally, always append the mandatory NULL to terminate the list. + return _delegates.wglCreateContextAttribsARB(hdc, hShareContext: IntPtr.Zero, attribList + .Select(a => (int)a) + .Append(0) + .ToArray() + ); + } + + // WGL_ARB_extensions_string + + public string? wglGetExtensionsStringARB(IntPtr hdc) + { + return Marshal.PtrToStringAnsi(_delegates.wglGetExtensionsStringARB(hdc)); + } + + // Helpers + + public void FlushGlError() + { + GLenumError error; + + do + { + error = glGetError(); + } + while (error != GLenumError.GL_NO_ERROR); + } + + // IDisposable interface + + protected virtual void Dispose(bool disposing) + { + if (!_disposed) + { + if (disposing) + { + // TODO: dispose managed state (managed objects) + } + + if (!OpenGLDll.wglDeleteContext(_hRenderCtx)) + { + L.CallerError(Win32.GetLastErrorMessage(nameof(OpenGLDll.wglDeleteContext))); + } + + _disposed = true; + } + } + + ~Ctx() + { + // Do not change this code. Put cleanup code in 'Dispose(bool disposing)' method + Dispose(disposing: false); + } + + public void Dispose() + { + // Do not change this code. Put cleanup code in 'Dispose(bool disposing)' method + Dispose(disposing: true); + GC.SuppressFinalize(this); + } + } +} diff --git a/PD2Launcherv2/Utils/Gl/Internal/CtxDelegates.cs b/PD2Launcherv2/Utils/Gl/Internal/CtxDelegates.cs new file mode 100644 index 00000000..2d9064ce --- /dev/null +++ b/PD2Launcherv2/Utils/Gl/Internal/CtxDelegates.cs @@ -0,0 +1,73 @@ +using System.Runtime.InteropServices; + +namespace PD2Launcherv2.Utils.Gl.Internal +{ + internal class CtxDelegates + { + // OpenGL core profile and ARB extension interfaces (https://registry.khronos.org/OpenGL/api/GL/glcorearb.h) + + protected delegate GLenumError glGetErrorDelegate(); + protected glGetErrorDelegate? _glGetError = null; + public GLenumError glGetError() + { + if (_glGetError == null) + { + throw new EntryPointNotFoundException(nameof(glGetError)); + } + + return _glGetError(); + } + + protected delegate void glGetIntegervDelegate(GLenum pname, out int data); + protected glGetIntegervDelegate? _glGetIntegerv = null; + public void glGetIntegerv(GLenum pname, out int data) + { + if (_glGetIntegerv == null) + { + throw new NotImplementedException(nameof(glGetIntegerv)); + } + + _glGetIntegerv(pname, out data); + } + + protected delegate IntPtr glGetStringDelegate(GLenum name); + protected glGetStringDelegate? _glGetString = null; + public IntPtr glGetString(GLenum name) + { + if (_glGetString == null) + { + throw new NotImplementedException(nameof(glGetString)); + } + + return _glGetString(name); + } + + // WGL_ARB_create_context (https://registry.khronos.org/OpenGL/extensions/ARB/WGL_ARB_create_context.txt) + + protected delegate IntPtr wglCreateContextAttribsARBDelegate(IntPtr hdc, IntPtr hShareContext, [MarshalAs(UnmanagedType.LPArray, ArraySubType = UnmanagedType.I4)][In] int[] attribList); + protected wglCreateContextAttribsARBDelegate? _wglCreateContextAttribsARB = null; + public IntPtr wglCreateContextAttribsARB(IntPtr hdc, IntPtr hShareContext, int[] attribList) + { + if (_wglCreateContextAttribsARB == null) + { + throw new NotImplementedException(nameof(wglCreateContextAttribsARB)); + } + + return _wglCreateContextAttribsARB(hdc, hShareContext, attribList); + } + + // WGL_ARB_extensions_string (https://registry.khronos.org/OpenGL/extensions/ARB/WGL_ARB_extensions_string.txt) + + protected delegate IntPtr wglGetExtensionsStringARBDelegate(IntPtr hdc); + protected wglGetExtensionsStringARBDelegate? _wglGetExtensionsStringARB = null; + public IntPtr wglGetExtensionsStringARB(IntPtr hdc) + { + if (_wglGetExtensionsStringARB == null) + { + throw new NotImplementedException(nameof(wglGetExtensionsStringARB)); + } + + return _wglGetExtensionsStringARB(hdc); + } + } +} diff --git a/PD2Launcherv2/Utils/Gl/Internal/DllImports.cs b/PD2Launcherv2/Utils/Gl/Internal/DllImports.cs new file mode 100644 index 00000000..2b260204 --- /dev/null +++ b/PD2Launcherv2/Utils/Gl/Internal/DllImports.cs @@ -0,0 +1,274 @@ +using System.Runtime.InteropServices; +using PD2Shared.Utils; + +namespace PD2Launcherv2.Utils.Gl.Internal +{ + internal static class DllImports + { + [DllImport("kernel32.dll", SetLastError = true, CharSet = CharSet.Unicode)] + public static extern IntPtr GetModuleHandle([Optional] string? lpModuleName); + + [DllImport("kernel32.dll", SetLastError = true, CharSet = CharSet.Unicode)] + public static extern IntPtr LoadLibrary(string lpLibFileName); + + [DllImport("kernel32.dll", ExactSpelling = true, SetLastError = true, CharSet = CharSet.Ansi)] + [System.Diagnostics.CodeAnalysis.SuppressMessage("Globalization", + "CA2101:Specify marshaling for P/Invoke string arguments", + Justification = "ANSI strings are expected, thus CharSet.Ansi is the correct choice.")] + public static extern IntPtr GetProcAddress(IntPtr hModule, string lpProcName); + + private static IntPtr _hThisInstance = IntPtr.Zero; + public static IntPtr HThisInstance + { + get + { + if (_hThisInstance == IntPtr.Zero) + { + _hThisInstance = DllImports.GetModuleHandle(null); + + if (_hThisInstance == IntPtr.Zero) + { + throw Win32.GetLastException(nameof(GetModuleHandle), (string?)null); + } + } + + return _hThisInstance; + } + } + + [StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)] + public class WNDCLASSEX + { + public WNDCLASSEX(string className) + { + cbSize = (uint)Marshal.SizeOf(this); + lpfnWndProc = Marshal.GetFunctionPointerForDelegate(WindowsWndProc); + hInstance = DllImports.HThisInstance; + lpszClassName = className; + } + + public uint cbSize; + public ClassStyles style; + public IntPtr lpfnWndProc; + public int cbClsExtra; + public int cbWndExtra; + public IntPtr hInstance; + public IntPtr hIcon; + public IntPtr hCursor; + public IntPtr hbrBackground; + [MarshalAs(UnmanagedType.LPTStr)] + public string? lpszMenuName; + [MarshalAs(UnmanagedType.LPTStr)] + public string lpszClassName; + public IntPtr hIconSm; + } + + // Class styles + [Flags] + public enum ClassStyles : uint + { +#pragma warning disable format + CS_VREDRAW = 0x0001, + CS_HREDRAW = 0x0002, + CS_DBLCLKS = 0x0008, + CS_OWNDC = 0x0020, + CS_CLASSDC = 0x0040, + CS_PARENTDC = 0x0080, + CS_NOCLOSE = 0x0200, + CS_SAVEBITS = 0x0800, + CS_BYTEALIGNCLIENT = 0x1000, + CS_BYTEALIGNWINDOW = 0x2000, + CS_GLOBALCLASS = 0x4000, + + CS_IME = 0x00010000, + CS_DROPSHADOW = 0x00020000, +#pragma warning restore format + } + + [DllImport("user32.dll", SetLastError = true, CharSet = CharSet.Unicode)] + public static extern IntPtr DefWindowProc(IntPtr hWnd, uint msg, IntPtr wParam, IntPtr lParam); + + private delegate IntPtr WndProc(IntPtr hWnd, uint msg, IntPtr wParam, IntPtr lParam); + private static readonly WndProc WindowsWndProc = DefWindowProc; + + [DllImport("user32.dll", SetLastError = true, CharSet = CharSet.Unicode)] + public static extern ushort RegisterClassEx([In] WNDCLASSEX lpWndClass); + + [DllImport("user32.dll", SetLastError = true, CharSet = CharSet.Unicode)] + [return: MarshalAs(UnmanagedType.Bool)] + public static extern bool UnregisterClass(ushort lpClassAtom, [Optional] IntPtr hInstance); + + [DllImport("user32.dll", SetLastError = true, CharSet = CharSet.Unicode)] + public static extern IntPtr CreateWindowEx( + // Extended window styles enum has not been extracted + uint dwExStyle, + [Optional] string? lpClassName, + [Optional] string? lpWindowName, + // Window styles enum has not been extracted + uint dwStyle, + int x, + int y, + int nWidth, + int nHeight, + [Optional] IntPtr hWndParent, + [Optional] IntPtr hMenu, + [Optional] IntPtr hInstance, + [Optional] IntPtr lpParam + ); + + [DllImport("user32.dll", ExactSpelling = true, SetLastError = true)] + [return: MarshalAs(UnmanagedType.Bool)] + public static extern bool DestroyWindow(IntPtr hWnd); + + [DllImport("user32.dll", ExactSpelling = true)] + public static extern IntPtr GetDC(IntPtr hWnd); + + [DllImport("user32.dll", ExactSpelling = true)] + public static extern bool ReleaseDC(IntPtr hWnd, IntPtr hDc); + + [StructLayout(LayoutKind.Sequential)] + public class PIXELFORMATDESCRIPTOR + { + public PIXELFORMATDESCRIPTOR() + { + nSize = (ushort)Marshal.SizeOf(this); + nVersion = 1; + } + + public ushort nSize; + public ushort nVersion; + public PfdFlags dwFlags; + public PfdPixelType iPixelType; + public byte cColorBits; + public byte cRedBits; + public byte cRedShift; + public byte cGreenBits; + public byte cGreenShift; + public byte cBlueBits; + public byte cBlueShift; + public byte cAlphaBits; + public byte cAlphaShift; + public byte cAccumBits; + public byte cAccumRedBits; + public byte cAccumGreenBits; + public byte cAccumBlueBits; + public byte cAccumAlphaBits; + public byte cDepthBits; + public byte cStencilBits; + public byte cAuxBuffers; + public PfdLayerType iLayerType; + public byte bReserved; + public uint dwLayerMask; + public uint dwVisibleMask; + public uint dwDamageMask; + + private class ChannelDescription + { + public char name; + public byte size; + public byte shift; + } + + public string Describe() + { + if (iPixelType != PfdPixelType.PFD_TYPE_RGBA) + { + return "?"; + } + + var channels = new ChannelDescription[] + { + new() { + name = 'R', + size = cRedBits, + shift = cRedShift + }, + new() { + name = 'G', + size = cGreenBits, + shift = cGreenShift + }, + new() { + name = 'B', + size = cBlueBits, + shift = cBlueShift + }, + new() { + name = 'A', + size = cAlphaBits, + shift = cAlphaShift + }, + } + .Where(c => c.size > 0) + .OrderBy(c => c.shift); + + string channelDesc = string.Join(string.Empty, channels.Select(c => $"{c.name}{c.size}")); + string channelOrder = string.Join(string.Empty, channels.Select(c => c.name)); + + return $"{channelDesc} ({channelOrder}) Z:{cDepthBits} Stencil:{cStencilBits}"; + } + } + + [Flags] + public enum PfdFlags : uint + { +#pragma warning disable format + PFD_DOUBLEBUFFER = 0x00000001, + PFD_STEREO = 0x00000002, + PFD_DRAW_TO_WINDOW = 0x00000004, + PFD_DRAW_TO_BITMAP = 0x00000008, + PFD_SUPPORT_GDI = 0x00000010, + PFD_SUPPORT_OPENGL = 0x00000020, + PFD_GENERIC_FORMAT = 0x00000040, + PFD_NEED_PALETTE = 0x00000080, + PFD_NEED_SYSTEM_PALETTE = 0x00000100, + PFD_SWAP_EXCHANGE = 0x00000200, + PFD_SWAP_COPY = 0x00000400, + PFD_SWAP_LAYER_BUFFERS = 0x00000800, + PFD_GENERIC_ACCELERATED = 0x00001000, + PFD_SUPPORT_DIRECTDRAW = 0x00002000, + PFD_DIRECT3D_ACCELERATED = 0x00004000, + PFD_SUPPORT_COMPOSITION = 0x00008000, + + // PIXELFORMATDESCRIPTOR flags for use in ChoosePixelFormat only + PFD_DEPTH_DONTCARE = 0x20000000, + PFD_DOUBLEBUFFER_DONTCARE = 0x40000000, + PFD_STEREO_DONTCARE = 0x80000000, +#pragma warning restore format + } + + [Flags] + public enum PfdPixelType : byte + { +#pragma warning disable format + PFD_TYPE_RGBA = 0, + PFD_TYPE_COLORINDEX = 1, +#pragma warning restore format + } + + [Flags] + public enum PfdLayerType : byte + { +#pragma warning disable format + PFD_MAIN_PLANE = 0, + PFD_OVERLAY_PLANE = 1, + PFD_UNDERLAY_PLANE = unchecked((byte)-1), +#pragma warning restore format + } + + [DllImport("gdi32.dll", ExactSpelling = true, SetLastError = true)] + public static extern int ChoosePixelFormat(IntPtr hdc, [In] PIXELFORMATDESCRIPTOR ppfd); + + [DllImport("gdi32.dll", ExactSpelling = true, SetLastError = true)] + private static extern int DescribePixelFormat(IntPtr hdc, int pixelFormat, uint bytes, [In, Out] PIXELFORMATDESCRIPTOR ppfd); + + public static int DescribePixelFormat(IntPtr hdc, int pixelFormat, PIXELFORMATDESCRIPTOR ppfd) + { + return DescribePixelFormat(hdc, pixelFormat, (uint)Marshal.SizeOf(ppfd), ppfd); + } + + [DllImport("gdi32.dll", ExactSpelling = true, SetLastError = true)] + [return: MarshalAs(UnmanagedType.Bool)] + public static extern bool SetPixelFormat(IntPtr hdc, int pixelFormat, [In] PIXELFORMATDESCRIPTOR ppfd); + } +} diff --git a/PD2Launcherv2/Utils/Gl/Internal/OpenGLDll.cs b/PD2Launcherv2/Utils/Gl/Internal/OpenGLDll.cs new file mode 100644 index 00000000..1bf9b725 --- /dev/null +++ b/PD2Launcherv2/Utils/Gl/Internal/OpenGLDll.cs @@ -0,0 +1,29 @@ +using System.Runtime.InteropServices; + +namespace PD2Launcherv2.Utils.Gl.Internal +{ + internal static class OpenGLDll + { + public const string LibraryName = "opengl32.dll"; + + [DllImport(LibraryName, ExactSpelling = true)] + public static extern IntPtr wglGetCurrentContext(); + + [DllImport(LibraryName, ExactSpelling = true, SetLastError = true)] + public static extern IntPtr wglCreateContext(IntPtr hdc); + + [DllImport(LibraryName, ExactSpelling = true, SetLastError = true)] + [return: MarshalAs(UnmanagedType.Bool)] + public static extern bool wglMakeCurrent(IntPtr hdc, IntPtr hglrc); + + [DllImport(LibraryName, ExactSpelling = true, SetLastError = true)] + [return: MarshalAs(UnmanagedType.Bool)] + public static extern bool wglDeleteContext(IntPtr hglrc); + + [DllImport(LibraryName, ExactSpelling = true, SetLastError = true, CharSet = CharSet.Ansi)] + [System.Diagnostics.CodeAnalysis.SuppressMessage("Globalization", + "CA2101:Specify marshaling for P/Invoke string arguments", + Justification = "ANSI strings are expected, thus CharSet.Ansi is the correct choice.")] + public static extern IntPtr wglGetProcAddress(string lpszProc); + } +} diff --git a/PD2Launcherv2/Utils/Gl/Internal/types.cs b/PD2Launcherv2/Utils/Gl/Internal/types.cs new file mode 100644 index 00000000..b419c32f --- /dev/null +++ b/PD2Launcherv2/Utils/Gl/Internal/types.cs @@ -0,0 +1,90 @@ +namespace PD2Launcherv2.Utils.Gl.Internal +{ + // OpenGL core profile and ARB extension interfaces + + [Flags] + internal enum GLenum : uint + { + // Include only a small subset +#pragma warning disable format + // GL_VERSION_1_0 + GL_VENDOR = 0x1F00, + GL_RENDERER = 0x1F01, + GL_VERSION = 0x1F02, + + // GL_VERSION_2_0 + GL_SHADING_LANGUAGE_VERSION = 0x8B8C, + + // GL_VERSION_3_0 + GL_CONTEXT_FLAG_FORWARD_COMPATIBLE_BIT = 0x00000001, + GL_CONTEXT_FLAG_DEBUG_BIT = 0x00000002, + + GL_MAJOR_VERSION = 0x821B, + GL_MINOR_VERSION = 0x821C, + + GL_CONTEXT_FLAGS = 0x821E, + + // GL_VERSION_3_2 + GL_CONTEXT_CORE_PROFILE_BIT = 0x00000001, + GL_CONTEXT_COMPATIBILITY_PROFILE_BIT = 0x00000002, + + GL_CONTEXT_PROFILE_MASK = 0x9126, + + // GL_VERSION_4_5 + GL_CONTEXT_FLAG_ROBUST_ACCESS_BIT = 0x00000004, + + // GL_VERSION_4_6 + GL_CONTEXT_FLAG_NO_ERROR_BIT = 0x00000008, +#pragma warning restore format + } + + internal enum GLenumError : uint + { +#pragma warning disable format + // GL_VERSION_1_0 + GL_NO_ERROR = 0, + GL_INVALID_ENUM = 0x0500, + GL_INVALID_VALUE = 0x0501, + GL_INVALID_OPERATION = 0x0502, + GL_STACK_OVERFLOW = 0x0503, + GL_STACK_UNDERFLOW = 0x0504, + GL_OUT_OF_MEMORY = 0x0505, + + // GL_VERSION_3_0 + GL_INVALID_FRAMEBUFFER_OPERATION = 0x0506, + + // GL_VERSION_4_5 (or ARB_KHR_robustness) + GL_CONTEXT_LOST = 0x0507, +#pragma warning restore format +} + + // WGL_ARB_create_context + + internal enum WglContextAttribs : int + { +#pragma warning disable format + // Accepted as an attribute name in <*attribList>: + WGL_CONTEXT_MAJOR_VERSION_ARB = 0x2091, + WGL_CONTEXT_MINOR_VERSION_ARB = 0x2092, + WGL_CONTEXT_LAYER_PLANE_ARB = 0x2093, + WGL_CONTEXT_FLAGS_ARB = 0x2094, + WGL_CONTEXT_PROFILE_MASK_ARB = 0x9126, + + // Accepted as bits in the attribute value for WGL_CONTEXT_FLAGS in <*attribList>: + WGL_CONTEXT_DEBUG_BIT_ARB = 0x0001, + WGL_CONTEXT_FORWARD_COMPATIBLE_BIT_ARB = 0x0002, + + // Accepted as bits in the attribute value for WGL_CONTEXT_PROFILE_MASK_ARB in <*attribList>: + // (Only available if WGL_ARB_create_context_profile is available or if >=3.2) + WGL_CONTEXT_CORE_PROFILE_BIT_ARB = 0x00000001, + WGL_CONTEXT_COMPATIBILITY_PROFILE_BIT_ARB = 0x00000002, +#pragma warning restore format + } + + // Additional GetLastError() WGL values + internal enum LastGLerror : int + { + ERROR_INVALID_VERSION_ARB = 0x2095, + ERROR_INVALID_PROFILE_ARB = 0x2096, + } +} diff --git a/PD2Launcherv2/Utils/MsgBox.cs b/PD2Launcherv2/Utils/MsgBox.cs new file mode 100644 index 00000000..a0776730 --- /dev/null +++ b/PD2Launcherv2/Utils/MsgBox.cs @@ -0,0 +1,133 @@ +using System.Text; +using System.Windows; + +namespace PD2Launcherv2.Utils +{ + public static class MsgBox + { + public const string DefaultDialogTitle = "Project Diablo 2 Launcher"; + + private static MessageBoxResult ShowWrapper(string messageBoxText, MessageBoxImage icon, MessageBoxButton button, MessageBoxResult defaultResult = MessageBoxResult.None) + { + if (App.Current.MainWindow != null) + { + return MessageBox.Show(App.Current.MainWindow, messageBoxText, DefaultDialogTitle, button, icon, defaultResult); + } + else + { + return MessageBox.Show(messageBoxText, DefaultDialogTitle, button, icon, defaultResult); + } + } + + public static MessageBoxResult Info(string messageBoxText, MessageBoxButton button = MessageBoxButton.OK, MessageBoxResult defaultResult = MessageBoxResult.None) + { + return ShowWrapper(messageBoxText, MessageBoxImage.Information, button, defaultResult); + } + + public static MessageBoxResult Warn(string messageBoxText, MessageBoxButton button = MessageBoxButton.OK, MessageBoxResult defaultResult = MessageBoxResult.None) + { + return ShowWrapper(messageBoxText, MessageBoxImage.Warning, button, defaultResult); + } + + public static MessageBoxResult Error(string messageBoxText, MessageBoxButton button = MessageBoxButton.OK, MessageBoxResult defaultResult = MessageBoxResult.None) + { + return ShowWrapper(messageBoxText, MessageBoxImage.Error, button, defaultResult); + } + + public static MessageBoxResult Exception( + Exception? exception, + string? messageBoxText = null, + MessageBoxImage icon = MessageBoxImage.Error, + MessageBoxButton button = MessageBoxButton.OK, + MessageBoxResult defaultResult = MessageBoxResult.None) + { + if (exception == null && messageBoxText == null) + { + throw new ArgumentNullException($"Method '{nameof(Exception)}' cannot be called with both: '{nameof(exception)}' and '{nameof(messageBoxText)}' being null.", (Exception)null!); + } + + // Available options: + // + // = = = = = = = = = = = + // + // [message/exception] + // + // = = = = = = = = = = = + // + // [message] + // + // [exception] + // + // = = = = = = = = = = = + // + // [message/exception] + // + // --- + // + // [exception] + // + // [exception] + // + // [...] + // + // = = = = = = = = = = = + // + // Conclusion: Add "---" at entry index 1 where total entry count >= 3 + + int entryCount = messageBoxText != null ? 1 : 0; + + for (var currentException = exception!; currentException != null; currentException = currentException.InnerException) + { + if (currentException is AggregateException) + { + // Ignore AggregateException exception itself, only its InnerExceptions count + continue; + } + + if (++entryCount >= 3) + { + // This much should be enough + break; + } + } + + StringBuilder sb = new(messageBoxText); + + var lastMessage = messageBoxText; + var currEntryIdx = lastMessage == null ? 0 : 1; + + for (var currentException = exception!; currentException != null; currentException = currentException.InnerException) + { + if (currentException is AggregateException) + { + // Ignore AggregateException exception itself, only its InnerExceptions contain useful messages + continue; + } + + if (lastMessage == currentException.Message) + { + // Skip duplicate messages + continue; + } + + if (sb.Length > 0) + { + if (entryCount >= 3 && currEntryIdx == 1) + { + sb.Append("\n\n---\n\n"); + } + else + { + sb.Append("\n\n"); + } + } + + sb.Append(currentException.Message); + lastMessage = currentException.Message; + ++currEntryIdx; + } + + return ShowWrapper(sb.ToString(), icon, button, defaultResult); + } + } +} diff --git a/PD2Launcherv2/Utils/ProgressWithCookie.cs b/PD2Launcherv2/Utils/ProgressWithCookie.cs new file mode 100644 index 00000000..9e3f0906 --- /dev/null +++ b/PD2Launcherv2/Utils/ProgressWithCookie.cs @@ -0,0 +1,111 @@ +namespace PD2Launcherv2.Utils +{ + class ProgressWithCookie : IProgress + { + private readonly ProgressCookie _cookieRef; + private readonly ProgressCookie _cookieCopy; + + private readonly Action _handler; + private readonly Progress _progress; + private readonly IProgress _progressAsIProgress; + + public ProgressWithCookie(ProgressCookie cookie, Action handler) + { + ArgumentNullException.ThrowIfNull(cookie, nameof(cookie)); + ArgumentNullException.ThrowIfNull(handler, nameof(handler)); + + _cookieRef = cookie; + _cookieCopy = new ProgressCookie(cookie); + + _handler = handler; + _progress = new Progress(HandlerWrapper); + _progressAsIProgress = (IProgress)_progress; + } + + void IProgress.Report(T value) + { + _progressAsIProgress.Report(value); + } + + public event EventHandler? ProgressChanged + { + add + { + _progress.ProgressChanged += value; + } + remove + { + _progress.ProgressChanged -= value; + } + } + + private void HandlerWrapper(T value) + { + if (_cookieRef != _cookieCopy) + { + return; + } + + _handler(value); + } + } + + class ProgressCookie + { + private ulong _value; + + public ProgressCookie() + { + } + + public ProgressCookie(ProgressCookie other) + { + _value = other._value; + } + + public void Advance() + { + ++_value; + } + + public static bool operator ==(ProgressCookie? left, ProgressCookie? right) + { + if (left is null && right is null) return true; + if (left is null || right is null) return false; + + return left._value == right._value; + } + + public static bool operator !=(ProgressCookie? left, ProgressCookie? right) + { + return !(left == right); + } + + public override bool Equals(object? obj) + { + if (ReferenceEquals(this, obj)) + { + return true; + } + + if (ReferenceEquals(obj, null)) + { + return false; + } + + var asProgressCookie = obj as ProgressCookie; + + if (asProgressCookie == null) + { + return false; + } + + return this == asProgressCookie; + } + + public override int GetHashCode() + { + return _value.GetHashCode(); + } + } +} diff --git a/PD2Launcherv2/Utils/Shell.cs b/PD2Launcherv2/Utils/Shell.cs new file mode 100644 index 00000000..27fd8044 --- /dev/null +++ b/PD2Launcherv2/Utils/Shell.cs @@ -0,0 +1,107 @@ +using System.IO; +using System.Runtime.InteropServices; + +namespace PD2Launcherv2.Utils +{ + public static class Shell + { + private static class DllImports + { + [DllImport("shell32.dll")] + [return: MarshalAs(UnmanagedType.Error)] + public static extern int SHOpenFolderAndSelectItems( + IntPtr pidlFolder, + uint cidl, + [In, MarshalAs(UnmanagedType.LPArray)] IntPtr[] apidl, + uint dwFlags); + + [DllImport("shell32.dll")] + [return: MarshalAs(UnmanagedType.Error)] + public static extern int SHParseDisplayName( + [MarshalAs(UnmanagedType.LPWStr)] string name, + IntPtr pBindCtx, + [Out] out IntPtr pidl, + uint sfgaoIn, + [Out] out uint sfgaoOut); + } + + public static async Task OpenFolderAndSelectItemsAsync(string dirPath, params string[] fileNames) + { + // No need for a full set of constants + const int S_OK = 0; + + IntPtr dirPidl = IntPtr.Zero; + List filePidls = new(fileNames.Length); + + try + { + // Docs for SHParseDisplayName() suggest calling it from a separate thread (https://learn.microsoft.com/en-us/windows/win32/api/shlobj_core/nf-shlobj_core-shparsedisplayname#remarks). + // Additionally, all .NET threads have COM initialized with multi-threaded apartment (https://stackoverflow.com/a/70127040), so this should be safe. + await Task.Run(() => + { + int hResult; + + hResult = DllImports.SHParseDisplayName( + dirPath, + pBindCtx: IntPtr.Zero, + out dirPidl, + sfgaoIn: 0, + sfgaoOut: out _); + + if (hResult != S_OK) + { + Marshal.ThrowExceptionForHR(hResult); + } + + foreach (string fileName in fileNames) + { + hResult = DllImports.SHParseDisplayName( + Path.Combine(dirPath, fileName), + pBindCtx: IntPtr.Zero, + out var filePidl, + sfgaoIn: 0, + sfgaoOut: out _); + + if (hResult != S_OK) + { + Marshal.ThrowExceptionForHR(hResult); + } + + filePidls.Add(filePidl); + } + + if (!filePidls.Any()) + { + // Make sure not to pass zero 'cidl' (item identifier list count) as that will change the behavior of SHOpenFolderAndSelectItems(). + // Passing a NULL item identifier list in the array is harmless, however. + filePidls.Add(IntPtr.Zero); + } + }); + + { + IntPtr[] filePidsArray = filePidls.ToArray(); + + int hResult = DllImports.SHOpenFolderAndSelectItems( + dirPidl, + (uint)filePidsArray.Length, + filePidsArray, + dwFlags: 0); + + if (hResult != S_OK) + { + Marshal.ThrowExceptionForHR(hResult); + } + } + } + finally + { + Marshal.FreeCoTaskMem(dirPidl); + + foreach (var filePidl in filePidls) + { + Marshal.FreeCoTaskMem(filePidl); + } + } + } + } +} diff --git a/PD2Launcherv2/ViewModels/AboutViewModel.cs b/PD2Launcherv2/ViewModels/AboutViewModel.cs index f3134998..620585b6 100644 --- a/PD2Launcherv2/ViewModels/AboutViewModel.cs +++ b/PD2Launcherv2/ViewModels/AboutViewModel.cs @@ -74,8 +74,7 @@ public void ProdBucketAssign() FilePath = "Live" }; _localStorage.Update(StorageKey.FileUpdateModel, fileUpdateModel); - var launcherArgs = _localStorage.LoadSection(StorageKey.LauncherArgs); - Messenger.Default.Send(new ConfigurationChangeMessage { IsBeta = false , IsCustom = false, IsDisableUpdates = launcherArgs.disableAutoUpdate}); + Messenger.Default.Send(new ConfigurationChangeMessage { IsBeta = false , IsCustom = false }); Debug.WriteLine("end ProdBucketAssign\n"); Messenger.Default.Send(new NavigationMessage { Action = NavigationAction.GoBack }); } @@ -88,9 +87,8 @@ public void BetaBucketAssign() FilePath = "Beta" }; _localStorage.Update(StorageKey.FileUpdateModel, fileUpdateModel); - var launcherArgs = _localStorage.LoadSection(StorageKey.LauncherArgs); - Messenger.Default.Send(new ConfigurationChangeMessage { IsBeta = true , IsCustom = false , IsDisableUpdates = launcherArgs.disableAutoUpdate }); + Messenger.Default.Send(new ConfigurationChangeMessage { IsBeta = true , IsCustom = false }); Debug.WriteLine("end BetaBucketAssign \n"); Messenger.Default.Send(new NavigationMessage { Action = NavigationAction.GoBack }); } @@ -118,15 +116,12 @@ private void CustomBucketAssign() _localStorage.Update(StorageKey.FileUpdateModel, fileUpdateModel); - var launcherArgs = _localStorage.LoadSection(StorageKey.LauncherArgs); - Messenger.Default.Send(new ConfigurationChangeMessage { IsBeta = false, - IsCustom = false, - IsDisableUpdates = launcherArgs.disableAutoUpdate + IsCustom = false }); - Messenger.Default.Send(new ConfigurationChangeMessage { IsBeta = false, IsCustom = true, IsDisableUpdates = launcherArgs.disableAutoUpdate }); + Messenger.Default.Send(new ConfigurationChangeMessage { IsBeta = false, IsCustom = true }); Debug.WriteLine("end SetCustomEnvironment\n"); Messenger.Default.Send(new NavigationMessage { Action = NavigationAction.GoBack }); } diff --git a/PD2Launcherv2/ViewModels/OptionsViewModel.cs b/PD2Launcherv2/ViewModels/OptionsViewModel.cs index f6de3757..a72e432d 100644 --- a/PD2Launcherv2/ViewModels/OptionsViewModel.cs +++ b/PD2Launcherv2/ViewModels/OptionsViewModel.cs @@ -2,12 +2,16 @@ using GalaSoft.MvvmLight.Messaging; using PD2Launcherv2.Enums; using PD2Launcherv2.Helpers; +using PD2Shared; using PD2Shared.Interfaces; using PD2Shared.Models; -using ProjectDiablo2Launcherv2; using System.Windows; using System.Diagnostics; using PD2Launcherv2.Messages; +using PD2Launcherv2.Utils; +using PD2Shared.Logging; +using static PD2Shared.Logging.LoggingStatic; +using PD2Shared.Utils; namespace PD2Launcherv2.ViewModels { @@ -40,6 +44,7 @@ public OptionsViewModel(ILocalStorage localStorage) MinFpsPickerItems = Constants.MinFpsPickerItems(); ShaderPickerItems = Constants.ShaderPickerItems(); LoadLauncherArgs(); + LoadLauncherOptions(); LoadDDrawStorage(); DealWithLoadingModeComboBox(_localStorage); CloseCommand = new RelayCommand(CloseView); @@ -100,6 +105,13 @@ public bool IsDdrawSelected _isDdrawSelected = value; OnPropertyChanged(nameof(IsDdrawSelected)); OnPropertyChanged(nameof(DDrawControlsVisible)); + + Messenger.Default.Send(new RendererChangeMessage + { + UseD2GL = !_isDdrawSelected, + // This is quite horrible and should be made into an enum + CncDdrawUsesOGL = _selectedRenderer == "opengl" + }); } } } @@ -136,6 +148,28 @@ private void ToggleAdvancedOptions() private void SetWindowsPermissions() { + if (Wine.IsRunningUnderWine) + { + try + { + Wine.ApplyWineConfiguration(); + + MsgBox.Info("Configuration applied successfully."); + } + catch (Wine.WineException ex) + { + L.CallerError(ex.InnerException, ex.Message); + MsgBox.Exception(ex, "Failed to apply configuration:"); + } + catch (Exception ex) + { + L.CallerError(ex, "Failed to apply configuration."); + MsgBox.Exception(ex, "Failed to apply configuration:"); + } + + return; + } + var startInfo = new ProcessStartInfo() { FileName = "powershell.exe", @@ -147,6 +181,28 @@ private void SetWindowsPermissions() private void RemoveWindowsPermissions() { + if (Wine.IsRunningUnderWine) + { + try + { + Wine.RemoveWineConfiguration(); + + MsgBox.Info("Configuration removed successfully."); + } + catch (Wine.WineException ex) + { + L.CallerError(ex.InnerException, ex.Message); + MsgBox.Exception(ex, "Failed to remove configuration:"); + } + catch (Exception ex) + { + L.CallerError(ex, "Failed to remove configuration."); + MsgBox.Exception(ex, "Failed to remove configuration:"); + } + + return; + } + var startInfo = new ProcessStartInfo() { FileName = "powershell.exe", @@ -274,6 +330,13 @@ public string SelectedRenderer { _selectedRenderer = value; OnPropertyChanged(nameof(SelectedRenderer)); + + Messenger.Default.Send(new RendererChangeMessage + { + UseD2GL = !_isDdrawSelected, + // This is quite horrible and should be made into an enum + CncDdrawUsesOGL = _selectedRenderer == "opengl" + }); } } } @@ -502,6 +565,46 @@ public bool SkipToBnet } } + private bool _forceSoftwareRederer; + public bool ForceSoftwareRenderer + { + get => _forceSoftwareRederer; + set + { + if (_forceSoftwareRederer != value) + { + _forceSoftwareRederer = value; + OnPropertyChanged(); + Messenger.Default.Send(new LauncherOptionsChangeMessage + { + ForceSoftwareRenderer = value, + UseHttp2 = UseHttp2, + DisableAutoUpdate = AutoUpdate + }); + } + } + } + + private bool _useHttp2; + public bool UseHttp2 + { + get => _useHttp2; + set + { + if (_useHttp2 != value) + { + _useHttp2 = value; + OnPropertyChanged(); + Messenger.Default.Send(new LauncherOptionsChangeMessage + { + ForceSoftwareRenderer = ForceSoftwareRenderer, + UseHttp2 = value, + DisableAutoUpdate = AutoUpdate + }); + } + } + } + private bool _autoUpdate; public bool AutoUpdate { @@ -513,9 +616,12 @@ public bool AutoUpdate Debug.WriteLine($"Set _autoUpdate {value}"); _autoUpdate = value; OnPropertyChanged(); - var fileUpdateMode = _localStorage.LoadSection(StorageKey.FileUpdateModel); - bool amIBeta = fileUpdateMode.FilePath.Equals("Beta"); - Messenger.Default.Send(new ConfigurationChangeMessage { IsDisableUpdates = value, IsBeta = amIBeta }); + Messenger.Default.Send(new LauncherOptionsChangeMessage + { + ForceSoftwareRenderer = ForceSoftwareRenderer, + UseHttp2 = UseHttp2, + DisableAutoUpdate = value + }); } } } @@ -600,11 +706,23 @@ private void LoadLauncherArgs() IsDdrawSelected = launcherArgs.graphics; SkipToBnet = launcherArgs.skiptobnet; SndBkg = launcherArgs.sndbkg; - AutoUpdate = launcherArgs.disableAutoUpdate; } Debug.WriteLine("end LoadLauncherArgs\n"); } + private void LoadLauncherOptions() + { + Debug.WriteLine("\nStart LoadLauncherOptions"); + LauncherOptions launcherOptions = _localStorage.LoadSection(StorageKey.LauncherOptions); + if (launcherOptions != null) + { + ForceSoftwareRenderer = launcherOptions.ForceSoftwareRenderer; + UseHttp2 = launcherOptions.UseHttp2; + AutoUpdate = launcherOptions.DisableAutoUpdate; + } + Debug.WriteLine("end LoadLauncherOptions\n"); + } + private void UpdateLauncherArgsStorage() { Debug.WriteLine("\nStart UpdateLauncherArgsStorage"); @@ -613,13 +731,25 @@ private void UpdateLauncherArgsStorage() // Again, assuming true represents "ddraw" graphics = IsDdrawSelected, skiptobnet = SkipToBnet, - sndbkg = SndBkg, - disableAutoUpdate = AutoUpdate, + sndbkg = SndBkg }; _localStorage.Update(StorageKey.LauncherArgs, launcherArgs); Debug.WriteLine("end UpdateLauncherArgsStorage\n"); } + private void UpdateLauncherOptionsStorage() + { + Debug.WriteLine("\nStart UpdateLauncherOptionsStorage"); + var launcherOptions = new LauncherOptions + { + ForceSoftwareRenderer = ForceSoftwareRenderer, + UseHttp2 = UseHttp2, + DisableAutoUpdate = AutoUpdate + }; + _localStorage.Update(StorageKey.LauncherOptions, launcherOptions); + Debug.WriteLine("end UpdateLauncherOptionsStorage\n"); + } + private void LoadDDrawCheckBoxOptions() { DdrawOptions dDrawOptions = _localStorage.LoadSection(StorageKey.DdrawOptions); @@ -769,6 +899,7 @@ private void CloseView() { //save LauncherArgs Storage UpdateLauncherArgsStorage(); + UpdateLauncherOptionsStorage(); //save UpdateDDrawStorage(); //write ddrawstorage to .ini diff --git a/PD2Launcherv2/Views/AboutView.xaml b/PD2Launcherv2/Views/AboutView.xaml index e022fc9c..0530f11e 100644 --- a/PD2Launcherv2/Views/AboutView.xaml +++ b/PD2Launcherv2/Views/AboutView.xaml @@ -5,10 +5,19 @@ xmlns:d="http://schemas.microsoft.com/expression/blend/2008" xmlns:local="clr-namespace:PD2Launcherv2.ViewModels" Width="750" Height="550" + Style="{StaticResource PixelPerfectFrameworkElement}" mc:Ignorable="d" Title="AboutView" DataContext="{Binding AboutViewModel, Source={StaticResource ViewModelLocator}}"> + + + + + + + + diff --git a/PD2Launcherv2/Views/FiltersView.xaml b/PD2Launcherv2/Views/FiltersView.xaml index d5605677..2b32bef2 100644 --- a/PD2Launcherv2/Views/FiltersView.xaml +++ b/PD2Launcherv2/Views/FiltersView.xaml @@ -7,6 +7,7 @@ xmlns:converters="clr-namespace:PD2Launcherv2.Converters" xmlns:wv2="clr-namespace:Microsoft.Web.WebView2.Wpf;assembly=Microsoft.Web.WebView2.Wpf" Width="750" Height="550" + Style="{StaticResource PixelPerfectFrameworkElement}" mc:Ignorable="d" Title="FiltersView" Loaded="FiltersView_Loaded" @@ -17,6 +18,12 @@ + + + + + + @@ -24,18 +31,17 @@ + - - + - + FontSize="14" HorizontalAlignment="Left" Margin="10,5,0,0"/> - - + + + + + + - + FontFamily="{StaticResource BlizzMedium}" Foreground="{StaticResource GoldBrush}" IsChecked="{Binding AutoUpdate, Mode=TwoWay}" + FontSize="12" Margin="10,10,10,0"/> + diff --git a/PD2Launcherv2/Views/OptionsView.xaml.cs b/PD2Launcherv2/Views/OptionsView.xaml.cs index a1dfda27..5d84617e 100644 --- a/PD2Launcherv2/Views/OptionsView.xaml.cs +++ b/PD2Launcherv2/Views/OptionsView.xaml.cs @@ -1,5 +1,6 @@ -using PD2Launcherv2.ViewModels; -using System.Windows.Controls; +using System.Windows.Controls; +using PD2Launcherv2.ViewModels; +using PD2Shared.Utils; namespace PD2Launcherv2.Views { @@ -12,11 +13,21 @@ public OptionsView() { InitializeComponent(); DataContext = App.Resolve(); - } - - private void ComboBox_SelectionChanged(object sender, SelectionChangedEventArgs e) - { + // It's much easier to have this set up here than via the model + if (Wine.IsRunningUnderWine) + { + WindowsPermissionsText.Text = "Wine configuration"; + SetWindowsPermissions.Content = "Apply"; + SetWindowsPermissions.ToolTip = null; + RemoveWindowsPermissions.Content = "Remove"; + RemoveWindowsPermissions.ToolTip = null; + } + else + { + WineLogo32Image.Visibility = System.Windows.Visibility.Collapsed; + WineLogo32ImageCopy.Visibility = System.Windows.Visibility.Collapsed; + } } } } \ No newline at end of file diff --git a/PD2Shared/Constants.cs b/PD2Shared/Constants.cs index 86888820..184c2baf 100644 --- a/PD2Shared/Constants.cs +++ b/PD2Shared/Constants.cs @@ -4,6 +4,9 @@ namespace PD2Shared { public static class Constants { + // This could be generated and ingested during build as well + public const string VersionString = "2.14.3"; + public static class LauncherUpdate { public const string LegacyGcpMetadataUrl = diff --git a/PD2Shared/Extensions/HttpResponseMessageEx.cs b/PD2Shared/Extensions/HttpResponseMessageEx.cs new file mode 100644 index 00000000..4ca9cb69 --- /dev/null +++ b/PD2Shared/Extensions/HttpResponseMessageEx.cs @@ -0,0 +1,17 @@ +namespace PD2Shared.Extensions +{ + public static class HttpResponseMessageEx + { + // An alternative to EnsureSuccessStatusCode() since it only produces HttpRequestExceptions with (questionably useful) messages such as: + // 'Response status code does not indicate success: 404 (Not Found).' + public static void ThrowIfUnsuccessful(this HttpResponseMessage httpResponseMessage) + { + if (!httpResponseMessage.IsSuccessStatusCode) + { + var reqMsg = httpResponseMessage.RequestMessage!; + + throw new HttpRequestException($"{reqMsg.Method} '{reqMsg.RequestUri}' failed with: {(int)httpResponseMessage.StatusCode} ({httpResponseMessage.StatusCode})", inner: null, httpResponseMessage.StatusCode); + } + } + } +} diff --git a/PD2Shared/GameFileUpdate/GameFileUpdateException.cs b/PD2Shared/GameFileUpdate/GameFileUpdateException.cs new file mode 100644 index 00000000..3c964cf2 --- /dev/null +++ b/PD2Shared/GameFileUpdate/GameFileUpdateException.cs @@ -0,0 +1,60 @@ +namespace PD2Shared.GameFileUpdate +{ + // Base class for all exceptions + public abstract class GameFileUpdateException : Exception + { + public GameFileUpdateException(Exception? innerException = null, string? message = null) : base(message, innerException) { } + } + + public class LoadManifestException : GameFileUpdateException + { + public LoadManifestException(string message, Exception? innerException = null) : base(innerException, message) { } + } + + public class SaveManifestException : GameFileUpdateException + { + public SaveManifestException(string message, Exception? innerException = null) : base(innerException, message) { } + } + + public class LoadMetadataException : GameFileUpdateException + { + public LoadMetadataException(string message, Exception? innerException = null) : base(innerException, message) { } + } + + // Base class for fatal exceptions + public abstract class FatalGameFileUpdateException : GameFileUpdateException + { + public FatalGameFileUpdateException(Exception? innerException = null, string? message = null) : base(innerException, message) { } + } + + // Offline (fresh metadata not retrieved, either due to an error or being forced to work offline) and the available manifest (if any) contains no data to work with + public class OfflineInvalidManifest : FatalGameFileUpdateException + { + public OfflineInvalidManifest(Exception? innerException = null, string? message = null) : base(innerException, message) { } + } + + // Retrieved metadata appears to be invalid (rare) + public class InvalidMetadataRetrieved : FatalGameFileUpdateException + { + public InvalidMetadataRetrieved(Exception? innerException = null, string? message = null) : base(innerException, message) { } + } + + // Offline (fresh metadata not retrieved, either due to an error or being forced to work offline), + // validation failed based on the available manifest and files need to be re-downloaded, which is impossible. + public class OfflineNeedsDownload : FatalGameFileUpdateException + { + public OfflineNeedsDownload(Exception? innerException = null, string? message = null) : base(innerException, message) { } + } + + // Base download failure exception + public class DownloadException : GameFileUpdateException + { + public DownloadException(Exception? innerException = null, string? message = null) : base(innerException, message) { } + } + + // Download failed due to hash mismatch (rare) + public class DownloadHashMismatchException : DownloadException + { + public DownloadHashMismatchException(Exception? innerException = null, string? message = null) : base(innerException, message) { } + } +} diff --git a/PD2Shared/GameFileUpdate/GameFileUpdater.cs b/PD2Shared/GameFileUpdate/GameFileUpdater.cs new file mode 100644 index 00000000..32c9b67d --- /dev/null +++ b/PD2Shared/GameFileUpdate/GameFileUpdater.cs @@ -0,0 +1,1862 @@ +using Newtonsoft.Json.Linq; +using Serilog.Events; +using System.Buffers; +using System.Collections.Immutable; +using System.Data; +using System.Diagnostics; +using System.Net.Http.Headers; +using System.Text.Json; +using System.Text.Json.Nodes; +using PD2Shared.Extensions; +using PD2Shared.GameFileUpdate.Internal; +using PD2Shared.Logging; +using static PD2Shared.Logging.LoggingStatic; +using PD2Shared.Models; +using PD2Shared.Utils; + +namespace PD2Shared.GameFileUpdate +{ + using PV = ProgressValues; + + public class GameFileUpdater + { + // The default buffer size of FileStream() (https://learn.microsoft.com/en-us/dotnet/api/system.io.filestream.-ctor#system-io-filestream-ctor(system-string-system-io-filemode-system-io-fileaccess-system-io-fileshare-system-int32)) + private const int DefaultStreamBufferSize = 4096; + // The default buffer size of Stream.CopyToAsync() (https://learn.microsoft.com/en-us/dotnet/api/system.io.stream.copytoasync#system-io-stream-copytoasync(system-io-stream-system-int32)) + private const int DefaultLargeStreamBufferSize = 81920; + // A reasonably small buffer of just one page + private const int DefaultNetworkStreamBufferSize = 4096; + + private readonly Dictionary _fileUpdateModelToContext = new(new FileUpdateModelEqualityComparer()); + + private static int CalculateFileBufferSize(long fileSize) + { + return (int)Math.Clamp(fileSize, (long)DefaultStreamBufferSize, (long)DefaultLargeStreamBufferSize); + } + + private static int CalculateFileBufferSize(string path) + { + // Both: FileInfo constructor and its properties can throw + return CalculateFileBufferSize(new FileInfo(path).Length); + } + + private static FileStream OpenReadFileStream(string path, int bufferSize) + { + return new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.Read, bufferSize, FileOptions.Asynchronous | FileOptions.SequentialScan); + } + + private static FileStream OpenCreateFileStream(string path, int bufferSize) + { + Env.EnsureDirectoryExists(path); + + return new FileStream(path, FileMode.Create, FileAccess.Write, FileShare.None, bufferSize, FileOptions.Asynchronous | FileOptions.SequentialScan); + } + + private static FileStream OpenWriteFileStream(string path, int bufferSize, long offset) + { + return new FileStream(path, FileMode.Open, FileAccess.Write, FileShare.None, bufferSize, FileOptions.Asynchronous | FileOptions.SequentialScan) + { + // While this can throw, Validation should have made sure that the file is of sufficient size + Position = offset + }; + } + + private static void LogManifestStats(ManifestEntry[] manifestEntries) + { + L.CallerDebug($"{manifestEntries.Count(e => e.Size != null)}/{manifestEntries.Length} sizes; {manifestEntries.Count(e => e.Xxh3Hash != null)}/{manifestEntries.Length} XXH3s"); + } + + private static async Task CopyFileAsync(string sourcePath, string destinationPath, CancellationToken ct, IProgress>? progress = null) + { + int bufferSize = CalculateFileBufferSize(sourcePath); + + using (var inStream = OpenReadFileStream(sourcePath, bufferSize)) + { + using (var outStream = OpenCreateFileStream(destinationPath, bufferSize)) + { + if (progress == null) + { + await inStream.CopyToAsync(outStream, ct).ConfigureAwait(false); + } + else + { + var buffer = ArrayPool.Shared.Rent(bufferSize); + + try + { + int bytesRead; + long totalBytesCopied = 0; + + while ((bytesRead = await inStream.ReadAsync(buffer, 0, buffer.Length, ct).ConfigureAwait(false)) > 0) + { + await outStream.WriteAsync(buffer, 0, bytesRead, ct).ConfigureAwait(false); + + totalBytesCopied += bytesRead; + progress.Report(Tuple.Create(bytesRead, totalBytesCopied, inStream.Length)); + } + } + finally + { + ArrayPool.Shared.Return(buffer); + } + } + } + } + } + + private static async Task ComputeHashesAsync(Digest[] digests, string path, long? sizeLimit, CancellationToken ct, IProgress>? progress = null) + { + int bufferSize = CalculateFileBufferSize(path); + + using var inStream = OpenReadFileStream(path, bufferSize); + + if (progress == null && digests.Length == 1) + { + return new Hash[] { await digests.First().HashStream(inStream, ct).ConfigureAwait(false) }; + } + else + { + var buffer = ArrayPool.Shared.Rent(bufferSize); + + try + { + int bytesRead; + long totalBytesRead = 0; + + var sizeToRead = sizeLimit != null ? (int)Math.Min(buffer.Length, sizeLimit.Value - totalBytesRead) : buffer.Length; + + while ((bytesRead = await inStream.ReadAsync(buffer, 0, sizeToRead, ct).ConfigureAwait(false)) > 0) + { + foreach (var d in digests) + { + d.Update(buffer, 0, bytesRead); + } + + totalBytesRead += bytesRead; + progress?.Report(Tuple.Create(bytesRead, totalBytesRead, sizeLimit != null ? sizeLimit.Value : inStream.Length)); + + if (sizeLimit != null && totalBytesRead >= sizeLimit.Value) + { + break; + } + } + + return digests.Select(d => d.GetHash()).ToArray(); + } + finally + { + ArrayPool.Shared.Return(buffer); + } + } + } + + private static ManifestEntry[] LoadManifest(string path) + { + // Expected input: + // + // { + // "manifest": { + // "entries": { + // "BH-LICENSE.md": { + // "md5": "990edf479f989d2f07dd0d95dadfdc95", + // "size": 35181, + // "xxh3": "1940485fe884a490" + // }, + // "BH.dll": { + // "md5": "ecdf6624097328a390926b0dcddc2d79", + // "size": 1423360, + // "xxh3": "2753ec14b38fd8fa" + // }, + // "binkw32.dll": { + // "md5": "f0c8199c01b623d97d6597f38e5b52a0", + // "size": null + // }, + // [...] + // }, + // "count": 47 + // } + // } + + L.CallerInformation($"Loading '{path}'..."); + + FileStream inStream; + try + { + inStream = OpenReadFileStream(path, 0); + } + catch (Exception ex) + { + throw new LoadManifestException("Failed to open manifest.", ex); + } + + JsonNode? rootNode; + + try + { + using (inStream) + { + rootNode = JsonNode.Parse(inStream, new JsonNodeOptions { PropertyNameCaseInsensitive = true }); + } + } + catch (JsonException ex) + { + throw new LoadManifestException("Failed to parse manifest.", ex); + } + + if (rootNode == null) + { + throw new LoadManifestException("Manifest JSON payload is null."); + } + + const string rootFieldName = "manifest"; + + var manifestNode = rootNode![rootFieldName] ?? throw new LoadManifestException($"Manifest JSON root field '{rootFieldName}' is absent or null."); + + SerializableManifest? serializableManifest; + + try + { + serializableManifest = JsonSerializer.Deserialize(manifestNode, new JsonSerializerOptions + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + // AllowDuplicateProperties = false // Only available since .NET 10 (https://learn.microsoft.com/en-us/dotnet/api/system.text.json.jsondocumentoptions.allowduplicateproperties) + }); + } + catch (JsonException ex) + { + throw new LoadManifestException("Failed to deserialize manifest entries.", ex); + } + + if (serializableManifest == null || serializableManifest.Entries == null) + { + throw new LoadManifestException("Manifest entries are null."); + } + + if (serializableManifest.Entries.Count != serializableManifest.Count) + { + throw new LoadManifestException(message: $"Actual JSON entry count ({serializableManifest.Entries.Count}) does not match the manifest ({serializableManifest.Count})."); + } + + { + var uniquePaths = new HashSet(serializableManifest.Entries.Count, StringComparer.OrdinalIgnoreCase); + + return serializableManifest.Entries + // De-duplicate entries + .Where(kvp => + { + if (!uniquePaths.Add(kvp.Key)) + { + throw new LoadManifestException($"Manifest entry with duplicate path encountered: '{kvp.Key}'"); + } + + return true; + }) + .Select(kvp => + { + try + { + return new ManifestEntry(kvp.Key, kvp.Value); + } + catch (Exception ex) + { + // Let Serilog output JSON-formatted SerializableManifest.Entry here + L.CallerError($"Failed to construct {nameof(ManifestEntry)} for '{kvp.Key}' using {kvp.Value}: {{@SerializableManifest.Entry}}", ExplicitArray(kvp.Value)); + throw new LoadManifestException($"Failed to construct {nameof(ManifestEntry)} for '{kvp.Key}' with given {kvp.Value}.", ex); + } + }) + .ToArray(); + } + } + + private static async Task SaveManifest(string path, ManifestEntry[] manifestEntries, CancellationToken ct) + { + if (!manifestEntries.Any(e => e.Dirty)) + { + L.CallerDebug("No dirty manifest entries found. Skipping saving the manifest."); + return false; + } + + L.CallerInformation($"Saving manifest to: '{path}'..."); + + FileStream outStream; + try + { + outStream = OpenCreateFileStream(path, 0); + } + catch (Exception ex) + { + throw new SaveManifestException($"Failed to create manifest file: '{path}'.", ex); + } + + using (outStream) + { + // Refer to "Expected input" detailed in deserialization logic + + await JsonSerializer.SerializeAsync(outStream, new + { + manifest = new SerializableManifest(manifestEntries) + }, new JsonSerializerOptions + { + WriteIndented = true, + PropertyNamingPolicy = JsonNamingPolicy.CamelCase + }, + ct).ConfigureAwait(false); + } + + // Clear Dirty flags on all entries + foreach (var e in manifestEntries) + { + e.Dirty = false; + } + + LogManifestStats(manifestEntries); + + return true; + } + + private static async Task> TrySaveManifest(string path, ManifestEntry[] manifestEntries, CancellationToken ct) + { + bool? res = null; + + try + { + res = await SaveManifest(path, manifestEntries, ct).ConfigureAwait(false); + } + catch( Exception ex) + { + return Tuple.Create(res, (Exception?)ex); + } + + return Tuple.Create(res, (Exception?)null); + } + + private static async Task DownloadMetadata(HttpClient httpClient, string url, CancellationToken ct) + { + L.CallerInformation($"Downloading metadata from: '{url}'..."); + + using var response = await httpClient.GetAsync(url, ct).ConfigureAwait(false); + response.ThrowIfUnsuccessful(); + + JObject rootNode; + + try + { + rootNode = JObject.Parse(await response.Content.ReadAsStringAsync(ct).ConfigureAwait(false)); + } + catch (Newtonsoft.Json.JsonReaderException ex) + { + throw new LoadMetadataException("Failed to parse metadata.", ex); + } + + if (rootNode == null) + { + throw new LoadMetadataException("Metadata JSON payload is null."); + } + + const string rootFieldName = "checksum"; + + var checksumNode = rootNode[rootFieldName] ?? throw new LoadMetadataException($"Metadata JSON root field '{rootFieldName}' is absent or null."); + + List? stringEntries; + + try + { + stringEntries = checksumNode.ToObject>(); + } + catch (Newtonsoft.Json.JsonReaderException ex) + { + throw new LoadMetadataException("Failed to deserialize metadata entries.", ex); + } + + if (stringEntries == null) + { + throw new LoadMetadataException("Metadata entries are null."); + } + + var uniquePaths = new HashSet(stringEntries.Count, StringComparer.OrdinalIgnoreCase); + + return stringEntries + .Select(entry => + { + var parts = entry.Split(" ", 2, StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); + if (parts.Length != 2) + { + throw new LoadMetadataException($"Invalid metadata entry: '{entry}'"); + } + + var path = parts[1]; + var md5 = parts[0]; + + // Exclude directories? + if (path.EndsWith('/')) + { + throw new LoadMetadataException($"Metadata entry with directory-like path encountered: '{entry}'"); + } + + if (!uniquePaths.Add(path)) + { + throw new LoadMetadataException($"Metadata entry with duplicate path encountered: '{entry}'"); + } + + try + { + return new ManifestEntry(path, md5); + } + catch (Exception ex) + { + throw new LoadMetadataException($"Failed to construct {nameof(ManifestEntry)} with metadata entry: '{entry}'", ex); + } + }) + .ToArray(); + } + + private static async Task> ValidateFileAsync( + string path, + long? size, + Hash expectedHash, + CancellationToken ct, + bool looseValidation = false, + bool sizeIsMinimumSize = false, + IProgress>? progress = null) + { + if (sizeIsMinimumSize && size == null) + { + throw new ArgumentException($"'{nameof(size)}' cannot be null when '{nameof(sizeIsMinimumSize)}' is true", nameof(size)); + } + + if (!await Env.FileExistsAsync(path).ConfigureAwait(false)) + { + L.CallerWarning($"{path}: missing"); + + return Tuple.Create(false, (long?)null, (Xxh3Hash?)null); + } + + (var actualSize, var ex) = await Env.TryGetFileSizeAsync(path).ConfigureAwait(false); + if (ex != null) + { + L.CallerError(ex, $"{nameof(Env.TryGetFileSizeAsync)}() for '{path}' failed."); + + return Tuple.Create(false, (long?)null, (Xxh3Hash?)null); + } + + Debug.Assert(actualSize != null); + + if (sizeIsMinimumSize) + { + Debug.Assert(size != null); + + if (size.Value != actualSize.Value) + { + if (size.Value > actualSize.Value) + { + // Partial download cannot be smaller than declared in PartialDownload + L.CallerWarning($"{path}: partial size mismatch: {size.Value} > {actualSize.Value} (actual)"); + + return Tuple.Create(false, actualSize, (Xxh3Hash ?)null); + } + else + { + L.CallerDebug($"{path}: acceptable partial size mismatch: {size.Value} <= {actualSize.Value} (actual)"); + } + } + } + else + { + if (looseValidation) + { + if (actualSize.Value > 0) + { + // If the file exists and has non-zero size -- that's good enough + L.CallerDebug($"{path}: loosely validated: {actualSize.Value} (actual)"); + + return Tuple.Create(true, actualSize, (Xxh3Hash ?)null); + } + else + { + L.CallerWarning($"{path}: failed loose validation (empty file)"); + + return Tuple.Create(false, actualSize, (Xxh3Hash?)null); + } + } + else + { + if (size != null && size.Value != actualSize.Value) + { + L.CallerWarning($"{path}: size mismatch: {size.Value} != {actualSize.Value} (actual)"); + + return Tuple.Create(false, actualSize, (Xxh3Hash?)null); + } + } + } + + List digests = new(2); + + try + { + // Attempt to pick either DisposableMd5 or DisposableXxh3 digests. + // + // DisposableMd5 is significantly faster than NonFinalizingMd5 as it's merely a wrapper around native implementation (System.Security.Cryptography). + // Meanwhile, NonFinalizingMd5 is a wrapper around BouncyCastle, which is purely managed code. + // + // Since performance matters in this scenario and there's no use for the digest to be non-finalizing, go with the disposable variant. + digests.Add(Digest.GetDisposable(expectedHash)); + + L.CallerVerbose($"{path}: validating against {expectedHash.Name} using {digests.First().GetType().Name}..."); + + // If not validating against XXH3, make sure to compute one as well (and add it as the last element to be returned in the end) + if (!digests.First().IsHashType()) + { + digests.Add(new DisposableXxh3()); + } + + Hash[] hashes = await ComputeHashesAsync(digests.ToArray(), path, sizeLimit: sizeIsMinimumSize ? size : (long?)null, ct, progress).ConfigureAwait(false); + + if (hashes.First() == expectedHash) + { + return Tuple.Create(true, actualSize, (Xxh3Hash?)hashes.Last()); + } + else + { + L.CallerWarning($"{path}: {digests.First().HashName} mismatch"); + + return Tuple.Create(false, actualSize, (Xxh3Hash?)null); + } + } + finally + { + foreach (var d in digests) + { + d.Dispose(); + } + } + } + + private static async Task ValidateFilesAsync( + WorkItem[] filesToValidate, + ValidationKind validationKind, + ParallelOptions parallelOptions, + IProgress? progress) + { + if (!filesToValidate.Any()) + { + // Return early not to end up with totalBytesToValidate == 0 + L.CallerWarning($"Nothing to validate for {validationKind}."); + return; + } + + using var loggedRoutine = new LoggedRoutine(); + + int totalFilesValidated = 0; + int totalFilesToValidate = filesToValidate.Length; + long totalBytesValidated = 0; + // Don't use Nullable to allow atomic operations. Use the symbolic IsInvalidSize() instead. + long totalBytesToValidate = default(long).GetInvalidSize(); + + if (validationKind.IsDownloadFiles()) + { + // Factor in PartialDownloads + if (filesToValidate.All(d => d.PartialDownload?.PartialSize != null || d.ManifestEntry.Size != null)) + { + totalBytesToValidate = filesToValidate.Sum(d => d.PartialDownload?.PartialSize ?? d.ManifestEntry.Size!.Value); + } + } + else + { + if (filesToValidate.All(d => d.ManifestEntry.Size != null || d.ManifestEntry.LooseValidation)) + { + totalBytesToValidate = filesToValidate + .Where(d => d.ManifestEntry.Size != null || d.ManifestEntry.LooseValidation) + .Sum(d => d.ManifestEntry.LooseValidation ? 0 : d.ManifestEntry.Size!.Value); + } + } + + { + var totalBytesToValidateStr = totalBytesToValidate.IsInvalidSize() ? "?" : Formatting.FormatSizeInMiB(totalBytesToValidate); + + L.CallerInformation($"Validating {totalFilesToValidate} {validationKind} ({totalBytesToValidateStr} total)..."); + } + + progress?.Report(new PV() + .Clear() + .SetFileCount(totalFilesValidated, totalFilesToValidate) + .SetBytes(totalBytesValidated, totalBytesToValidate) + .Extract() + ); + + SimpleTimer? updateTimer = null; + + if (progress != null) + { + updateTimer = new(() => + { + var localTotalBytesValidated = Interlocked.Read(ref totalBytesValidated); + var localTotalBytesToValidate = Interlocked.Read(ref totalBytesToValidate); + var localTotalFilesValidated = totalFilesValidated; + + var pv = new PV(); + + pv.SetFileCount(localTotalFilesValidated, totalFilesToValidate); + if (localTotalBytesToValidate.IsInvalidSize()) + { + pv.SetTotal(localTotalFilesValidated, totalFilesToValidate); + } + else + { + pv.SetTotal(localTotalBytesValidated, localTotalBytesToValidate); + } + pv.SetBytes(localTotalBytesValidated, localTotalBytesToValidate); + + progress.Report(pv.Extract()); + }); + } + + using (updateTimer) + { + await Parallel.ForEachAsync(filesToValidate, parallelOptions, async (f, ct) => + { + bool validatingPartialDownload = validationKind.IsDownloadFiles() && f.PartialDownload != null; + + string path = validationKind.IsDownloadFiles() ? f.DownloadPath : f.InstallPath; + long? expectedSize = validatingPartialDownload ? f.PartialDownload!.PartialSize : f.ManifestEntry.Size; + Hash expectedHash = validatingPartialDownload ? f.PartialDownload!.PartialXxh3Hash : f.ManifestEntry.BestHash; + + var doingLooseValidation = validationKind.IsInstallFiles() && f.ManifestEntry.LooseValidation; + + (bool validationSucceeded, long? actualSize, Xxh3Hash? xxh3Hash) = await ValidateFileAsync( + path, + expectedSize, + expectedHash, + ct, + doingLooseValidation, + sizeIsMinimumSize: validatingPartialDownload, + new DirectProgress>(t => + { + (var fileBytesValidated, _, _) = t; + + Interlocked.Add(ref totalBytesValidated, fileBytesValidated); + })).ConfigureAwait(false); + + if (!validationSucceeded) + { + // Discard PartialDownload due to failed validation + if (validatingPartialDownload) + { + L.CallerWarning($"{path}: partial download failed validation. Discarding..."); + + f.PartialDownload = null; + } + } + else + { + // Loosely validated file's actual size is irrelevant + if (!doingLooseValidation && !validatingPartialDownload) + { + Debug.Assert(actualSize != null); + Debug.Assert(xxh3Hash is not null); + + if (f.ManifestEntry.Size != actualSize) + { + if (!totalBytesToValidate.IsInvalidSize()) + { + Interlocked.Add(ref totalBytesToValidate, actualSize.Value - f.ManifestEntry.Size.GetValueOrDefault(0)); + } + + if (f.ManifestEntry.Size != null) + { + var fromSizeStr = Formatting.FormatSizeInMiB(f.ManifestEntry.Size.Value, appendUnits: false); + var toSizeStr = Formatting.FormatSizeInMiB(actualSize.Value); + + L.CallerWarning($"{path}: updating {nameof(f.ManifestEntry.Size)} in manifest: {f.ManifestEntry.Size} -> {actualSize} bytes ({fromSizeStr} -> {toSizeStr})"); + } + + f.ManifestEntry.Size = actualSize.Value; + } + + if (f.ManifestEntry.Xxh3Hash != xxh3Hash) + { + if (f.ManifestEntry.Xxh3Hash != null) + { + L.CallerWarning($"{path}: updating {nameof(f.ManifestEntry.Xxh3Hash)} in manifest"); + } + + f.ManifestEntry.Xxh3Hash = xxh3Hash; + } + } + } + + switch (validationKind) + { + case ValidationKind.InstallFiles: + f.InstallFileValidated = validationSucceeded; + break; + + case ValidationKind.DownloadFiles: + f.DownloadFileValidated = validationSucceeded; + break; + } + + Interlocked.Increment(ref totalFilesValidated); + }).ConfigureAwait(false); + } + } + + private static async Task QueryDownloadAsync(HttpClient httpClient, string url, CancellationToken ct) + { + using var request = new HttpRequestMessage(HttpMethod.Head, url); + + // Make absolutely clear no encoding is requested (https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Accept-Encoding) + request.Headers.AcceptEncoding.Clear(); + request.Headers.AcceptEncoding.Add(new StringWithQualityHeaderValue("identity")); + // Explicitly request a "0-" range (the entire file) (https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Range#syntax) + request.Headers.Range = new RangeHeaderValue((long?)0, (long?)null); + + using var response = await httpClient.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, ct).ConfigureAwait(false); + response.ThrowIfUnsuccessful(); + + // Ranges are explicitly supported if server responds with 206. + // A response of 200 and "Accept-Ranges: bytes" means the server generally accepts ranges, but cannot fulfill this particular request + // (likely due to encoding) and will send the entire file instead. + var acceptsRanges = response.StatusCode == System.Net.HttpStatusCode.PartialContent; + + if (!acceptsRanges) + { + L.CallerWarning($"{url}: {response.StatusCode}; Cannot resume"); + } + + { + var h = response.Content.Headers; + + // 206 should also contain Content-Range header + if (acceptsRanges && h.ContentRange != null) + { + var contentLengthStr = h.ContentLength == null ? "?" : Formatting.FormatSizeInMiB(h.ContentLength.Value); + + var cr = h.ContentRange; + L.CallerDebug($"{url}: {response.StatusCode}; Content-Range: {cr.From?.ToString() ?? ""}-{cr.To?.ToString() ?? ""}/{cr.Length?.ToString() ?? "*"}; Content-Length: {h.ContentLength?.ToString() ?? "?"} ({contentLengthStr})"); + + return cr.Length ?? h.ContentLength; + } + else + { + var contentLengthStr = h.ContentLength == null ? "?" : Formatting.FormatSizeInMiB(h.ContentLength.Value); + + L.CallerDebug($"{url}: {response.StatusCode}; Content-Length: {h.ContentLength?.ToString() ?? "?"} ({contentLengthStr})"); + + return h.ContentLength; + } + } + } + + private static async Task QueryDownloadsAsync( + HttpClient httpClient, + WorkItem[] filesToQuery, + long initialSize, + ParallelOptions parallelOptions, + IProgress? progress) + { + using var loggedScope = new LoggedScope($"Querying {filesToQuery.Length} files..."); + + long totalBytesQueried = initialSize; + int totalFilesToQuery = filesToQuery.Length; + int totalFilesQueried = 0; + + progress?.Report(new PV() + .Clear() + .SetFileCount(totalFilesQueried, totalFilesToQuery) + .SetBytes(totalBytesQueried) + .Extract() + ); + + SimpleTimer? updateTimer = null; + + if (progress != null) + { + updateTimer = new(() => + { + var localTotalBytesQueried = Interlocked.Read(ref totalBytesQueried); + var localTotalFilesQueried = totalFilesQueried; + + var pv = new PV(); + + progress?.Report(new PV() + .SetTotal(localTotalFilesQueried, totalFilesToQuery) + .SetFileCount(localTotalFilesQueried, totalFilesToQuery) + .SetBytes(localTotalBytesQueried) + .Extract() + ); + }); + } + + using (updateTimer) + { + await Parallel.ForEachAsync(filesToQuery, parallelOptions, async (f, ct) => + { + var queriedSize = await QueryDownloadAsync(httpClient, f.Url, ct).ConfigureAwait(false); + + Interlocked.Increment(ref totalFilesQueried); + + if (queriedSize != null) + { + Interlocked.Add(ref totalBytesQueried, queriedSize.Value); + + if (f.ManifestEntry.Size != queriedSize) + { + if (f.ManifestEntry.Size != null) + { + var fromSizeStr = Formatting.FormatSizeInMiB(f.ManifestEntry.Size.Value, appendUnits: false); + var toSizeStr = Formatting.FormatSizeInMiB(queriedSize.Value); + + L.CallerWarning($"{f.ManifestEntry.Path}: updating Size in manifest {f.ManifestEntry.Size} -> {queriedSize} bytes ({fromSizeStr} -> {toSizeStr})"); + } + + f.ManifestEntry.Size = queriedSize.Value; + } + } + }).ConfigureAwait(false); + } + } + + private static async Task DownloadFileAsync( + HttpClient httpClient, + string url, + string destinationPath, + Md5Hash referenceMd5Hash, + Hash expectedHash, + CancellationToken ct, + PartialDownload? downloadToResume = null, + IProgress>? progress = null) + { + using var request = new HttpRequestMessage(HttpMethod.Get, url); + + if (downloadToResume != null) + { + request.Headers.Range = new RangeHeaderValue(downloadToResume.PartialSize, (long?)null); + } + + Hash actualExpectedHash = null!; + NonFinalizingDigest digest = null!; + NonFinalizingXxh3 xxh3Digest = null!; + long totalBytesWritten = 0; + + try + { + using var response = await httpClient.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, ct).ConfigureAwait(false); + response.ThrowIfUnsuccessful(); + + // Ranges are explicitly supported if server responds with 206. + // A response of 200 and "Accept-Ranges: bytes" means the server generally accepts ranges, but cannot fulfill this particular request + // (likely due to encoding) and will send the entire file instead. + bool resuming = downloadToResume != null && response.StatusCode == System.Net.HttpStatusCode.PartialContent; + + if (resuming) + { + L.CallerDebug($"{url}: resuming download at {downloadToResume!.PartialSize} ({Formatting.FormatSizeInMiB(downloadToResume.PartialSize)})..."); + + actualExpectedHash = downloadToResume!.ExpectedHash; + digest = downloadToResume!.Digest; + xxh3Digest = downloadToResume!.Xxh3Digest; + totalBytesWritten = downloadToResume!.PartialSize; + } + else + { + if (downloadToResume != null) + { + L.CallerWarning($"{url}: restarting download..."); + } + else + { + L.CallerDebug($"{url}: downloading..."); + } + + actualExpectedHash = expectedHash; + digest = Digest.GetNonFinalizing(actualExpectedHash); + xxh3Digest = digest.IsHashType() ? (NonFinalizingXxh3)digest : new NonFinalizingXxh3(); + totalBytesWritten = 0; + } + + L.CallerVerbose($"{url}: validating against {actualExpectedHash.Name} using {digest.GetType().Name}..."); + + using var inStream = await response.Content.ReadAsStreamAsync(ct).ConfigureAwait(false); + + FileStream outStream; + + if (resuming) + { + outStream = OpenWriteFileStream(destinationPath, CalculateFileBufferSize(response.Content.Headers.ContentRange?.Length ?? 0), offset: downloadToResume!.PartialSize); + } + else + { + outStream = OpenCreateFileStream(destinationPath, CalculateFileBufferSize(response.Content.Headers.ContentLength ?? 0)); + } + + using (outStream) + { + var buffer = ArrayPool.Shared.Rent(DefaultNetworkStreamBufferSize); + + try + { + long? totalFileSize = resuming ? response.Content.Headers.ContentRange?.Length : response.Content.Headers.ContentLength; + + int bytesRead; + + while (true) + { + try + { + progress?.Report(Tuple.Create(0, totalBytesWritten, totalFileSize, true)); + + bytesRead = await inStream.ReadAsync(buffer, 0, buffer.Length, ct).ConfigureAwait(false); + } + finally + { + progress?.Report(Tuple.Create(0, totalBytesWritten, totalFileSize, false)); + } + + if (bytesRead <= 0) + { + break; + } + + await outStream.WriteAsync(buffer, 0, bytesRead, ct).ConfigureAwait(false); + + digest.Update(buffer, 0, bytesRead); + if (digest != xxh3Digest) + { + xxh3Digest.Update(buffer, 0, bytesRead); + } + totalBytesWritten += bytesRead; + progress?.Report(Tuple.Create(bytesRead, totalBytesWritten, totalFileSize, false)); + } + + if (digest.GetHash() != actualExpectedHash) + { + L.CallerError($"{url}: {digest.HashName} mismatch"); + throw new DownloadHashMismatchException(innerException: null, digest.HashName); + } + + return new DownloadResult((Xxh3Hash)xxh3Digest.GetHash()); + } + finally + { + ArrayPool.Shared.Return(buffer); + } + } + } + catch (OperationCanceledException ex) + { + bool userRequested = ex.CancellationToken == ct; + + var stateStr = userRequested ? "canceled" : "interrupted"; + var logEventLevel = userRequested ? LogEventLevel.Warning : LogEventLevel.Error; + + if (totalBytesWritten > 0) + { + L.CallerWrite(logEventLevel, $"{url}: download {stateStr} at {totalBytesWritten} ({Formatting.FormatSizeInMiB(totalBytesWritten)})"); + return new DownloadResult( + ex, + totalBytesWritten, + (Xxh3Hash)xxh3Digest.GetHash(), + xxh3Digest, + referenceMd5Hash, + digest, + actualExpectedHash); + } + else + { + // Treat a zero-sized partial download candidate as an unrecoverable failed download + + L.CallerError($"{url}: download {stateStr}."); + return new DownloadResult(ex); + } + } + catch (GameFileUpdateException ex) + { + // These should have logged their errors by now + + return new DownloadResult(ex); + } + catch (Exception ex) + { + L.CallerError(ex, $"{url}: download failed."); + + return new DownloadResult(ex); + } + } + + private static async Task DownloadFilesAsync( + HttpClient httpClient, + WorkItem[] filesToDownload, + ParallelOptions parallelOptions, + IProgress? progress, + IProgress? offlineIndicatorProgress, + IProgress? downloadErrorIndicatorProgress) + { + if (!filesToDownload.Any()) + { + // Return early not to end up with totalBytesToDownload == 0 + L.CallerWarning($"Nothing to download."); + return Array.Empty(); + } + + using var loggedRoutine = new LoggedRoutine(); + + int totalFilesDownloaded = 0; + int totalFilesToDownload = filesToDownload.Length; + long totalBytesDownloaded = filesToDownload + .Where(f => f.PartialDownload != null) + .Sum(f => f.PartialDownload!.PartialSize); + + // Don't use Nullable to allow atomic operations. Use the symbolic IsInvalidSize() instead. + long totalBytesToDownload = filesToDownload.Any(f => f.ManifestEntry.Size == null) ? default(long).GetInvalidSize() : filesToDownload + .Sum(f => f.ManifestEntry.Size!.Value); + + // Monotonic value for throughput estimation + long totalBytesDownloadedEver = 0; + // Number of active network stream reads + // Throughput estimator will not report stalls unless this value > 0 + int networkStreamReadsCount = 0; + + { + var totalRemainingBytesToDownloadStr = totalBytesToDownload.IsInvalidSize() ? "?" : Formatting.FormatSizeInMiB(totalBytesToDownload - totalBytesDownloaded); + var totalPartialDownloads = filesToDownload.Count(f => f.PartialDownload != null); + + L.CallerInformation($"Downloading {totalFilesToDownload} file(s) ({totalRemainingBytesToDownloadStr} total; {totalPartialDownloads} partial download(s) being resumed)..."); + } + + { + var pv = new PV().Clear(); + + pv.SetFileCount(totalFilesDownloaded, totalFilesToDownload); + if (totalBytesToDownload.IsInvalidSize()) + { + pv.SetTotal(totalFilesDownloaded, totalFilesToDownload); + } + else + { + pv.SetTotal(totalBytesDownloaded, totalBytesToDownload); + } + pv.SetBytes(totalBytesDownloaded, totalBytesToDownload); + + progress?.Report(pv.Extract()); + } + + List downloadResults = new(totalFilesToDownload); + + try + { + // Do throughput estimation asynchronously to be able to detect and present any connection stalls + + var throughputStopwatch = Stopwatch.StartNew(); + + const int ThroughputEstimatorIntervalMilliseconds = 100; + const int OverSecondWorthSampleCount = 1000 / ThroughputEstimatorIntervalMilliseconds + 2; + // A poor-man's circular buffer that stores slightly more than a second worth of samples + LinkedList throughputSamples = new(); + // First ever-recorded sample + ThroughputEstimatorSample firstEverSample = null!; + // First sample with the final download size + ThroughputEstimatorSample firstFinalSample = null!; + bool connectionStalled = false; + + var throughputProgressThrottle = new UpdateThrottle(intervalMilliseconds: 200); + // Hopefully this won't end up being too spammy on slower connections + var throughputLoggingThrottle = new UpdateThrottle(intervalMilliseconds: 1000); + bool throughputLoggingPaused = false; + long maxBytesPerSec = 0; + + using var throughputEstimationTimer = new SimpleTimer(TimeSpan.FromMilliseconds(ThroughputEstimatorIntervalMilliseconds), () => + { + long localTotalBytesDownloadedEver = Interlocked.Read(ref totalBytesDownloadedEver); + var timePointMilliseconds = throughputStopwatch.ElapsedMilliseconds; + + var currentSample = new ThroughputEstimatorSample(localTotalBytesDownloadedEver, timePointMilliseconds); + throughputSamples.AddFirst(currentSample); + + if (throughputSamples.Count == 1) + { + firstEverSample = currentSample; + } + + if (firstFinalSample == null || currentSample.Bytes > firstFinalSample.Bytes) + { + firstFinalSample = currentSample; + } + + if (throughputSamples.Count > OverSecondWorthSampleCount) + { + throughputSamples.RemoveLast(); + } + + bool stalled = + networkStreamReadsCount > 0 && + throughputSamples.Count >= OverSecondWorthSampleCount && + throughputSamples + .Take(OverSecondWorthSampleCount) + .Select(s => s.Bytes) + .Distinct() + .Count() == 1; + + // Stop logging if connection has stalled -- no need to keep putting out zeros into the log + if (stalled && throughputLoggingPaused) + { + offlineIndicatorProgress?.Report(true); + + if (!connectionStalled) + { + connectionStalled = true; + L.CallerWarning("Connection stalled."); + } + + return; + } + else + { + offlineIndicatorProgress?.Report(false); + + throughputLoggingPaused = false; + + if (connectionStalled) + { + connectionStalled = false; + L.CallerWarning("Connection restored."); + } + } + + // Estimating using samples spanning across less than a second will inflate the throughput + if (throughputSamples.Count < OverSecondWorthSampleCount) + { + return; + } + + var lastSample = throughputSamples.Last!.Value; + + var bytesDownloaded = currentSample.Bytes - lastSample.Bytes; + var elapsedMilliseconds = currentSample.TimePointMilliseconds - lastSample.TimePointMilliseconds; + + throughputProgressThrottle.UpdateIfPossible(() => + { + progress?.Report(new PV() + .SetBytesPerSec(bytesDownloaded, elapsedMilliseconds) + .Extract() + ); + }); + + var bytesPerSec = bytesDownloaded * 1000 / elapsedMilliseconds; + maxBytesPerSec = Math.Max(maxBytesPerSec, bytesPerSec); + + throughputLoggingThrottle.UpdateIfPossible(() => + { + throughputLoggingPaused = stalled; + + L.CallerDebug($"Throughput: {Formatting.FormatThroughputInMiB(bytesPerSec)}"); + }); + }, onDispose: () => + { + if (maxBytesPerSec > 0) + { + L.CallerDebug($"Max throughput: {Formatting.FormatThroughputInMiB(maxBytesPerSec)}"); + } + + if (firstEverSample != null && firstFinalSample != null) + { + L.CallerDebug($"Avg throughput: {Formatting.FormatThroughputInMiB(firstFinalSample.Bytes - firstEverSample.Bytes, firstFinalSample.TimePointMilliseconds - firstEverSample.TimePointMilliseconds)}"); + } + }); + + // The actual download... + + SimpleTimer? updateTimer = null; + + if (progress != null) + { + updateTimer = new(() => + { + var localTotalBytesDownloaded = Interlocked.Read(ref totalBytesDownloaded); + var localTotalBytesToDownload = Interlocked.Read(ref totalBytesToDownload); + var localTotalFilesDownloaded = totalFilesDownloaded; + + var pv = new PV(); + + pv.SetFileCount(localTotalFilesDownloaded, totalFilesToDownload); + if (localTotalBytesToDownload.IsInvalidSize()) + { + pv.SetTotal(localTotalFilesDownloaded, totalFilesToDownload); + } + else + { + pv.SetTotal(localTotalBytesDownloaded, localTotalBytesToDownload); + } + pv.SetBytes(localTotalBytesDownloaded, localTotalBytesToDownload); + + progress.Report(pv.Extract()); + }); + } + + using (updateTimer) + { + await Parallel.ForEachAsync(filesToDownload.OrderByDescending(f => f.ManifestEntry.Size), parallelOptions, async (f, ct) => + { + bool sizeConfirmed = false; + bool lastReadingNetworkStream = false; + + long previousTotalFileBytesDownloaded = f.PartialDownload?.PartialSize ?? 0; + + f.DownloadResult = await DownloadFileAsync( + httpClient, + f.Url, + f.DownloadPath, + f.ManifestEntry.Md5Hash, + f.ManifestEntry.BestHash, + ct, + f.PartialDownload, + new DirectProgress>(t => + { + (var fileBytesDownloaded, var totalFileBytesDownloaded, var totalFileSize, var readingNetworkStream) = t; + + if (!sizeConfirmed) + { + sizeConfirmed = true; + + if (totalFileSize != null) + { + if (!totalBytesToDownload.IsInvalidSize()) + { + Interlocked.Add(ref totalBytesToDownload, totalFileSize.Value - f.ManifestEntry.Size.GetValueOrDefault(0)); + } + + if (f.ManifestEntry.Size != totalFileSize) + { + if (f.ManifestEntry.Size != null) + { + var fromSizeStr = Formatting.FormatSizeInMiB(f.ManifestEntry.Size.Value, appendUnits: false); + var toSizeStr = Formatting.FormatSizeInMiB(totalFileSize.Value); + + L.CallerWarning($"{f.ManifestEntry.Path}: updating {nameof(f.ManifestEntry.Size)} in manifest: {f.ManifestEntry.Size} -> {totalFileSize} bytes ({fromSizeStr} -> {toSizeStr})"); + } + + f.ManifestEntry.Size = totalFileSize; + } + } + } + + Interlocked.Add(ref totalBytesDownloaded, totalFileBytesDownloaded - previousTotalFileBytesDownloaded); + previousTotalFileBytesDownloaded = totalFileBytesDownloaded; + Interlocked.Add(ref totalBytesDownloadedEver, fileBytesDownloaded); + + if (lastReadingNetworkStream != readingNetworkStream) + { + if (readingNetworkStream) + { + Interlocked.Increment(ref networkStreamReadsCount); + } + else + { + Interlocked.Decrement(ref networkStreamReadsCount); + } + + lastReadingNetworkStream = readingNetworkStream; + } + })).ConfigureAwait(false); + + lock (downloadResults) + { + downloadResults.Add(f.DownloadResult); + } + + if (!f.DownloadResult.IsSuccess) + { + downloadErrorIndicatorProgress?.Report(true); + } + else + { + if (f.ManifestEntry.Xxh3Hash != f.DownloadResult.Xxh3Hash) + { + if (f.ManifestEntry.Xxh3Hash != null) + { + L.CallerWarning($"{f.ManifestEntry.Path}: updating {nameof(f.ManifestEntry.Xxh3Hash)} in manifest"); + } + + f.ManifestEntry.Xxh3Hash = f.DownloadResult.Xxh3Hash; + } + + Interlocked.Increment(ref totalFilesDownloaded); + } + }).ConfigureAwait(false); + } + } + catch (OperationCanceledException) + { + // Swallow any cancellations to make sure DownloadResults get returned + } + + return downloadResults.ToArray(); + } + + private static async Task RestoreFilesAsync( + WorkItem[] filesToRestore, + CancellationToken ct, + IProgress? progress) + { + if (!filesToRestore.Any()) + { + // Return early not to end up with totalBytesToRestore == 0 + L.CallerWarning($"Nothing to restore."); + return; + } + + using var loggedRoutine = new LoggedRoutine(); + + var updateThrottle = new UpdateThrottle(); + + int totalFilesRestored = 0; + int totalFilesToRestore = filesToRestore.Length; + long totalBytesRestored = 0; + long? totalBytesToRestore = filesToRestore.Any(f => f.ManifestEntry.Size == null) ? (long?)null : + filesToRestore + .Sum(f => f.ManifestEntry.Size!.Value); + + { + var totalBytesToRestoreStr = totalBytesToRestore == null ? "?" : Formatting.FormatSizeInMiB(totalBytesToRestore.Value); + + L.CallerInformation($"Restoring {totalFilesToRestore} files ({totalBytesToRestoreStr} total)..."); + } + + progress?.Report(new PV() + .Clear() + .SetFileCount(totalFilesRestored, totalFilesToRestore) + .SetBytes(totalBytesRestored, totalBytesToRestore) + .Extract() + ); + + // Perform this stage sequentially + + foreach (var f in filesToRestore) + { + L.CallerDebug($"{f.InstallPath}: restoring..."); + + await CopyFileAsync(f.DownloadPath, f.InstallPath, ct, new DirectProgress>(t => + { + (var fileBytesCopied, var _, var _) = t; + + totalBytesRestored += fileBytesCopied; + + updateThrottle.UpdateIfPossible(() => + { + var pv = new PV(); + + if (totalBytesToRestore != null) + { + pv.SetTotal(totalBytesRestored, totalBytesToRestore.Value); + } + else + { + pv.SetTotal(totalFilesRestored, totalFilesToRestore); + } + pv.SetBytes(totalBytesRestored, totalBytesToRestore); + progress?.Report(pv.Extract()); + }); + })).ConfigureAwait(false); + + ++totalFilesRestored; + + var pv = new PV(); + + pv.SetFileCount(totalFilesRestored, totalFilesToRestore); + if (totalBytesToRestore != null) + { + pv.SetTotal(totalBytesRestored, totalBytesToRestore.Value); + } + else + { + pv.SetTotal(totalFilesRestored, totalFilesToRestore); + } + pv.SetBytes(totalBytesRestored, totalBytesToRestore); + progress?.Report(pv.Extract()); + } + } + + public async Task UpdateAsync( + bool workOffline, + UpdateMode updateMode, + bool useHttp2, + FileUpdateModel fileUpdateModel, + IProgress? progress = null, + IProgress? disabledTextProgress = null, + IProgress? offlineIndicatorProgress = null, + IProgress? downloadErrorIndicatorProgress = null, + CancellationToken cancellationToken = default) + { + using var loggedRoutine = new LoggedRoutine(); + + var parallelOptions = new ParallelOptions + { + CancellationToken = cancellationToken, + MaxDegreeOfParallelism = Environment.ProcessorCount + }; + + using var socketsHttpHandler = new SocketsHttpHandler() + { + ConnectTimeout = TimeSpan.FromSeconds(3), + + // These seem to be only relevant for HTTP/2 + KeepAlivePingPolicy = HttpKeepAlivePingPolicy.Always, + KeepAlivePingDelay = TimeSpan.FromSeconds(5), + KeepAlivePingTimeout = TimeSpan.FromSeconds(5), + }; + + using var httpClient = new HttpClient(socketsHttpHandler) + { + DefaultVersionPolicy = useHttp2 ? HttpVersionPolicy.RequestVersionOrHigher : HttpVersionPolicy.RequestVersionOrLower, + DefaultRequestVersion = useHttp2 ? new Version(2, 0) : new Version(1, 1), + +#if !DEBUG + // Slightly above the 15 sec timeout of a DNS query (https://learn.microsoft.com/en-us/dotnet/api/system.net.http.httpclient.timeout#remarks). + // This will only affect stalling connections in practice. + // Any downloads interrupted due to timeout can be subsequently resumed. + Timeout = TimeSpan.FromSeconds(20) +#else + // Use an aggressive timeout for debugging + Timeout = TimeSpan.FromSeconds(2) +#endif + }; + + httpClient.DefaultRequestHeaders.UserAgent.Clear(); + httpClient.DefaultRequestHeaders.UserAgent.Add(new ProductInfoHeaderValue("PD2Launcher", Constants.VersionString)); + + L.CallerWrite(workOffline ? LogEventLevel.Warning : LogEventLevel.Information, $"Attempting to work {(workOffline ? "OFFLINE" : "online")}..."); + L.CallerWrite(updateMode.IsNormal() ? LogEventLevel.Information : LogEventLevel.Warning, $"Using {updateMode} {nameof(UpdateMode)}"); + L.CallerInformation($"Using HttpClient with HTTP/{httpClient.DefaultRequestVersion}"); + + // Make sure remoteUrlRoot ends with a single '/' for easy concatenation + string remoteUrlRoot = fileUpdateModel.Client.TrimEnd('/') + "/"; + // The only better alternative to simple concatenation here is a dedicated library + string metadataUrl = remoteUrlRoot + "metadata.json"; + + string installRoot = Env.GetCwd(); + string launcherFilesRoot = Env.GetLauncherFilesRootDirPath(); + string updateModelRoot = Path.Combine(launcherFilesRoot, fileUpdateModel.FilePath); + string downloadRoot = Path.Combine(updateModelRoot, "downloads"); + + string manifestPath = Path.Combine(updateModelRoot, "manifest.json"); + + L.CallerInformation($"Using install path: '{installRoot}'"); + L.CallerInformation($"Using download path: '{downloadRoot}'"); + + // Load current context + + if (!_fileUpdateModelToContext.TryGetValue(fileUpdateModel, value: out Context? ctx)) + { + ctx = new(); + _fileUpdateModelToContext.Add(fileUpdateModel, ctx); + } + + // Load local manifest if not loaded already + + // ...unless running in Reset mode + if (updateMode.IsReset()) + { + L.CallerWarning($"Clearing manifest due to {updateMode} {nameof(UpdateMode)}..."); + + ctx.manifestEntries = Array.Empty(); + } + else + { + if (!ctx.manifestEntries.Any()) + { + try + { + ctx.manifestEntries = LoadManifest(manifestPath); + + L.CallerInformation($"Loaded {ctx.manifestEntries.Length} manifest entries"); + } + catch (LoadManifestException ex) + { + L.CallerError(ex.InnerException, ex.Message); + } + catch (Exception ex) + { + L.CallerError(ex.InnerException, $"{nameof(LoadManifest)}() threw"); + } + } + else + { + L.CallerInformation($"Manifest entries already loaded: {ctx.manifestEntries.Length}"); + } + } + + // Retrieve remote metadata... + + bool isOffline = true; + Exception? metadataEx = null; + + // ...unless working offline + if (workOffline) + { + L.CallerWarning($"Skipping metadata download due to working OFFLINE..."); + } + else + { + disabledTextProgress?.Report("Metadata..."); + + ManifestEntry[] metadataEntries = Array.Empty(); + + try + { + metadataEntries = await DownloadMetadata(httpClient, metadataUrl, parallelOptions.CancellationToken).ConfigureAwait(false); + + L.CallerInformation($"Metadata entries retrieved: {metadataEntries.Length}"); + } + catch (OperationCanceledException ex) when (ex.CancellationToken == cancellationToken) + { + // Rethrow own cancellation + throw; + } + catch (LoadMetadataException ex) + { + metadataEx = ex; + + L.CallerError(ex.InnerException, ex.Message); + } + catch (Exception ex) + { + metadataEx = ex; + + if (ex is HttpRequestException || ex is OperationCanceledException) + { + // Treat external cancellation as a likely connection disruption + offlineIndicatorProgress?.Report(true); + } + + L.CallerError(ex.InnerException, $"{nameof(DownloadMetadata)} threw"); + } + + if (metadataEx == null) + { + isOffline = false; + + if (!metadataEntries.Any()) + { + offlineIndicatorProgress?.Report(true); + + // This should really never happen, but if the retrieved metadata is, in fact, invalid, it's impossible to proceed. + throw new InvalidMetadataRetrieved(); + } + + // ...and merge with manifestEntries + + int newMetadataEntries = 0; + int reusedMetadataEntries = 0; + int updatedMetadataEntries = 0; + + // For every matching entry in manifestEntries with the same MD5, retain any additional info present in manifestEntries... + // + // Since left outer joins are only available since .NET 10, rely on GroupJoin (https://learn.microsoft.com/en-us/dotnet/csharp/linq/standard-query-operators/join-operations#emulate-a-left-outer-join) + foreach (var e in metadataEntries.GroupJoin(ctx.manifestEntries, meta => meta.Path, mani => mani.Path, (meta, manis) => new + { + meta, + // Expect manifest entries to be de-duplicated at this point, therefore the collection should either contain one element or none + mani = manis.FirstOrDefault() + }, StringComparer.OrdinalIgnoreCase)) + { + // No matching manifestEntry for metadata one + if (e.mani == null) + { + ++newMetadataEntries; + continue; + } + + if (e.meta.Md5Hash == e.mani.Md5Hash) + { + ++reusedMetadataEntries; + e.meta.Size = e.mani.Size; + e.meta.Xxh3Hash = e.mani.Xxh3Hash; + e.meta.Dirty = false; + } + else + { + ++updatedMetadataEntries; + } + } + + // ...and eventually, replace local manifestEntries with trusted metadataEntries. + ctx.manifestEntries = metadataEntries; + + L.CallerInformation($"Final manifest entries: {metadataEntries.Length} ({newMetadataEntries} new, {reusedMetadataEntries} reused, {updatedMetadataEntries} updated)"); + LogManifestStats(ctx.manifestEntries); + } + + // Based on successful metadata download, indicate whether we think we're offline or not + + L.CallerWrite(isOffline ? LogEventLevel.Warning : LogEventLevel.Information, $"Deemed {(isOffline ? "OFFLINE" : "online")}"); + offlineIndicatorProgress?.Report(isOffline); + } + + if (isOffline && !ctx.manifestEntries.Any()) + { + // Running offline with no prior manifest + throw new OfflineInvalidManifest(metadataEx); + } + + // Validate files according to the manifest and, if needed, determine files to restore and to download + + var filesToRestore = Array.Empty(); + var filesToDownload = Array.Empty(); + + disabledTextProgress?.Report("Validating..."); + { + using var loggedScope = new LoggedScope("Validating..."); + + var workItems = ctx.manifestEntries + .Select(e => new WorkItem( + manifestEntry: e, + // Just concat as that's the most reliable approach given remoteUrlRoot has been sanitized + url: remoteUrlRoot + e.Path, + // Use Path.GetFullPath() in place of Path.Combine() since Path is expected to be relative and GetFullPath() + // can deal with and transform all path separators to native ones + downloadPath: Path.GetFullPath(e.Path, downloadRoot), + installPath: Path.GetFullPath(e.Path, installRoot), + partialDownload: ctx.partialDownloads.GetValueOrDefault(e.Path))) + .ToArray(); + + // Verify that PartialDownloads refer to the exact files present in the manifest (in case of metadata update occurring between cancel/resume) + foreach (var d in workItems) + { + if (d.PartialDownload != null && d.PartialDownload.ReferenceMd5Hash != d.ManifestEntry.Md5Hash) + { + L.CallerWarning($"{d.ManifestEntry.Path}: partial download refers to a different version of the file. Discarding..."); + + d.PartialDownload = null; + } + } + + switch (updateMode) + { + case UpdateMode.Normal: + // (I1) FilesToRestore = [All files] -> [InstallFiles that failed Validation] + { + await ValidateFilesAsync(workItems, ValidationKind.InstallFiles, parallelOptions, progress).ConfigureAwait(false); + + // Restore all InstallFiles that explicitly failed validation (excluding loosely validated InstallFiles) + filesToRestore = workItems + .Where(wi => !wi.InstallFileValidated) + .ToArray(); + } + break; + + case UpdateMode.Restore: + // (I2) FilesToRestore = [All files] -> [InstallFiles that failed Validation] + [InstallFiles loosely validated] + { + await ValidateFilesAsync(workItems, ValidationKind.InstallFiles, parallelOptions, progress).ConfigureAwait(false); + + // InstallFiles that either failed validation or were loosely validated + filesToRestore = workItems + .Where(wi => !wi.InstallFileValidated || wi.ManifestEntry.LooseValidation) + .ToArray(); + } + break; + + case UpdateMode.Download: + // (I3) FilesToRestore = [None] + { + L.CallerWarning($"Not restoring any files due to {updateMode} {nameof(UpdateMode)}..."); + } + break; + + case UpdateMode.Reset: + // (I4) FilesToRestore = [All files] + { + L.CallerWarning($"Forcing unconditional restore of all {workItems.Length} files due to {updateMode} {nameof(UpdateMode)}..."); + + filesToRestore = workItems; + } + break; + } + + L.CallerInformation($"Files to restore: {filesToRestore.Length}"); + + switch (updateMode) + { + case UpdateMode.Normal: + case UpdateMode.Restore: + // (D1) FilesToDownload = [FilesToRestore] -> [DownloadFiles that failed Validation] + [DownloadFiles with PartialDownloads] + { + await ValidateFilesAsync(filesToRestore, ValidationKind.DownloadFiles, parallelOptions, progress).ConfigureAwait(false); + + filesToDownload = filesToRestore + .Where(wi => !wi.DownloadFileValidated || wi.PartialDownload != null) + .ToArray(); + } + break; + + case UpdateMode.Download: + // (D2) FilesToDownload = [All files] -> [DownloadFiles that failed Validation] + [DownloadFiles with PartialDownloads] + { + await ValidateFilesAsync(workItems, ValidationKind.DownloadFiles, parallelOptions, progress).ConfigureAwait(false); + + filesToDownload = workItems + .Where(wi => !wi.DownloadFileValidated || wi.PartialDownload != null) + .ToArray(); + } + break; + + case UpdateMode.Reset: + // (D3) FilesToDownload = [All files] + { + // Validate any associated PartialDownloads + var partialDownloads = workItems + .Where(wi => wi.PartialDownload != null) + .ToArray(); + + L.CallerWarning($"Forcing validation of {partialDownloads.Length} {ValidationKind.DownloadFiles} with a {nameof(PartialDownload)} due to {updateMode} {nameof(UpdateMode)}..."); + + await ValidateFilesAsync(partialDownloads, ValidationKind.DownloadFiles, parallelOptions, progress).ConfigureAwait(false); + + L.CallerWarning($"Forcing download of all {workItems.Length} {ValidationKind.DownloadFiles} due to {updateMode} {nameof(UpdateMode)}..."); + + filesToDownload = workItems; + } + break; + } + + { + var partialDownloadsCount = filesToDownload.Count(f => f.PartialDownload != null); + + L.CallerInformation($"Files to download: {filesToDownload.Length} (including {partialDownloadsCount} partial download(s))"); + } + } + + // Attempt to save the manifest at this stage. + // + // This can succeed when the files were already there (and have been validated) but the manifest was missing. + // + // (Disallow cancelling this) + await SaveManifest(manifestPath, ctx.manifestEntries, ct: default).ConfigureAwait(false); + + // Validation failed and some files need to be re-downloaded, which is impossible + if (isOffline && filesToDownload.Any()) + { + throw new OfflineNeedsDownload(metadataEx); + } + + DownloadResult[] downloadResults = Array.Empty(); + + if (!isOffline && filesToDownload.Any()) + { + // Determine Content-Length and availability of Content-Range for any download missing Size... + + // ...or all of them in case of running in Reset mode + if (updateMode.IsReset()) + { + L.CallerWarning($"Forcing query of all {filesToDownload.Length} files due to {updateMode} {nameof(UpdateMode)}..."); + } + + var filesMissingSize = updateMode.IsReset() ? filesToDownload : + filesToDownload + .Where(f => f.ManifestEntry.Size == null) + .ToArray(); + + if (filesMissingSize.Any()) + { + disabledTextProgress?.Report("Querying..."); + + var initialSize = updateMode.IsReset() ? 0 : + filesToDownload + .Where(f => f.ManifestEntry.Size != null) + .Sum(f => f.ManifestEntry.Size!.Value); + + await QueryDownloadsAsync(httpClient, filesMissingSize, initialSize, parallelOptions, progress).ConfigureAwait(false); + + // Attempt to save the manifest at this stage. + // + // This should succeed as all missing Sizes should have been just retrieved. + // + // (Disallow cancelling this) + await SaveManifest(manifestPath, ctx.manifestEntries, ct: default).ConfigureAwait(false); + } + + disabledTextProgress?.Report("Downloading..."); + + // Prune PartialDownloads before starting downloads. + // These should have been already assigned to their respective WorkItems before Validation. + // Once the downloads start, these PartialDownloads are instantly inaccurate/invalid. + ctx.partialDownloads.Clear(); + + // DownloadFilesAsync() should catch most exceptions to be able to return its DownloadResults + downloadResults = await DownloadFilesAsync( + httpClient, + filesToDownload, + parallelOptions, + progress, + offlineIndicatorProgress, + downloadErrorIndicatorProgress).ConfigureAwait(false); + + // Go over all DownloadResults and store PartialDownloads + foreach (var f in filesToDownload) + { + if (f.DownloadResult?.PartialDownload != null) + { + L.CallerInformation($"{f.ManifestEntry.Path}: storing partial download at {f.DownloadResult.PartialDownload.PartialSize} ({Formatting.FormatSizeInMiB(f.DownloadResult.PartialDownload.PartialSize)})"); + + ctx.partialDownloads.Add(f.ManifestEntry.Path, f.DownloadResult.PartialDownload); + } + } + + L.CallerInformation($"Successfully downloaded {filesToDownload.Count(f => f.DownloadResult?.IsSuccess == true)}/{filesToDownload.Length} files"); + + // Just throw if cancellation occurred (likely inside DownloadFilesAsync()) without going over all of DownloadResults' Exceptions. + // Since subsequent operations will also trip up on OperationCanceledException, there's no point in trying to continue. + parallelOptions.CancellationToken.ThrowIfCancellationRequested(); + } + + // Attempt to try to save the manifest as soon as possible after downloading. + // + // This should succeed in case Sizes could only be determined by downloading files in full. + // However, if any of the files fail to download, the manifest will never get saved. + // + // Due to variety of possible failures in DownloadFilesAsync(), attempt to save the manifest safely, so that any failure in doing so + // won't overshadow exceptions thrown by DownloadFilesAsync(). + // + // (Disallow cancelling this) + (var _, var saveManifestEx) = await TrySaveManifest(manifestPath, ctx.manifestEntries, ct: default).ConfigureAwait(false); + + if (saveManifestEx != null) + { + L.CallerError(saveManifestEx, $"{nameof(TrySaveManifest)}() failed."); + } + + // Rethrow any exceptions from DownloadFilesAsync() + if (downloadResults.Any(r => !r.IsSuccess && r.PartialDownload == null)) + { + throw new DownloadException(new AggregateException(downloadResults + .Where(r => !r.IsSuccess && r.PartialDownload == null) + .Select(r => r.Exception!) + .ToArray()), + $"Failed downloads: {downloadResults.Count(r => !r.IsSuccess && r.PartialDownload == null)}/{downloadResults.Length}"); + } + + // Rethrow any exceptions from TrySaveManifest() + if (saveManifestEx != null) + { + throw saveManifestEx; + } + + // Restore files + + if (filesToRestore.Any()) + { + var restorableFiles = filesToRestore + .Where(f => f.DownloadFileValidated || f.DownloadResult?.IsSuccess == true) + .ToArray(); + + L.CallerInformation($"Restorable files: {restorableFiles.Length}/{filesToRestore.Length}"); + + if (restorableFiles.Any()) + { + disabledTextProgress?.Report("Restoring..."); + + await RestoreFilesAsync(restorableFiles, parallelOptions.CancellationToken, progress).ConfigureAwait(false); + } + } + } + } +} diff --git a/PD2Shared/GameFileUpdate/Hash.cs b/PD2Shared/GameFileUpdate/Hash.cs new file mode 100644 index 00000000..3e032b69 --- /dev/null +++ b/PD2Shared/GameFileUpdate/Hash.cs @@ -0,0 +1,126 @@ +namespace PD2Shared.GameFileUpdate +{ + public abstract class Hash + { + protected Hash() + { + this.Bytes = Array.Empty(); + } + + protected Hash(byte[] bytes) + { + if (bytes.Length != this.SizeInBytes) + { + throw new ArgumentException($"Invalid hash size for {this.SizeInBytes * 8} bit {this.Name}: {this.SizeInBytes} != {bytes.Length} bytes (actual)", nameof(bytes)); + } + + this.Bytes = bytes; + } + + protected Hash(string hexString) + { + ArgumentNullException.ThrowIfNull(hexString, nameof(hexString)); + + byte[] bytes; + + try + { + bytes = Convert.FromHexString(hexString); + } + catch (FormatException ex) + { + throw new ArgumentException($"Not a valid hash: '{hexString}'", nameof(hexString), ex); + } + + if (bytes.Length != this.SizeInBytes) + { + throw new ArgumentException($"Not a valid {this.SizeInBytes * 8} bit {this.Name}: '{hexString}'", nameof(hexString)); + } + + this.Bytes = bytes; + } + + public string ToHexString() + { + return Convert.ToHexString(Bytes).ToLowerInvariant(); + } + + public abstract string Name { get; } + public abstract int SizeInBytes { get; } + + public byte[] Bytes { get; } + + public static bool operator ==(Hash? left, Hash? right) + { + if (left is null && right is null) return true; + if (left is null || right is null) return false; + + if (left.SizeInBytes != right.SizeInBytes) + { + return false; + } + + if (left.Name != right.Name) + { + return false; + } + + return left.Bytes.SequenceEqual(right.Bytes); + } + public static bool operator !=(Hash? left, Hash? right) + { + return !(left == right); + } + + public override bool Equals(object? obj) + { + if (ReferenceEquals(this, obj)) + { + return true; + } + + if (ReferenceEquals(obj, null)) + { + return false; + } + + var objAsHash = obj as Hash; + + if (objAsHash is null) + { + return false; + } + + return this == objAsHash; + } + + public override int GetHashCode() + { + return this.Bytes.GetHashCode(); + } + } + + // ...and all the available concrete classes + + public class Md5Hash : Hash + { + public Md5Hash() : base() { } + + public Md5Hash(byte[] bytes) : base(bytes) { } + public Md5Hash(string hexString) : base(hexString) { } + + public override string Name { get => "MD5"; } + public override int SizeInBytes { get => 128 / 8; } + } + + public class Xxh3Hash : Hash + { + public Xxh3Hash() : base() { } + + public Xxh3Hash(byte[] bytes) : base(bytes) { } + public Xxh3Hash(string hexString) : base(hexString) { } + + public override string Name { get => "XXH3"; } + public override int SizeInBytes { get => 64 / 8; } + } +} diff --git a/PD2Shared/GameFileUpdate/Internal/Context.cs b/PD2Shared/GameFileUpdate/Internal/Context.cs new file mode 100644 index 00000000..aa657529 --- /dev/null +++ b/PD2Shared/GameFileUpdate/Internal/Context.cs @@ -0,0 +1,8 @@ +namespace PD2Shared.GameFileUpdate.Internal +{ + internal class Context + { + public ManifestEntry[] manifestEntries = Array.Empty(); + public Dictionary partialDownloads = new(); + } +} diff --git a/PD2Shared/GameFileUpdate/Internal/Digest.cs b/PD2Shared/GameFileUpdate/Internal/Digest.cs new file mode 100644 index 00000000..4ce8916d --- /dev/null +++ b/PD2Shared/GameFileUpdate/Internal/Digest.cs @@ -0,0 +1,250 @@ +using Org.BouncyCastle.Crypto.Digests; +using System.IO.Hashing; +using System.Security.Cryptography; + +namespace PD2Shared.GameFileUpdate.Internal +{ + internal abstract class Digest + { + public static NonFinalizingDigest GetNonFinalizing(Hash hash) + { + if (hash is Md5Hash) + { + return new NonFinalizingMd5(); + } + else if (hash is Xxh3Hash) + { + return new NonFinalizingXxh3(); + } + else + { + throw new NotSupportedException($"Unable to create {nameof(NonFinalizingDigest)} for {nameof(Hash)} of type {hash.GetType()}."); + } + } + + public static DisposableDigest GetDisposable(Hash hash) + { + if (hash is Md5Hash) + { + return new DisposableMd5(); + } + else if (hash is Xxh3Hash) + { + return new DisposableXxh3(); + } + else + { + throw new NotSupportedException($"Unable to create {nameof(DisposableDigest)} for {nameof(Hash)} of type {hash.GetType()}."); + } + } + + private readonly bool _isFinalizing; + private Hash _finalizedHash = null!; + private bool _finalized = false; + + protected Digest() : this(isFinalizing: true) + { + } + + protected Digest(bool isFinalizing) + { + _isFinalizing = isFinalizing; + } + + public abstract string HashName { get; } + + public abstract bool IsHashType() where THash : Hash; + + private void CheckIfFinalized() + { + if (_finalized) + { + throw new InvalidOperationException("Digest is finalized"); + } + } + + public async Task HashStream(Stream inputStream, CancellationToken cancellationToken) + { + CheckIfFinalized(); + + if (_isFinalizing) + { + _finalized = true; + _finalizedHash = await HashStreamInternal(inputStream, cancellationToken).ConfigureAwait(false); + + return _finalizedHash; + } + else + { + return await HashStreamInternal(inputStream, cancellationToken).ConfigureAwait(false); + } + } + + protected abstract Task HashStreamInternal(Stream inputStream, CancellationToken cancellationToken); + + public void Update(byte[] buffer, int offset, int count) + { + CheckIfFinalized(); + + UpdateInternal(buffer, offset, count); + } + + protected abstract void UpdateInternal(byte[] buffer, int offset, int count); + + public Hash GetHash() + { + CheckIfFinalized(); + + if (_isFinalizing) + { + _finalized = true; + _finalizedHash = GetHashInternal(); + + return _finalizedHash; + } + else + { + return GetHashInternal(); + } + } + + protected abstract Hash GetHashInternal(); + } + + internal abstract class NonFinalizingDigest : Digest + { + protected NonFinalizingDigest() : base(isFinalizing: false) + { + } + } + + internal abstract class DisposableDigest : Digest, IDisposable + { + public abstract void Dispose(); + } + + internal abstract class StrongNonFinalizingDigest : NonFinalizingDigest where THash : Hash, new() + { + private static readonly THash _emptyHash = new(); + + public override string HashName => _emptyHash.Name; + public override bool IsHashType() + { + return typeof(THash) == typeof(TOtherHash); + } + } + + internal abstract class StrongDisposableDigest : DisposableDigest where THash : Hash, new() + { + private static readonly THash _emptyHash = new(); + + public override string HashName => _emptyHash.Name; + public override bool IsHashType() + { + return typeof(THash) == typeof(TOtherHash); + } + } + + // Concrete classes: + + internal class DisposableMd5 : StrongDisposableDigest + { + private static readonly byte[] _emptyBuffer = Array.Empty(); + private readonly MD5 _md5 = MD5.Create(); + + protected override async Task HashStreamInternal(Stream inputStream, CancellationToken cancellationToken) + { + return new Md5Hash(await _md5.ComputeHashAsync(inputStream, cancellationToken).ConfigureAwait(false)); + } + + protected override void UpdateInternal(byte[] buffer, int offset, int count) + { + _md5.TransformBlock(buffer, offset, count, outputBuffer: null, outputOffset: 0); + } + + protected override Md5Hash GetHashInternal() + { + _md5.TransformFinalBlock(_emptyBuffer, inputOffset: 0, inputCount: 0); + + return new Md5Hash(_md5.Hash!); + } + + public override void Dispose() + { + ((IDisposable)_md5).Dispose(); + } + } + + // This is merely a thin wrapper around NonFinalizingXxh3 to provide a DisposableDigest counterpart to DisposableMd5 so the two could be used interchangeably + internal class DisposableXxh3 : StrongDisposableDigest + { + private readonly NonFinalizingXxh3 _xxh3 = new(); + + protected override async Task HashStreamInternal(Stream inputStream, CancellationToken cancellationToken) + { + return (Xxh3Hash)await _xxh3.HashStream(inputStream, cancellationToken).ConfigureAwait(false); + } + + protected override void UpdateInternal(byte[] buffer, int offset, int count) + { + _xxh3.Update(buffer, offset, count); + } + + protected override Xxh3Hash GetHashInternal() + { + return (Xxh3Hash)_xxh3.GetHash(); + } + + public override void Dispose() + { + // Nothing to do here + } + } + + internal class NonFinalizingMd5 : StrongNonFinalizingDigest + { + private readonly MD5Digest _md5 = new(); + + protected override async Task HashStreamInternal(Stream inputStream, CancellationToken cancellationToken) + { + // This won't be needed anyway + + throw new NotImplementedException(); + } + + protected override void UpdateInternal(byte[] buffer, int offset, int count) + { + _md5.BlockUpdate(buffer, offset, count); + } + + protected override Md5Hash GetHashInternal() + { + var bytes = new byte[_md5.GetDigestSize()]; + + new MD5Digest(_md5).DoFinal(bytes, outOff: 0); + + return new Md5Hash(bytes); + } + } + + internal class NonFinalizingXxh3 : StrongNonFinalizingDigest + { + private readonly XxHash3 _xxh3 = new(); + + protected override async Task HashStreamInternal(Stream inputStream, CancellationToken cancellationToken) + { + await _xxh3.AppendAsync(inputStream, cancellationToken).ConfigureAwait(false); + return new Xxh3Hash(_xxh3.GetCurrentHash()); + } + + protected override void UpdateInternal(byte[] buffer, int offset, int count) + { + _xxh3.Append(new ReadOnlySpan(buffer, offset, count)); + } + + protected override Xxh3Hash GetHashInternal() + { + return new Xxh3Hash(_xxh3.GetCurrentHash()); + } + } +} diff --git a/PD2Shared/GameFileUpdate/Internal/DownloadResult.cs b/PD2Shared/GameFileUpdate/Internal/DownloadResult.cs new file mode 100644 index 00000000..d6f4da1b --- /dev/null +++ b/PD2Shared/GameFileUpdate/Internal/DownloadResult.cs @@ -0,0 +1,41 @@ +namespace PD2Shared.GameFileUpdate.Internal +{ + internal class DownloadResult + { + public DownloadResult(Exception exception) + { + this.Exception = exception; + } + + public DownloadResult(Xxh3Hash xxh3Hash) + { + this.Xxh3Hash = xxh3Hash; + } + + public DownloadResult( + Exception? exception, + long partialSize, + Xxh3Hash partialXxh3Hash, + NonFinalizingXxh3 xxh3Digest, + Md5Hash referenceMd5Hash, + NonFinalizingDigest digest, + Hash expectedHash) + { + if (partialSize <= 0) + { + throw new ArgumentException($"Valid '{nameof(partialSize)}' for {nameof(PartialDownload)} must be >0", nameof(partialSize)); + } + + this.Exception = exception; + + this.PartialDownload = new PartialDownload(partialSize, partialXxh3Hash, xxh3Digest, referenceMd5Hash, digest, expectedHash); + + } + + public bool IsSuccess { get => Exception == null; } + + public Exception? Exception { get; } = null; + public Xxh3Hash? Xxh3Hash { get; } = null; + public PartialDownload? PartialDownload { get; } = null; + } +} diff --git a/PD2Shared/GameFileUpdate/Internal/FileUpdateModelEqualityComparer.cs b/PD2Shared/GameFileUpdate/Internal/FileUpdateModelEqualityComparer.cs new file mode 100644 index 00000000..9493fabb --- /dev/null +++ b/PD2Shared/GameFileUpdate/Internal/FileUpdateModelEqualityComparer.cs @@ -0,0 +1,21 @@ +using PD2Shared.Models; +using System.Diagnostics.CodeAnalysis; + +namespace PD2Shared.GameFileUpdate.Internal +{ + internal class FileUpdateModelEqualityComparer : IEqualityComparer + { + public bool Equals(FileUpdateModel? x, FileUpdateModel? y) + { + return + x?.Client == y?.Client && + x?.FilePath == y?.FilePath && + x?.Other == y?.Other; + } + + public int GetHashCode([DisallowNull] FileUpdateModel obj) + { + return HashCode.Combine(obj.Client, obj.FilePath, obj.Other); + } + } +} diff --git a/PD2Shared/GameFileUpdate/Internal/ManifestEntry.cs b/PD2Shared/GameFileUpdate/Internal/ManifestEntry.cs new file mode 100644 index 00000000..94f4f861 --- /dev/null +++ b/PD2Shared/GameFileUpdate/Internal/ManifestEntry.cs @@ -0,0 +1,105 @@ +using System.Diagnostics.CodeAnalysis; + +namespace PD2Shared.GameFileUpdate.Internal +{ + internal class ManifestEntry + { + // Set of files that are subject to "loose validation" (they need to merely exist and have a non-zero size) as InstallFiles. + // Treat contained keys as exact paths. If any globbing is needed -- use a proper library for that. + // + // The file list provided by Constants.excludedFiles might be too broad, but stick with it for now... + private static readonly HashSet LooseValidationFileSet = new(Constants.excludedFiles, StringComparer.OrdinalIgnoreCase); + + public ManifestEntry(string path, string md5, long? size = null, string? xxh3 = null) + { + this.Path = path; + this.LooseValidation = LooseValidationFileSet.Contains(this.Path); + this.Md5Hash = new Md5Hash(md5); + + // These implicitly set Dirty = true + + this.Size = size; + this.Xxh3Hash = xxh3 == null ? (Xxh3Hash?)null : new Xxh3Hash(xxh3); + } + + public ManifestEntry(string path, SerializableManifest.Entry entry) : this(path, entry.Md5, entry.Size, entry.Xxh3) + { + this.Dirty = false; + } + + private string _path; + public string Path + { + get => _path; + [MemberNotNull(nameof(_path))] + init + { + ArgumentNullException.ThrowIfNull(value, nameof(value)); + + if (string.IsNullOrWhiteSpace(value)) + { + throw new ArgumentException($"Invalid empty path: '{value}'", nameof(value)); + } + + if (System.IO.Path.IsPathRooted(value)) + { + throw new ArgumentException($"The path is rooted: '{value}'", nameof(value)); + } + + _path = value; + } + } + public bool LooseValidation { get; } + + public Md5Hash Md5Hash { get; } + + private long? _size; + public long? Size + { + get => _size; + set + { + if (_size == value) + { + return; + } + + if (value != null && value <= 0) + { + throw new ArgumentException($"'{nameof(Size)}' must be > 0", nameof(value)); + } + + _size = value; + Dirty = true; + } + } + + private Xxh3Hash? _xxh3Hash = null; + public Xxh3Hash? Xxh3Hash + { + get => _xxh3Hash; + set + { + if (_xxh3Hash == value) + { + return; + } + + _xxh3Hash = value; + Dirty = true; + } + } + + public Hash BestHash + { + get => Xxh3Hash != null ? Xxh3Hash : Md5Hash; + } + + public bool Dirty { get; set; } + + public SerializableManifest.Entry ToSerializable() + { + return new SerializableManifest.Entry(this.Md5Hash.ToHexString(), this.Size, this.Xxh3Hash?.ToHexString()); + } + } +} diff --git a/PD2Shared/GameFileUpdate/Internal/PartialDownload.cs b/PD2Shared/GameFileUpdate/Internal/PartialDownload.cs new file mode 100644 index 00000000..d3ef9fc6 --- /dev/null +++ b/PD2Shared/GameFileUpdate/Internal/PartialDownload.cs @@ -0,0 +1,28 @@ +namespace PD2Shared.GameFileUpdate.Internal +{ + internal class PartialDownload + { + public PartialDownload( + long partialSize, + Xxh3Hash partialXxh3Hash, + NonFinalizingXxh3 xxh3Digest, + Md5Hash referenceMd5Hash, + NonFinalizingDigest digest, + Hash expectedHash) + { + this.PartialSize = partialSize; + this.PartialXxh3Hash = partialXxh3Hash; + this.Xxh3Digest = xxh3Digest; + this.ReferenceMd5Hash = referenceMd5Hash; + this.Digest = digest; + this.ExpectedHash = expectedHash; + } + + public long PartialSize { get; } + public Xxh3Hash PartialXxh3Hash { get; } + public NonFinalizingXxh3 Xxh3Digest { get; } + public Md5Hash ReferenceMd5Hash { get; } + public NonFinalizingDigest Digest { get; } + public Hash ExpectedHash { get; } + } +} diff --git a/PD2Shared/GameFileUpdate/Internal/ProgressValuesEx.cs b/PD2Shared/GameFileUpdate/Internal/ProgressValuesEx.cs new file mode 100644 index 00000000..cfd4044e --- /dev/null +++ b/PD2Shared/GameFileUpdate/Internal/ProgressValuesEx.cs @@ -0,0 +1,12 @@ +namespace PD2Shared.GameFileUpdate.Internal +{ + internal static class ProgressValuesEx + { + private const long _invalidSize = -1; + + // Since Int64 is commonly used to store file sizes, whenever using a Nullable is undesirable, resort to the special value of "InvalidSize". + public static long GetInvalidSize(this long _) => _invalidSize; + + public static bool IsInvalidSize(this long value) => value == _invalidSize; + } +} diff --git a/PD2Shared/GameFileUpdate/Internal/SerializableManifest.cs b/PD2Shared/GameFileUpdate/Internal/SerializableManifest.cs new file mode 100644 index 00000000..fbcb22d8 --- /dev/null +++ b/PD2Shared/GameFileUpdate/Internal/SerializableManifest.cs @@ -0,0 +1,40 @@ +using System.Collections.Immutable; +using System.Text.Json.Serialization; + +namespace PD2Shared.GameFileUpdate.Internal +{ + internal class SerializableManifest + { + internal class Entry + { + [JsonConstructor] + public Entry(string md5, long? size, string? xxh3) + { + Md5 = md5; + Size = size; + Xxh3 = xxh3; + } + + public string Md5 { get; } + public long? Size { get; } + public string? Xxh3 { get; } + } + + public SerializableManifest(ManifestEntry[] manifestEntries) + { + Entries = manifestEntries + .ToImmutableSortedDictionary(e => e.Path, e => e.ToSerializable(), StringComparer.OrdinalIgnoreCase); + Count = Entries.Count; + } + + [JsonConstructor] + public SerializableManifest(ImmutableSortedDictionary entries, int count) + { + Entries = entries; + Count = count; + } + + public ImmutableSortedDictionary Entries { get; } + public int Count { get; } + } +} diff --git a/PD2Shared/GameFileUpdate/Internal/ThroughputEstimatorSample.cs b/PD2Shared/GameFileUpdate/Internal/ThroughputEstimatorSample.cs new file mode 100644 index 00000000..15f9930e --- /dev/null +++ b/PD2Shared/GameFileUpdate/Internal/ThroughputEstimatorSample.cs @@ -0,0 +1,14 @@ +namespace PD2Shared.GameFileUpdate.Internal +{ + internal class ThroughputEstimatorSample + { + public long Bytes { get; } + public long TimePointMilliseconds { get; } + + public ThroughputEstimatorSample(long bytes, long timePointMilliseconds) + { + Bytes = bytes; + TimePointMilliseconds = timePointMilliseconds; + } + } +} diff --git a/PD2Shared/GameFileUpdate/Internal/ValidationKind.cs b/PD2Shared/GameFileUpdate/Internal/ValidationKind.cs new file mode 100644 index 00000000..8e757c3e --- /dev/null +++ b/PD2Shared/GameFileUpdate/Internal/ValidationKind.cs @@ -0,0 +1,22 @@ +namespace PD2Shared.GameFileUpdate.Internal +{ + internal enum ValidationKind + { + DownloadFiles, + InstallFiles, + } + + // Sneaking in an extension class for convenience + internal static class ValidationKindEx + { + public static bool IsDownloadFiles(this ValidationKind validationKind) + { + return validationKind == ValidationKind.DownloadFiles; + } + + public static bool IsInstallFiles(this ValidationKind validationKind) + { + return validationKind == ValidationKind.InstallFiles; + } + } +} diff --git a/PD2Shared/GameFileUpdate/Internal/WorkItem.cs b/PD2Shared/GameFileUpdate/Internal/WorkItem.cs new file mode 100644 index 00000000..16446f5a --- /dev/null +++ b/PD2Shared/GameFileUpdate/Internal/WorkItem.cs @@ -0,0 +1,24 @@ +namespace PD2Shared.GameFileUpdate.Internal +{ + internal class WorkItem + { + public WorkItem(ManifestEntry manifestEntry, string url, string downloadPath, string installPath, PartialDownload? partialDownload) + { + ManifestEntry = manifestEntry; + Url = url; + DownloadPath = downloadPath; + InstallPath = installPath; + PartialDownload = partialDownload; + } + + public ManifestEntry ManifestEntry { get; } + public string Url { get; } + public string DownloadPath { get; } + public string InstallPath { get; } + + public bool InstallFileValidated { get; set; } = false; + public bool DownloadFileValidated { get; set; } = false; + public PartialDownload? PartialDownload { get; set; } = null; + public DownloadResult? DownloadResult { get; set; } = null; + } +} diff --git a/PD2Shared/GameFileUpdate/ProgressValues.Data.cs b/PD2Shared/GameFileUpdate/ProgressValues.Data.cs new file mode 100644 index 00000000..61761acd --- /dev/null +++ b/PD2Shared/GameFileUpdate/ProgressValues.Data.cs @@ -0,0 +1,20 @@ +namespace PD2Shared.GameFileUpdate +{ + public partial class ProgressValues + { + private class Data : IData + { + public double? Total { get; set; } = null; + public bool TotalSet { get; set; } = false; + + public FileCountProgress? FileCount { get; set; } = null; + public bool FileCountSet { get; set; } = false; + + public BytesProgress? Bytes { get; set; } = null; + public bool BytesSet { get; set; } = false; + + public BytesPerSecProgress? BytesPerSec { get; set; } = null; + public bool BytesPerSecSet { get; set; } = false; + } + } +} diff --git a/PD2Shared/GameFileUpdate/ProgressValues.IData.cs b/PD2Shared/GameFileUpdate/ProgressValues.IData.cs new file mode 100644 index 00000000..aeed7fa4 --- /dev/null +++ b/PD2Shared/GameFileUpdate/ProgressValues.IData.cs @@ -0,0 +1,24 @@ +namespace PD2Shared.GameFileUpdate +{ + public partial class ProgressValues + { + public interface IData + { + // Main progress indicator (0..1) + public double? Total { get; } + public bool TotalSet { get; } + + // / counter indicator + public FileCountProgress? FileCount { get; } + public bool FileCountSet { get; } + + // "(/) MiB" indicator + public BytesProgress? Bytes { get; } + public bool BytesSet { get; } + + // " MiB/s" indicator + public BytesPerSecProgress? BytesPerSec { get; } + public bool BytesPerSecSet { get; } + } + } +} diff --git a/PD2Shared/GameFileUpdate/ProgressValues.cs b/PD2Shared/GameFileUpdate/ProgressValues.cs new file mode 100644 index 00000000..f3b37b64 --- /dev/null +++ b/PD2Shared/GameFileUpdate/ProgressValues.cs @@ -0,0 +1,179 @@ +using PD2Shared.GameFileUpdate.Internal; + +namespace PD2Shared.GameFileUpdate +{ + public partial class ProgressValues + { + public class FileCountProgress + { + public int Current { get; set; } + public int Total { get; set; } + } + + public class BytesProgress + { + public long Current { get; set; } + public long? Total { get; set; } + } + + public class BytesPerSecProgress + { + public long Bytes { get; set; } + public long ElapsedMilliseconds { get; set; } + } + + private Data _data = new(); + + public ProgressValues SetTotal(double total) + { + CheckIfExtracted(); + + if (!double.IsFinite(total)) + { + throw new ArithmeticException($"'{nameof(total)}' must be finite"); + } + + _data.Total = total; + _data.TotalSet = true; + return this; + } + + public ProgressValues SetTotal(double current, double total) + { + CheckIfExtracted(); + + if (!double.IsFinite(current)) + { + throw new ArithmeticException($"'{nameof(current)}' must be finite"); + } + + if (!double.IsFinite(total)) + { + throw new ArithmeticException($"'{nameof(total)}' must be finite"); + } + + if (total == 0) + { + throw new ArithmeticException($"'{nameof(total)}' must not be 0"); + } + + _data.Total = (double)current / total; + _data.TotalSet = true; + return this; + } + + public ProgressValues ClearTotal() + { + CheckIfExtracted(); + + _data.Total = null; + _data.TotalSet = true; + return this; + } + + public ProgressValues SetFileCount(int current, int total) + { + CheckIfExtracted(); + + _data.FileCount = new FileCountProgress { Current = current, Total = total }; + _data.FileCountSet = true; + return this; + } + + public ProgressValues ClearFileCount() + { + CheckIfExtracted(); + + _data.FileCount = null; + _data.FileCountSet = true; + return this; + } + + public ProgressValues SetBytes(long current, long? total) + { + CheckIfExtracted(); + + _data.Bytes = new BytesProgress { Current = current, Total = total }; + _data.BytesSet = true; + return this; + } + + public ProgressValues SetBytes(long current, long total) + { + CheckIfExtracted(); + + _data.Bytes = new BytesProgress { Current = current, Total = total.IsInvalidSize() ? null : total }; + _data.BytesSet = true; + return this; + } + + public ProgressValues SetBytes(long current) + { + CheckIfExtracted(); + + _data.Bytes = new BytesProgress { Current = current, Total = null }; + _data.BytesSet = true; + return this; + } + + public ProgressValues ClearBytes() + { + CheckIfExtracted(); + + _data.Bytes = null; + _data.BytesSet = true; + return this; + } + + public ProgressValues SetBytesPerSec(long bytes, long elapsedMilliseconds) + { + CheckIfExtracted(); + + if (elapsedMilliseconds == 0) + { + throw new ArithmeticException($"'{nameof(elapsedMilliseconds)}' must not be 0"); + } + + _data.BytesPerSec = new BytesPerSecProgress { Bytes = bytes, ElapsedMilliseconds = elapsedMilliseconds }; + _data.BytesPerSecSet = true; + return this; + } + + public ProgressValues ClearBytesPerSec() + { + CheckIfExtracted(); + + _data.BytesPerSec = null; + _data.BytesPerSecSet = true; + return this; + } + + public ProgressValues Clear() + { + CheckIfExtracted(); + + this.ClearTotal(); + this.ClearFileCount(); + this.ClearBytes(); + this.ClearBytesPerSec(); + return this; + } + + private void CheckIfExtracted() + { + if (_data == null) + { + throw new InvalidOperationException($"{nameof(ProgressValues)} already extracted"); + } + } + + public IData Extract() + { + CheckIfExtracted(); + + var res = _data; + _data = null!; + return res; + } + } +} diff --git a/PD2Shared/GameFileUpdate/UpdateMode.cs b/PD2Shared/GameFileUpdate/UpdateMode.cs new file mode 100644 index 00000000..758eb870 --- /dev/null +++ b/PD2Shared/GameFileUpdate/UpdateMode.cs @@ -0,0 +1,42 @@ +namespace PD2Shared.GameFileUpdate +{ + public enum UpdateMode + { + Normal, // (I1) FilesToRestore = [All files] -> [InstallFiles that failed Validation] + // (D1) FilesToDownload = [FilesToRestore] -> [DownloadFiles that failed Validation] + [DownloadFiles with PartialDownloads] + + Restore, // (I2) FilesToRestore = [All files] -> [InstallFiles that failed Validation] + [InstallFiles loosely validated] + // (D1) FilesToDownload = [FilesToRestore] -> [DownloadFiles that failed Validation] + [DownloadFiles with PartialDownloads] + + Download, // (I3) FilesToRestore = [None] + // (D2) FilesToDownload = [All files] -> [DownloadFiles that failed Validation] + [DownloadFiles with PartialDownloads] + + Reset // (I4) FilesToRestore = [All files] + // (D3) FilesToDownload = [All files] (...but Validate PartialDownloads beforehand) + // ...additionally force-query all files for good measure + } + + // Sneaking in an extension class for convenience + public static class UpdateModeEx + { + public static bool IsNormal(this UpdateMode updateMode) + { + return updateMode == UpdateMode.Normal; + } + + public static bool IsRestore(this UpdateMode updateMode) + { + return updateMode == UpdateMode.Restore; + } + + public static bool IsDownload(this UpdateMode updateMode) + { + return updateMode == UpdateMode.Download; + } + + public static bool IsReset(this UpdateMode updateMode) + { + return updateMode == UpdateMode.Reset; + } + } +} diff --git a/PD2Shared/GameFileUpdate/Xxh3HashJsonConverter.cs b/PD2Shared/GameFileUpdate/Xxh3HashJsonConverter.cs new file mode 100644 index 00000000..65ccca50 --- /dev/null +++ b/PD2Shared/GameFileUpdate/Xxh3HashJsonConverter.cs @@ -0,0 +1,18 @@ +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace PD2Shared.GameFileUpdate +{ + internal class Xxh3HashJsonConverter : JsonConverter + { + public override Xxh3Hash Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + return new Xxh3Hash(reader.GetString()!); + } + + public override void Write(Utf8JsonWriter writer, Xxh3Hash value, JsonSerializerOptions options) + { + writer.WriteStringValue(value.ToHexString()); + } + } +} diff --git a/PD2Shared/GameUpdateManager.cs b/PD2Shared/GameUpdateManager.cs index 3714000a..2c8f23e8 100644 --- a/PD2Shared/GameUpdateManager.cs +++ b/PD2Shared/GameUpdateManager.cs @@ -32,8 +32,8 @@ public static async Task RunUpdateAndLaunchAsync( } } - var launcherArgs = storage.LoadSection(StorageKey.LauncherArgs); - if (launcherArgs != null && launcherArgs.disableAutoUpdate) + var launcherOptions = storage.LoadSection(StorageKey.LauncherOptions); + if (launcherOptions?.DisableAutoUpdate == true) { gameLauncher.LaunchGame(storage); return; diff --git a/PD2Shared/Helpers/GameFileUpdateHelpers.cs b/PD2Shared/Helpers/GameFileUpdateHelpers.cs deleted file mode 100644 index a8bb6dd8..00000000 --- a/PD2Shared/Helpers/GameFileUpdateHelpers.cs +++ /dev/null @@ -1,118 +0,0 @@ -using PD2Shared.Models; -using System.Diagnostics; -using System.Security.Cryptography; -using Newtonsoft.Json.Linq; -using PD2Shared.Storage; -using static PD2Shared.Constants; -using PD2Shared.Interfaces; - -namespace PD2Shared.Helpers -{ - public class GameFileUpdateHelpers - { - private readonly HttpClient _httpClient; - - public GameFileUpdateHelpers(HttpClient httpClient) - { - _httpClient = httpClient; - _httpClient.Timeout = TimeSpan.FromMinutes(30); - } - - public async Task UpdateFromShaMetadataAsync(ILocalStorage localStorage, IProgress progress, Action onComplete) - { - FileUpdateModel fileUpdateModel = localStorage.LoadSection(PD2Shared.Models.StorageKey.FileUpdateModel); - string installPath = Directory.GetCurrentDirectory(); - string fullUpdatePath = Path.Combine(installPath, fileUpdateModel.FilePath); - Directory.CreateDirectory(fullUpdatePath); - - string metadataUrl = $"{fileUpdateModel.Client.TrimEnd('/')}/metadata.json"; - string localMetaPath = Path.Combine(installPath, "local_metadata.json"); - string? localMetadataContent = File.Exists(localMetaPath) ? await File.ReadAllTextAsync(localMetaPath) : null; - - try - { - Debug.WriteLine($"\n================ metadata URL: {metadataUrl}\n"); - var response = await _httpClient.GetAsync(metadataUrl); - response.EnsureSuccessStatusCode(); - string remoteMetadataContent = await response.Content.ReadAsStringAsync(); - - if (localMetadataContent != null && localMetadataContent == remoteMetadataContent) - { - Debug.WriteLine("Metadata unchanged. Skipping SHA1 update."); - return; - } - - var parsed = JObject.Parse(remoteMetadataContent); - var checksums = parsed["checksum"]?.ToObject>() ?? new(); - - var shaFiles = checksums.Select(entry => - { - var parts = entry.Split(" ", 2); - if (parts.Length != 2) return null; - return new ShaHashFileItem - { - Name = parts[1].Trim(), - MediaLink = metadataUrl.Replace("metadata.json", "") + parts[1].Trim(), - Hash = parts[0].Trim() - }; - }).Where(x => x != null).ToList(); - - int processed = 0; - - foreach (var file in shaFiles!) - { - Debug.WriteLine($"{file.Name}"); - if (file.Name.EndsWith("/")) continue; - - string localFile = Path.Combine(fullUpdatePath, file.Name.Replace("/", Path.DirectorySeparatorChar.ToString())); - string destination = Path.Combine(installPath, file.Name.Replace("/", Path.DirectorySeparatorChar.ToString())); - - Directory.CreateDirectory(Path.GetDirectoryName(localFile)!); - bool fileExists = File.Exists(localFile); - - if (!fileExists || !CompareSha1(localFile, file.Hash)) - { - await DownloadFileAsync(file.MediaLink, localFile); - File.Copy(localFile, destination, true); - } - - processed++; - progress?.Report((double)processed / shaFiles.Count); - } - - await File.WriteAllTextAsync(localMetaPath, remoteMetadataContent); - onComplete?.Invoke(); - } - catch (Exception ex) - { - Debug.WriteLine($"[SHA Update] Failed: {ex.Message}"); - onComplete?.Invoke(); - } - } - - public bool CompareSha1(string filePath, string expectedHash) - { - using var sha1 = SHA1.Create(); - using var stream = File.OpenRead(filePath); - var hashBytes = sha1.ComputeHash(stream); - var localHash = BitConverter.ToString(hashBytes).Replace("-", "").ToLowerInvariant(); - return localHash == expectedHash.ToLowerInvariant(); - } - - private async Task DownloadFileAsync(string url, string destination) - { - using var response = await _httpClient.GetAsync(url, HttpCompletionOption.ResponseHeadersRead); - response.EnsureSuccessStatusCode(); - await using var stream = await response.Content.ReadAsStreamAsync(); - await using var fs = new FileStream(destination, FileMode.Create, FileAccess.Write, FileShare.None, 8192, true); - await stream.CopyToAsync(fs); - } - } - - public class ShaHashFileItem - { - public string Name { get; set; } - public string MediaLink { get; set; } - public string Hash { get; set; } - } -} \ No newline at end of file diff --git a/PD2Shared/Helpers/LaunchGameHelpers.cs b/PD2Shared/Helpers/LaunchGameHelpers.cs index 297ade89..3b3a8846 100644 --- a/PD2Shared/Helpers/LaunchGameHelpers.cs +++ b/PD2Shared/Helpers/LaunchGameHelpers.cs @@ -1,38 +1,57 @@ - -using PD2Shared.Interfaces; +using PD2Shared.Interfaces; using PD2Shared.Models; +using PD2Shared.Logging; +using static PD2Shared.Logging.LoggingStatic; +using PD2Shared.Utils; using System.Diagnostics; -using System.IO; -using System.Windows; +using System.Runtime.InteropServices; namespace PD2Shared.Helpers { public class LaunchGameHelpers : ILaunchGameHelpers { - public void LaunchGame(ILocalStorage localStorage) + private static class DllImports { - var fileUpdateModel = localStorage.LoadSection(PD2Shared.Models.StorageKey.FileUpdateModel); + [DllImport("user32.dll", CharSet = CharSet.Unicode)] + public static extern IntPtr FindWindow([Optional] string? className, [Optional] string? windowName); + } - string diabloIIExePath = Path.Combine(Directory.GetCurrentDirectory(), "Game.exe"); - if (!File.Exists(diabloIIExePath)) + public static bool IsGameRunning + { + get { - Debug.WriteLine("Game.exe not found."); - return; + // This is basically the same check the actual game uses + return DllImports.FindWindow(className: "Diablo II", windowName: null) != IntPtr.Zero; } + } - LauncherArgs launcherArgs = localStorage.LoadSection(PD2Shared.Models.StorageKey.LauncherArgs); - - string args = ConstructLaunchArguments(launcherArgs); + public Process LaunchGame(ILocalStorage localStorage, EventHandler? exitedEventHandler = null) + { + LauncherArgs launcherArgs = localStorage.LoadSection(StorageKey.LauncherArgs); - // Launch the game with the specified arguments. - var startInfo = new ProcessStartInfo + Process process = new() { - FileName = diabloIIExePath, - Arguments = args, - WorkingDirectory = Path.GetDirectoryName(diabloIIExePath) + EnableRaisingEvents = exitedEventHandler != null, + StartInfo = new() + { + WorkingDirectory = Env.GetCwd(), + FileName = Path.Combine(Env.GetCwd(), "Game.exe"), + Arguments = ConstructLaunchArguments(launcherArgs), + // Run via shell to prevent throwing a Win32Exception with 'The requested operation requires elevation' + // whenever the executable is marked to run as elevated. + UseShellExecute = true + } }; - Process.Start(startInfo); + if (exitedEventHandler != null) + { + process.Exited += exitedEventHandler; + } + + L.CallerInformation($"Launching: '\"{process.StartInfo.FileName}\" {process.StartInfo.Arguments}'..."); + + process.Start(); + return process; } private string ConstructLaunchArguments(LauncherArgs launcherArgs) diff --git a/PD2Shared/Interfaces/ILaunchGameHelpers.cs b/PD2Shared/Interfaces/ILaunchGameHelpers.cs index e9c5cd92..b71a58c7 100644 --- a/PD2Shared/Interfaces/ILaunchGameHelpers.cs +++ b/PD2Shared/Interfaces/ILaunchGameHelpers.cs @@ -1,9 +1,9 @@ - +using System.Diagnostics; namespace PD2Shared.Interfaces { public interface ILaunchGameHelpers { - void LaunchGame(ILocalStorage storage); + Process LaunchGame(ILocalStorage storage, EventHandler? exitedEventHandler = null); } } diff --git a/PD2Shared/Interfaces/ILocalStorage.cs b/PD2Shared/Interfaces/ILocalStorage.cs index 62427860..7bf27e82 100644 --- a/PD2Shared/Interfaces/ILocalStorage.cs +++ b/PD2Shared/Interfaces/ILocalStorage.cs @@ -12,8 +12,10 @@ public interface ILocalStorage //save a setting bucket by keyname void Update(StorageKey key, T value) where T : class; + T? LoadSectionIfExists(StorageKey key) where T : class; + //load a setting bucket by keyname - T LoadSection(StorageKey key) where T : class; + T LoadSection(StorageKey key) where T : class, new(); void InitializeIfNotExists(StorageKey key, T defaultValue) where T : class, new(); } diff --git a/PD2Shared/Logging/ILoggerEx.cs b/PD2Shared/Logging/ILoggerEx.cs new file mode 100644 index 00000000..ada31bfa --- /dev/null +++ b/PD2Shared/Logging/ILoggerEx.cs @@ -0,0 +1,107 @@ +using Serilog; +using System.Runtime.CompilerServices; + +namespace PD2Shared.Logging +{ + public static class ILoggerEx + { + private static readonly string SeparatorLine = "- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -"; + + private static readonly object _lastExceptionStackTraceLock = new(); + private static string? _lastExceptionStackTrace = null; + + private static void CallerWrite(this ILogger logger, Serilog.Events.LogEventLevel logEventLevel, Exception? exception, string messageTemplate, string callerName, object?[]? propertyValues) + { + if (exception != null) + { + // This can have moderate impact on the logger's performance, but should help de-clutter the log. + lock (_lastExceptionStackTraceLock) + { + if (_lastExceptionStackTrace == exception.StackTrace) + { + messageTemplate = messageTemplate + Environment.NewLine + + exception.GetType() + ": " + exception.Message + Environment.NewLine + + " "; + exception = null; + } + else + { + _lastExceptionStackTrace = exception.StackTrace; + } + } + } + + logger.Write(logEventLevel, exception, $"{(callerName + "()"),-30} " + messageTemplate, propertyValues); + } + + public static void CallerWrite(this ILogger logger, Serilog.Events.LogEventLevel logEventLevel, string messageTemplate, object?[]? propertyValues = null, [CallerMemberName] string callerName = "?") + { + CallerWrite(logger, logEventLevel, exception: null, messageTemplate, callerName, propertyValues); + } + + public static void CallerVerbose(this ILogger logger, string messageTemplate, object?[]? propertyValues = null, [CallerMemberName] string callerName = "?") + { + CallerWrite(logger, Serilog.Events.LogEventLevel.Verbose, exception: null, messageTemplate, callerName, propertyValues); + } + + public static void CallerVerbose(this ILogger logger, Exception? exception, string messageTemplate, object?[]? propertyValues = null, [CallerMemberName] string callerName = "?") + { + CallerWrite(logger, Serilog.Events.LogEventLevel.Verbose, exception, messageTemplate, callerName, propertyValues); + } + + public static void CallerDebug(this ILogger logger, string messageTemplate, object?[]? propertyValues = null, [CallerMemberName] string callerName = "?") + { + CallerWrite(logger, Serilog.Events.LogEventLevel.Debug, exception: null, messageTemplate, callerName, propertyValues); + } + + public static void CallerDebug(this ILogger logger, Exception? exception, string messageTemplate, object?[]? propertyValues = null, [CallerMemberName] string callerName = "?") + { + CallerWrite(logger, Serilog.Events.LogEventLevel.Debug, exception, messageTemplate, callerName, propertyValues); + } + + public static void CallerInformation(this ILogger logger, string messageTemplate, object?[]? propertyValues = null, [CallerMemberName] string callerName = "?") + { + CallerWrite(logger, Serilog.Events.LogEventLevel.Information, exception: null, messageTemplate, callerName, propertyValues); + } + + public static void CallerInformation(this ILogger logger, Exception? exception, string messageTemplate, object?[]? propertyValues = null, [CallerMemberName] string callerName = "?") + { + CallerWrite(logger, Serilog.Events.LogEventLevel.Information, exception, messageTemplate, callerName, propertyValues); + } + + public static void CallerWarning(this ILogger logger, string messageTemplate, object?[]? propertyValues = null, [CallerMemberName] string callerName = "?") + { + CallerWrite(logger, Serilog.Events.LogEventLevel.Warning, exception: null, messageTemplate, callerName, propertyValues); + } + + public static void CallerWarning(this ILogger logger, Exception? exception, string messageTemplate, object?[]? propertyValues = null, [CallerMemberName] string callerName = "?") + { + CallerWrite(logger, Serilog.Events.LogEventLevel.Warning, exception, messageTemplate, callerName, propertyValues); + } + + public static void CallerError(this ILogger logger, string messageTemplate, object?[]? propertyValues = null, [CallerMemberName] string callerName = "?") + { + CallerWrite(logger, Serilog.Events.LogEventLevel.Error, exception: null, messageTemplate, callerName, propertyValues); + } + + public static void CallerError(this ILogger logger, Exception? exception, string messageTemplate, object?[]? propertyValues = null, [CallerMemberName] string callerName = "?") + { + CallerWrite(logger, Serilog.Events.LogEventLevel.Error, exception, messageTemplate, callerName, propertyValues); + } + + public static void CallerFatal(this ILogger logger, string messageTemplate, object?[]? propertyValues = null, [CallerMemberName] string callerName = "?") + { + CallerWrite(logger, Serilog.Events.LogEventLevel.Fatal, exception: null, messageTemplate, callerName, propertyValues); + } + + public static void CallerFatal(this ILogger logger, Exception? exception, string messageTemplate, object?[]? propertyValues = null, [CallerMemberName] string callerName = "?") + { + CallerWrite(logger, Serilog.Events.LogEventLevel.Fatal, exception, messageTemplate, callerName, propertyValues); + } + + public static void Separator(this ILogger logger, Serilog.Events.LogEventLevel logEventLevel = Serilog.Events.LogEventLevel.Information) + { + logger.Write(logEventLevel, SeparatorLine); + } + } +} diff --git a/PD2Shared/Logging/LoggedRoutine.cs b/PD2Shared/Logging/LoggedRoutine.cs new file mode 100644 index 00000000..1341fe92 --- /dev/null +++ b/PD2Shared/Logging/LoggedRoutine.cs @@ -0,0 +1,23 @@ +using System.Runtime.CompilerServices; +using PD2Shared.Utils; +using static PD2Shared.Logging.LoggingStatic; + +namespace PD2Shared.Logging +{ + public class LoggedRoutine : TimedDisposable + { + public LoggedRoutine( + Serilog.Events.LogEventLevel logEventLevel = Serilog.Events.LogEventLevel.Information, + [CallerMemberName] string callerName = null!) + : base(() => + { + L.CallerWrite(logEventLevel, $">>", propertyValues: null, callerName); + }, + timeSpan => + { + L.CallerWrite(logEventLevel, $"<< {timeSpan}", propertyValues: null, callerName); + }) + { + } + } +} diff --git a/PD2Shared/Logging/LoggedScope.cs b/PD2Shared/Logging/LoggedScope.cs new file mode 100644 index 00000000..c674eff5 --- /dev/null +++ b/PD2Shared/Logging/LoggedScope.cs @@ -0,0 +1,23 @@ +using System.Runtime.CompilerServices; +using PD2Shared.Utils; +using static PD2Shared.Logging.LoggingStatic; + +namespace PD2Shared.Logging +{ + public class LoggedScope : TimedDisposable + { + public LoggedScope(string message, + Serilog.Events.LogEventLevel logEventLevel = Serilog.Events.LogEventLevel.Information, + [CallerMemberName] string callerName = null!) + : base(() => + { + L.CallerWrite(logEventLevel, $"> {message}", propertyValues: null, callerName); + }, + timeSpan => + { + L.CallerWrite(logEventLevel, $"< {message} Finished in {timeSpan}.", propertyValues: null, callerName); + }) + { + } + } +} diff --git a/PD2Shared/Logging/Logging.cs b/PD2Shared/Logging/Logging.cs new file mode 100644 index 00000000..54c6c30e --- /dev/null +++ b/PD2Shared/Logging/Logging.cs @@ -0,0 +1,237 @@ +using System.Runtime.InteropServices; +using System.Text; +using Serilog; +using PD2Shared.Utils; +using static PD2Shared.Logging.LoggingStatic; + +namespace PD2Shared.Logging +{ + public static class Logging + { + private static class DllImports + { + [DllImport("kernel32.dll", SetLastError = true)] + [return: MarshalAs(UnmanagedType.Bool)] + public static extern bool AllocConsole(); + + [DllImport("kernel32.dll", SetLastError = true)] + [return: MarshalAs(UnmanagedType.Bool)] + public static extern bool FreeConsole(); + + [DllImport("kernel32.dll", SetLastError = true)] + [return: MarshalAs(UnmanagedType.Bool)] + public static extern bool SetConsoleOutputCP([In] uint wCodePageID); + + [DllImport("kernel32.dll", SetLastError = true, CharSet = CharSet.Unicode)] + [return: MarshalAs(UnmanagedType.Bool)] + public static extern bool SetConsoleTitle(string lpConsoleTitle); + + [DllImport("kernel32.dll")] + public static extern IntPtr GetConsoleWindow(); + + public enum ShowWindowCommand : int + { +#pragma warning disable format + SW_HIDE = 0, + SW_SHOWNORMAL = 1, + SW_NORMAL = 1, + SW_SHOWMINIMIZED = 2, + SW_SHOWMAXIMIZED = 3, + SW_MAXIMIZE = 3, + SW_SHOWNOACTIVATE = 4, + SW_SHOW = 5, + SW_MINIMIZE = 6, + SW_SHOWMINNOACTIVE = 7, + SW_SHOWNA = 8, + SW_RESTORE = 9, + SW_SHOWDEFAULT = 10, + SW_FORCEMINIMIZE = 11, + SW_MAX = 11, +#pragma warning restore format + } + + [DllImport("user32.dll", ExactSpelling = true)] + [return: MarshalAs(UnmanagedType.Bool)] + private static extern bool ShowWindowAsync(IntPtr hWnd, int nCmdShow); + + public static bool ShowWindowAsync(IntPtr hWnd, ShowWindowCommand nCmdShow) + { + return ShowWindowAsync(hWnd, (int)nCmdShow); + } + } + + private const string ConsoleOutputTemplate = "{Timestamp:HH:mm:ss.fff} {Level:u1}{Level:u1} {Message:lj}{NewLine}{Exception}"; + private const string FileOutputTemplate = "{Timestamp:yyyy-MM-dd HH:mm:ss.fff zzz} {Level:u1}{Level:u1} {Message:lj}{NewLine}{Exception}"; + + private static readonly object _logSyncObject = new(); + + private static readonly string _logFileName; + private static readonly string _logDirPath; + private static readonly string _logPath; + + private static readonly string _previousLogFileName; + private static readonly string _previousLogPath; + + private static bool _alreadySetUp = false; + private static bool _consoleCreated = false; + + static Logging() + { + // Name the log file after the actual executable filename + var processFileName = Path.GetFileName(Env.ProcessPath); + var stem = Path.GetFileNameWithoutExtension(processFileName); + + _logFileName = string.Concat(stem, ".log"); + _previousLogFileName = string.Concat(stem, ".previous", ".log"); + + // Since GetLauncherFilesRootDirPath() depends on GetCwd(), cache this + _logDirPath = Env.GetLauncherFilesRootDirPath(); + + _logPath = Path.Combine(Env.GetLauncherFilesRootDirPath(), _logFileName); + _previousLogPath = Path.Combine(Env.GetLauncherFilesRootDirPath(), _previousLogFileName); + } + + public static string LogPath { get => _logPath; } + public static string LogDirPath { get => _logDirPath; } + public static string LogFileName { get => _logFileName; } + + public static void SetUp(bool createConsole) + { +#if DEBUG + createConsole = true; +#endif + + if (_alreadySetUp) + { + throw new InvalidOperationException("The log has been already set up."); + } + + if (createConsole) + { + if (!DllImports.AllocConsole()) + { + throw Win32.GetLastException(nameof(DllImports.AllocConsole)); + } + + // Force using UTF-8 as the output code page + if (!DllImports.SetConsoleOutputCP((uint)Encoding.UTF8.CodePage)) + { + throw Win32.GetLastException(nameof(DllImports.SetConsoleOutputCP), Encoding.UTF8.CodePage); + } + + const string ConsoleTitle = "Log"; + + if (!DllImports.SetConsoleTitle(ConsoleTitle)) + { + throw Win32.GetLastException(nameof(DllImports.SetConsoleTitle), ConsoleTitle); + } + + IntPtr consoleHwnd = DllImports.GetConsoleWindow(); + + if (consoleHwnd == IntPtr.Zero) + { + throw Win32.GetLastException(nameof(DllImports.GetConsoleWindow)); + } + + if (!DllImports.ShowWindowAsync(consoleHwnd, DllImports.ShowWindowCommand.SW_SHOWMAXIMIZED)) + { + throw Win32.GetLastException(nameof(DllImports.ShowWindowAsync), DllImports.ShowWindowCommand.SW_SHOWMAXIMIZED); + } + + _consoleCreated = true; + } + +#if DEBUG + // Since ILoggingFailureListener doesn't help much on sink initialization failure as of Serilog 4.4.0, resort to this: + Serilog.Debugging.SelfLog.Enable(message => + { + lock (_logSyncObject) + { + Console.Error.WriteLine(message); + } + }); +#endif + + // Rotate the log file manually, *sigh*... + // + // (Serilog blindly appends to an existing file and doesn't support simple file rotation unless files reach their configured limits). + (var _, var logRotateEx) = TryRotateLogFile(_logPath, _previousLogPath); + + var loggerConfiguration = new LoggerConfiguration() + .MinimumLevel.Verbose() + .WriteTo.Async(c => c.File(path: _logPath, outputTemplate: FileOutputTemplate, restrictedToMinimumLevel: Serilog.Events.LogEventLevel.Debug)); + + if (createConsole) + { + loggerConfiguration.WriteTo.Async(c => c.Console(syncRoot: _logSyncObject, outputTemplate: ConsoleOutputTemplate)); + } + + Log.Logger = loggerConfiguration.CreateLogger(); + + // Should be able to tell here if the sink wasn't created successfully. + // Sadly, Serilog doesn't support any of that as of 4.4.0. Instead, it handles any exceptions internally and writes them to SelfLog, as per: + // https://github.com/serilog/serilog/wiki/Reliability#configuration. + +#if DEBUG + // And now disable self-logging past sink creation as this excessive output won't be of much use. + Serilog.Debugging.SelfLog.Disable(); +#endif + + L.CallerInformation($"{Env.ProcessFileName} {Constants.VersionString}"); + L.CallerInformation($"Process path: '{Env.ProcessPath}'"); + L.CallerInformation($"Current working directory: '{Env.GetCwd()}'"); + L.CallerInformation($"Log file path: '{_logPath}'"); + + if (logRotateEx != null) + { + L.CallerError(logRotateEx, $"{nameof(TryRotateLogFile)}() threw"); + } + + _alreadySetUp = true; + } + + private static bool RotateLogFile(string logPath, string previousLogPath) + { + if (File.Exists(logPath)) + { + File.Move(logPath, previousLogPath, overwrite: true); + return true; + } + + return false; + } + + private static Tuple TryRotateLogFile(string logPath, string previousLogPath) + { + bool? res; + + try + { + res = RotateLogFile(logPath, previousLogPath); + } + catch (Exception ex) + { + return Tuple.Create((bool?)null, (Exception?)ex); + } + + return Tuple.Create(res, (Exception?)null); + } + + public static async void ShutDown(int exitCode) + { + if (!_alreadySetUp) + { + return; + } + + L.CallerInformation($"Exiting with {exitCode} exit code..."); + L.CallerInformation("Closing logger..."); + await Log.CloseAndFlushAsync(); + + if (_consoleCreated) + { + DllImports.FreeConsole(); + } + } + } +} diff --git a/PD2Shared/Logging/LoggingStatic.cs b/PD2Shared/Logging/LoggingStatic.cs new file mode 100644 index 00000000..9e1f29e5 --- /dev/null +++ b/PD2Shared/Logging/LoggingStatic.cs @@ -0,0 +1,14 @@ +namespace PD2Shared.Logging +{ + public static class LoggingStatic + { + // A convenience property to be combined with ILoggerEx + public static Serilog.ILogger L { get => Serilog.Log.Logger; } + + // A convenience method for constructing arrays from "params" to be easily passed to ILoggerEx methods + public static object?[]? ExplicitArray(params object?[] args) + { + return args; + } + } +} diff --git a/PD2Shared/Models/AllSettings.cs b/PD2Shared/Models/AllSettings.cs index a1f263cb..89370155 100644 --- a/PD2Shared/Models/AllSettings.cs +++ b/PD2Shared/Models/AllSettings.cs @@ -8,6 +8,7 @@ public class AllSettings public SelectedAuthorAndFilter SelectedAuthorAndFilter { get; set; } public Pd2AuthorList Pd2AuthorList { get; set; } public LauncherArgs LauncherArgs { get; set; } + public LauncherOptions LauncherOptions { get; set; } public News News { get; set; } public WindowPositionModel WindowPosition { get; set; } public ResetInfo ResetInfo { get; set; } diff --git a/PD2Shared/Models/LauncherArgs.cs b/PD2Shared/Models/LauncherArgs.cs index be281248..ce24ed86 100644 --- a/PD2Shared/Models/LauncherArgs.cs +++ b/PD2Shared/Models/LauncherArgs.cs @@ -5,6 +5,5 @@ public class LauncherArgs public bool graphics { get; set; } = false; public bool skiptobnet { get; set; } = true; public bool sndbkg { get; set; } = false; - public bool disableAutoUpdate { get; set; } = false; } } \ No newline at end of file diff --git a/PD2Shared/Models/LauncherOptions.cs b/PD2Shared/Models/LauncherOptions.cs new file mode 100644 index 00000000..c56c2b6a --- /dev/null +++ b/PD2Shared/Models/LauncherOptions.cs @@ -0,0 +1,10 @@ +namespace PD2Shared.Models +{ + public class LauncherOptions + { + public bool ForceSoftwareRenderer { get; set; } = false; + public bool UseHttp2 { get; set; } = false; + public bool DisableAutoUpdate { get; set; } = false; + public bool AutoCloseAfterLaunch { get; set; } = false; + } +} diff --git a/PD2Shared/Models/StorageKey.cs b/PD2Shared/Models/StorageKey.cs index cbbec05a..8c2e96c5 100644 --- a/PD2Shared/Models/StorageKey.cs +++ b/PD2Shared/Models/StorageKey.cs @@ -4,6 +4,7 @@ namespace PD2Shared.Models public enum StorageKey { LauncherArgs, + LauncherOptions, DdrawOptions, FileUpdateModel, Pd2AuthorList, diff --git a/PD2Shared/PD2Shared.csproj b/PD2Shared/PD2Shared.csproj index 9963de93..40cf0216 100644 --- a/PD2Shared/PD2Shared.csproj +++ b/PD2Shared/PD2Shared.csproj @@ -9,10 +9,24 @@ win-x86 + + full + + + + embedded + + + + + + + + diff --git a/PD2Shared/Storage/LocalStorage.cs b/PD2Shared/Storage/LocalStorage.cs index 051c1df6..3738bfaf 100644 --- a/PD2Shared/Storage/LocalStorage.cs +++ b/PD2Shared/Storage/LocalStorage.cs @@ -68,6 +68,9 @@ public void Update(StorageKey key, T value) where T : class case StorageKey.LauncherArgs: settings.LauncherArgs = value as LauncherArgs ?? new LauncherArgs(); break; + case StorageKey.LauncherOptions: + settings.LauncherOptions = value as LauncherOptions ?? new LauncherOptions(); + break; case StorageKey.DdrawOptions: settings.DdrawOptions = value as DdrawOptions ?? new DdrawOptions(); break; @@ -107,7 +110,7 @@ public void Update(StorageKey key, T value) where T : class if (IsValidJson(json)) { File.WriteAllText(tempFilePath, json); - File.Replace(tempFilePath, filePath, null); + File.Move(tempFilePath, filePath, overwrite: true); Debug.WriteLine("Settings updated successfully."); } else @@ -121,24 +124,30 @@ public void Update(StorageKey key, T value) where T : class } } - public T LoadSection(StorageKey key) where T : class + public T? LoadSectionIfExists(StorageKey key) where T : class { var settings = Load(); return key switch { - StorageKey.LauncherArgs => settings.LauncherArgs as T ?? Activator.CreateInstance(), - StorageKey.DdrawOptions => settings.DdrawOptions as T ?? Activator.CreateInstance(), - StorageKey.FileUpdateModel => settings.FileUpdateModel as T ?? Activator.CreateInstance(), - StorageKey.Pd2AuthorList => settings.Pd2AuthorList as T ?? Activator.CreateInstance(), - StorageKey.SelectedAuthorAndFilter => settings.SelectedAuthorAndFilter as T ?? Activator.CreateInstance(), - StorageKey.WindowPosition => settings.WindowPosition as T ?? Activator.CreateInstance(), - StorageKey.News => settings.News as T ?? Activator.CreateInstance(), - StorageKey.ResetInfo => settings.ResetInfo as T ?? Activator.CreateInstance(), - _ => Activator.CreateInstance() + StorageKey.LauncherArgs => settings.LauncherArgs as T, + StorageKey.LauncherOptions => settings.LauncherOptions as T, + StorageKey.DdrawOptions => settings.DdrawOptions as T, + StorageKey.FileUpdateModel => settings.FileUpdateModel as T, + StorageKey.Pd2AuthorList => settings.Pd2AuthorList as T, + StorageKey.SelectedAuthorAndFilter => settings.SelectedAuthorAndFilter as T, + StorageKey.WindowPosition => settings.WindowPosition as T, + StorageKey.News => settings.News as T, + StorageKey.ResetInfo => settings.ResetInfo as T, + _ => default }; } + public T LoadSection(StorageKey key) where T : class, new() + { + return LoadSectionIfExists(key) ?? new(); + } + public void InitializeIfNotExists(StorageKey key, T defaultValue) where T : class, new() { if (!Directory.Exists(_storageDirectory)) @@ -160,6 +169,9 @@ public T LoadSection(StorageKey key) where T : class case StorageKey.LauncherArgs: settings.LauncherArgs = defaultValue as LauncherArgs ?? new LauncherArgs(); break; + case StorageKey.LauncherOptions: + settings.LauncherOptions = defaultValue as LauncherOptions ?? new LauncherOptions(); + break; case StorageKey.DdrawOptions: settings.DdrawOptions = defaultValue as DdrawOptions ?? new DdrawOptions(); break; diff --git a/PD2Shared/Utils/DirectProgress.cs b/PD2Shared/Utils/DirectProgress.cs new file mode 100644 index 00000000..212fa10d --- /dev/null +++ b/PD2Shared/Utils/DirectProgress.cs @@ -0,0 +1,17 @@ +namespace PD2Shared.Utils +{ + public class DirectProgress : IProgress + { + private readonly Action _handler; + + public DirectProgress(Action handler) + { + _handler = handler; + } + + void IProgress.Report(T value) + { + this._handler(value); + } + } +} diff --git a/PD2Shared/Utils/Env.cs b/PD2Shared/Utils/Env.cs new file mode 100644 index 00000000..d83b5cde --- /dev/null +++ b/PD2Shared/Utils/Env.cs @@ -0,0 +1,110 @@ +using System.Text; + +namespace PD2Shared.Utils +{ + public static class Env + { + static Env() + { + // In theory this could return null (https://learn.microsoft.com/en-us/dotnet/api/system.environment.processpath#remarks), + // but it's unlikely on Windows as a running process should have an associated handle open, preventing the file from being altered. + ProcessPath = Environment.ProcessPath!; + + ProcessFileName = Path.GetFileName(ProcessPath); + // ProcessPath will never be null, empty nor a root directory + ProcessDirPath = Path.GetDirectoryName(ProcessPath)!; + + // Retrieves system-default ANSI encoding for non-Unicode programs. Will correctly return UTF-8 when forced system-wide. + // (Taken from https://stackoverflow.com/a/70258850) + Encoding.RegisterProvider(CodePagesEncodingProvider.Instance); + AnsiEncoding = Encoding.GetEncoding(0); + } + + public static string ProcessFileName { get; } + public static string ProcessPath { get; } + public static string ProcessDirPath { get; } + + public static Encoding AnsiEncoding { get; } + + public static string GetCwd() + { + // While this method isn't much on its own, it's used for consistency + return Environment.CurrentDirectory; + } + + public static string GetLauncherFilesRootDirPath() + { + return Path.Combine(GetCwd(), "launcher.files"); + } + + public static Exception? CheckIfDirectoryIsWritable(string directoryPath) + { + var filePath = Path.Combine(directoryPath, Path.GetRandomFileName()); + + try + { + using var stream = new FileStream( + filePath, + FileMode.CreateNew, + FileAccess.Write, + FileShare.None, + bufferSize: 0, + FileOptions.DeleteOnClose); + } + catch(Exception ex) + { + return ex; + } + + return null; + } + + public static void EnsureDirectoryExists(string fileFullPath) + { + ArgumentNullException.ThrowIfNull(fileFullPath, nameof(fileFullPath)); + + if (!Path.IsPathFullyQualified(fileFullPath)) + { + throw new ArgumentException("Must be a fully qualified path", nameof(fileFullPath)); + } + + string? dirPath = Path.GetDirectoryName(fileFullPath); + + // Skip when in root directory + if (dirPath != null) + { + Directory.CreateDirectory(dirPath); + } + } + + public static async Task FileExistsAsync(string path) + { + return await Task.Run(() => + { + return File.Exists(path); + }).ConfigureAwait(false); + } + + public static Tuple TryGetFileSize(string path) + { + long? fileSize = null; + Exception? exception = null; + + try + { + fileSize = new FileInfo(path).Length; + } + catch (Exception ex) + { + exception = ex; + } + + return Tuple.Create(fileSize, exception); + } + + public static async Task> TryGetFileSizeAsync(string path) + { + return await Task.Run(() => TryGetFileSize(path)).ConfigureAwait(false); + } + } +} diff --git a/PD2Shared/Utils/Formatting.cs b/PD2Shared/Utils/Formatting.cs new file mode 100644 index 00000000..b5b91ea1 --- /dev/null +++ b/PD2Shared/Utils/Formatting.cs @@ -0,0 +1,40 @@ +using System.Globalization; + +namespace PD2Shared.Utils +{ + public static class Formatting + { + private const long Mebibyte = 1024 * 1024; + + private const int DefaultPrecision = 2; + + private static string FormatSizeInMiB(double value, string unitsStr, int precision) + { + string format = $"{{0:N{precision}}}{{1}}"; + + return string.Format(CultureInfo.InvariantCulture, format, value, unitsStr); + } + + // Don't attempt any sophisticated human-readable formatting for now + public static string FormatSizeInMiB(long bytes, bool appendUnits = true, int precision = DefaultPrecision) + { + return FormatSizeInMiB(bytes / (double)Mebibyte, appendUnits ? " MiB" : "", precision); + } + + public static string FormatThroughputInMiB(long bytes, long elapsedMilliseconds, bool appendUnits = true, int precision = DefaultPrecision) + { + // var mebibytesPerSec = bytes / ((double)elapsedMilliseconds / 1000) / Mebibyte; + // This is a transformation of the above equation ^ + var mebibytesPerSec = (bytes * 1000) / (elapsedMilliseconds * (double)Mebibyte); + + return FormatSizeInMiB(mebibytesPerSec, appendUnits ? " MiB/s" : "", precision); + } + + public static string FormatThroughputInMiB(long bytesPerSec, bool appendUnits = true, int precision = DefaultPrecision) + { + var mebibytesPerSec = bytesPerSec / (double)Mebibyte; + + return FormatSizeInMiB(mebibytesPerSec, appendUnits ? " MiB/s" : "", precision); + } + } +} diff --git a/PD2Shared/Utils/SimpleTimer.cs b/PD2Shared/Utils/SimpleTimer.cs new file mode 100644 index 00000000..829366cf --- /dev/null +++ b/PD2Shared/Utils/SimpleTimer.cs @@ -0,0 +1,97 @@ +using System.Diagnostics; + +namespace PD2Shared.Utils +{ + // A simplistic timer class that runs an uninterruptible action sequentially, on a dedicated thread, with a given interval until Disposed. + // + // It's meant to perform quick actions, such as periodic UI updates, hence the default AboveNormal ThreadPriority. + public class SimpleTimer : IDisposable + { + private bool _disposed = false; + + private readonly EventWaitHandle _interrupt; + + private readonly TimeSpan _interval; + private readonly Action _handler; + private readonly Action? _onDispose; + private readonly Thread _thread; + + public SimpleTimer(TimeSpan interval, Action handler, Action? onDispose = null, ThreadPriority priority = ThreadPriority.AboveNormal) + { + if (interval < TimeSpan.Zero) + { + throw new ArgumentOutOfRangeException(nameof(interval), $"{nameof(interval)} must not be negative"); + } + + ArgumentNullException.ThrowIfNull(handler, nameof(handler)); + + _interrupt = new(initialState: false, EventResetMode.ManualReset); + + _interval = interval; + + _handler = handler; + _onDispose = onDispose; + + _thread = new Thread(ThreadEntry) + { + Priority = priority + }; + _thread.Start(); + } + + public SimpleTimer(Action handler, Action? onDispose = null, ThreadPriority priority = ThreadPriority.AboveNormal) + : this(interval: TimeSpan.FromMilliseconds(UpdateThrottle.DefaultIntervalMilliseconds), handler, onDispose, priority) + { + } + + private void ThreadEntry() + { + Stopwatch stopwatch = new(); + TimeSpan timeToWait; + + while (true) + { + stopwatch.Restart(); + + _handler(); + + timeToWait = _interval - stopwatch.Elapsed; + + if (timeToWait > TimeSpan.Zero) + { + if (_interrupt.WaitOne(timeToWait)) + { + return; + } + } + } + } + + protected virtual void Dispose(bool disposing) + { + if (_disposed) + { + return; + } + + if (disposing) + { + _interrupt.Set(); + _thread.Join(); + + _interrupt.Dispose(); + + _onDispose?.Invoke(); + } + + _disposed = true; + } + + public void Dispose() + { + // Do not change this code. Put cleanup code in 'Dispose(bool disposing)' method + Dispose(disposing: true); + GC.SuppressFinalize(this); + } + } +} diff --git a/PD2Shared/Utils/TimedDisposable.cs b/PD2Shared/Utils/TimedDisposable.cs new file mode 100644 index 00000000..b407e920 --- /dev/null +++ b/PD2Shared/Utils/TimedDisposable.cs @@ -0,0 +1,42 @@ +using System.Diagnostics; + +namespace PD2Shared.Utils +{ + public class TimedDisposable : IDisposable + { + private bool _disposed = false; + + private readonly Stopwatch _stopwatch; + private readonly Action _onDisposeAction; + + public TimedDisposable(Action onConstructAction, Action onDisposeAction) + { + _onDisposeAction = onDisposeAction; + _stopwatch = Stopwatch.StartNew(); + + onConstructAction(); + } + + protected virtual void Dispose(bool disposing) + { + if (_disposed) + { + return; + } + + if (disposing) + { + _onDisposeAction(_stopwatch.Elapsed); + } + + _disposed = true; + } + + public void Dispose() + { + // Do not change this code. Put cleanup code in 'Dispose(bool disposing)' method + Dispose(disposing: true); + GC.SuppressFinalize(this); + } + } +} diff --git a/PD2Shared/Utils/UpdateThrottle.cs b/PD2Shared/Utils/UpdateThrottle.cs new file mode 100644 index 00000000..cebdac4e --- /dev/null +++ b/PD2Shared/Utils/UpdateThrottle.cs @@ -0,0 +1,62 @@ +using System.Diagnostics; + +namespace PD2Shared.Utils +{ + // Beware, this class isn't thread-safe + public class UpdateThrottle + { + public const int DefaultIntervalMilliseconds = 50; + + private readonly Stopwatch _stopwatch; + private readonly int _interval; + + public UpdateThrottle(int intervalMilliseconds = DefaultIntervalMilliseconds, bool waitInitialInterval = false) + { + _stopwatch = new Stopwatch(); + _interval = intervalMilliseconds; + + if (waitInitialInterval) + { + _stopwatch.Start(); + } + } + + public void Reset() + { + _stopwatch.Restart(); + } + + private bool CanUpdateInternal(out long elapsed) + { + if (!_stopwatch.IsRunning) + { + _stopwatch.Start(); + + elapsed = _interval; + return true; + } + + elapsed = _stopwatch.ElapsedMilliseconds; + return elapsed >= _interval; + } + + public void UpdateIfPossible(Action action) + { + if (CanUpdateInternal(out long elapsed)) + { + Reset(); + } + else + { + return; + } + + action(elapsed); + } + + public void UpdateIfPossible(Action action) + { + UpdateIfPossible((_) => action()); + } + } +} diff --git a/PD2Shared/Utils/Win32.cs b/PD2Shared/Utils/Win32.cs new file mode 100644 index 00000000..024f65a3 --- /dev/null +++ b/PD2Shared/Utils/Win32.cs @@ -0,0 +1,50 @@ +using System.ComponentModel; +using System.Runtime.InteropServices; + +namespace PD2Shared.Utils +{ + public static class Win32 + { + public const int ERROR_SUCCESS = 0; + + public static string GetLastErrorMessage(int error) + { + return GetLastException(error).Message; + } + + public static string GetLastErrorMessage() + { + return GetLastException().Message; + } + + public static string GetLastErrorMessage(int error, string functionName, params object?[] args) + { + return GetLastException(error, functionName, args).Message; + } + + public static string GetLastErrorMessage(string functionName, params object?[] args) + { + return GetLastException(functionName, args).Message; + } + + public static Win32Exception GetLastException(int error) + { + return new Win32Exception(error); + } + + public static Win32Exception GetLastException() + { + return new Win32Exception(Marshal.GetLastWin32Error()); + } + + public static Win32Exception GetLastException(int error, string functionName, params object?[] args) + { + return new Win32Exception(error, $"{functionName}({string.Join(", ", args.Select(a => a is null ? "NULL" : a is string ? $"\"{a}\"" : a))}) failed: {new Win32Exception(error).Message}"); + } + + public static Win32Exception GetLastException(string functionName, params object?[] args) + { + return GetLastException(Marshal.GetLastWin32Error(), functionName, args); + } + } +} diff --git a/PD2Shared/Utils/Wine.cs b/PD2Shared/Utils/Wine.cs new file mode 100644 index 00000000..0de08b40 --- /dev/null +++ b/PD2Shared/Utils/Wine.cs @@ -0,0 +1,202 @@ +using System.Runtime.InteropServices; +using Microsoft.Win32; +using PD2Shared.Logging; +using static PD2Shared.Logging.LoggingStatic; + +namespace PD2Shared.Utils +{ + public static class Wine + { + private static class DllImports + { + [DllImport("ntdll.dll", CallingConvention = CallingConvention.Cdecl)] + [System.Diagnostics.CodeAnalysis.SuppressMessage("Globalization", + "CA2101:Specify marshaling for P/Invoke string arguments", + Justification = "UTF-8 strings are expected, thus MarshalAs(UnmanagedType.LPUTF8Str) is the correct choice.")] + [return: MarshalAs(UnmanagedType.LPUTF8Str)] + public static extern string wine_get_version(); + + [DllImport("ntdll.dll", CallingConvention = CallingConvention.Cdecl)] + [System.Diagnostics.CodeAnalysis.SuppressMessage("Globalization", + "CA2101:Specify marshaling for P/Invoke string arguments", + Justification = "UTF-8 strings are expected, thus MarshalAs(UnmanagedType.LPUTF8Str) is the correct choice.")] + [return: MarshalAs(UnmanagedType.LPUTF8Str)] + public static extern string wine_get_build_id(); + + [DllImport("ntdll.dll", CallingConvention = CallingConvention.Cdecl)] + public static extern void wine_get_host_version(ref IntPtr sysname, ref IntPtr release); + } + + const string HkcuExeKeyPath = @"Software\Wine\AppDefaults\Game.exe"; + const string HkcuDllOverridesKeyPath = HkcuExeKeyPath + @"\DllOverrides"; + const string HkcuX11KeyPath = HkcuExeKeyPath + @"\X11 Driver"; + + // MSVC2019 runtime + private static readonly string[] Msvc2019Libs = { + "api-ms-win-crt-conio-l1-1-0", + "api-ms-win-crt-heap-l1-1-0", + "api-ms-win-crt-locale-l1-1-0", + "api-ms-win-crt-math-l1-1-0", + "api-ms-win-crt-private-l1-1-0", + "api-ms-win-crt-runtime-l1-1-0", + "api-ms-win-crt-stdio-l1-1-0", + "api-ms-win-crt-time-l1-1-0", + "atl140", + "concrt140", + "msvcp140", + "msvcp140_1", + "msvcp140_2", + "msvcp140_atomic_wait", + "msvcp140_codecvt_ids", + "ucrtbase", + "vcamp140", + "vccorlib140", + "vcomp140", + "vcruntime140", + "vcruntime140_1" + }; + + public class WineException : Exception + { + public WineException(string? message, Exception? innerException = null) : base(message, innerException) { } + } + + static Wine() + { + string? versionString; + + try + { + versionString = DllImports.wine_get_version(); + } + catch (EntryPointNotFoundException) + { + return; + } + + IsRunningUnderWine = true; + + if (Version.TryParse(versionString, out Version? version)) + { + Version = version; + } + else + { + L.CallerError($"Failed to parse output of {nameof(DllImports.wine_get_version)}(): '{versionString}'"); + } + + try + { + BuildId = DllImports.wine_get_build_id(); + } + catch (EntryPointNotFoundException) + { + L.CallerWarning($"{nameof(DllImports.wine_get_build_id)}() entry point not found"); + } + + try + { + IntPtr sysnamePtr = IntPtr.Zero; + IntPtr releasePtr = IntPtr.Zero; + + DllImports.wine_get_host_version(ref sysnamePtr, ref releasePtr); + + OsName = Marshal.PtrToStringUTF8(sysnamePtr); + OsRelease = Marshal.PtrToStringUTF8(releasePtr); + } + catch (EntryPointNotFoundException) + { + L.CallerWarning($"{nameof(DllImports.wine_get_host_version)}() entry point not found"); + } + } + + public static bool IsRunningUnderWine { get; } + public static Version? Version { get; } + public static string? BuildId { get; } + public static string? OsName { get; } + public static string? OsRelease { get; } + + public static void ApplyWineConfiguration() + { + // What needs to be set: + // + // [HKEY_CURRENT_USER\Software\Wine\AppDefaults\Game.exe] + // ; Workaround for InitializeCriticalSection() in Wine>=9.9 (won't help if running >=9.5, which introduced the unconditional API change) + // "Version"="win7" + // + // [HKEY_CURRENT_USER\Software\Wine\AppDefaults\Game.exe\DllOverrides] + // ; Provided DDraw wrapper + // "ddraw"="native" + // ; MSVC2019 runtime + // "*api-ms-win-crt-conio-l1-1-0"="native,builtin" + // "*api-ms-win-crt-heap-l1-1-0"="native,builtin" + // "*api-ms-win-crt-locale-l1-1-0"="native,builtin" + // "*api-ms-win-crt-math-l1-1-0"="native,builtin" + // "*api-ms-win-crt-private-l1-1-0"="native,builtin" + // "*api-ms-win-crt-runtime-l1-1-0"="native,builtin" + // "*api-ms-win-crt-stdio-l1-1-0"="native,builtin" + // "*api-ms-win-crt-time-l1-1-0"="native,builtin" + // "*atl140"="native,builtin" + // "*concrt140"="native,builtin" + // "*msvcp140"="native,builtin" + // "*msvcp140_1"="native,builtin" + // "*msvcp140_2"="native,builtin" + // "*msvcp140_atomic_wait"="native,builtin" + // "*msvcp140_codecvt_ids"="native,builtin" + // "*ucrtbase"="native,builtin" + // "*vcamp140"="native,builtin" + // "*vccorlib140"="native,builtin" + // "*vcomp140"="native,builtin" + // "*vcruntime140"="native,builtin" + // "*vcruntime140_1"="native,builtin" + // + // [HKEY_CURRENT_USER\Software\Wine\AppDefaults\Game.exe\X11 Driver] + // ; Emulate modesetting -- don't change the actual display resolution + // ; Available since Wine 9.22 + // "EmulateModeset"="Y" + + using (var key = Registry.CurrentUser.CreateSubKey(HkcuExeKeyPath, writable: true)) + { + if (key == null) + { + throw new WineException($"Failed to create registry key: '{Registry.CurrentUser}\\{HkcuExeKeyPath}'"); + } + + key.SetValue("Version", "win7", RegistryValueKind.String); + } + + using (var key = Registry.CurrentUser.CreateSubKey(HkcuDllOverridesKeyPath, writable: true)) + { + if (key == null) + { + throw new WineException($"Failed to create registry key: '{Registry.CurrentUser}\\{HkcuDllOverridesKeyPath}'"); + } + + // Provided DDraw wrapper + key.SetValue("ddraw", "native", RegistryValueKind.String); + + // MSVC2019 runtime libs + foreach (var libName in Msvc2019Libs) + { + key.SetValue($"*{libName}", "native,builtin", RegistryValueKind.String); + } + } + + using (var key = Registry.CurrentUser.CreateSubKey(HkcuX11KeyPath, writable: true)) + { + if (key == null) + { + throw new WineException($"Failed to create registry key: '{Registry.CurrentUser}\\{HkcuX11KeyPath}'"); + } + + // Emulate modesetting + key.SetValue("EmulateModeset", "Y", RegistryValueKind.String); + } + } + + public static void RemoveWineConfiguration() + { + Registry.CurrentUser.DeleteSubKeyTree(HkcuExeKeyPath, throwOnMissingSubKey: false); + } + } +} diff --git a/SteamPD2/Program.cs b/SteamPD2/Program.cs index 17368fa0..8936d986 100644 --- a/SteamPD2/Program.cs +++ b/SteamPD2/Program.cs @@ -36,8 +36,8 @@ static async Task Run(string[] args) var filterHelpers = new FilterHelpers(new HttpClient(), localStorage); var launchGameHelpers = new LaunchGameHelpers(); - var launcherArgs = localStorage.LoadSection(StorageKey.LauncherArgs); - if (launcherArgs?.disableAutoUpdate == true) + var launcherOptions = localStorage.LoadSection(StorageKey.LauncherOptions); + if (launcherOptions?.DisableAutoUpdate == true) { Log("disableAutoUpdate is enabled. Skipping all update checks."); launchGameHelpers.LaunchGame(localStorage); diff --git a/eqpublish.ps1 b/eqpublish.ps1 index e85e70c4..5b173713 100644 --- a/eqpublish.ps1 +++ b/eqpublish.ps1 @@ -4,27 +4,27 @@ $buildFailed = $false Push-Location $PSScriptRoot try { - $mainWindowPath = Join-Path ` + $constantsPath = Join-Path ` $PSScriptRoot ` - "PD2Launcherv2\MainWindow.xaml" + "PD2Shared\Constants.cs" - if (-not (Test-Path $mainWindowPath -PathType Leaf)) { - throw "Could not find MainWindow.xaml at: $mainWindowPath" + if (-not (Test-Path $constantsPath -PathType Leaf)) { + throw "Could not find Constants.cs at: $constantsPath" } - $xamlContent = Get-Content $mainWindowPath -Raw + $csContent = Get-Content $constantsPath -Raw # Accepts versions such as: # v 2.14.1 # v 2.14.AWS1 # v 2.14.SUCCESS $versionMatch = [regex]::Match( - $xamlContent, - 'Text="v\s*([^"]+)"' + $csContent, + 'VersionString\s*=\s*"([^"]+)"' ) if (-not $versionMatch.Success) { - throw "Could not find version text in MainWindow.xaml" + throw "Could not find version text in Constants.cs" } $version = $versionMatch.Groups[1].Value.Trim()