+
this.ViewModel.DownloadVersion(version))"
+ @onclick:stopPropagation="true">
+ Title="Download version">
diff --git a/Daybreak.Core/Views/VersionManagementView.razor.cs b/Daybreak.Core/Views/VersionManagementView.razor.cs
index 80e3cff3..c7a54c87 100644
--- a/Daybreak.Core/Views/VersionManagementView.razor.cs
+++ b/Daybreak.Core/Views/VersionManagementView.razor.cs
@@ -1,5 +1,5 @@
-using Daybreak.Shared.Services.Updater;
-using System.Diagnostics;
+using Daybreak.Shared.Services.Shell;
+using Daybreak.Shared.Services.Updater;
using System.Extensions;
using TrailBlazr.Services;
using TrailBlazr.ViewModels;
@@ -7,13 +7,15 @@
namespace Daybreak.Views;
public sealed class VersionManagementViewModel(
IViewManager viewManager,
- IApplicationUpdater applicationUpdater)
+ IApplicationUpdater applicationUpdater,
+ IShellExecutor shellExecutor)
: ViewModelBase
{
private const string VersionPlaceholder = "{VERSION}";
private const string ReleaseURL = $"https://github.com/AlexMacocian/Daybreak/releases/tag/v{VersionPlaceholder}";
private readonly IViewManager viewManager = viewManager;
private readonly IApplicationUpdater applicationUpdater = applicationUpdater;
+ private readonly IShellExecutor shellExecutor = shellExecutor;
public List Versions { get; init; } = [];
public Version? CurrentVersion { get; set; }
@@ -31,6 +33,6 @@ public void DownloadVersion(Version version)
public void OpenVersionPage(Version version)
{
- Process.Start("explorer.exe", ReleaseURL.Replace(VersionPlaceholder, version.ToString()));
+ this.shellExecutor.OpenUrl(ReleaseURL.Replace(VersionPlaceholder, version.ToString()));
}
}
diff --git a/Daybreak.Core/Views/VersionManagementView.razor.css b/Daybreak.Core/Views/VersionManagementView.razor.css
index fd8d0b39..e848b79b 100644
--- a/Daybreak.Core/Views/VersionManagementView.razor.css
+++ b/Daybreak.Core/Views/VersionManagementView.razor.css
@@ -13,15 +13,55 @@
margin: 50px 0px 0px 0px;
}
-.version-row {
+.version-item {
display: flex;
+ align-items: center;
justify-content: space-between;
- width: 100%;
+ padding: 8px 12px;
+ border-bottom: 1px solid var(--neutral-stroke-divider-rest);
+ cursor: pointer;
+ transition: background-color 0.2s ease;
+ box-sizing: border-box;
+ border-radius: 6px;
+ height: 56px;
}
-.version-row-center {
- cursor: pointer;
- text-align: center;
- align-content: center;
+ .version-item:hover {
+ background-color: var(--accent-fill-hover);
+ }
+
+ .version-item:focus {
+ outline: 2px solid var(--accent-fill-focus);
+ outline-offset: -2px;
+ }
+
+.version-info {
+ flex: 1;
+ display: flex;
+ flex-direction: row;
+ align-items: center;
+ gap: 8px;
+ min-width: 0;
+}
+
+.version-icon {
+ width: 20px;
+ height: 20px;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ flex-shrink: 0;
+}
+
+.version-name {
+ font-weight: 600;
font-size: var(--font-size-medium);
+ color: var(--neutral-foreground-rest);
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+}
+
+.version-actions {
+ flex-shrink: 0;
}
\ No newline at end of file
diff --git a/Daybreak.Linux/Configuration/LinuxPlatformConfiguration.cs b/Daybreak.Linux/Configuration/LinuxPlatformConfiguration.cs
index 4cf42969..adf155b1 100644
--- a/Daybreak.Linux/Configuration/LinuxPlatformConfiguration.cs
+++ b/Daybreak.Linux/Configuration/LinuxPlatformConfiguration.cs
@@ -13,6 +13,7 @@
using Daybreak.Linux.Services.Registry;
using Daybreak.Linux.Services.Themes;
using Daybreak.Linux.Services.SevenZip;
+using Daybreak.Linux.Services.Shell;
using Daybreak.Linux.Services.Window;
using Daybreak.Linux.Services.ExceptionHandling;
using Daybreak.Linux.Services.Wine;
@@ -32,6 +33,7 @@
using Daybreak.Shared.Services.Screens;
using Daybreak.Shared.Services.Themes;
using Daybreak.Shared.Services.SevenZip;
+using Daybreak.Shared.Services.Shell;
using Daybreak.Shared.Services.UMod;
using Daybreak.Shared.Services.ExceptionHandling;
using Daybreak.Shared.Services.Window;
@@ -55,11 +57,13 @@ public override void RegisterServices(IServiceCollection services)
services.AddSingleton();
services.AddScoped();
services.AddScoped();
+ services.AddSingleton();
services.AddSingleton();
services.AddSingleton();
services.AddScoped();
services.AddSingleton();
services.AddSingleton();
+ services.AddSingleton();
services.AddSingleton();
services.AddSingleton();
services.AddSingleton();
diff --git a/Daybreak.Linux/Services/ApplicationLauncher/SteamService.cs b/Daybreak.Linux/Services/ApplicationLauncher/SteamService.cs
new file mode 100644
index 00000000..f7365231
--- /dev/null
+++ b/Daybreak.Linux/Services/ApplicationLauncher/SteamService.cs
@@ -0,0 +1,76 @@
+using System.Diagnostics;
+using Daybreak.Shared.Services.ApplicationLauncher;
+
+namespace Daybreak.Linux.Services.ApplicationLauncher;
+
+///
+/// Linux-specific Steam service.
+/// Detects the running Steam client primarily via the Steam client's pid file
+/// (~/.steam/steam.pid), which holds the live Steam PID. Falls back to matching the Steam
+/// client processes by name.
+///
+public sealed class SteamService : ISteamService
+{
+ private static readonly string[] SteamPidFileRelativePaths =
+ [
+ ".steam/steam.pid",
+ ".steam/steam/steam.pid"
+ ];
+
+ private static readonly string[] SteamProcessNames = ["steam", "steamwebhelper"];
+
+ public bool IsSteamLoginSupported => false;
+
+ public bool IsSteamRunning()
+ {
+ return IsSteamRunningViaPidFile() ?? IsSteamRunningViaProcessName();
+ }
+
+ private static bool? IsSteamRunningViaPidFile()
+ {
+ var home = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile);
+ if (string.IsNullOrEmpty(home))
+ {
+ return default;
+ }
+
+ foreach (var relativePath in SteamPidFileRelativePaths)
+ {
+ var pidFilePath = Path.Combine(home, relativePath);
+ if (!File.Exists(pidFilePath))
+ {
+ continue;
+ }
+
+ try
+ {
+ if (!int.TryParse(File.ReadAllText(pidFilePath).Trim(), out var pid) || pid <= 0)
+ {
+ continue;
+ }
+
+ try
+ {
+ using var _ = Process.GetProcessById(pid);
+ return true;
+ }
+ catch (ArgumentException)
+ {
+ // No process with the recorded PID is running, the pid file is stale.
+ return false;
+ }
+ }
+ catch
+ {
+ // Unreadable pid file, fall through to the next candidate / process-name check.
+ }
+ }
+
+ return default;
+ }
+
+ private static bool IsSteamRunningViaProcessName()
+ {
+ return SteamProcessNames.Any(name => Process.GetProcessesByName(name).Length > 0);
+ }
+}
diff --git a/Daybreak.Linux/Services/Shell/ShellExecutor.cs b/Daybreak.Linux/Services/Shell/ShellExecutor.cs
new file mode 100644
index 00000000..f814f03c
--- /dev/null
+++ b/Daybreak.Linux/Services/Shell/ShellExecutor.cs
@@ -0,0 +1,45 @@
+using Daybreak.Shared.Services.Shell;
+using Microsoft.Extensions.Logging;
+using System.Diagnostics;
+
+namespace Daybreak.Linux.Services.Shell;
+
+///
+/// Linux implementation of .
+/// Both URLs and file-system paths are delegated to xdg-open, which routes to the
+/// user's default browser or file manager as appropriate.
+///
+internal sealed class ShellExecutor(
+ ILogger logger) : IShellExecutor
+{
+ private const string XdgOpenExecutable = "xdg-open";
+
+ private readonly ILogger logger = logger;
+
+ public void OpenUrl(string url) => this.Open(url);
+
+ public void OpenPath(string path) => this.Open(path);
+
+ private void Open(string target)
+ {
+ if (string.IsNullOrWhiteSpace(target))
+ {
+ return;
+ }
+
+ try
+ {
+ var startInfo = new ProcessStartInfo
+ {
+ FileName = XdgOpenExecutable,
+ UseShellExecute = false,
+ };
+ startInfo.ArgumentList.Add(target);
+ Process.Start(startInfo);
+ }
+ catch (Exception ex)
+ {
+ this.logger.LogError(ex, "Failed to open target {target}", target);
+ }
+ }
+}
diff --git a/Daybreak.Shared/Models/LoginCredentials.cs b/Daybreak.Shared/Models/LoginCredentials.cs
index 22fe81a1..9b6a6a23 100644
--- a/Daybreak.Shared/Models/LoginCredentials.cs
+++ b/Daybreak.Shared/Models/LoginCredentials.cs
@@ -2,10 +2,21 @@
public sealed class LoginCredentials : IEquatable
{
+ public const string SteamLoginIdentifier = "__STEAM_LOGIN__";
+
+ public static LoginCredentials SteamLogin { get; } = new()
+ {
+ Identifier = SteamLoginIdentifier,
+ Username = "Steam Login",
+ Password = string.Empty
+ };
+
public string? Identifier { get; set; }
public string? Username { get; set; }
public string? Password { get; set; }
+ public bool IsSteamLogin => string.Equals(this.Identifier, SteamLoginIdentifier, StringComparison.Ordinal);
+
public bool Equals(LoginCredentials? other)
{
return this?.Identifier?.Equals(other?.Identifier) is true &&
diff --git a/Daybreak.Shared/Services/ApplicationLauncher/ISteamService.cs b/Daybreak.Shared/Services/ApplicationLauncher/ISteamService.cs
new file mode 100644
index 00000000..eb69d12c
--- /dev/null
+++ b/Daybreak.Shared/Services/ApplicationLauncher/ISteamService.cs
@@ -0,0 +1,19 @@
+namespace Daybreak.Shared.Services.ApplicationLauncher;
+
+///
+/// Platform-specific service for interacting with the Steam client.
+/// Used to determine whether Steam is available before launching Guild Wars with Steam login.
+///
+public interface ISteamService
+{
+ ///
+ /// Returns true if Steam login is supported on the current platform.
+ /// Steam login is currently only supported on Windows.
+ ///
+ bool IsSteamLoginSupported { get; }
+
+ ///
+ /// Returns true if the Steam client is currently running.
+ ///
+ bool IsSteamRunning();
+}
diff --git a/Daybreak.Shared/Services/Shell/IShellExecutor.cs b/Daybreak.Shared/Services/Shell/IShellExecutor.cs
new file mode 100644
index 00000000..83cd5247
--- /dev/null
+++ b/Daybreak.Shared/Services/Shell/IShellExecutor.cs
@@ -0,0 +1,21 @@
+namespace Daybreak.Shared.Services.Shell;
+
+///
+/// Opens URLs and file-system paths using the operating system's default handler.
+/// Provides a cross-platform abstraction over platform-specific shell invocations
+/// (e.g. explorer.exe on Windows, xdg-open on Linux).
+///
+public interface IShellExecutor
+{
+ ///
+ /// Opens the given URL using the default web browser.
+ ///
+ /// The absolute URL to open.
+ void OpenUrl(string url);
+
+ ///
+ /// Opens the given file or folder using the default file manager or associated program.
+ ///
+ /// The file-system path to open.
+ void OpenPath(string path);
+}
diff --git a/Daybreak.Tests/Services/Credentials/CredentialManagerTests.cs b/Daybreak.Tests/Services/Credentials/CredentialManagerTests.cs
index dcc41020..02b9b63f 100644
--- a/Daybreak.Tests/Services/Credentials/CredentialManagerTests.cs
+++ b/Daybreak.Tests/Services/Credentials/CredentialManagerTests.cs
@@ -128,4 +128,25 @@ public void CreateUniqueCredentials_HasUniqueGuidIdentifierAndEmptyStrings()
a.Username.Should().BeEmpty();
a.Password.Should().BeEmpty();
}
+
+ [TestMethod]
+ public void TryGetCredentialsByIdentifier_SteamLoginSentinel_ReturnsVirtualCredential()
+ {
+ var ok = this.manager.TryGetCredentialsByIdentifier(LoginCredentials.SteamLoginIdentifier, out var found);
+
+ ok.Should().BeTrue();
+ found.Should().BeSameAs(LoginCredentials.SteamLogin);
+ found!.IsSteamLogin.Should().BeTrue();
+ }
+
+ [TestMethod]
+ public void StoreCredentials_DoesNotPersistSteamLoginSentinel()
+ {
+ var real = new LoginCredentials { Identifier = "id-1", Username = "alice", Password = "pw" };
+
+ this.manager.StoreCredentials([LoginCredentials.SteamLogin, real]);
+
+ this.options.ProtectedLoginCredentials.Should().ContainSingle()
+ .Which.Identifier.Should().Be("id-1");
+ }
}
diff --git a/Daybreak.Windows/Configuration/WindowsPlatformConfiguration.cs b/Daybreak.Windows/Configuration/WindowsPlatformConfiguration.cs
index ab43b6fd..c85e72a5 100644
--- a/Daybreak.Windows/Configuration/WindowsPlatformConfiguration.cs
+++ b/Daybreak.Windows/Configuration/WindowsPlatformConfiguration.cs
@@ -13,6 +13,7 @@
using Daybreak.Shared.Services.DirectSong;
using Daybreak.Shared.Services.Registry;
using Daybreak.Shared.Services.SevenZip;
+using Daybreak.Shared.Services.Shell;
using Daybreak.Shared.Services.Themes;
using Daybreak.Shared.Services.UMod;
using Daybreak.Shared.Services.ReShade;
@@ -28,6 +29,7 @@
using Daybreak.Windows.Services.Screens;
using Daybreak.Windows.Services.Shortcuts;
using Daybreak.Windows.Services.SevenZip;
+using Daybreak.Windows.Services.Shell;
using Daybreak.Windows.Services.UMod;
using Daybreak.Windows.Services.Window;
using Daybreak.Windows.Services.ExceptionHandling;
@@ -72,11 +74,13 @@ public override void RegisterServices(IServiceCollection services)
services.AddScoped();
services.AddScoped();
services.AddScoped();
+ services.AddSingleton();
services.AddSingleton();
services.AddSingleton();
services.AddScoped();
services.AddSingleton();
services.AddSingleton();
+ services.AddSingleton();
services.AddSingleton();
services.AddSingleton();
services.AddSingleton();
diff --git a/Daybreak.Windows/Services/ApplicationLauncher/SteamService.cs b/Daybreak.Windows/Services/ApplicationLauncher/SteamService.cs
new file mode 100644
index 00000000..bade400b
--- /dev/null
+++ b/Daybreak.Windows/Services/ApplicationLauncher/SteamService.cs
@@ -0,0 +1,19 @@
+using System.Diagnostics;
+using Daybreak.Shared.Services.ApplicationLauncher;
+
+namespace Daybreak.Windows.Services.ApplicationLauncher;
+
+///
+/// Windows-specific Steam service. Detects the running Steam client by process name.
+///
+public sealed class SteamService : ISteamService
+{
+ private const string SteamProcessName = "steam";
+
+ public bool IsSteamLoginSupported => true;
+
+ public bool IsSteamRunning()
+ {
+ return Process.GetProcessesByName(SteamProcessName).Length > 0;
+ }
+}
diff --git a/Daybreak.Windows/Services/Shell/ShellExecutor.cs b/Daybreak.Windows/Services/Shell/ShellExecutor.cs
new file mode 100644
index 00000000..f07b112c
--- /dev/null
+++ b/Daybreak.Windows/Services/Shell/ShellExecutor.cs
@@ -0,0 +1,51 @@
+using Daybreak.Shared.Services.Shell;
+using Microsoft.Extensions.Logging;
+using System.Diagnostics;
+
+namespace Daybreak.Windows.Services.Shell;
+
+///
+/// Windows implementation of .
+/// URLs are opened via shell execution (default browser); paths are opened via explorer.exe.
+///
+internal sealed class ShellExecutor(
+ ILogger logger) : IShellExecutor
+{
+ private const string ExplorerExecutable = "explorer.exe";
+
+ private readonly ILogger logger = logger;
+
+ public void OpenUrl(string url)
+ {
+ if (string.IsNullOrWhiteSpace(url))
+ {
+ return;
+ }
+
+ try
+ {
+ Process.Start(new ProcessStartInfo { FileName = url, UseShellExecute = true });
+ }
+ catch (Exception ex)
+ {
+ this.logger.LogError(ex, "Failed to open url {url}", url);
+ }
+ }
+
+ public void OpenPath(string path)
+ {
+ if (string.IsNullOrWhiteSpace(path))
+ {
+ return;
+ }
+
+ try
+ {
+ Process.Start(ExplorerExecutable, path);
+ }
+ catch (Exception ex)
+ {
+ this.logger.LogError(ex, "Failed to open path {path}", path);
+ }
+ }
+}
diff --git a/Daybreak.wiki b/Daybreak.wiki
index 62452af2..936d25cf 160000
--- a/Daybreak.wiki
+++ b/Daybreak.wiki
@@ -1 +1 @@
-Subproject commit 62452af26d36bcf3187604de0419110c194b2ae2
+Subproject commit 936d25cf806a73e5299b6964459bdc827ccba5cd
diff --git a/Directory.Build.props b/Directory.Build.props
index d8b40546..a25b0ee3 100644
--- a/Directory.Build.props
+++ b/Directory.Build.props
@@ -2,7 +2,7 @@
$(NoWarn);IL2104;IL3053;IL3000;IL3002;NU1701;CS0108
- 0.9.10.16
+ 0.9.10.17
enable
enable