From a0f1d318e222b21794c78b7a62e52c7462b6c7d0 Mon Sep 17 00:00:00 2001 From: altpyrion <294315026+altpyrion@users.noreply.github.com> Date: Sat, 11 Jul 2026 16:21:27 +0200 Subject: [PATCH] refactor of installation locators and rough setup popup implementation --- .../Abstractions/DolphinInstallation.cs | 3 + .../Abstractions/IDolphinInstaller.cs | 7 + .../Abstractions/IDolphinLocator.cs | 6 + .../DolphinManagmentExtensions.cs | 18 ++ .../Linux/LinuxCommandEnvironment.cs | 22 ++ .../Linux/LinuxDolphinInstaller.cs | 0 .../Linux/LinuxDolphinLocator.cs | 33 +++ .../Linux/LinuxProcessService.cs | 123 +++++++++++ .../Services/Storage/FilePickerHelper.cs | 2 +- WheelWizard/SetupExtensions.cs | 3 +- WheelWizard/Views/App.axaml.cs | 12 ++ .../Popups/Generic/FirstTimeSetupPopup.axaml | 86 ++++++++ .../Generic/FirstTimeSetupPopup.axaml.cs | 200 ++++++++++++++++++ build-linux.bat | 0 14 files changed, 513 insertions(+), 2 deletions(-) create mode 100644 WheelWizard/Features/DolphinManagent/Abstractions/DolphinInstallation.cs create mode 100644 WheelWizard/Features/DolphinManagent/Abstractions/IDolphinInstaller.cs create mode 100644 WheelWizard/Features/DolphinManagent/Abstractions/IDolphinLocator.cs create mode 100644 WheelWizard/Features/DolphinManagent/DolphinManagmentExtensions.cs create mode 100644 WheelWizard/Features/DolphinManagent/Linux/LinuxCommandEnvironment.cs create mode 100644 WheelWizard/Features/DolphinManagent/Linux/LinuxDolphinInstaller.cs create mode 100644 WheelWizard/Features/DolphinManagent/Linux/LinuxDolphinLocator.cs create mode 100644 WheelWizard/Features/DolphinManagent/Linux/LinuxProcessService.cs create mode 100644 WheelWizard/Views/Popups/Generic/FirstTimeSetupPopup.axaml create mode 100644 WheelWizard/Views/Popups/Generic/FirstTimeSetupPopup.axaml.cs mode change 100644 => 100755 build-linux.bat diff --git a/WheelWizard/Features/DolphinManagent/Abstractions/DolphinInstallation.cs b/WheelWizard/Features/DolphinManagent/Abstractions/DolphinInstallation.cs new file mode 100644 index 00000000..6f0050c9 --- /dev/null +++ b/WheelWizard/Features/DolphinManagent/Abstractions/DolphinInstallation.cs @@ -0,0 +1,3 @@ +namespace WheelWizard.DolphinManagent.Abstractions; + +public record DolphinInstallation(string DisplayName, string LaunchTarget, bool Found); diff --git a/WheelWizard/Features/DolphinManagent/Abstractions/IDolphinInstaller.cs b/WheelWizard/Features/DolphinManagent/Abstractions/IDolphinInstaller.cs new file mode 100644 index 00000000..8800612e --- /dev/null +++ b/WheelWizard/Features/DolphinManagent/Abstractions/IDolphinInstaller.cs @@ -0,0 +1,7 @@ +namespace WheelWizard.DolphinManagent.Abstractions; + +public interface IDolphinInstaller +{ + IReadOnlyList AvailableInstallationMethods(); + //bool InstallDolphin(DolphinInstallation method); +} diff --git a/WheelWizard/Features/DolphinManagent/Abstractions/IDolphinLocator.cs b/WheelWizard/Features/DolphinManagent/Abstractions/IDolphinLocator.cs new file mode 100644 index 00000000..9abfd6c5 --- /dev/null +++ b/WheelWizard/Features/DolphinManagent/Abstractions/IDolphinLocator.cs @@ -0,0 +1,6 @@ +namespace WheelWizard.DolphinManagent.Abstractions; + +public interface IDolphinLocator +{ + IReadOnlyList DetectInstallations(); +} diff --git a/WheelWizard/Features/DolphinManagent/DolphinManagmentExtensions.cs b/WheelWizard/Features/DolphinManagent/DolphinManagmentExtensions.cs new file mode 100644 index 00000000..ec77cb56 --- /dev/null +++ b/WheelWizard/Features/DolphinManagent/DolphinManagmentExtensions.cs @@ -0,0 +1,18 @@ +using WheelWizard.DolphinManagent.Abstractions; +using WheelWizard.DolphinManagent.Linux; + +namespace WheelWizard.DolphinManagent; + +public static class DolphinManagmentExtensions +{ + public static IServiceCollection AddDolphinManagement(this IServiceCollection services) + { +#if LINUX + services.AddSingleton(); + services.AddSingleton(); + //services.AddSingleton(); + services.AddSingleton(); +#endif + return services; + } +} diff --git a/WheelWizard/Features/DolphinManagent/Linux/LinuxCommandEnvironment.cs b/WheelWizard/Features/DolphinManagent/Linux/LinuxCommandEnvironment.cs new file mode 100644 index 00000000..570bc419 --- /dev/null +++ b/WheelWizard/Features/DolphinManagent/Linux/LinuxCommandEnvironment.cs @@ -0,0 +1,22 @@ +using WheelWizard.Helpers; + +namespace WheelWizard.DolphinManagent.Linux; + +public interface ILinuxCommandEnvironment +{ + bool IsCommandAvailable(string command); + string DetectPackageManagerInstallCommand(); +} + +public sealed class LinuxCommandEnvironment : ILinuxCommandEnvironment +{ + public bool IsCommandAvailable(string command) + { + return EnvHelper.IsValidUnixCommand(command); + } + + public string DetectPackageManagerInstallCommand() + { + return EnvHelper.DetectLinuxPackageManagerInstallCommand(); + } +} diff --git a/WheelWizard/Features/DolphinManagent/Linux/LinuxDolphinInstaller.cs b/WheelWizard/Features/DolphinManagent/Linux/LinuxDolphinInstaller.cs new file mode 100644 index 00000000..e69de29b diff --git a/WheelWizard/Features/DolphinManagent/Linux/LinuxDolphinLocator.cs b/WheelWizard/Features/DolphinManagent/Linux/LinuxDolphinLocator.cs new file mode 100644 index 00000000..ddfedb54 --- /dev/null +++ b/WheelWizard/Features/DolphinManagent/Linux/LinuxDolphinLocator.cs @@ -0,0 +1,33 @@ +using WheelWizard.DolphinManagent.Abstractions; + +namespace WheelWizard.DolphinManagent.Linux; + +public sealed class LinuxDolphinLocator(ILinuxCommandEnvironment commandEnvironment, ILinuxProcessService processService) : IDolphinLocator +{ + private bool IsDolphinInstalledInFlatpak() + { + const string dolphinAppId = "org.DolphinEmu.dolphin-emu"; + var processResult = processService.Run("flatpak", "list --app --columns=application", out var stdOut, out _); + + return processResult.IsSuccess && processResult.Value == 0 && stdOut.Split('\n').Any(line => line == dolphinAppId); + } + + private bool IsDolphinInstalledNative() + { + if (!commandEnvironment.IsCommandAvailable("dolphin-emu")) + { + return false; + } + var processResult = processService.Run("dolphin-emu", "--version"); + return processResult.IsSuccess && processResult.Value == 0; + } + + public IReadOnlyList DetectInstallations() + { + return + [ + new("Flatpak", "flatpak run org.DolphinEmu.dolphin-emu", IsDolphinInstalledInFlatpak()), + new("Native", "dolphin-emu", IsDolphinInstalledNative()), + ]; + } +} diff --git a/WheelWizard/Features/DolphinManagent/Linux/LinuxProcessService.cs b/WheelWizard/Features/DolphinManagent/Linux/LinuxProcessService.cs new file mode 100644 index 00000000..e0a2cba3 --- /dev/null +++ b/WheelWizard/Features/DolphinManagent/Linux/LinuxProcessService.cs @@ -0,0 +1,123 @@ +using System.Diagnostics; +using System.Text.RegularExpressions; + +namespace WheelWizard.DolphinManagent.Linux; + +public interface ILinuxProcessService +{ + OperationResult Run(string fileName, string arguments, out string stdOut, out string stdErr); + OperationResult Run(string fileName, string arguments); + Task> RunWithProgressAsync(string fileName, string arguments, IProgress? progress = null); + Task LaunchAndStopAsync(string fileName, string arguments, TimeSpan duration); +} + +public sealed class LinuxProcessService : ILinuxProcessService +{ + public OperationResult Run(string fileName, string arguments, out string stdOut, out string stdErr) + { + var localStdOut = ""; + var localStdErr = ""; + var result = TryCatch( + () => + { + var processInfo = new ProcessStartInfo + { + FileName = fileName, + Arguments = arguments, + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false, + CreateNoWindow = true, + }; + + using var process = Process.Start(processInfo); + if (process == null) + return -1; + + localStdOut = process.StandardOutput.ReadToEnd(); + localStdErr = process.StandardError.ReadToEnd(); + process.WaitForExit(); + return process.ExitCode; + }, + $"Failed to run process: {fileName} {arguments}" + ); + + stdOut = localStdOut; + stdErr = localStdErr; + return result; + } + + public OperationResult Run(string fileName, string arguments) + { + return Run(fileName, arguments, out _, out _); + } + + public async Task> RunWithProgressAsync(string fileName, string arguments, IProgress? progress = null) + { + return await TryCatch( + async () => + { + var processInfo = new ProcessStartInfo + { + FileName = fileName, + Arguments = arguments, + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false, + CreateNoWindow = true, + }; + + using var process = Process.Start(processInfo); + if (process == null) + return -1; + + process.OutputDataReceived += (_, eventArgs) => ReportProgress(eventArgs.Data, progress); + process.ErrorDataReceived += (_, eventArgs) => ReportProgress(eventArgs.Data, progress); + + process.BeginOutputReadLine(); + process.BeginErrorReadLine(); + await process.WaitForExitAsync(); + return process.ExitCode; + }, + $"Failed to run process: {fileName} {arguments}" + ); + } + + public async Task LaunchAndStopAsync(string fileName, string arguments, TimeSpan duration) + { + return await TryCatch( + async () => + { + using var process = new Process + { + StartInfo = new() + { + FileName = fileName, + Arguments = arguments, + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false, + CreateNoWindow = true, + }, + }; + + process.Start(); + await Task.Delay(duration); + + if (!process.HasExited) + process.Kill(); + }, + $"Failed to run process: {fileName} {arguments}" + ); + } + + private static void ReportProgress(string? output, IProgress? progress) + { + if (string.IsNullOrWhiteSpace(output)) + return; + + var match = Regex.Match(output, @"(\d+)%"); + if (match.Success && int.TryParse(match.Groups[1].Value, out var percent)) + progress?.Report(percent); + } +} diff --git a/WheelWizard/Services/Storage/FilePickerHelper.cs b/WheelWizard/Services/Storage/FilePickerHelper.cs index e01a8abd..4b6270c2 100644 --- a/WheelWizard/Services/Storage/FilePickerHelper.cs +++ b/WheelWizard/Services/Storage/FilePickerHelper.cs @@ -47,7 +47,7 @@ public static async Task> OpenFilePickerAsync( if (storageProvider == null) return null; - var topLevel = TopLevel.GetTopLevel(storageProvider.MainWindow); + var topLevel = TopLevel.GetTopLevel(storageProvider.MainWindow); // Makes file picker popup not work when called from popup if (topLevel?.StorageProvider == null) return null; diff --git a/WheelWizard/SetupExtensions.cs b/WheelWizard/SetupExtensions.cs index ec4de64c..d9de9393 100644 --- a/WheelWizard/SetupExtensions.cs +++ b/WheelWizard/SetupExtensions.cs @@ -7,6 +7,7 @@ using WheelWizard.CustomCharacters; using WheelWizard.CustomDistributions; using WheelWizard.DolphinInstaller; +using WheelWizard.DolphinManagent; using WheelWizard.Features.Archives; using WheelWizard.Features.Patches; using WheelWizard.GameBanana; @@ -33,7 +34,7 @@ public static class SetupExtensions public static void AddWheelWizardServices(this IServiceCollection services) { // Features - services.AddDolphinInstaller(); + services.AddDolphinManagement(); services.AddLocalization(); services.AddSettings(); services.AddCustomCharacters(); diff --git a/WheelWizard/Views/App.axaml.cs b/WheelWizard/Views/App.axaml.cs index 399fa5a2..7f2ab655 100644 --- a/WheelWizard/Views/App.axaml.cs +++ b/WheelWizard/Views/App.axaml.cs @@ -197,6 +197,18 @@ private async Task InitializeDesktopAsync(IClassicDesktopStyleApplicationLifetim { try { + var settingsManager = Services.GetRequiredService(); + if (!settingsManager.PathsSetupCorrectly()) + { + var firstTimeSetup = new FirstTimeSetupPopup(); + var setupCompleted = await firstTimeSetup.ShowAndAwaitCompletionAsync(); + if (!setupCompleted) + { + desktop.Shutdown(); + return; + } + } + var resourceInstaller = Services.GetRequiredService(); if (resourceInstaller.GetResolvedResourcePath().IsFailure) { diff --git a/WheelWizard/Views/Popups/Generic/FirstTimeSetupPopup.axaml b/WheelWizard/Views/Popups/Generic/FirstTimeSetupPopup.axaml new file mode 100644 index 00000000..ac8ffcb8 --- /dev/null +++ b/WheelWizard/Views/Popups/Generic/FirstTimeSetupPopup.axaml @@ -0,0 +1,86 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/WheelWizard/Views/Popups/Generic/FirstTimeSetupPopup.axaml.cs b/WheelWizard/Views/Popups/Generic/FirstTimeSetupPopup.axaml.cs new file mode 100644 index 00000000..8129743f --- /dev/null +++ b/WheelWizard/Views/Popups/Generic/FirstTimeSetupPopup.axaml.cs @@ -0,0 +1,200 @@ +using System.Runtime.InteropServices; +using Avalonia.Controls; +using Avalonia.Interactivity; +using Avalonia.Layout; +using Avalonia.Media; +using Avalonia.Platform.Storage; +using Microsoft.Extensions.Logging; +using WheelWizard.DolphinManagent.Abstractions; +using WheelWizard.Services; +using WheelWizard.Settings; +using WheelWizard.Shared.DependencyInjection; +using WheelWizard.Views.Popups.Base; + +namespace WheelWizard.Views.Popups.Generic; + +public partial class FirstTimeSetupPopup : PopupContent +{ + private sealed record DolphinCandidate(string DisplayName, string? Path, bool Found); + + private readonly TaskCompletionSource _completionSource = new(); + private bool _setupCompleted; + + private string? _selectedDolphinTarget; + + [Inject] + private ILogger Logger { get; set; } = null!; + + [Inject] + private IDolphinLocator DolphinLocator { get; set; } = null!; + + [Inject] + private ISettingsManager SettingsService { get; set; } = null!; + + public FirstTimeSetupPopup() + : base(true, false, true, "Wheel Wizard") + { + InitializeComponent(); + + PlatformTextBlock.Text = $"Detected platform: {GetPlatformName()}"; + PopulateDetectedLocations(); + } + + public Task ShowAndAwaitCompletionAsync() + { + Show(); + return _completionSource.Task; + } + + private static string GetPlatformName() + { + if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) + return "Windows"; + if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX)) + return "macOS"; + if (RuntimeInformation.IsOSPlatform(OSPlatform.Linux)) + return "Linux"; + return "Unknown"; + } + + private void PopulateDetectedLocations() + { + DetectedLocationsPanel.Children.Clear(); + + var candidates = DolphinLocator.DetectInstallations(); + if (candidates.Count == 0) + { + DetectedLocationsPanel.Children.Add( + new TextBlock + { + Classes = { "BodyText" }, + Opacity = 0.75, + TextWrapping = TextWrapping.Wrap, + Text = "No Dolphin installations were detected automatically. Please select one manually below.", + } + ); + return; + } + + foreach (var candidate in candidates) + DetectedLocationsPanel.Children.Add(CreateCandidateRow(candidate)); + } + + private RadioButton CreateCandidateRow(DolphinInstallation candidate) + { + var subtitle = candidate.Found ? candidate.LaunchTarget ?? string.Empty : "Not found on this system"; + + var content = new StackPanel { Spacing = 2 }; + content.Children.Add( + new TextBlock + { + Classes = { "BodyText" }, + FontWeight = FontWeight.SemiBold, + Text = candidate.DisplayName, + } + ); + content.Children.Add( + new TextBlock + { + Classes = { "BodyText" }, + Opacity = 0.7, + TextWrapping = TextWrapping.Wrap, + Text = subtitle, + } + ); + + var radio = new RadioButton + { + GroupName = "DolphinLocation", + Content = content, + IsEnabled = candidate.Found, + Tag = candidate.LaunchTarget, + HorizontalContentAlignment = HorizontalAlignment.Stretch, + }; + radio.IsCheckedChanged += DetectedLocation_OnChecked; + return radio; + } + + private void DetectedLocation_OnChecked(object? sender, RoutedEventArgs e) + { + if (sender is not RadioButton { IsChecked: true } radio) + return; + + ManualPathTextBox.Text = string.Empty; + SetSelectedTarget(radio.Tag as string); + } + + private async void BrowseButton_OnClick(object? sender, RoutedEventArgs e) + { + try + { + var path = await OpenDolphinFilePickerAsync(); + Logger.LogInformation(path); + if (string.IsNullOrWhiteSpace(path)) + return; + + // A manual pick wins: clear any detected radio selection. + foreach (var child in DetectedLocationsPanel.Children) + { + if (child is RadioButton radio) + radio.IsChecked = false; + } + + ManualPathTextBox.Text = path; + SetSelectedTarget(path); + } + catch (Exception ex) + { + Logger.LogError(ex, "Failed to pick a Dolphin location."); + ShowError("Something went wrong while selecting the file."); + } + } + + private void SetSelectedTarget(string? target) + { + _selectedDolphinTarget = string.IsNullOrWhiteSpace(target) ? null : target; + ContinueButton.IsEnabled = _selectedDolphinTarget != null; + ErrorTextBlock.IsVisible = false; + } + + private void ContinueButton_OnClick(object? sender, RoutedEventArgs e) + { + if (string.IsNullOrWhiteSpace(_selectedDolphinTarget)) + { + ShowError("Please select a Dolphin installation to continue."); + return; + } + + SettingsService.Set(SettingsService.DOLPHIN_LOCATION, _selectedDolphinTarget); + + _setupCompleted = true; + _completionSource.TrySetResult(true); + Close(); + } + + private void CloseButton_OnClick(object? sender, RoutedEventArgs e) => Close(); + + protected override void BeforeClose() => _completionSource.TrySetResult(_setupCompleted); + + private void ShowError(string message) + { + ErrorTextBlock.Text = message; + ErrorTextBlock.IsVisible = true; + } + + private static async Task OpenDolphinFilePickerAsync() + { + var executableFileType = new FilePickerFileType("Executable files") + { + Patterns = Environment.OSVersion.Platform switch + { + PlatformID.Win32NT => new[] { "*.exe" }, + PlatformID.Unix => new[] { "*", "*.sh" }, + PlatformID.MacOSX => new[] { "*", "*.app" }, + _ => new[] { "*" }, // Fallback + }, + }; + var filePath = await FilePickerHelper.OpenSingleFileAsync("Select the Dolphin executable", [executableFileType]); + return filePath; + } +} diff --git a/build-linux.bat b/build-linux.bat old mode 100644 new mode 100755