Native linux support (#1378) (Closes #1375 Closes #1376 Closes #1377 Closes #1374 Closes #1373 Closes #1372 Closes #1371)

This commit is contained in:
2026-02-07 17:04:20 -08:00
parent 77e3d5ddf2
commit 824881c108
626 changed files with 7640 additions and 1803 deletions
@@ -0,0 +1,88 @@
using Daybreak.Extensions;
using Daybreak.Linux.Services.ApplicationLauncher;
using Daybreak.Linux.Services.Credentials;
using Daybreak.Linux.Services.Injection;
using Daybreak.Linux.Services.Keyboard;
using Daybreak.Linux.Services.MDns;
using Daybreak.Linux.Services.Privilege;
using Daybreak.Linux.Services.Screens;
using Daybreak.Linux.Services.Startup.Actions;
using Daybreak.Linux.Services.Startup.Notifications;
using Daybreak.Linux.Services.UMod;
using Daybreak.Linux.Services.DirectSong;
using Daybreak.Linux.Services.Registry;
using Daybreak.Linux.Services.Themes;
using Daybreak.Linux.Services.SevenZip;
using Daybreak.Linux.Services.Window;
using Daybreak.Linux.Services.ExceptionHandling;
using Daybreak.Linux.Services.Wine;
using Daybreak.Shared.Models.Plugins;
using Daybreak.Shared.Services.ApplicationLauncher;
using Daybreak.Shared.Services.Credentials;
using Daybreak.Shared.Services.DirectSong;
using Daybreak.Shared.Services.Registry;
using Daybreak.Shared.Services.FileProviders;
using Daybreak.Shared.Services.Initialization;
using Daybreak.Shared.Services.Injection;
using Daybreak.Shared.Services.Keyboard;
using Daybreak.Shared.Services.MDns;
using Daybreak.Shared.Services.Privilege;
using Daybreak.Shared.Services.Screens;
using Daybreak.Shared.Services.Themes;
using Daybreak.Shared.Services.SevenZip;
using Daybreak.Shared.Services.UMod;
using Daybreak.Shared.Services.ExceptionHandling;
using Daybreak.Shared.Services.Window;
using Microsoft.Extensions.DependencyInjection;
namespace Daybreak.Linux.Configuration;
/// <summary>
/// Linux-specific platform configuration.
/// Registers Linux-specific services and overrides.
/// </summary>
public sealed class LinuxPlatformConfiguration : PluginConfigurationBase
{
public override void RegisterServices(IServiceCollection services)
{
services.AddHostedSingleton<IScreenManager, ScreenManager>();
services.AddHostedSingleton<IKeyboardHookService, KeyboardHookService>();
services.AddScoped<IPrivilegeManager, PrivilegeManager>();
services.AddScoped<IDaybreakInjector, DaybreakInjector>();
services.AddSingleton<ICredentialProtector, CredentialProtector>();
services.AddSingleton<IWinePidMapper, WinePidMapper>();
services.AddScoped<IGuildWarsReadyChecker, GuildWarsReadyChecker>();
services.AddScoped<IGuildWarsProcessFinder, GuildWarsProcessFinder>();
services.AddSingleton<IModPathResolver, ModPathResolver>();
services.AddSingleton<IDirectSongRegistrar, DirectSongRegistrar>();
services.AddScoped<IRegistryService, RegistryService>();
services.AddSingleton<ISystemThemeDetector, SystemThemeDetector>();
services.AddSingleton<ISevenZipExtractor, SevenZipArchiveExtractor>();
services.AddHostedSingleton<IMDomainRegistrar, PortScanningDomainRegistrar>();
services.AddSingleton<IWindowManipulationService, WindowManipulationService>();
services.AddSingleton<ICrashDumpService, CrashDumpService>();
}
public override void RegisterStartupActions(IStartupActionProducer startupActionProducer)
{
// Check Wine prefix setup on startup
startupActionProducer.RegisterAction<SetupWinePrefixAction>();
}
public override void RegisterProviderAssemblies(IFileProviderProducer fileProviderProducer)
{
fileProviderProducer.RegisterAssembly(typeof(LinuxPlatformConfiguration).Assembly);
}
public override void RegisterNotificationHandlers(
INotificationHandlerProducer notificationHandlerProducer
)
{
notificationHandlerProducer.RegisterNotificationHandler<WinePrefixSetupHandler>();
}
public override void RegisterMods(IModsProducer modsProducer)
{
modsProducer.RegisterMod<IWinePrefixManager, WinePrefixManager>(singleton: true);
}
}
+38
View File
@@ -0,0 +1,38 @@
<Project Sdk="Microsoft.NET.Sdk.Razor">
<PropertyGroup>
<OutputType>Exe</OutputType>
<AssemblyName>Daybreak</AssemblyName>
<RootNamespace>Daybreak.Linux</RootNamespace>
<TargetFramework>$(TargetFrameworkVersion)</TargetFramework>
<Nullable>enable</Nullable>
<Platforms>x64</Platforms>
<LangVersion>preview</LangVersion>
<RuntimeIdentifier>linux-x64</RuntimeIdentifier>
<SelfContained>true</SelfContained>
<PublishSingleFile>false</PublishSingleFile>
<SatelliteResourceLanguages>en</SatelliteResourceLanguages>
</PropertyGroup>
<PropertyGroup Condition="'$(Configuration)' == 'Release'">
<StripSymbols>true</StripSymbols>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Photino.Blazor" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\Daybreak.Core\Daybreak.Core.csproj" />
</ItemGroup>
<ItemGroup>
<Content Include="Daybreak.Wayland.sh" CopyToOutputDirectory="PreserveNewest" />
</ItemGroup>
<Target Name="SetShellScriptPermissions" AfterTargets="Build;Publish">
<Exec Command="chmod +x $(OutputPath)Daybreak.Wayland.sh" Condition="Exists('$(OutputPath)Daybreak.Wayland.sh')" />
<Exec Command="chmod +x $(PublishDir)Daybreak.Wayland.sh" Condition="Exists('$(PublishDir)Daybreak.Wayland.sh')" />
</Target>
</Project>
+17
View File
@@ -0,0 +1,17 @@
#!/usr/bin/env bash
# Daybreak Linux launcher wrapper
# Sets environment variables BEFORE the .NET runtime loads, so native
# libraries (Mesa, EGL, libwayland, WebKitGTK) see them at init time.
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
# Force X11 backend — Photino/WebKitGTK/GTK3 doesn't support Wayland natively
export GDK_BACKEND="${GDK_BACKEND:-x11}"
# Prevent WebKitGTK from using DMA-BUF (crashes on some Nvidia/Mesa combos)
export WEBKIT_DISABLE_DMABUF_RENDERER="${WEBKIT_DISABLE_DMABUF_RENDERER:-1}"
# Disable GPU compositing (blank windows on some drivers)
export WEBKIT_DISABLE_COMPOSITING_MODE="${WEBKIT_DISABLE_COMPOSITING_MODE:-1}"
exec "$SCRIPT_DIR/Daybreak" "$@"
+51
View File
@@ -0,0 +1,51 @@
using Daybreak.Linux.Configuration;
using System.Diagnostics;
namespace Daybreak.Linux;
public static partial class Launcher
{
public static void Main(string[] args)
{
ConfigureEnvironment();
var bootstrap = Launch.Launcher.SetupBootstrap();
var platformConfiguration = new LinuxPlatformConfiguration();
Launch.Launcher.LaunchSequence(args, bootstrap, platformConfiguration);
}
/// <summary>
/// Configures environment variables needed for WebKitGTK/Photino to work
/// reliably across different Linux display servers and GPU drivers.
/// Variables are only set when not already defined by the user, so they
/// can always be overridden externally.
/// </summary>
private static void ConfigureEnvironment()
{
// These environment variables must ideally be set BEFORE the process
// starts (e.g. via the launch wrapper script) so that native libraries
// like Mesa/EGL and libwayland see them at load time. Setting them here
// serves as a fallback for cases where the wrapper isn't used, but may
// not prevent all Wayland-related crashes.
// WebKitGTK's DMA-BUF renderer can crash or produce artifacts on
// certain GPU driver combinations (especially Nvidia proprietary).
SetIfUnset("WEBKIT_DISABLE_DMABUF_RENDERER", "1");
// GPU-accelerated compositing in WebKitGTK can cause blank windows
// or crashes with some Mesa/Nvidia drivers.
SetIfUnset("WEBKIT_DISABLE_COMPOSITING_MODE", "1");
// Photino (via WebKitGTK + GTK3) does not fully support native Wayland.
// Force X11 (via XWayland on Wayland sessions).
SetIfUnset("GDK_BACKEND", "x11");
}
private static void SetIfUnset(string variable, string value)
{
if (string.IsNullOrEmpty(Environment.GetEnvironmentVariable(variable)))
{
Environment.SetEnvironmentVariable(variable, value);
}
}
}
@@ -0,0 +1,183 @@
using Daybreak.Linux.Services.Wine;
using Daybreak.Shared.Models.LaunchConfigurations;
using Daybreak.Shared.Services.ApplicationLauncher;
using Microsoft.Extensions.Logging;
using System.Diagnostics;
using System.Extensions.Core;
namespace Daybreak.Linux.Services.ApplicationLauncher;
/// <summary>
/// Linux-specific Guild Wars process finder.
/// Scans /proc for Wine-hosted GW.exe processes in our Wine prefix,
/// then matches them to launch configurations by executable path
/// read from the process command line.
/// </summary>
public sealed class GuildWarsProcessFinder(
IWinePrefixManager winePrefixManager,
ILogger<GuildWarsProcessFinder> logger)
: IGuildWarsProcessFinder
{
private const string GuildWarsExeName = "Gw.exe";
private readonly IWinePrefixManager winePrefixManager = winePrefixManager;
private readonly ILogger<GuildWarsProcessFinder> logger = logger;
public IReadOnlyList<Process> GetGuildWarsProcesses()
{
var scopedLogger = this.logger.CreateScopedLogger();
var processes = new List<Process>();
foreach (var (pid, _) in this.ScanForGuildWarsProcesses())
{
try
{
processes.Add(Process.GetProcessById(pid));
}
catch (Exception ex)
{
scopedLogger.LogError(ex, "Could not get process for PID {Pid}", pid);
}
}
scopedLogger.LogDebug("Found {Count} Guild Wars process(es) under Wine", processes.Count);
return processes;
}
public GuildWarsApplicationLaunchContext? FindProcess(LaunchConfigurationWithCredentials configuration)
{
return this.FindProcesses(configuration).FirstOrDefault();
}
public IEnumerable<GuildWarsApplicationLaunchContext?> FindProcesses(params LaunchConfigurationWithCredentials[] configurations)
{
var scopedLogger = this.logger.CreateScopedLogger();
foreach (var (pid, executablePath) in this.ScanForGuildWarsProcesses())
{
// Match executable path from Wine cmdline to configuration
var matchedConfig = configurations.FirstOrDefault(c =>
ConfigurationMatchesPath(c, executablePath));
if (matchedConfig is null)
{
continue;
}
Process process;
try
{
process = Process.GetProcessById(pid);
}
catch (Exception ex)
{
scopedLogger.LogError(ex, "Could not get process for PID {Pid}", pid);
continue;
}
yield return new GuildWarsApplicationLaunchContext
{
GuildWarsProcess = process,
LaunchConfiguration = matchedConfig,
ProcessId = (uint)pid
};
}
}
/// <summary>
/// Scans /proc for processes running Gw.exe under our Wine prefix.
/// Returns tuples of (Linux PID, executable path from cmdline).
/// </summary>
private IEnumerable<(int Pid, string ExecutablePath)> ScanForGuildWarsProcesses()
{
var prefixPath = this.winePrefixManager.GetWinePrefixPath();
foreach (var procDir in Directory.EnumerateDirectories("/proc"))
{
var dirName = Path.GetFileName(procDir);
if (!int.TryParse(dirName, out var pid))
{
continue;
}
string? executablePath = null;
try
{
var cmdlinePath = Path.Combine(procDir, "cmdline");
if (!File.Exists(cmdlinePath))
{
continue;
}
var cmdline = File.ReadAllText(cmdlinePath);
if (!cmdline.Contains(GuildWarsExeName, StringComparison.OrdinalIgnoreCase))
{
continue;
}
// Verify this process belongs to our Wine prefix
var environPath = Path.Combine(procDir, "environ");
if (!File.Exists(environPath))
{
continue;
}
var environ = File.ReadAllText(environPath);
if (!environ.Contains(prefixPath, StringComparison.Ordinal))
{
continue;
}
// Extract the executable path from cmdline
// cmdline is null-separated; find the segment containing Gw.exe
var segments = cmdline.Split('\0', StringSplitOptions.RemoveEmptyEntries);
var exeSegment = segments.FirstOrDefault(s =>
s.EndsWith(GuildWarsExeName, StringComparison.OrdinalIgnoreCase));
if (exeSegment is not null)
{
// Convert Wine path (Z:\mnt\...\Gw.exe) to Linux path
executablePath = WinePathToLinuxPath(exeSegment);
}
}
catch (UnauthorizedAccessException) { continue; }
catch (IOException) { continue; }
if (executablePath is not null)
{
yield return (pid, executablePath);
}
}
}
/// <summary>
/// Converts a Wine Z: path back to a Linux path.
/// "Z:\mnt\seagate\Games\Guild Wars\Gw.exe" -> "/mnt/seagate/Games/Guild Wars/Gw.exe"
/// </summary>
private static string WinePathToLinuxPath(string winePath)
{
// Strip Z: prefix and convert backslashes
if (winePath.StartsWith("Z:", StringComparison.OrdinalIgnoreCase) ||
winePath.StartsWith("z:", StringComparison.OrdinalIgnoreCase))
{
winePath = winePath[2..];
}
return winePath.Replace('\\', '/');
}
private static bool ConfigurationMatchesPath(
LaunchConfigurationWithCredentials configuration,
string executablePath)
{
if (configuration.ExecutablePath is null)
{
return false;
}
return string.Equals(
Path.GetFullPath(configuration.ExecutablePath),
Path.GetFullPath(executablePath),
StringComparison.OrdinalIgnoreCase);
}
}
@@ -0,0 +1,32 @@
using Daybreak.Shared.Services.ApplicationLauncher;
using Microsoft.Extensions.Logging;
using System.Diagnostics;
using System.Extensions.Core;
namespace Daybreak.Linux.Services.ApplicationLauncher;
/// <summary>
/// Linux-specific implementation that skips Win32 window enumeration.
/// Under Wine, we cannot enumerate windows from the Linux host process,
/// so we just verify the process is alive and return immediately.
/// </summary>
internal sealed class GuildWarsReadyChecker(
ILogger<GuildWarsReadyChecker> logger
) : IGuildWarsReadyChecker
{
private readonly ILogger<GuildWarsReadyChecker> logger = logger;
public Task<bool> WaitForReady(Process process, TimeSpan timeout, CancellationToken cancellationToken)
{
var scopedLogger = this.logger.CreateScopedLogger();
if (process.HasExited)
{
scopedLogger.LogError("Guild Wars process has already exited. Process ID: {ProcessId}", process.Id);
return Task.FromResult(false);
}
scopedLogger.LogInformation("Guild Wars process is running (PID {ProcessId}). Skipping window readiness check on Linux", process.Id);
return Task.FromResult(true);
}
}
@@ -0,0 +1,125 @@
using Daybreak.Shared.Services.Credentials;
using Microsoft.Extensions.Logging;
using System.Security.Cryptography;
using System.Text;
namespace Daybreak.Linux.Services.Credentials;
/// <summary>
/// Linux-specific credential protector using AES encryption with a machine-derived key.
/// The key is derived from /etc/machine-id which is unique per Linux installation.
/// </summary>
public sealed class CredentialProtector : ICredentialProtector
{
private const string MachineIdPath = "/etc/machine-id";
private const string FallbackMachineIdPath = "/var/lib/dbus/machine-id";
private static readonly byte[] Salt = Convert.FromBase64String("uXB8Vmz5MmuDar36v8SRGzpALi0Wv5Gx");
private readonly ILogger<CredentialProtector> logger;
private readonly byte[]? derivedKey;
public CredentialProtector(ILogger<CredentialProtector> logger)
{
this.logger = logger;
this.derivedKey = DeriveKeyFromMachineId();
}
public byte[]? Protect(byte[] data)
{
if (this.derivedKey is null)
{
this.logger.LogError("Cannot protect data: machine key not available");
return null;
}
try
{
using var aes = Aes.Create();
aes.Key = this.derivedKey;
aes.GenerateIV();
using var encryptor = aes.CreateEncryptor();
var encrypted = encryptor.TransformFinalBlock(data, 0, data.Length);
// Prepend IV to the encrypted data so we can decrypt later
var result = new byte[aes.IV.Length + encrypted.Length];
Buffer.BlockCopy(aes.IV, 0, result, 0, aes.IV.Length);
Buffer.BlockCopy(encrypted, 0, result, aes.IV.Length, encrypted.Length);
return result;
}
catch (Exception ex)
{
this.logger.LogError(ex, "Failed to protect data using AES");
return null;
}
}
public byte[]? Unprotect(byte[] data)
{
if (this.derivedKey is null)
{
this.logger.LogError("Cannot unprotect data: machine key not available");
return null;
}
try
{
using var aes = Aes.Create();
aes.Key = this.derivedKey;
// Extract IV from the beginning of the data
var iv = new byte[aes.BlockSize / 8];
var encrypted = new byte[data.Length - iv.Length];
Buffer.BlockCopy(data, 0, iv, 0, iv.Length);
Buffer.BlockCopy(data, iv.Length, encrypted, 0, encrypted.Length);
aes.IV = iv;
using var decryptor = aes.CreateDecryptor();
return decryptor.TransformFinalBlock(encrypted, 0, encrypted.Length);
}
catch (Exception ex)
{
this.logger.LogError(ex, "Failed to unprotect data using AES");
return null;
}
}
private byte[]? DeriveKeyFromMachineId()
{
try
{
string? machineId = null;
if (File.Exists(MachineIdPath))
{
machineId = File.ReadAllText(MachineIdPath).Trim();
}
else if (File.Exists(FallbackMachineIdPath))
{
machineId = File.ReadAllText(FallbackMachineIdPath).Trim();
}
if (string.IsNullOrEmpty(machineId))
{
this.logger.LogWarning("Could not read machine-id. Falling back to hostname");
machineId = Environment.MachineName;
}
// Derive a 256-bit key using PBKDF2
return Rfc2898DeriveBytes.Pbkdf2(
Encoding.UTF8.GetBytes(machineId),
Salt,
iterations: 100000,
HashAlgorithmName.SHA256,
outputLength: 32); // 256 bits for AES-256
}
catch (Exception ex)
{
this.logger.LogError(ex, "Failed to derive encryption key from machine-id");
return null;
}
}
}
@@ -0,0 +1,41 @@
using Daybreak.Linux.Services.Wine;
using Daybreak.Shared.Services.DirectSong;
using Daybreak.Shared.Utils;
using Microsoft.Extensions.Logging;
namespace Daybreak.Linux.Services.DirectSong;
/// <summary>
/// Linux implementation that runs the DirectSong registry editor through Wine.
/// </summary>
internal sealed class DirectSongRegistrar(
IWinePrefixManager winePrefixManager,
ILogger<DirectSongRegistrar> logger) : IDirectSongRegistrar
{
private readonly IWinePrefixManager winePrefixManager = winePrefixManager;
private readonly ILogger<DirectSongRegistrar> logger = logger;
public async Task<bool> RegisterDirectSongDirectory(string installationDirectory, string registryEditorName, CancellationToken cancellationToken)
{
var fullInstallDir = Path.GetFullPath(installationDirectory);
var wineInstallDir = PathUtils.ToWinePath(fullInstallDir);
var exePath = Path.Combine(fullInstallDir, registryEditorName);
this.logger.LogDebug("Running DirectSong registry editor through Wine: {ExePath}. Expecting output: {NativePath} or {WinePath}", exePath, fullInstallDir, wineInstallDir);
var (output, error, exitCode) = await this.winePrefixManager.LaunchProcess(
exePath,
fullInstallDir,
[],
cancellationToken,
completionChecker: (line, allLines) =>
line.Trim().Equals(fullInstallDir, StringComparison.OrdinalIgnoreCase) ||
line.Trim().Equals(wineInstallDir, StringComparison.OrdinalIgnoreCase));
var success = output is not null &&
(output.Contains(fullInstallDir, StringComparison.OrdinalIgnoreCase) ||
output.Contains(wineInstallDir, StringComparison.OrdinalIgnoreCase));
this.logger.LogDebug("DirectSong registry registration via Wine result: {Success}, ExitCode: {ExitCode}", success, exitCode);
return success;
}
}
@@ -0,0 +1,20 @@
using Daybreak.Shared.Services.ExceptionHandling;
using Microsoft.Extensions.Logging;
namespace Daybreak.Linux.Services.ExceptionHandling;
//TODO: Implement crash dump writing for Linux. This is currently a stub that just logs that the feature is not supported.
internal sealed class CrashDumpService : ICrashDumpService
{
private readonly ILogger<CrashDumpService> logger;
public CrashDumpService(ILogger<CrashDumpService> logger)
{
this.logger = logger;
}
public void WriteCrashDump(string dumpFilePath)
{
this.logger.LogDebug("Crash dump writing is not supported on Linux");
}
}
@@ -0,0 +1,294 @@
using System.Extensions.Core;
using Daybreak.Linux.Services.Wine;
using Daybreak.Shared.Models;
using Daybreak.Shared.Services.Injection;
using Daybreak.Shared.Utils;
using Microsoft.Extensions.Logging;
namespace Daybreak.Linux.Services.Injection;
/// <summary>
/// Linux-specific implementation of IDaybreakInjector.
/// Uses Wine to run Daybreak.Injector.exe for launching and injecting into GuildWars.
/// </summary>
public class DaybreakInjector(
ILogger<DaybreakInjector> logger,
IWinePrefixManager winePrefixManager,
IWinePidMapper winePidMapper
) : IDaybreakInjector
{
private const string InjectorRelativePath = "Injector/Daybreak.Injector.exe";
private readonly ILogger<DaybreakInjector> logger = logger;
private readonly IWinePrefixManager winePrefixManager = winePrefixManager;
private readonly IWinePidMapper winePidMapper = winePidMapper;
public bool InjectorAvailable()
{
var scopedLogger = this.logger.CreateScopedLogger();
// Check if Wine is installed and the injector executable exists
if (!this.winePrefixManager.IsAvailable())
{
scopedLogger.LogWarning("Wine is not available");
return false;
}
var injectorPath = PathUtils.GetAbsolutePathFromRoot(InjectorRelativePath);
var exists = File.Exists(injectorPath);
if (!exists)
{
scopedLogger.LogWarning("Injector not found at {InjectorPath}", injectorPath);
}
return exists;
}
public async Task<InjectorResponses.InjectResult> InjectWinApi(
int processId,
string dllPath,
CancellationToken cancellationToken
)
{
var scopedLogger = this.logger.CreateScopedLogger();
if (!this.InjectorAvailable())
{
scopedLogger.LogError("Injector not available");
return InjectorResponses.InjectResult.InvalidInjector;
}
// Convert Linux PID to Wine PID for the injector
var winePid = this.winePidMapper.LinuxPidToWinePid(processId);
if (winePid is null)
{
scopedLogger.LogError("No Wine PID mapping found for Linux PID {ProcessId}", processId);
return InjectorResponses.InjectResult.InvalidProcess;
}
var wineDllPath = PathUtils.ToWinePath(dllPath);
var (output, error, exitCode) = await this.LaunchInjector(
["winapi", winePid.Value.ToString(), $"\"{wineDllPath}\""],
cancellationToken,
// All injector paths now print "ExitCode: X" as their last line.
completionChecker: (line, _) => line.StartsWith("ExitCode: ")
);
exitCode = ParseExitCodeFromOutput(output, exitCode);
scopedLogger.LogInformation(
"Injector exit code: {ExitCode}\nInjector output: {Output}\nInjector error: {Error}",
exitCode,
output ?? string.Empty,
error ?? string.Empty
);
return (InjectorResponses.InjectResult)exitCode;
}
public async Task<InjectorResponses.InjectResult> InjectStub(
int processId,
string dllPath,
string entryPoint,
CancellationToken cancellationToken
)
{
var scopedLogger = this.logger.CreateScopedLogger();
if (!this.InjectorAvailable())
{
scopedLogger.LogError("Injector not available");
return InjectorResponses.InjectResult.InvalidInjector;
}
// Convert Linux PID to Wine PID for the injector
var winePid = this.winePidMapper.LinuxPidToWinePid(processId);
if (winePid is null)
{
scopedLogger.LogError("No Wine PID mapping found for Linux PID {ProcessId}", processId);
return InjectorResponses.InjectResult.InvalidProcess;
}
var wineDllPath = PathUtils.ToWinePath(dllPath);
var (output, error, exitCode) = await this.LaunchInjector(
["stub", winePid.Value.ToString(), $"\"{entryPoint}\"", $"\"{wineDllPath}\""],
cancellationToken,
completionChecker: (line, _) => line.StartsWith("ExitCode: ")
);
exitCode = ParseExitCodeFromOutput(output, exitCode);
scopedLogger.LogInformation(
"Injector exit code: {ExitCode}\nInjector output: {Output}\nInjector error: {Error}",
exitCode,
output ?? string.Empty,
error ?? string.Empty
);
return (InjectorResponses.InjectResult)exitCode;
}
public async Task<(
InjectorResponses.LaunchResult ExitCode,
int ThreadHandle,
int ProcessId
)> Launch(
string executablePath,
bool elevated,
string[] args,
CancellationToken cancellationToken
)
{
var scopedLogger = this.logger.CreateScopedLogger();
if (!this.InjectorAvailable())
{
scopedLogger.LogError("Injector not available");
return (InjectorResponses.LaunchResult.InvalidInjector, -1, -1);
}
// Wine doesn't support SaferCreateLevel/SaferComputeTokenFromLevel APIs used for non-elevated launch.
// Always use elevated mode on Linux to skip the restricted token creation.
const bool forceElevated = true;
if (!elevated)
{
scopedLogger.LogDebug("Forcing elevated mode for Wine compatibility");
}
var wineExePath = PathUtils.ToWinePath(executablePath);
var (output, error, exitCode) = await this.LaunchInjector(
["launch", forceElevated.ToString(), $"\"{wineExePath}\"", string.Join(' ', args)],
cancellationToken,
// All injector paths now print "ExitCode: X" as their last line.
completionChecker: (line, _) => line.StartsWith("ExitCode: ")
);
exitCode = ParseExitCodeFromOutput(output, exitCode);
scopedLogger.LogInformation(
"Injector exit code: {ExitCode}\nInjector output: {Output}\nInjector error: {Error}",
exitCode,
output ?? string.Empty,
error ?? string.Empty
);
// Parse the output for process ID and thread handle
var lines =
output?.Split(Environment.NewLine, StringSplitOptions.RemoveEmptyEntries) ?? [];
var processId = 0;
var threadHandle = 0;
foreach (var line in lines)
{
if (line.StartsWith("ProcessId: "))
{
var processIdString = line["ProcessId: ".Length..];
_ = int.TryParse(processIdString, out processId);
}
else if (line.StartsWith("ThreadHandle: "))
{
var threadHandleString = line["ThreadHandle: ".Length..];
_ = int.TryParse(threadHandleString, out threadHandle);
}
}
// Convert Wine PID to Linux PID so the rest of Daybreak can use Process.GetProcessById()
if (processId > 0)
{
var executableName = Path.GetFileName(executablePath);
var linuxPid = this.winePidMapper.WinePidToLinuxPid(processId, executableName);
if (linuxPid is not null)
{
scopedLogger.LogInformation(
"Mapped Wine PID {WinePid} to Linux PID {LinuxPid}",
processId,
linuxPid.Value
);
processId = linuxPid.Value;
}
else
{
scopedLogger.LogWarning(
"Could not map Wine PID {WinePid} to Linux PID. Process.GetProcessById will likely fail",
processId
);
}
}
return ((InjectorResponses.LaunchResult)exitCode, threadHandle, processId);
}
public async Task<InjectorResponses.ResumeResult> Resume(
int threadHandle,
CancellationToken cancellationToken
)
{
var scopedLogger = this.logger.CreateScopedLogger();
if (!this.InjectorAvailable())
{
scopedLogger.LogError("Injector not available");
return InjectorResponses.ResumeResult.InvalidInjector;
}
var (output, error, exitCode) = await this.LaunchInjector(
["resume", threadHandle.ToString()],
cancellationToken,
completionChecker: (line, _) => line.StartsWith("ExitCode: ")
);
exitCode = ParseExitCodeFromOutput(output, exitCode);
scopedLogger.LogInformation(
"Injector exit code: {ExitCode}\nInjector output: {Output}\nInjector error: {Error}",
exitCode,
output ?? string.Empty,
error ?? string.Empty
);
return (InjectorResponses.ResumeResult)exitCode;
}
private async Task<(string? Output, string? Error, int ExitCode)> LaunchInjector(
string[] arguments,
CancellationToken cancellationToken,
Func<string, IReadOnlyList<string>, bool>? completionChecker = null
)
{
var injectorPath = PathUtils.GetAbsolutePathFromRoot(InjectorRelativePath);
var workingDirectory = Path.GetDirectoryName(injectorPath) ?? PathUtils.GetRootFolder();
return await this.winePrefixManager.LaunchProcess(
injectorPath,
workingDirectory,
arguments,
cancellationToken,
completionChecker
);
}
/// <summary>
/// Parses the exit code from the injector's stdout output.
/// The injector writes "ExitCode: X" as its last line on all paths.
/// Falls back to the provided default if parsing fails.
/// </summary>
private static int ParseExitCodeFromOutput(string? output, int defaultExitCode)
{
if (output is null)
{
return defaultExitCode;
}
var lines = output.Split(Environment.NewLine, StringSplitOptions.RemoveEmptyEntries);
for (var i = lines.Length - 1; i >= 0; i--)
{
if (lines[i].StartsWith("ExitCode: ") &&
int.TryParse(lines[i]["ExitCode: ".Length..], out var parsedCode))
{
return parsedCode;
}
}
return defaultExitCode;
}
}
@@ -0,0 +1,37 @@
using Daybreak.Shared.Models;
using Daybreak.Shared.Services.Keyboard;
using Microsoft.Extensions.Hosting;
namespace Daybreak.Linux.Services.Keyboard;
// TODO: Implement proper Linux keyboard hook using X11/Wayland APIs or libinput
public sealed class KeyboardHookService : IHostedService, IKeyboardHookService, IDisposable
{
public event EventHandler<KeyboardEventArgs>? KeyDown;
public event EventHandler<KeyboardEventArgs>? KeyUp;
Task IHostedService.StartAsync(CancellationToken cancellationToken)
{
return Task.CompletedTask;
}
Task IHostedService.StopAsync(CancellationToken cancellationToken)
{
return Task.CompletedTask;
}
public void Start()
{
// TODO: Implement Linux keyboard hook
}
public void Stop()
{
// TODO: Implement Linux keyboard hook cleanup
}
public void Dispose()
{
this.Stop();
}
}
@@ -0,0 +1,184 @@
using System.Net.Http.Json;
using System.Text.Json;
using Daybreak.Linux.Services.Wine;
using Daybreak.Shared.Models.Api;
using Daybreak.Shared.Services.MDns;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using System.Extensions.Core;
namespace Daybreak.Linux.Services.MDns;
/// <summary>
/// Linux-specific implementation of <see cref="IMDomainRegistrar"/>.
/// Since mDNS announcements from Wine don't propagate to the host,
/// this implementation scans a known port range (50805100) for running
/// Daybreak API instances and matches them to Guild Wars processes
/// by querying the API's health endpoint for its Wine PID, then
/// converting it to a Linux PID via <see cref="IWinePidMapper"/>.
/// All probes fire in parallel with a 500ms total timeout.
/// </summary>
public sealed class PortScanningDomainRegistrar(
IWinePidMapper winePidMapper,
ILogger<PortScanningDomainRegistrar> logger)
: IMDomainRegistrar, IHostedService, IDisposable
{
private const int StartPort = 5080;
private const int EndPort = 5100;
private const string HealthPath = "/api/v1/rest/health";
private const string DaybreakApiServicePrefix = "daybreak-api-";
private static readonly TimeSpan ProbeTimeout = TimeSpan.FromMilliseconds(500);
private static readonly TimeSpan ScanInterval = TimeSpan.FromSeconds(15);
private static readonly JsonSerializerOptions JsonOptions = new() { PropertyNameCaseInsensitive = true };
private readonly IWinePidMapper winePidMapper = winePidMapper;
private readonly ILogger<PortScanningDomainRegistrar> logger = logger;
private readonly HttpClient httpClient = new() { Timeout = ProbeTimeout };
// Rebuilt on each scan — survives Daybreak restarts since it's based on live port probing.
private volatile Dictionary<string, List<Uri>> discoveredServices = [];
private CancellationTokenSource? backgroundCts;
Task IHostedService.StartAsync(CancellationToken cancellationToken)
{
this.backgroundCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
_ = Task.Factory.StartNew(
() => this.ScanPeriodically(this.backgroundCts.Token),
this.backgroundCts.Token,
TaskCreationOptions.LongRunning,
TaskScheduler.Current);
return Task.CompletedTask;
}
Task IHostedService.StopAsync(CancellationToken cancellationToken)
{
this.backgroundCts?.Cancel();
return Task.CompletedTask;
}
public void Dispose()
{
this.backgroundCts?.Dispose();
this.httpClient.Dispose();
}
public IReadOnlyList<Uri>? Resolve(string service)
{
if (this.discoveredServices.TryGetValue(service, out var uris) && uris.Count > 0)
{
return uris;
}
return default;
}
public IReadOnlyList<Uri>? QueryByServiceName(Func<string, bool> query)
{
var results = new List<Uri>();
foreach (var (serviceName, uris) in this.discoveredServices)
{
if (query(serviceName))
{
results.AddRange(uris);
}
}
return results.Count > 0 ? results : default;
}
public void QueryAllServices()
{
_ = Task.Run(() => this.ScanPortsAsync(CancellationToken.None));
}
private async Task ScanPeriodically(CancellationToken cancellationToken)
{
while (!cancellationToken.IsCancellationRequested)
{
await this.ScanPortsAsync(cancellationToken);
await Task.Delay(ScanInterval, cancellationToken);
}
}
private async Task ScanPortsAsync(CancellationToken cancellationToken)
{
var scopedLogger = this.logger.CreateScopedLogger();
var newServices = new Dictionary<string, List<Uri>>();
using var timeoutCts = new CancellationTokenSource(ProbeTimeout);
using var linkedCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken, timeoutCts.Token);
var probeTasks = new List<Task<(int Port, int? ProcessId)>>();
for (var port = StartPort; port <= EndPort; port++)
{
probeTasks.Add(this.ProbePortAsync(port, linkedCts.Token));
}
try
{
await Task.WhenAll(probeTasks);
}
catch (OperationCanceledException)
{
}
foreach (var task in probeTasks)
{
if (!task.IsCompletedSuccessfully)
{
continue;
}
var (port, processId) = task.Result;
if (processId is null)
{
continue;
}
// The API reports its Wine PID. Convert to Linux PID.
var linuxPid = this.winePidMapper.WinePidToLinuxPid(processId.Value, "Gw.exe");
var effectivePid = linuxPid ?? processId.Value;
var serviceName = $"{DaybreakApiServicePrefix}{effectivePid}";
var uri = new Uri($"http://localhost:{port}");
scopedLogger.LogDebug(
"Found Daybreak API on port {Port} with Wine PID {WinePid} (Linux PID {LinuxPid})",
port,
processId.Value,
effectivePid);
if (!newServices.TryGetValue(serviceName, out var uriList))
{
uriList = [];
newServices[serviceName] = uriList;
}
uriList.Add(uri);
}
this.discoveredServices = newServices;
scopedLogger.LogDebug("Port scan complete. Found {Count} Daybreak API instance(s)", newServices.Count);
}
private async Task<(int Port, int? ProcessId)> ProbePortAsync(int port, CancellationToken cancellationToken)
{
try
{
var url = $"http://localhost:{port}{HealthPath}";
using var response = await this.httpClient.GetAsync(url, cancellationToken);
if (!response.IsSuccessStatusCode)
{
return (port, null);
}
var processIdResponse = await response.Content.ReadFromJsonAsync<ProcessIdResponse>(JsonOptions, cancellationToken);
return (port, processIdResponse?.ProcessId);
}
catch
{
return (port, null);
}
}
}
@@ -0,0 +1,31 @@
using Daybreak.Shared.Services.Privilege;
using Microsoft.AspNetCore.Components;
using Microsoft.Extensions.Logging;
namespace Daybreak.Linux.Services.Privilege;
/// <summary>
/// Linux implementation of the privilege manager.
/// Since Linux relies on Wine, we assume admin privileges are always available and do not implement elevation logic.
/// </summary>
internal sealed class PrivilegeManager(
ILogger<PrivilegeManager> logger) : IPrivilegeManager
{
private readonly ILogger<PrivilegeManager> logger = logger;
public bool AdminPrivileges => true;
public Task<bool> RequestAdminPrivileges<TCancelView>(string messageToUser, (string, object)[]? cancelViewParams, CancellationToken cancellationToken)
where TCancelView : ComponentBase
{
this.logger.LogWarning("AdminPrivileges not implemented on Linux.");
return Task.FromResult(true);
}
public Task<bool> RequestNormalPrivileges<TCancelView>(string messageToUser, (string, object)[]? cancelViewParams, CancellationToken cancellationToken)
where TCancelView : ComponentBase
{
this.logger.LogWarning("Admin privileges not implemented on Linux.");
return Task.FromResult(true);
}
}
@@ -0,0 +1,86 @@
using Daybreak.Shared.Services.Registry;
using Daybreak.Shared.Utils;
using System.Text.Json;
namespace Daybreak.Linux.Services.Registry;
/// <summary>
/// Linux implementation of IRegistryService that uses a JSON file instead of Windows Registry.
/// Values are stored in a JSON file at the application root.
/// </summary>
internal sealed class RegistryService : IRegistryService
{
private static readonly string RegistryFilePath = PathUtils.GetAbsolutePathFromRoot("Daybreak.registry.json");
private readonly object fileLock = new();
public bool SaveValue<T>(string key, T value)
{
if (value is null)
{
return false;
}
lock (this.fileLock)
{
var store = this.LoadStore();
store[key] = JsonSerializer.Serialize(value);
this.SaveStore(store);
return true;
}
}
public bool TryGetValue<T>(string key, out T? value)
{
lock (this.fileLock)
{
var store = this.LoadStore();
if (store.TryGetValue(key, out var serialized))
{
value = JsonSerializer.Deserialize<T>(serialized);
return value is not null;
}
value = default;
return false;
}
}
public bool DeleteValue(string key)
{
lock (this.fileLock)
{
var store = this.LoadStore();
var removed = store.Remove(key);
if (removed)
{
this.SaveStore(store);
}
return removed;
}
}
private Dictionary<string, string> LoadStore()
{
if (!File.Exists(RegistryFilePath))
{
return [];
}
try
{
var json = File.ReadAllText(RegistryFilePath);
return JsonSerializer.Deserialize<Dictionary<string, string>>(json) ?? [];
}
catch
{
return [];
}
}
private void SaveStore(Dictionary<string, string> store)
{
var json = JsonSerializer.Serialize(store, new JsonSerializerOptions { WriteIndented = true });
File.WriteAllText(RegistryFilePath, json);
}
}
@@ -0,0 +1,116 @@
using Daybreak.Configuration.Options;
using Daybreak.Shared.Models;
using Daybreak.Shared.Services.Options;
using Daybreak.Shared.Services.Screens;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using Photino.NET;
using System.Core.Extensions;
using System.Drawing;
using System.Extensions;
namespace Daybreak.Linux.Services.Screens;
// TODO: Implement proper Linux screen management using X11/Wayland APIs
internal sealed class ScreenManager(
PhotinoWindow photinoWindow,
IOptionsProvider optionsProvider,
IOptionsMonitor<ScreenManagerOptions> liveUpdateableOptions,
ILogger<ScreenManager> logger) : IScreenManager, IHostedService
{
private readonly PhotinoWindow photinoWindow = photinoWindow.ThrowIfNull();
private readonly IOptionsProvider optionsProvider = optionsProvider.ThrowIfNull();
private readonly IOptionsMonitor<ScreenManagerOptions> liveUpdateableOptions = liveUpdateableOptions.ThrowIfNull();
private readonly ILogger<ScreenManager> logger = logger.ThrowIfNull();
// TODO: Implement proper screen enumeration for Linux
public IEnumerable<Screen> Screens => GetDummyScreens();
Task IHostedService.StartAsync(CancellationToken cancellationToken)
{
return Task.CompletedTask;
}
Task IHostedService.StopAsync(CancellationToken cancellationToken)
{
this.SaveWindowPositionAndSize();
return Task.CompletedTask;
}
public void MoveWindowToSavedPosition()
{
var savedPosition = this.GetSavedPosition();
this.photinoWindow.Left = savedPosition.Left;
this.photinoWindow.Top = savedPosition.Top;
this.photinoWindow.Width = savedPosition.Width;
this.photinoWindow.Height = savedPosition.Height;
}
public void SaveWindowPositionAndSize()
{
var position = new Rectangle(
this.photinoWindow.Left,
this.photinoWindow.Top,
this.photinoWindow.Width,
this.photinoWindow.Height);
var options = this.liveUpdateableOptions.CurrentValue;
options.X = position.Left;
options.Y = position.Top;
options.Width = position.Width;
options.Height = position.Height;
this.optionsProvider.SaveOption(options);
}
public void ResetSavedPosition()
{
var options = this.liveUpdateableOptions.CurrentValue;
options.X = 0;
options.Y = 0;
options.Width = 0;
options.Height = 0;
this.optionsProvider.SaveOption(options);
}
// TODO: Implement for Linux - this is Windows-specific
public bool MoveGuildwarsToScreen(Screen screen)
{
this.logger.LogWarning("MoveGuildwarsToScreen is not implemented on Linux");
return false;
}
public Rectangle GetSavedPosition()
{
var savedPosition = new Rectangle(
(int)this.liveUpdateableOptions.CurrentValue.X,
(int)this.liveUpdateableOptions.CurrentValue.Y,
(int)this.liveUpdateableOptions.CurrentValue.Width,
(int)this.liveUpdateableOptions.CurrentValue.Height);
if (savedPosition.Width is 0 || savedPosition.Height is 0)
{
var firstScreen = this.Screens.FirstOrDefault();
if (firstScreen.Size.IsEmpty)
{
// Return a reasonable default instead of throwing
return new Rectangle(0, 100, 1000, 900);
}
return new Rectangle(
firstScreen.Size.X + (firstScreen.Size.Width / 4),
firstScreen.Size.Y + (firstScreen.Size.Height / 4),
firstScreen.Size.Width / 2,
firstScreen.Size.Height / 2);
}
return savedPosition;
}
private static List<Screen> GetDummyScreens()
{
// TODO: Use X11/Wayland APIs to enumerate actual screens
// For now, return a single dummy screen with reasonable defaults
return [new Screen(0, new Rectangle(0, 0, 1920, 1080))];
}
}
@@ -0,0 +1,154 @@
using Daybreak.Shared.Services.Notifications;
using Daybreak.Shared.Services.SevenZip;
using Microsoft.Extensions.Logging;
using System.Diagnostics;
namespace Daybreak.Linux.Services.SevenZip;
/// <summary>
/// Linux implementation of ISevenZipExtractor that uses the native 7z command-line tool.
/// Requires p7zip-full (or equivalent) to be installed on the system.
/// </summary>
internal sealed class SevenZipArchiveExtractor(
INotificationService notificationService,
ILogger<SevenZipArchiveExtractor> logger) : ISevenZipExtractor
{
private readonly INotificationService notificationService = notificationService;
private readonly ILogger<SevenZipArchiveExtractor> logger = logger;
public async Task<bool> ExtractToDirectory(string sourceFile, string destinationDirectory, Action<double, string> progressTracker, CancellationToken cancellationToken)
{
if (!Is7zAvailable())
{
this.logger.LogError("7z is not installed. Cannot extract archives");
this.notificationService.NotifyError(
title: "7z not found",
description: "The 7z command is required to extract archives on Linux. Please install p7zip-full (e.g. 'sudo apt install p7zip-full') and restart Daybreak.",
expirationTime: DateTime.UtcNow + TimeSpan.FromSeconds(15));
return false;
}
try
{
var root = Path.GetFullPath(destinationDirectory);
Directory.CreateDirectory(root);
// First, list the archive to get entry count for progress tracking
var entries = await this.ListArchiveEntries(sourceFile, cancellationToken);
var total = Math.Max(entries.Count, 1);
// Extract using 7z command
var startInfo = new ProcessStartInfo
{
FileName = "7z",
Arguments = $"x \"{sourceFile}\" -o\"{root}\" -y",
RedirectStandardOutput = true,
RedirectStandardError = true,
UseShellExecute = false,
CreateNoWindow = true
};
using var process = new Process { StartInfo = startInfo };
var processed = 0;
process.OutputDataReceived += (sender, e) =>
{
if (e.Data is null)
{
return;
}
// 7z outputs lines like "- filename" for each extracted file
if (e.Data.StartsWith("- "))
{
var fileName = e.Data[2..].Trim();
processed++;
progressTracker((double)processed / total, fileName);
}
};
process.Start();
process.BeginOutputReadLine();
var stderr = await process.StandardError.ReadToEndAsync(cancellationToken);
await process.WaitForExitAsync(cancellationToken);
if (process.ExitCode != 0)
{
this.logger.LogError("7z extraction failed with exit code {ExitCode}. Error: {Error}", process.ExitCode, stderr);
return false;
}
return true;
}
catch (Exception ex) when (ex is not OperationCanceledException)
{
this.logger.LogError(ex, "Failed to extract archive {SourceFile} to {DestinationDirectory}", sourceFile, destinationDirectory);
return false;
}
}
private async Task<List<string>> ListArchiveEntries(string sourceFile, CancellationToken cancellationToken)
{
var entries = new List<string>();
try
{
var startInfo = new ProcessStartInfo
{
FileName = "7z",
Arguments = $"l \"{sourceFile}\" -slt",
RedirectStandardOutput = true,
RedirectStandardError = true,
UseShellExecute = false,
CreateNoWindow = true
};
using var process = new Process { StartInfo = startInfo };
process.Start();
var output = await process.StandardOutput.ReadToEndAsync(cancellationToken);
await process.WaitForExitAsync(cancellationToken);
foreach (var line in output.Split('\n'))
{
if (line.StartsWith("Path = "))
{
entries.Add(line[7..].Trim());
}
}
}
catch (Exception ex)
{
this.logger.LogWarning(ex, "Failed to list archive entries for progress tracking, progress will be approximate");
}
return entries;
}
private static bool Is7zAvailable()
{
try
{
using var process = new Process
{
StartInfo = new ProcessStartInfo
{
FileName = "which",
Arguments = "7z",
RedirectStandardOutput = true,
RedirectStandardError = true,
UseShellExecute = false,
CreateNoWindow = true
}
};
process.Start();
process.WaitForExit();
return process.ExitCode == 0;
}
catch
{
return false;
}
}
}
@@ -0,0 +1,55 @@
using System.Extensions.Core;
using Daybreak.Linux.Services.Startup.Notifications;
using Daybreak.Linux.Services.Wine;
using Daybreak.Shared.Models;
using Daybreak.Shared.Services.Notifications;
using Microsoft.Extensions.Logging;
namespace Daybreak.Linux.Services.Startup.Actions;
/// <summary>
/// Startup action that checks if Wine is available and the prefix is initialized.
/// If Wine is available but the prefix is not set up, notifies the user to initialize it.
/// </summary>
public sealed class SetupWinePrefixAction(
IWinePrefixManager winePrefixManager,
INotificationService notificationService,
ILogger<SetupWinePrefixAction> logger
) : StartupActionBase
{
private readonly IWinePrefixManager winePrefixManager = winePrefixManager;
private readonly INotificationService notificationService = notificationService;
private readonly ILogger<SetupWinePrefixAction> logger = logger;
public override void ExecuteOnStartup()
{
var scopedLogger = this.logger.CreateScopedLogger();
if (!this.winePrefixManager.IsAvailable())
{
scopedLogger.LogWarning("Wine is not installed. Game launching will not be available.");
this.notificationService.NotifyError(
title: "Wine not installed",
description: "Wine is required to launch Guild Wars on Linux. Please install Wine and restart Daybreak.",
expirationTime: DateTime.UtcNow + TimeSpan.FromSeconds(30)
);
return;
}
if (this.winePrefixManager.IsInitialized())
{
scopedLogger.LogDebug(
"Wine prefix already initialized at {PrefixPath}",
this.winePrefixManager.GetWinePrefixPath()
);
return;
}
scopedLogger.LogInformation("Wine prefix not initialized. Prompting user to set it up.");
this.notificationService.NotifyInformation<WinePrefixSetupHandler>(
title: "Wine prefix setup required",
description: "Click here to initialize the Wine prefix for Guild Wars launching.",
expirationTime: DateTime.UtcNow + TimeSpan.FromSeconds(15)
);
}
}
@@ -0,0 +1,82 @@
using System.Extensions.Core;
using Daybreak.Linux.Services.Wine;
using Daybreak.Shared.Models.Notifications;
using Daybreak.Shared.Models.Notifications.Handling;
using Daybreak.Shared.Services.Notifications;
using Microsoft.Extensions.Logging;
namespace Daybreak.Linux.Services.Startup.Notifications;
/// <summary>
/// Notification handler that triggers Wine prefix installation when the user clicks the notification.
/// </summary>
public sealed class WinePrefixSetupHandler(
IWinePrefixManager winePrefixManager,
INotificationService notificationService,
ILogger<WinePrefixSetupHandler> logger
) : INotificationHandler
{
private readonly IWinePrefixManager winePrefixManager = winePrefixManager;
private readonly INotificationService notificationService = notificationService;
private readonly ILogger<WinePrefixSetupHandler> logger = logger;
public void OpenNotification(Notification notification)
{
var scopedLogger = this.logger.CreateScopedLogger();
scopedLogger.LogInformation(
"User clicked Wine prefix setup notification. Starting installation..."
);
// Start the installation in the background
_ = Task.Factory.StartNew(this.PerformInstallation);
}
private async Task PerformInstallation()
{
var scopedLogger = this.logger.CreateScopedLogger();
try
{
var installOperation = this.winePrefixManager.Install(CancellationToken.None);
// Subscribe to progress updates
installOperation.ProgressChanged += (sender, progress) =>
{
scopedLogger.LogDebug(
"Wine prefix setup progress: {Progress:P0} - {Message}",
progress.Percentage.Value,
progress.StatusMessage ?? string.Empty
);
};
var result = await installOperation;
if (result)
{
scopedLogger.LogInformation("Wine prefix setup completed successfully");
this.notificationService.NotifyInformation(
title: "Wine prefix ready",
description: "Wine prefix has been initialized successfully. You can now launch Guild Wars.",
expirationTime: DateTime.UtcNow + TimeSpan.FromSeconds(5)
);
}
else
{
scopedLogger.LogError("Wine prefix setup failed");
this.notificationService.NotifyError(
title: "Wine prefix setup failed",
description: "Failed to initialize the Wine prefix. Check the logs for more details.",
expirationTime: DateTime.UtcNow + TimeSpan.FromSeconds(5)
);
}
}
catch (Exception ex)
{
scopedLogger.LogError(ex, "Exception during Wine prefix setup");
this.notificationService.NotifyError(
title: "Wine prefix setup error",
description: $"An error occurred: {ex.Message}",
expirationTime: DateTime.UtcNow + TimeSpan.FromSeconds(5)
);
}
}
}
@@ -0,0 +1,133 @@
using Daybreak.Shared.Services.Themes;
using System.Diagnostics;
namespace Daybreak.Linux.Services.Themes;
/// <summary>
/// Linux implementation of ISystemThemeDetector.
/// Checks the XDG Desktop Portal color-scheme (works on Hyprland, Sway, GNOME, KDE, etc.),
/// falls back to gsettings for GNOME-based desktops, then GTK_THEME env var.
/// </summary>
internal sealed class SystemThemeDetector : ISystemThemeDetector
{
public bool IsLightTheme()
{
// 1. XDG Desktop Portal — most universal, works on Hyprland, Sway, GNOME, KDE, etc.
if (TryReadXdgPortalColorScheme(out var portalResult))
{
return portalResult;
}
// 2. gsettings — GNOME 42+ / GTK4 color-scheme
if (TryReadGsettingsColorScheme(out var gsettingsResult))
{
return gsettingsResult;
}
// 3. GTK_THEME environment variable (e.g. "Adwaita:dark")
var gtkThemeEnv = Environment.GetEnvironmentVariable("GTK_THEME");
if (!string.IsNullOrWhiteSpace(gtkThemeEnv))
{
return !gtkThemeEnv.Contains("dark", StringComparison.OrdinalIgnoreCase);
}
return false; // Default to dark
}
/// <summary>
/// Queries org.freedesktop.appearance color-scheme via the XDG Desktop Portal D-Bus interface.
/// Returns: 0 = no preference, 1 = prefer dark, 2 = prefer light.
/// Requires xdg-desktop-portal (xdg-desktop-portal-hyprland, -gnome, -kde, -wlr, etc.)
/// </summary>
private static bool TryReadXdgPortalColorScheme(out bool isLight)
{
isLight = false;
try
{
var output = RunCommand("busctl", "--user call org.freedesktop.portal.Desktop " +
"/org/freedesktop/portal/desktop org.freedesktop.portal.Settings " +
"Read ss org.freedesktop.appearance color-scheme");
if (output is null)
{
return false;
}
// busctl output looks like: v u 1
// where the last number is: 0=no preference, 1=dark, 2=light
if (output.Contains('2'))
{
isLight = true;
}
else if (output.Contains('1'))
{
isLight = false;
}
else
{
// 0 = no preference, treat as light
isLight = true;
}
return true;
}
catch
{
return false;
}
}
private static bool TryReadGsettingsColorScheme(out bool isLight)
{
isLight = false;
try
{
// GNOME 42+ color-scheme setting
var colorScheme = RunCommand("gsettings", "get org.gnome.desktop.interface color-scheme");
if (colorScheme is not null)
{
isLight = !colorScheme.Contains("dark", StringComparison.OrdinalIgnoreCase);
return true;
}
// Older GTK3 theme name fallback
var gtkTheme = RunCommand("gsettings", "get org.gnome.desktop.interface gtk-theme");
if (gtkTheme is not null)
{
isLight = !gtkTheme.Contains("dark", StringComparison.OrdinalIgnoreCase);
return true;
}
}
catch
{
}
return false;
}
private static string? RunCommand(string command, string arguments)
{
try
{
using var process = new Process
{
StartInfo = new ProcessStartInfo
{
FileName = command,
Arguments = arguments,
RedirectStandardOutput = true,
UseShellExecute = false,
CreateNoWindow = true
}
};
process.Start();
var output = process.StandardOutput.ReadToEnd().Trim();
process.WaitForExit(TimeSpan.FromSeconds(2));
return string.IsNullOrWhiteSpace(output) ? null : output;
}
catch
{
return null;
}
}
}
@@ -0,0 +1,13 @@
using Daybreak.Shared.Services.UMod;
using Daybreak.Shared.Utils;
namespace Daybreak.Linux.Services.UMod;
/// <summary>
/// Linux implementation - converts native Linux paths to Wine-compatible paths (Z:\...)
/// since gMod.dll reads the modlist from inside the Wine process.
/// </summary>
public sealed class ModPathResolver : IModPathResolver
{
public string ResolveForModLoader(string nativePath) => PathUtils.ToWinePath(nativePath);
}
@@ -0,0 +1,16 @@
using Daybreak.Shared.Services.Window;
using Daybreak.Shared.Utils;
namespace Daybreak.Linux.Services.Window;
//TODO: Implement window manipulation for Linux. This is currently a stub that does nothing, as default window functionality is kept on Linux
internal sealed class WindowManipulationService : IWindowManipulationService
{
public void DragWindow()
{
}
public void ResizeWindow(ResizeDirection direction)
{
}
}
@@ -0,0 +1,27 @@
namespace Daybreak.Linux.Services.Wine;
/// <summary>
/// Stateless translator between Wine-internal PIDs and Linux system PIDs.
/// Uses /proc scanning and winedbg to resolve mappings on demand.
/// </summary>
public interface IWinePidMapper
{
/// <summary>
/// Converts a Wine-internal PID to a Linux system PID.
/// Scans /proc for a process whose cmdline contains the executable name
/// and whose environment references our Wine prefix.
/// </summary>
/// <param name="winePid">The Wine-internal process ID (unused for lookup, kept for logging).</param>
/// <param name="executableName">The executable name (e.g. "Gw.exe") to search for in /proc.</param>
/// <returns>The Linux PID, or null if not found.</returns>
int? WinePidToLinuxPid(int winePid, string executableName);
/// <summary>
/// Converts a Linux system PID back to a Wine-internal PID.
/// Reads the process's cmdline to determine the executable, then queries
/// winedbg to find the corresponding Wine PID.
/// </summary>
/// <param name="linuxPid">The Linux system PID.</param>
/// <returns>The Wine PID, or null if not found.</returns>
int? LinuxPidToWinePid(int linuxPid);
}
@@ -0,0 +1,55 @@
using Daybreak.Shared.Models.Async;
using Daybreak.Shared.Services.Mods;
namespace Daybreak.Linux.Services.Wine;
/// <summary>
/// Manages a dedicated Wine prefix for running Windows executables on Linux.
/// Also implements IModService to appear in the mod manager UI.
/// </summary>
public interface IWinePrefixManager : IModService
{
/// <summary>
/// Checks if Wine is installed and available on the system.
/// </summary>
bool IsAvailable();
/// <summary>
/// Checks if the Wine prefix has been initialized.
/// </summary>
bool IsInitialized();
/// <summary>
/// Initializes the Wine prefix if it doesn't exist (wineboot --init).
/// Returns a progress-reporting async operation.
/// </summary>
IProgressAsyncOperation<bool> Install(CancellationToken cancellationToken);
/// <summary>
/// Returns the path to Daybreak's Wine prefix directory.
/// </summary>
string GetWinePrefixPath();
/// <summary>
/// Launches a Windows executable through Wine with the managed prefix.
/// Uses event-based stdout/stderr reading to avoid pipe deadlocks.
/// Because Wine's wrapper process may not exit even after the Windows exe finishes
/// (due to lingering child processes like Guild Wars), an optional completionChecker
/// callback can be provided. When supplied, stdout lines are passed to the checker;
/// once it returns true, the method returns immediately without waiting for Wine to exit.
/// If no checker is provided, falls back to WaitForExitAsync.
/// </summary>
/// <param name="exePath">Path to the Windows executable (Linux path, will be converted to Wine path).</param>
/// <param name="workingDirectory">Working directory for the process (Linux path).</param>
/// <param name="arguments">Arguments to pass to the executable.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <param name="completionChecker">Optional callback invoked with each stdout line and all lines so far.
/// Return true to signal the process output is complete and stop waiting.</param>
/// <returns>Output, error, and exit code from the process.</returns>
Task<(string? Output, string? Error, int ExitCode)> LaunchProcess(
string exePath,
string workingDirectory,
string[] arguments,
CancellationToken cancellationToken,
Func<string, IReadOnlyList<string>, bool>? completionChecker = null);
}
@@ -0,0 +1,189 @@
using System.Diagnostics;
using System.Globalization;
using Microsoft.Extensions.Logging;
namespace Daybreak.Linux.Services.Wine;
/// <summary>
/// Stateless translator between Wine-internal PIDs and Linux system PIDs.
/// Uses /proc scanning for Wine→Linux and winedbg for Linux→Wine.
/// </summary>
public sealed class WinePidMapper(
IWinePrefixManager winePrefixManager,
ILogger<WinePidMapper> logger
) : IWinePidMapper
{
private readonly IWinePrefixManager winePrefixManager = winePrefixManager;
private readonly ILogger<WinePidMapper> logger = logger;
/// <inheritdoc />
public int? WinePidToLinuxPid(int winePid, string executableName)
{
var prefixPath = this.winePrefixManager.GetWinePrefixPath();
try
{
foreach (var procDir in Directory.EnumerateDirectories("/proc"))
{
var dirName = Path.GetFileName(procDir);
if (!int.TryParse(dirName, out var pid))
{
continue;
}
try
{
var cmdlinePath = Path.Combine(procDir, "cmdline");
if (!File.Exists(cmdlinePath))
{
continue;
}
var cmdline = File.ReadAllText(cmdlinePath);
if (!cmdline.Contains(executableName, StringComparison.OrdinalIgnoreCase))
{
continue;
}
// Verify this process belongs to our Wine prefix
var environPath = Path.Combine(procDir, "environ");
if (!File.Exists(environPath))
{
continue;
}
var environ = File.ReadAllText(environPath);
if (!environ.Contains(prefixPath, StringComparison.Ordinal))
{
continue;
}
this.logger.LogDebug(
"Resolved Wine PID {WinePid} -> Linux PID {LinuxPid} for {ExeName}",
winePid, pid, executableName
);
return pid;
}
catch (UnauthorizedAccessException) { }
catch (IOException) { }
}
}
catch (Exception ex)
{
this.logger.LogError(ex, "Error scanning /proc for Wine process {ExeName}", executableName);
}
this.logger.LogWarning("Could not find Linux PID for Wine PID {WinePid} ({ExeName})", winePid, executableName);
return null;
}
/// <inheritdoc />
public int? LinuxPidToWinePid(int linuxPid)
{
// Step 1: Read the executable name from /proc/<pid>/cmdline
string? executableName;
try
{
var cmdline = File.ReadAllText($"/proc/{linuxPid}/cmdline");
// cmdline is null-separated; find the segment containing .exe
var segments = cmdline.Split('\0', StringSplitOptions.RemoveEmptyEntries);
var exeSegment = segments.FirstOrDefault(s => s.EndsWith(".exe", StringComparison.OrdinalIgnoreCase))
?? segments.FirstOrDefault(s => s.Contains(".exe", StringComparison.OrdinalIgnoreCase));
if (exeSegment is null)
{
this.logger.LogWarning("Could not determine executable name for Linux PID {LinuxPid}", linuxPid);
return null;
}
// Extract just the filename (e.g. "Gw.exe" from "Z:\mnt\...\Gw.exe")
executableName = exeSegment.Split('\\').Last().Split('/').Last();
}
catch (Exception ex)
{
this.logger.LogWarning(ex, "Could not read cmdline for Linux PID {LinuxPid}", linuxPid);
return null;
}
// Step 2: Query winedbg for the Wine PID of that executable
return this.QueryWineDbgForPid(executableName);
}
/// <summary>
/// Runs <c>winedbg --command "info proc"</c> in our prefix and parses the output
/// to find the Wine PID for the given executable name.
/// Output format: " 0000021c 1 'Gw.exe'"
/// </summary>
private int? QueryWineDbgForPid(string executableName)
{
var prefixPath = this.winePrefixManager.GetWinePrefixPath();
try
{
var startInfo = new ProcessStartInfo
{
FileName = "winedbg",
Arguments = "--command \"info proc\"",
RedirectStandardOutput = true,
RedirectStandardError = true,
UseShellExecute = false,
CreateNoWindow = true,
};
startInfo.Environment["WINEPREFIX"] = prefixPath;
using var process = new Process { StartInfo = startInfo };
process.Start();
var output = process.StandardOutput.ReadToEnd();
process.WaitForExit(TimeSpan.FromSeconds(5));
// Parse lines like:
// 0000021c 1 'Gw.exe'
// 00000038 12 \_ 'services.exe'
foreach (var line in output.Split('\n', StringSplitOptions.RemoveEmptyEntries))
{
var trimmed = line.Trim();
// Skip header line
if (trimmed.StartsWith("pid", StringComparison.OrdinalIgnoreCase))
{
continue;
}
// Check if this line contains our executable
if (!trimmed.Contains(executableName, StringComparison.OrdinalIgnoreCase))
{
continue;
}
// Strip tree prefixes like "\_ " or "= "
var cleaned = trimmed.TrimStart('=', ' ');
if (cleaned.StartsWith("\\_"))
{
cleaned = cleaned[2..].TrimStart();
}
// First token is the hex PID
var hexPid = cleaned.Split(' ', StringSplitOptions.RemoveEmptyEntries).FirstOrDefault();
if (hexPid is not null && int.TryParse(hexPid, NumberStyles.HexNumber, CultureInfo.InvariantCulture, out var winePid))
{
this.logger.LogDebug(
"Resolved Linux PID -> Wine PID {WinePid} (0x{WinePidHex}) for {ExeName}",
winePid, hexPid, executableName
);
return winePid;
}
}
}
catch (Exception ex)
{
this.logger.LogError(ex, "Failed to query winedbg for {ExeName}", executableName);
}
this.logger.LogWarning("Could not find Wine PID for {ExeName} via winedbg", executableName);
return null;
}
}
@@ -0,0 +1,442 @@
using System.Diagnostics;
using Daybreak.Linux.Services.Startup.Notifications;
using Daybreak.Shared.Models.Async;
using Daybreak.Shared.Models.Mods;
using Daybreak.Shared.Services.Notifications;
using Daybreak.Shared.Utils;
using Microsoft.Extensions.Logging;
namespace Daybreak.Linux.Services.Wine;
/// <summary>
/// Manages a dedicated Wine prefix for running Windows executables on Linux.
/// The prefix is stored in the application root under WinePrefix/
/// Also implements IModService to appear in the mod manager UI for easy installation.
/// </summary>
public sealed class WinePrefixManager(
INotificationService notificationService,
ILogger<WinePrefixManager> logger) : IWinePrefixManager
{
private const string WinePrefixFolder = "WinePrefix";
private const string WineExecutable = "wine";
private static readonly ProgressUpdate ProgressStarting = new(0, "Starting Wine prefix setup");
private static readonly ProgressUpdate ProgressCheckingWine = new(
0.1,
"Checking Wine installation"
);
private static readonly ProgressUpdate ProgressCreatingDirectory = new(
0.2,
"Creating Wine prefix directory"
);
private static readonly ProgressUpdate ProgressInitializing = new(
0.3,
"Initializing Wine prefix (this may take a moment)"
);
private static readonly ProgressUpdate ProgressFinished = new(1, "Wine prefix setup complete");
private static readonly ProgressUpdate ProgressAlreadyInitialized = new(
1,
"Wine prefix already initialized"
);
private static readonly ProgressUpdate ProgressFailed = new(1, "Failed to setup Wine prefix");
private readonly INotificationService notificationService = notificationService;
private readonly ILogger<WinePrefixManager> logger = logger;
private readonly string winePrefixPath = PathUtils.GetAbsolutePathFromRoot(WinePrefixFolder);
/// <inheritdoc />
public string Name => "Wine Prefix";
/// <inheritdoc />
public string Description => "Wine prefix required to run Guild Wars and the injector on Linux. Wine must be installed on your system.";
/// <inheritdoc />
public bool IsEnabled
{
get => true;
set { }
}
/// <inheritdoc />
public bool IsInstalled => this.IsInitialized();
/// <inheritdoc />
public bool IsVisible => this.IsAvailable();
/// <inheritdoc />
public bool CanCustomManage => false;
/// <inheritdoc />
public bool CanUninstall => false;
/// <inheritdoc />
public IProgressAsyncOperation<bool> PerformInstallation(CancellationToken cancellationToken)
{
return this.Install(cancellationToken);
}
/// <inheritdoc />
public IProgressAsyncOperation<bool> PerformUninstallation(CancellationToken cancellationToken)
{
return ProgressAsyncOperation.Create(
progress =>
{
progress.Report(new ProgressUpdate(0, "Removing Wine prefix"));
if (!this.IsInstalled)
{
progress.Report(new ProgressUpdate(1, "Wine prefix is not installed"));
return Task.FromResult(true);
}
try
{
if (Directory.Exists(this.winePrefixPath))
{
Directory.Delete(this.winePrefixPath, recursive: true);
}
progress.Report(new ProgressUpdate(1, "Wine prefix removed successfully"));
return Task.FromResult(true);
}
catch (Exception ex)
{
this.logger.LogError(ex, "Failed to remove Wine prefix");
progress.Report(new ProgressUpdate(1, $"Failed to remove Wine prefix: {ex.Message}"));
return Task.FromResult(false);
}
},
cancellationToken
);
}
/// <inheritdoc />
public IEnumerable<string> GetCustomArguments() => [];
/// <inheritdoc />
public Task OnGuildWarsStarting(GuildWarsStartingContext guildWarsStartingContext, CancellationToken cancellationToken)
{
if (!this.IsAvailable())
{
this.logger.LogError("Wine is not installed. Cannot launch Guild Wars");
guildWarsStartingContext.CancelStartup = true;
this.notificationService.NotifyError(
title: "Wine not installed",
description: "Wine is required to launch Guild Wars on Linux. Please install Wine and restart Daybreak.",
expirationTime: DateTime.UtcNow + TimeSpan.FromSeconds(15));
return Task.CompletedTask;
}
if (!this.IsInitialized())
{
this.logger.LogWarning("Wine prefix not initialized. Blocking Guild Wars startup");
guildWarsStartingContext.CancelStartup = true;
this.notificationService.NotifyError<WinePrefixSetupHandler>(
title: "Wine prefix not initialized",
description: "The Wine prefix needs to be set up before launching Guild Wars. Click here to initialize it.",
expirationTime: DateTime.UtcNow + TimeSpan.FromSeconds(15));
return Task.CompletedTask;
}
return Task.CompletedTask;
}
/// <inheritdoc />
public Task OnGuildWarsCreated(GuildWarsCreatedContext guildWarsCreatedContext, CancellationToken cancellationToken)
=> Task.CompletedTask;
/// <inheritdoc />
public Task OnGuildWarsStarted(GuildWarsStartedContext guildWarsStartedContext, CancellationToken cancellationToken)
=> Task.CompletedTask;
/// <inheritdoc />
public Task OnGuildWarsStartingDisabled(GuildWarsStartingDisabledContext guildWarsStartingDisabledContext, CancellationToken cancellationToken)
=> Task.CompletedTask;
/// <inheritdoc />
public Task<bool> ShouldRunAgain(GuildWarsRunningContext guildWarsRunningContext, CancellationToken cancellationToken)
=> Task.FromResult(false);
/// <inheritdoc />
public Task OnGuildWarsRunning(GuildWarsRunningContext guildWarsRunningContext, CancellationToken cancellationToken)
=> Task.CompletedTask;
/// <inheritdoc />
public Task OnCustomManagement(CancellationToken cancellationToken)
=> Task.CompletedTask;
/// <inheritdoc />
public Task<bool> IsUpdateAvailable(CancellationToken cancellationToken)
=> Task.FromResult(false);
/// <inheritdoc />
public Task<bool> PerformUpdate(CancellationToken cancellationToken)
=> Task.FromResult(true);
public bool IsAvailable()
{
try
{
// Check if 'wine' command exists
using var process = new Process
{
StartInfo = new ProcessStartInfo
{
FileName = "which",
Arguments = WineExecutable,
RedirectStandardOutput = true,
RedirectStandardError = true,
UseShellExecute = false,
CreateNoWindow = true,
},
};
process.Start();
process.WaitForExit();
var isAvailable = process.ExitCode == 0;
this.logger.LogInformation("Wine availability check: {IsAvailable}", isAvailable);
return isAvailable;
}
catch (Exception ex)
{
this.logger.LogError(ex, "Failed to check Wine availability");
return false;
}
}
public bool IsInitialized()
{
// Check if the prefix directory exists and has been initialized
// Wine creates a system.reg file when the prefix is initialized
var systemRegPath = Path.Combine(this.winePrefixPath, "system.reg");
return File.Exists(systemRegPath);
}
public string GetWinePrefixPath()
{
return this.winePrefixPath;
}
public IProgressAsyncOperation<bool> Install(CancellationToken cancellationToken)
{
return ProgressAsyncOperation.Create(
async progress =>
await Task
.Factory.StartNew(
() => this.InstallInternal(progress, cancellationToken),
cancellationToken,
TaskCreationOptions.LongRunning,
TaskScheduler.Current
)
.Unwrap(),
cancellationToken
);
}
private async Task<bool> InstallInternal(
IProgress<ProgressUpdate> progress,
CancellationToken cancellationToken
)
{
progress.Report(ProgressStarting);
progress.Report(ProgressCheckingWine);
if (!this.IsAvailable())
{
this.logger.LogError("Wine is not installed");
progress.Report(ProgressFailed);
return false;
}
if (this.IsInitialized())
{
this.logger.LogInformation(
"Wine prefix already initialized at {PrefixPath}",
this.winePrefixPath
);
progress.Report(ProgressAlreadyInitialized);
return true;
}
this.logger.LogInformation("Initializing Wine prefix at {PrefixPath}", this.winePrefixPath);
progress.Report(ProgressCreatingDirectory);
Directory.CreateDirectory(this.winePrefixPath);
progress.Report(ProgressInitializing);
// Initialize the Wine prefix using wineboot
var startInfo = new ProcessStartInfo
{
FileName = "wineboot",
Arguments = "--init",
WorkingDirectory = this.winePrefixPath,
RedirectStandardOutput = true,
RedirectStandardError = true,
UseShellExecute = false,
CreateNoWindow = true,
};
startInfo.Environment["WINEPREFIX"] = this.winePrefixPath;
try
{
using var process = new Process { StartInfo = startInfo };
process.Start();
await process.WaitForExitAsync(cancellationToken);
var output = await process.StandardOutput.ReadToEndAsync(cancellationToken);
var error = await process.StandardError.ReadToEndAsync(cancellationToken);
if (process.ExitCode != 0)
{
this.logger.LogError(
"Wine prefix initialization failed. Exit code: {ExitCode}, Error: {Error}",
process.ExitCode,
error
);
progress.Report(ProgressFailed);
return false;
}
this.logger.LogInformation("Wine prefix initialized successfully");
progress.Report(ProgressFinished);
return true;
}
catch (OperationCanceledException)
{
throw;
}
catch (Exception ex)
{
this.logger.LogError(ex, "Failed to initialize Wine prefix");
progress.Report(ProgressFailed);
return false;
}
}
public async Task<(string? Output, string? Error, int ExitCode)> LaunchProcess(
string exePath,
string workingDirectory,
string[] arguments,
CancellationToken cancellationToken,
Func<string, IReadOnlyList<string>, bool>? completionChecker = null)
{
if (!this.IsAvailable())
{
this.logger.LogError("Wine is not available");
return (null, "Wine is not installed", -1);
}
if (!this.IsInitialized())
{
this.logger.LogWarning("Wine prefix not initialized, initializing now...");
var result = await this.Install(cancellationToken);
if (!result)
{
return (null, "Failed to initialize Wine prefix", -1);
}
}
var wineExePath = PathUtils.ToWinePath(exePath);
var wineArgs = string.Join(' ', arguments);
this.logger.LogDebug("Launching Wine process: {WineExePath} {Args}", wineExePath, wineArgs);
var startInfo = new ProcessStartInfo
{
FileName = WineExecutable,
Arguments = $"\"{wineExePath}\" {wineArgs}",
WorkingDirectory = workingDirectory,
RedirectStandardOutput = true,
RedirectStandardError = true,
UseShellExecute = false,
CreateNoWindow = true,
};
startInfo.Environment["WINEPREFIX"] = this.winePrefixPath;
try
{
using var process = new Process { StartInfo = startInfo };
var outputLines = new List<string>();
var errorLines = new List<string>();
// When a completionChecker is provided, we use it to detect when the
// Windows exe has finished writing its expected output. This avoids
// waiting on WaitForExitAsync which hangs because Wine's wrapper process
// stays alive as long as child processes (e.g. Guild Wars) are running.
var completionTcs = completionChecker is not null
? new TaskCompletionSource<bool>(TaskCreationOptions.RunContinuationsAsynchronously)
: null;
process.OutputDataReceived += (sender, e) =>
{
if (e.Data is not null)
{
this.logger.LogDebug("Wine stdout: {Line}", e.Data);
outputLines.Add(e.Data);
if (completionTcs is not null &&
!completionTcs.Task.IsCompleted &&
completionChecker!(e.Data, outputLines))
{
this.logger.LogDebug("Completion checker signalled done");
completionTcs.TrySetResult(true);
}
}
};
process.ErrorDataReceived += (sender, e) =>
{
if (e.Data is not null)
{
this.logger.LogDebug("Wine stderr: {Line}", e.Data);
errorLines.Add(e.Data);
}
};
process.Start();
process.BeginOutputReadLine();
process.BeginErrorReadLine();
if (completionTcs is not null)
{
// Wait for the completion checker to signal, or cancellation.
// Don't wait for Wine to exit — it may never exit while GW runs.
using var registration = cancellationToken.Register(
() => completionTcs.TrySetCanceled(cancellationToken));
await completionTcs.Task;
}
else
{
await process.WaitForExitAsync(cancellationToken);
}
var output = string.Join(Environment.NewLine, outputLines);
var error = string.Join(Environment.NewLine, errorLines);
var exitCode = completionTcs is not null
? 0 // We didn't wait for exit, assume success since output was captured
: process.ExitCode;
this.logger.LogDebug(
"Wine process completed. ExitCode: {ExitCode}. Output: {Output}, Error: {Error}",
exitCode,
output,
error
);
return (output, error, exitCode);
}
catch (OperationCanceledException)
{
throw;
}
catch (Exception ex)
{
this.logger.LogError(ex, "Failed to launch Wine process");
return (null, ex.Message, -1);
}
}
}