diff --git a/Daybreak.Core/Services/ApplicationLauncher/ApplicationLauncher.cs b/Daybreak.Core/Services/ApplicationLauncher/ApplicationLauncher.cs index b3c7ee15..578e063d 100644 --- a/Daybreak.Core/Services/ApplicationLauncher/ApplicationLauncher.cs +++ b/Daybreak.Core/Services/ApplicationLauncher/ApplicationLauncher.cs @@ -28,9 +28,10 @@ internal sealed class ApplicationLauncher( INotificationService notificationService, IOptionsMonitor launcherOptions, IModsManager modsManager, - IPrivilegeManager privilegeManager, IGuildWarsReadyChecker guildWarsReadyChecker, IGuildWarsProcessFinder guildWarsProcessFinder, + IDaybreakRestartingService daybreakRestartingService, + IPrivilegeManager privilegeManager, ILogger logger ) : IApplicationLauncher { @@ -48,11 +49,15 @@ internal sealed class ApplicationLauncher( launcherOptions.ThrowIfNull(); private readonly IModsManager modsManager = modsManager.ThrowIfNull(); private readonly ILogger logger = logger.ThrowIfNull(); - private readonly IPrivilegeManager privilegeManager = privilegeManager.ThrowIfNull(); private readonly IGuildWarsReadyChecker guildWarsReadyChecker = guildWarsReadyChecker.ThrowIfNull(); private readonly IGuildWarsProcessFinder guildWarsProcessFinder = guildWarsProcessFinder.ThrowIfNull(); + private readonly IDaybreakRestartingService daybreakRestartingService = + daybreakRestartingService.ThrowIfNull(); + + private readonly IPrivilegeManager privilegeManager = + privilegeManager.ThrowIfNull(); public async Task LaunchGuildwars( LaunchConfigurationWithCredentials launchConfigurationWithCredentials, @@ -83,76 +88,19 @@ internal sealed class ApplicationLauncher( }; } - // TODO: Needs to be reworked to be cross-platform public void RestartDaybreak() { - if (this.privilegeManager.AdminPrivileges) - { - this.RestartDaybreakAsAdmin(); - } - else - { - this.RestartDaybreakAsNormalUser(); - } + this.daybreakRestartingService.RestartDaybreak(); } - // TODO: Needs to be reworked to be cross-platform public void RestartDaybreakAsAdmin() { - this.logger.LogInformation("Restarting daybreak with admin rights"); - var processName = Process.GetCurrentProcess()?.MainModule?.FileName; - if (processName!.IsNullOrWhiteSpace() || File.Exists(processName) is false) - { - throw new InvalidOperationException("Unable to find executable. Aborting restart"); - } - - var process = new Process() - { - StartInfo = new() - { - Verb = "runas", - WorkingDirectory = Environment.CurrentDirectory, - UseShellExecute = true, - CreateNoWindow = true, - FileName = "cmd.exe", - Arguments = - $"/c cd /d \"{Environment.CurrentDirectory}\" && timeout /t 1 /nobreak && start \"\" \"{processName}\" && exit", - }, - }; - if (process.Start() is false) - { - throw new InvalidOperationException($"Unable to start {processName} as admin"); - } - - this.photinoWindow.Close(); + this.daybreakRestartingService.RestartDaybreakAsAdmin(); } - // TODO: Needs to be reworked to be cross-platform public void RestartDaybreakAsNormalUser() { - this.logger.LogInformation("Restarting daybreak as normal user"); - var processName = Process.GetCurrentProcess()?.MainModule?.FileName; - if (processName!.IsNullOrWhiteSpace() || File.Exists(processName) is false) - { - throw new InvalidOperationException("Unable to find executable. Aborting restart"); - } - - var process = new Process() - { - StartInfo = new() - { - WorkingDirectory = Environment.CurrentDirectory, - FileName = processName, - UseShellExecute = true, - CreateNoWindow = true, - }, - }; - if (process.Start() is false) - { - throw new InvalidOperationException($"Unable to start {processName} as normal user"); - } - - this.photinoWindow.Close(); + this.daybreakRestartingService.RestartDaybreakAsNormalUser(); } private async Task LaunchGuildwarsProcess( diff --git a/Daybreak.Installer/Program.cs b/Daybreak.Installer/Program.cs index faa185df..0c7f49f1 100644 --- a/Daybreak.Installer/Program.cs +++ b/Daybreak.Installer/Program.cs @@ -4,8 +4,8 @@ using System.Net; using System.Text; const string LatestUrl = "https://github.com/gwdevhub/Daybreak/releases/latest"; -var daybreakExecutable = OperatingSystem.IsWindows() ? "Daybreak.exe" : "Daybreak.Linux"; -var daybreakProcessName = OperatingSystem.IsWindows() ? "Daybreak" : "Daybreak.Linux"; +var daybreakExecutable = OperatingSystem.IsWindows() ? "Daybreak.exe" : "Daybreak"; +var daybreakProcessName = "Daybreak"; var releaseAssetSuffix = OperatingSystem.IsWindows() ? "" : "-linux"; static void RenderProgressBar(int currentStep, int totalSteps, int barSize) diff --git a/Daybreak.Linux/Configuration/LinuxPlatformConfiguration.cs b/Daybreak.Linux/Configuration/LinuxPlatformConfiguration.cs index 9d11555f..3dd8b5d6 100644 --- a/Daybreak.Linux/Configuration/LinuxPlatformConfiguration.cs +++ b/Daybreak.Linux/Configuration/LinuxPlatformConfiguration.cs @@ -61,6 +61,7 @@ public sealed class LinuxPlatformConfiguration : PluginConfigurationBase services.AddHostedSingleton(); services.AddSingleton(); services.AddSingleton(); + services.AddSingleton(); } public override void RegisterStartupActions(IStartupActionProducer startupActionProducer) diff --git a/Daybreak.Linux/Services/ApplicationLauncher/DaybreakRestartingService.cs b/Daybreak.Linux/Services/ApplicationLauncher/DaybreakRestartingService.cs new file mode 100644 index 00000000..37d437da --- /dev/null +++ b/Daybreak.Linux/Services/ApplicationLauncher/DaybreakRestartingService.cs @@ -0,0 +1,154 @@ +using Daybreak.Shared.Services.ApplicationLauncher; +using Daybreak.Shared.Services.Privilege; +using Microsoft.Extensions.Logging; +using Photino.NET; +using System.Core.Extensions; +using System.Diagnostics; +using System.Extensions; +using System.Extensions.Core; + +namespace Daybreak.Linux.Services.ApplicationLauncher; + +/// +/// Linux-specific implementation for restarting the Daybreak application. +/// Uses bash for process spawning and pkexec/sudo for privilege elevation. +/// +internal sealed class DaybreakRestartingService( + PhotinoWindow photinoWindow, + IPrivilegeManager privilegeManager, + ILogger logger +) : IDaybreakRestartingService +{ + private readonly PhotinoWindow photinoWindow = photinoWindow.ThrowIfNull(); + private readonly IPrivilegeManager privilegeManager = privilegeManager.ThrowIfNull(); + private readonly ILogger logger = logger.ThrowIfNull(); + + public void RestartDaybreak() + { + if (this.privilegeManager.AdminPrivileges) + { + this.RestartDaybreakAsAdmin(); + } + else + { + this.RestartDaybreakAsNormalUser(); + } + } + + public void RestartDaybreakAsAdmin() + { + this.logger.LogInformation("Restarting daybreak with elevated privileges"); + var processName = Process.GetCurrentProcess()?.MainModule?.FileName; + if (processName!.IsNullOrWhiteSpace() || File.Exists(processName) is false) + { + throw new InvalidOperationException("Unable to find executable. Aborting restart"); + } + + // On Linux, use pkexec for graphical privilege elevation + // Fall back to sudo if pkexec is not available + var elevationCommand = GetElevationCommand(); + if (elevationCommand is null) + { + this.logger.LogWarning("No elevation command (pkexec/sudo) found. Restarting without elevation"); + this.RestartDaybreakAsNormalUser(); + return; + } + + var process = new Process() + { + StartInfo = new() + { + WorkingDirectory = Environment.CurrentDirectory, + UseShellExecute = false, + CreateNoWindow = true, + FileName = "/bin/bash", + Arguments = $"-c \"sleep 1 && {elevationCommand} \\\"{processName}\\\" &\"", + }, + }; + if (process.Start() is false) + { + throw new InvalidOperationException($"Unable to start {processName} with elevated privileges"); + } + + this.photinoWindow.Close(); + } + + public void RestartDaybreakAsNormalUser() + { + this.logger.LogInformation("Restarting daybreak as normal user"); + var processName = Process.GetCurrentProcess()?.MainModule?.FileName; + if (processName!.IsNullOrWhiteSpace() || File.Exists(processName) is false) + { + throw new InvalidOperationException("Unable to find executable. Aborting restart"); + } + + // Use nohup to ensure the process survives parent exit, and disown to detach from terminal + var process = new Process() + { + StartInfo = new() + { + WorkingDirectory = Environment.CurrentDirectory, + UseShellExecute = false, + CreateNoWindow = true, + FileName = "/bin/bash", + Arguments = $"-c \"sleep 1 && nohup \\\"{processName}\\\" > /dev/null 2>&1 &\"", + }, + }; + if (process.Start() is false) + { + throw new InvalidOperationException($"Unable to start {processName} as normal user"); + } + + this.photinoWindow.Close(); + } + + /// + /// Gets the appropriate elevation command for the system. + /// Prefers pkexec (graphical) over sudo (terminal-based). + /// + private static string? GetElevationCommand() + { + // Check for pkexec first (graphical polkit elevation) + if (CommandExists("pkexec")) + { + return "pkexec"; + } + + // Fall back to sudo + if (CommandExists("sudo")) + { + return "sudo"; + } + + return null; + } + + /// + /// Checks if a command exists in the system PATH. + /// + private static bool CommandExists(string command) + { + try + { + var process = new Process() + { + StartInfo = new() + { + FileName = "/bin/bash", + Arguments = $"-c \"command -v {command}\"", + UseShellExecute = false, + RedirectStandardOutput = true, + CreateNoWindow = true, + }, + }; + process.Start(); + var output = process.StandardOutput.ReadToEnd(); + process.WaitForExit(); + return process.ExitCode == 0 && !string.IsNullOrWhiteSpace(output); + } + catch + { + return false; + } + } +} diff --git a/Daybreak.Shared/Services/ApplicationLauncher/IDaybreakRestartingService.cs b/Daybreak.Shared/Services/ApplicationLauncher/IDaybreakRestartingService.cs new file mode 100644 index 00000000..26132c6d --- /dev/null +++ b/Daybreak.Shared/Services/ApplicationLauncher/IDaybreakRestartingService.cs @@ -0,0 +1,22 @@ +namespace Daybreak.Shared.Services.ApplicationLauncher; + +/// +/// Platform-specific service for restarting the Daybreak application. +/// +public interface IDaybreakRestartingService +{ + /// + /// Restarts Daybreak, choosing the appropriate privilege level based on current state. + /// + void RestartDaybreak(); + + /// + /// Restarts Daybreak with elevated privileges. + /// + void RestartDaybreakAsAdmin(); + + /// + /// Restarts Daybreak as a normal user (without elevation). + /// + void RestartDaybreakAsNormalUser(); +} diff --git a/Daybreak.Windows/Configuration/WindowsPlatformConfiguration.cs b/Daybreak.Windows/Configuration/WindowsPlatformConfiguration.cs index 7e2157bd..5b03800f 100644 --- a/Daybreak.Windows/Configuration/WindowsPlatformConfiguration.cs +++ b/Daybreak.Windows/Configuration/WindowsPlatformConfiguration.cs @@ -80,6 +80,7 @@ public sealed class WindowsPlatformConfiguration : PluginConfigurationBase services.AddHostedSingleton(); services.AddSingleton(); services.AddSingleton(); + services.AddSingleton(); } public override void RegisterMods(IModsProducer modsProducer) diff --git a/Daybreak.Windows/Services/ApplicationLauncher/DaybreakRestartingService.cs b/Daybreak.Windows/Services/ApplicationLauncher/DaybreakRestartingService.cs new file mode 100644 index 00000000..e1a95533 --- /dev/null +++ b/Daybreak.Windows/Services/ApplicationLauncher/DaybreakRestartingService.cs @@ -0,0 +1,94 @@ +using Daybreak.Shared.Services.ApplicationLauncher; +using Daybreak.Shared.Services.Privilege; +using Microsoft.Extensions.Logging; +using Photino.NET; +using System.Core.Extensions; +using System.Diagnostics; +using System.Extensions; +using System.Extensions.Core; + +namespace Daybreak.Windows.Services.ApplicationLauncher; + +/// +/// Windows-specific implementation for restarting the Daybreak application. +/// Uses cmd.exe with 'runas' verb for admin elevation. +/// +internal sealed class DaybreakRestartingService( + PhotinoWindow photinoWindow, + IPrivilegeManager privilegeManager, + ILogger logger +) : IDaybreakRestartingService +{ + private readonly PhotinoWindow photinoWindow = photinoWindow.ThrowIfNull(); + private readonly IPrivilegeManager privilegeManager = privilegeManager.ThrowIfNull(); + private readonly ILogger logger = logger.ThrowIfNull(); + + public void RestartDaybreak() + { + if (this.privilegeManager.AdminPrivileges) + { + this.RestartDaybreakAsAdmin(); + } + else + { + this.RestartDaybreakAsNormalUser(); + } + } + + public void RestartDaybreakAsAdmin() + { + this.logger.LogInformation("Restarting daybreak with admin rights"); + var processName = Process.GetCurrentProcess()?.MainModule?.FileName; + if (processName!.IsNullOrWhiteSpace() || File.Exists(processName) is false) + { + throw new InvalidOperationException("Unable to find executable. Aborting restart"); + } + + var process = new Process() + { + StartInfo = new() + { + Verb = "runas", + WorkingDirectory = Environment.CurrentDirectory, + UseShellExecute = true, + CreateNoWindow = true, + FileName = "cmd.exe", + Arguments = + $"/c cd /d \"{Environment.CurrentDirectory}\" && timeout /t 1 /nobreak && start \"\" \"{processName}\" && exit", + }, + }; + if (process.Start() is false) + { + throw new InvalidOperationException($"Unable to start {processName} as admin"); + } + + this.photinoWindow.Close(); + } + + public void RestartDaybreakAsNormalUser() + { + this.logger.LogInformation("Restarting daybreak as normal user"); + var processName = Process.GetCurrentProcess()?.MainModule?.FileName; + if (processName!.IsNullOrWhiteSpace() || File.Exists(processName) is false) + { + throw new InvalidOperationException("Unable to find executable. Aborting restart"); + } + + var process = new Process() + { + StartInfo = new() + { + WorkingDirectory = Environment.CurrentDirectory, + FileName = processName, + UseShellExecute = true, + CreateNoWindow = true, + }, + }; + if (process.Start() is false) + { + throw new InvalidOperationException($"Unable to start {processName} as normal user"); + } + + this.photinoWindow.Close(); + } +}