mirror of
https://github.com/gwdevhub/Daybreak.git
synced 2026-07-15 15:19:57 +00:00
Native linux support (#1378) (Closes #1375 Closes #1376 Closes #1377 Closes #1374 Closes #1373 Closes #1372 Closes #1371)
This commit is contained in:
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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" "$@"
|
||||
@@ -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 (5080–5100) 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user