diff --git a/WheelWizard.Test/Features/LinuxDolphinInstallerTests.cs b/WheelWizard.Test/Features/LinuxDolphinInstallerTests.cs index fb95aa3b..0c28325e 100644 --- a/WheelWizard.Test/Features/LinuxDolphinInstallerTests.cs +++ b/WheelWizard.Test/Features/LinuxDolphinInstallerTests.cs @@ -19,7 +19,10 @@ public LinuxDolphinInstallerTests() [Fact] public void IsDolphinInstalledInFlatpak_ReturnsTrue_WhenFlatpakListHasDolphin() { - _processService.Run("flatpak", "list --app --columns=application", out var stdOut, out _).Returns(Ok(0)).AndDoes(callInfo => callInfo[2] = "Application ID\norg.DolphinEmu.dolphin-emu\n"); + _processService + .Run("flatpak", "list --app --columns=application", out var stdOut, out _) + .Returns(Ok(0)) + .AndDoes(callInfo => callInfo[2] = "Application ID\norg.DolphinEmu.dolphin-emu\n"); var result = _installer.IsDolphinInstalledInFlatpak(); @@ -29,7 +32,10 @@ public void IsDolphinInstalledInFlatpak_ReturnsTrue_WhenFlatpakListHasDolphin() [Fact] public void IsDolphinInstalledInFlatpak_ReturnsFalse_WhenFlatpakListHasNoDolphin() { - _processService.Run("flatpak", "list --app --columns=application", out var stdOut, out _).Returns(Ok(0)).AndDoes(callInfo => callInfo[2] = "Application ID\n"); + _processService + .Run("flatpak", "list --app --columns=application", out var stdOut, out _) + .Returns(Ok(0)) + .AndDoes(callInfo => callInfo[2] = "Application ID\n"); var result = _installer.IsDolphinInstalledInFlatpak(); diff --git a/WheelWizard/Features/DolphinInstaller/LinuxDolphinInstaller.cs b/WheelWizard/Features/DolphinInstaller/LinuxDolphinInstaller.cs index 46ea14ee..61fef12b 100644 --- a/WheelWizard/Features/DolphinInstaller/LinuxDolphinInstaller.cs +++ b/WheelWizard/Features/DolphinInstaller/LinuxDolphinInstaller.cs @@ -17,9 +17,7 @@ public 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); + return processResult.IsSuccess && processResult.Value == 0 && stdOut.Split('\n').Any(line => line == dolphinAppId); } public bool IsDolphinInstalledNative() diff --git a/WheelWizard/Features/DolphinManagement/Abstractions/DolphinInstallation.cs b/WheelWizard/Features/DolphinManagement/Abstractions/DolphinInstallation.cs new file mode 100644 index 00000000..0bfae423 --- /dev/null +++ b/WheelWizard/Features/DolphinManagement/Abstractions/DolphinInstallation.cs @@ -0,0 +1,3 @@ +namespace WheelWizard.DolphinManagement.Abstractions; + +public record DolphinInstallation(string DisplayName, string LaunchTarget, bool Found); diff --git a/WheelWizard/Features/DolphinManagement/Abstractions/IDolphinInstaller.cs b/WheelWizard/Features/DolphinManagement/Abstractions/IDolphinInstaller.cs new file mode 100644 index 00000000..c0312af8 --- /dev/null +++ b/WheelWizard/Features/DolphinManagement/Abstractions/IDolphinInstaller.cs @@ -0,0 +1,7 @@ +namespace WheelWizard.DolphinManagement.Abstractions; + +public interface IDolphinInstaller +{ + //IReadOnlyList AvailableInstallationMethods(); + Task InstallDolphin(IProgress? progress); +} diff --git a/WheelWizard/Features/DolphinManagement/Abstractions/IDolphinLocator.cs b/WheelWizard/Features/DolphinManagement/Abstractions/IDolphinLocator.cs new file mode 100644 index 00000000..d451daf8 --- /dev/null +++ b/WheelWizard/Features/DolphinManagement/Abstractions/IDolphinLocator.cs @@ -0,0 +1,6 @@ +namespace WheelWizard.DolphinManagement.Abstractions; + +public interface IDolphinLocator +{ + IReadOnlyList DetectInstallations(); +} diff --git a/WheelWizard/Features/DolphinManagement/DolphinManagmentExtensions.cs b/WheelWizard/Features/DolphinManagement/DolphinManagmentExtensions.cs new file mode 100644 index 00000000..c0bd18b9 --- /dev/null +++ b/WheelWizard/Features/DolphinManagement/DolphinManagmentExtensions.cs @@ -0,0 +1,18 @@ +using WheelWizard.DolphinManagement.Abstractions; +using WheelWizard.DolphinManagement.Linux; + +namespace WheelWizard.DolphinManagement; + +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/DolphinManagement/Linux/LinuxCommandEnvironment.cs b/WheelWizard/Features/DolphinManagement/Linux/LinuxCommandEnvironment.cs new file mode 100644 index 00000000..f1441cf7 --- /dev/null +++ b/WheelWizard/Features/DolphinManagement/Linux/LinuxCommandEnvironment.cs @@ -0,0 +1,22 @@ +using WheelWizard.Helpers; + +namespace WheelWizard.DolphinManagement.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/DolphinManagement/Linux/LinuxDolphinInstaller.cs b/WheelWizard/Features/DolphinManagement/Linux/LinuxDolphinInstaller.cs new file mode 100644 index 00000000..01a3e8a0 --- /dev/null +++ b/WheelWizard/Features/DolphinManagement/Linux/LinuxDolphinInstaller.cs @@ -0,0 +1,81 @@ +using WheelWizard.DolphinManagement.Abstractions; + +namespace WheelWizard.DolphinManagement.Linux; + +public sealed class LinuxDolphinInstaller(ILinuxCommandEnvironment commandEnvironment, ILinuxProcessService processService) + : IDolphinInstaller +{ + private bool IsFlatpakInstalled() + { + return commandEnvironment.IsCommandAvailable("flatpak"); + } + + private async Task InstallFlatpak(IProgress? progress = null) + { + if (IsFlatpakInstalled()) + return Ok(); + + var packageManagerCommand = commandEnvironment.DetectPackageManagerInstallCommand(); + if (string.IsNullOrWhiteSpace(packageManagerCommand)) + return Fail("Unsupported Linux distribution. Could not detect a package manager command."); + + var installResult = await processService.RunWithProgressAsync("pkexec", $"{packageManagerCommand} flatpak", progress); + if (installResult.IsFailure) + return installResult.Error; + + if (installResult.Value is 126 or 127) + return Fail("You need to be an administrator to install Flatpak."); + + if (installResult.Value != 0) + return Fail($"Flatpak installation failed with exit code {installResult.Value}."); + + if (!IsFlatpakInstalled()) + return Fail("Flatpak installation completed, but Flatpak is still unavailable."); + + return Ok(); + } + + public async Task InstallDolphin(IProgress? progress = null) + { + if (!IsFlatpakInstalled()) + { + var installFlatpakResult = await InstallFlatpak(progress); + if (installFlatpakResult.IsFailure) + return installFlatpakResult; + } + + var addRemoteResult = processService.Run( + "flatpak", + "remote-add --if-not-exists --user dolphin https://flatpak.dolphin-emu.org/releases.flatpakrepo" + ); + if (addRemoteResult.IsFailure) + return addRemoteResult.Error; + + if (addRemoteResult.Value != 0) + return Fail($"Adding the Dolphin Flatpak remote failed with exit code {addRemoteResult.Value}."); + + addRemoteResult = processService.Run( + "flatpak", + "remote-add --if-not-exists --user flathub https://dl.flathub.org/repo/flathub.flatpakrepo" + ); + if (addRemoteResult.IsFailure) + return addRemoteResult.Error; + + if (addRemoteResult.Value != 0) + return Fail($"Adding the Flathub Flatpak remote failed with exit code {addRemoteResult.Value}."); + + var installDolphinResult = await processService.RunWithProgressAsync( + "flatpak", + "install --user -y dolphin org.DolphinEmu.dolphin-emu", + progress + ); + if (installDolphinResult.IsFailure) + return installDolphinResult.Error; + + if (installDolphinResult.Value != 0) + return Fail($"Dolphin installation failed with exit code {installDolphinResult.Value}."); + + var launchResult = await processService.LaunchAndStopAsync("flatpak", "run org.DolphinEmu.dolphin-emu", TimeSpan.FromSeconds(4)); + return launchResult.IsFailure ? launchResult.Error : Ok(); + } +} diff --git a/WheelWizard/Features/DolphinManagement/Linux/LinuxDolphinLocator.cs b/WheelWizard/Features/DolphinManagement/Linux/LinuxDolphinLocator.cs new file mode 100644 index 00000000..44b47e26 --- /dev/null +++ b/WheelWizard/Features/DolphinManagement/Linux/LinuxDolphinLocator.cs @@ -0,0 +1,33 @@ +using WheelWizard.DolphinManagement.Abstractions; + +namespace WheelWizard.DolphinManagement.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/DolphinManagement/Linux/LinuxProcessService.cs b/WheelWizard/Features/DolphinManagement/Linux/LinuxProcessService.cs new file mode 100644 index 00000000..746e8cc1 --- /dev/null +++ b/WheelWizard/Features/DolphinManagement/Linux/LinuxProcessService.cs @@ -0,0 +1,123 @@ +using System.Diagnostics; +using System.Text.RegularExpressions; + +namespace WheelWizard.DolphinManagement.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/Features/Settings/SettingsManager.cs b/WheelWizard/Features/Settings/SettingsManager.cs index 7f2f929a..91660837 100644 --- a/WheelWizard/Features/Settings/SettingsManager.cs +++ b/WheelWizard/Features/Settings/SettingsManager.cs @@ -23,11 +23,7 @@ public class SettingsManager : ISettingsManager private double _internalScale = -1.0; #region Constructor - public SettingsManager( - IWhWzSettingManager whWzSettingManager, - IDolphinSettingManager dolphinSettingManager, - IFileSystem fileSystem - ) + public SettingsManager(IWhWzSettingManager whWzSettingManager, IDolphinSettingManager dolphinSettingManager, IFileSystem fileSystem) { _whWzSettingManager = whWzSettingManager; _dolphinSettingManager = dolphinSettingManager; @@ -90,7 +86,10 @@ IFileSystem fileSystem return false; // `~/.dolphin-emu` would be used if it exists - if (!PathManager.IsFlatpakDolphinFilePath(dolphinLocation) && _fileSystem.Directory.Exists(PathManager.LinuxDolphinLegacyFolderPath)) + if ( + !PathManager.IsFlatpakDolphinFilePath(dolphinLocation) + && _fileSystem.Directory.Exists(PathManager.LinuxDolphinLegacyFolderPath) + ) return false; return true; diff --git a/WheelWizard/Services/Storage/FilePickerHelper.cs b/WheelWizard/Services/Storage/FilePickerHelper.cs index e01a8abd..8e6ea7e8 100644 --- a/WheelWizard/Services/Storage/FilePickerHelper.cs +++ b/WheelWizard/Services/Storage/FilePickerHelper.cs @@ -9,6 +9,18 @@ namespace WheelWizard.Services; public static class FilePickerHelper { + private static TopLevel? ResolveTopLevel(Visual? owner) + { + if (owner != null && TopLevel.GetTopLevel(owner) is { } ownerTopLevel) + return ownerTopLevel; + + if (Application.Current?.ApplicationLifetime is not IClassicDesktopStyleApplicationLifetime desktop) + return null; + + // During first time setup there is no main window yet, so fall back to whatever window is currently open + return desktop.MainWindow ?? desktop.Windows.FirstOrDefault(window => window.IsActive) ?? desktop.Windows.FirstOrDefault(); + } + /// /// Opens a file picker with the specified options. /// @@ -19,13 +31,12 @@ public static class FilePickerHelper public static async Task> OpenFilePickerAsync( FilePickerFileType fileType, bool allowMultiple = true, - string title = "Select Files" + string title = "Select Files", + Visual? owner = null ) { - var storageProvider = Application.Current?.ApplicationLifetime as IClassicDesktopStyleApplicationLifetime; - if (storageProvider == null) - return []; - if (storageProvider.MainWindow?.StorageProvider == null) + var topLevel = ResolveTopLevel(owner); + if (topLevel?.StorageProvider == null) return []; var options = new FilePickerOpenOptions @@ -35,19 +46,15 @@ public static async Task> OpenFilePickerAsync( FileTypeFilter = new List { fileType }, }; - var selectedFiles = await storageProvider.MainWindow.StorageProvider.OpenFilePickerAsync(options); + var selectedFiles = await topLevel.StorageProvider.OpenFilePickerAsync(options); return selectedFiles?.Select(TryResolveLocalPath).Where(path => !string.IsNullOrWhiteSpace(path)).Select(path => path!).ToList() ?? []; } - public static async Task OpenSingleFileAsync(string title, IEnumerable fileTypes) + public static async Task OpenSingleFileAsync(string title, IEnumerable fileTypes, Visual? owner = null) { - var storageProvider = Application.Current?.ApplicationLifetime as IClassicDesktopStyleApplicationLifetime; - if (storageProvider == null) - return null; - - var topLevel = TopLevel.GetTopLevel(storageProvider.MainWindow); + var topLevel = ResolveTopLevel(owner); if (topLevel?.StorageProvider == null) return null; @@ -73,13 +80,13 @@ public static async Task> OpenFilePickerAsync( return null; } - public static async Task> SelectFolderAsync(string title, IStorageFolder? suggestedStartLocation = null) + public static async Task> SelectFolderAsync( + string title, + IStorageFolder? suggestedStartLocation = null, + Visual? owner = null + ) { - var storageProvider = Application.Current?.ApplicationLifetime as IClassicDesktopStyleApplicationLifetime; - if (storageProvider == null) - return []; - - var topLevel = TopLevel.GetTopLevel(storageProvider.MainWindow); + var topLevel = ResolveTopLevel(owner); if (topLevel?.StorageProvider == null) return []; @@ -128,14 +135,11 @@ public static void OpenFolderInFileManager(string folderPath) string title, IEnumerable fileTypes, string defaultFileName = "untitled", - IStorageFolder? suggestedStartLocation = null + IStorageFolder? suggestedStartLocation = null, + Visual? owner = null ) { - var storageProvider = Application.Current?.ApplicationLifetime as IClassicDesktopStyleApplicationLifetime; - if (storageProvider == null) - return null; - - var topLevel = TopLevel.GetTopLevel(storageProvider.MainWindow); + var topLevel = ResolveTopLevel(owner); if (topLevel?.StorageProvider == null) return null; diff --git a/WheelWizard/SetupExtensions.cs b/WheelWizard/SetupExtensions.cs index ec4de64c..fc52ec5e 100644 --- a/WheelWizard/SetupExtensions.cs +++ b/WheelWizard/SetupExtensions.cs @@ -7,6 +7,7 @@ using WheelWizard.CustomCharacters; using WheelWizard.CustomDistributions; using WheelWizard.DolphinInstaller; +using WheelWizard.DolphinManagement; 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.DolphinStep.cs b/WheelWizard/Views/Popups/Generic/FirstTimeSetupPopup.DolphinStep.cs new file mode 100644 index 00000000..ec2d9f3d --- /dev/null +++ b/WheelWizard/Views/Popups/Generic/FirstTimeSetupPopup.DolphinStep.cs @@ -0,0 +1,212 @@ +using System.Runtime.InteropServices; +using Avalonia; +using Avalonia.Controls; +using Avalonia.Interactivity; +using Avalonia.Layout; +using Avalonia.Media; +using Avalonia.Platform.Storage; +using Microsoft.Extensions.Logging; +using WheelWizard.DolphinManagement.Abstractions; +using WheelWizard.Services; +using WheelWizard.Shared.DependencyInjection; + +namespace WheelWizard.Views.Popups.Generic; + +public partial class FirstTimeSetupPopup +{ + private string? _selectedDolphinTarget; + + [Inject] + private IDolphinLocator DolphinLocator { get; set; } = null!; + + [Inject] + private IDolphinInstaller DolphinInstaller { get; set; } = null!; + + private bool IsDolphinStepComplete => !string.IsNullOrWhiteSpace(_selectedDolphinTarget); + + private void InitializeDolphinStep() + { + PlatformTextBlock.Text = $"Detected platform: {GetPlatformName()}"; + //BrowseButton.IsEnabled = EnvHelper.IsFlatpakSandboxed(); Not sure how to check for flatpak installation because this doesnt really work + PopulateDetectedLocations(); + } + + private void SaveDolphinStep() => SettingsService.Set(SettingsService.DOLPHIN_LOCATION, _selectedDolphinTarget!); + + 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 Grid 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; + + var row = new Grid { ColumnDefinitions = new ColumnDefinitions("*,Auto") }; + row.Children.Add(radio); + + if (!candidate.Found && candidate.DisplayName.Contains("flatpak", StringComparison.OrdinalIgnoreCase)) + { + var installButton = new Components.Button + { + Variant = Components.Button.ButtonsVariantType.Default, + Text = "Install", + VerticalAlignment = VerticalAlignment.Center, + Margin = new Thickness(8, 0, 0, 0), + }; + installButton.Click += InstallFlatpakButton_OnClick; + Grid.SetColumn(installButton, 1); + row.Children.Add(installButton); + } + + return row; + } + + private async void InstallFlatpakButton_OnClick(object? sender, RoutedEventArgs e) + { + var progressWindow = new ProgressWindow() + .SetGoal(t("progress.installing_dolphin")) + .SetExtraText(t("progress.this_may_take_a_while")); + progressWindow.Show(); + var progress = new Progress(progressWindow.UpdateProgress); + var installResult = await DolphinInstaller.InstallDolphin(progress); + progressWindow.Close(); + if (installResult.IsFailure) + { + await new MessageBoxWindow() + .SetMessageType(MessageBoxWindow.MessageType.Error) + .SetTitleText("Failed to install Dolphin") + .SetInfoText(installResult.Error.Message) + .ShowDialog(); + return; + } + + // Reload all radio buttons + // TODO: Also reload the subtext of every radio button after install + var candidates = DolphinLocator.DetectInstallations(); + foreach (var radio in DetectedLocationsPanel.Children.SelectMany(GetRadioButtons)) + { + if (radio?.Tag is null) + continue; + var match = candidates.FirstOrDefault(c => radio.Tag.Equals(c.LaunchTarget)); + if (match != null) + { + radio.IsEnabled = match.Found; + } + } + } + + private static IEnumerable GetRadioButtons(Control row) => row is Panel panel ? panel.Children.OfType() : []; + + 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(this); + Logger.LogInformation(path); + if (string.IsNullOrWhiteSpace(path)) + return; + + // A manual pick wins: clear any detected radio selection. + foreach (var radio in DetectedLocationsPanel.Children.SelectMany(GetRadioButtons)) + 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; + UpdateContinueState(); + } + + private static async Task OpenDolphinFilePickerAsync(Visual owner) + { + 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], owner); + return filePath; + } +} diff --git a/WheelWizard/Views/Popups/Generic/FirstTimeSetupPopup.GamePathsStep.cs b/WheelWizard/Views/Popups/Generic/FirstTimeSetupPopup.GamePathsStep.cs new file mode 100644 index 00000000..effc1f73 --- /dev/null +++ b/WheelWizard/Views/Popups/Generic/FirstTimeSetupPopup.GamePathsStep.cs @@ -0,0 +1,72 @@ +using Avalonia.Interactivity; +using Avalonia.Platform.Storage; +using Microsoft.Extensions.Logging; +using WheelWizard.Services; + +namespace WheelWizard.Views.Popups.Generic; + +public partial class FirstTimeSetupPopup +{ + private string? _selectedUserFolder; + private string? _selectedGameFile; + + private bool IsGamePathsStepComplete => + !string.IsNullOrWhiteSpace(_selectedUserFolder) && !string.IsNullOrWhiteSpace(_selectedGameFile); + + private void SaveGamePathsStep() + { + SettingsService.Set(SettingsService.USER_FOLDER_PATH, _selectedUserFolder!); + SettingsService.Set(SettingsService.GAME_LOCATION, _selectedGameFile!); + } + + private void AutoDetectPaths() + { + var folderPath = PathManager.TryFindUserFolderPath(); + if (!string.IsNullOrEmpty(folderPath)) + UserFolderTextBox.Text = folderPath; + _selectedUserFolder = folderPath; + } + + private async void BrowseUserFolderButton_OnClick(object? sender, RoutedEventArgs e) + { + try + { + var folders = await FilePickerHelper.SelectFolderAsync("Select the Dolphin user folder", owner: this); + var path = FilePickerHelper.TryResolveLocalPath(folders.FirstOrDefault()); + if (string.IsNullOrWhiteSpace(path)) + return; + + UserFolderTextBox.Text = path; + _selectedUserFolder = path; + UpdateContinueState(); + } + catch (Exception ex) + { + Logger.LogError(ex, "Failed to pick the Dolphin user folder."); + ShowError("Something went wrong while selecting the folder."); + } + } + + private async void BrowseGameFileButton_OnClick(object? sender, RoutedEventArgs e) + { + try + { + var gameFileType = new FilePickerFileType("Wii game files") + { + Patterns = ["*.iso", "*.wbfs", "*.rvz", "*.ciso", "*.nkit.iso", "*.wia"], + }; + var path = await FilePickerHelper.OpenSingleFileAsync("Select your Mario Kart Wii game file", [gameFileType], this); + if (string.IsNullOrWhiteSpace(path)) + return; + + GameFileTextBox.Text = path; + _selectedGameFile = path; + UpdateContinueState(); + } + catch (Exception ex) + { + Logger.LogError(ex, "Failed to pick the Mario Kart Wii game file."); + ShowError("Something went wrong while selecting the file."); + } + } +} diff --git a/WheelWizard/Views/Popups/Generic/FirstTimeSetupPopup.axaml b/WheelWizard/Views/Popups/Generic/FirstTimeSetupPopup.axaml new file mode 100644 index 00000000..996e8ae5 --- /dev/null +++ b/WheelWizard/Views/Popups/Generic/FirstTimeSetupPopup.axaml @@ -0,0 +1,154 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/WheelWizard/Views/Popups/Generic/FirstTimeSetupPopup.axaml.cs b/WheelWizard/Views/Popups/Generic/FirstTimeSetupPopup.axaml.cs new file mode 100644 index 00000000..092b6374 --- /dev/null +++ b/WheelWizard/Views/Popups/Generic/FirstTimeSetupPopup.axaml.cs @@ -0,0 +1,119 @@ +using Avalonia.Interactivity; +using Microsoft.Extensions.Logging; +using WheelWizard.Settings; +using WheelWizard.Shared.DependencyInjection; +using WheelWizard.Views.Popups.Base; + +namespace WheelWizard.Views.Popups.Generic; + +public partial class FirstTimeSetupPopup : PopupContent +{ + private enum SetupStep + { + Dolphin, + GamePaths, + } + + private readonly TaskCompletionSource _completionSource = new(); + private bool _setupCompleted; + + private SetupStep _currentStep = SetupStep.Dolphin; + + [Inject] + private ILogger Logger { get; set; } = null!; + + [Inject] + private ISettingsManager SettingsService { get; set; } = null!; + + public bool WasSkipped { get; private set; } + + public FirstTimeSetupPopup() + : base(true, false, false, "Wheel Wizard") + { + InitializeComponent(); + + InitializeDolphinStep(); + ShowStep(SetupStep.Dolphin); + AutoDetectPaths(); + } + + public Task ShowAndAwaitCompletionAsync() + { + Show(); + return _completionSource.Task; + } + + private void ShowStep(SetupStep step) + { + _currentStep = step; + + DolphinStepPanel.IsVisible = step == SetupStep.Dolphin; + GamePathsStepPanel.IsVisible = step == SetupStep.GamePaths; + + BackButton.IsVisible = step == SetupStep.GamePaths; + SkipButton.IsVisible = step == SetupStep.GamePaths; + + UpdateContinueState(); + } + + private void UpdateContinueState() + { + ContinueButton.IsEnabled = _currentStep switch + { + SetupStep.Dolphin => IsDolphinStepComplete, + SetupStep.GamePaths => IsGamePathsStepComplete, + _ => false, + }; + ErrorTextBlock.IsVisible = false; + } + + private void ContinueButton_OnClick(object? sender, RoutedEventArgs e) + { + if (_currentStep == SetupStep.Dolphin) + { + if (!IsDolphinStepComplete) + { + ShowError("Please select a Dolphin installation to continue."); + return; + } + + SaveDolphinStep(); + ShowStep(SetupStep.GamePaths); + return; + } + + if (!IsGamePathsStepComplete) + { + ShowError("Please select both the Dolphin user folder and your Mario Kart Wii game file."); + return; + } + + SaveGamePathsStep(); + CompleteSetup(); + } + + private void BackButton_OnClick(object? sender, RoutedEventArgs e) => ShowStep(SetupStep.Dolphin); + + private void SkipButton_OnClick(object? sender, RoutedEventArgs e) + { + WasSkipped = true; + CompleteSetup(); + } + + private void CompleteSetup() + { + _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; + } +} diff --git a/build-linux.bat b/build-linux.bat old mode 100644 new mode 100755