From 510930d9ceaac825b65608fff26ee8faf1480cde Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Tue, 28 Jul 2026 13:04:37 +0000 Subject: [PATCH 1/5] Bump version to 0.9.10.17 --- Directory.Build.props | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 From 3e58849118fb4059b315b0b85bc09515c1f815c8 Mon Sep 17 00:00:00 2001 From: Macocian Alexandru Victor Date: Sun, 2 Aug 2026 13:56:19 +0200 Subject: [PATCH 2/5] Steam login support (Windows) (Closes #1597) (#1599) --- .../ApplicationLauncher.cs | 30 +++++++- .../Services/Credentials/CredentialManager.cs | 7 ++ .../Views/LaunchConfigurationsView.razor.cs | 1 + Daybreak.Core/Views/LaunchView.razor.cs | 5 +- .../LinuxPlatformConfiguration.cs | 1 + .../ApplicationLauncher/SteamService.cs | 76 +++++++++++++++++++ Daybreak.Shared/Models/LoginCredentials.cs | 11 +++ .../ApplicationLauncher/ISteamService.cs | 19 +++++ .../Credentials/CredentialManagerTests.cs | 21 +++++ .../WindowsPlatformConfiguration.cs | 1 + .../ApplicationLauncher/SteamService.cs | 19 +++++ 11 files changed, 187 insertions(+), 4 deletions(-) create mode 100644 Daybreak.Linux/Services/ApplicationLauncher/SteamService.cs create mode 100644 Daybreak.Shared/Services/ApplicationLauncher/ISteamService.cs create mode 100644 Daybreak.Windows/Services/ApplicationLauncher/SteamService.cs diff --git a/Daybreak.Core/Services/ApplicationLauncher/ApplicationLauncher.cs b/Daybreak.Core/Services/ApplicationLauncher/ApplicationLauncher.cs index 801946bc..9b191757 100644 --- a/Daybreak.Core/Services/ApplicationLauncher/ApplicationLauncher.cs +++ b/Daybreak.Core/Services/ApplicationLauncher/ApplicationLauncher.cs @@ -30,6 +30,7 @@ internal sealed class ApplicationLauncher( IGuildWarsProcessFinder guildWarsProcessFinder, IDaybreakRestartingService daybreakRestartingService, IPrivilegeManager privilegeManager, + ISteamService steamService, ILogger logger ) : IApplicationLauncher { @@ -55,6 +56,7 @@ ILogger logger private readonly IPrivilegeManager privilegeManager = privilegeManager.ThrowIfNull(); + private readonly ISteamService steamService = steamService.ThrowIfNull(); public async Task LaunchGuildwars( LaunchConfigurationWithCredentials launchConfigurationWithCredentials, @@ -63,6 +65,27 @@ CancellationToken cancellationToken { launchConfigurationWithCredentials.ThrowIfNull(); launchConfigurationWithCredentials.Credentials.ThrowIfNull(); + if (launchConfigurationWithCredentials.Credentials?.IsSteamLogin is true) + { + if (!this.steamService.IsSteamLoginSupported) + { + this.notificationService.NotifyError( + title: "Steam login is not supported", + description: "Steam login is not supported on this platform yet. Please select a different set of credentials" + ); + return default; + } + + if (!this.steamService.IsSteamRunning()) + { + this.notificationService.NotifyError( + title: "Steam is not running", + description: "Steam login is selected but the Steam client is not running. Please start Steam and log in, then try again" + ); + return default; + } + } + using var timeout = new CancellationTokenSource(LaunchTimeout); using var cancellation = CancellationTokenSource.CreateLinkedTokenSource( cancellationToken, @@ -107,8 +130,9 @@ CancellationToken cancellationToken ) { var scopedLogger = this.logger.CreateScopedLogger(); - var email = launchConfigurationWithCredentials.Credentials?.Username; - var password = launchConfigurationWithCredentials.Credentials?.Password; + var isSteamLogin = launchConfigurationWithCredentials.Credentials?.IsSteamLogin is true; + var email = isSteamLogin ? null : launchConfigurationWithCredentials.Credentials?.Username; + var password = isSteamLogin ? null : launchConfigurationWithCredentials.Credentials?.Password; var executable = launchConfigurationWithCredentials.ExecutablePath; if (executable is not null && File.Exists(executable) is false) { @@ -229,7 +253,7 @@ is null }; var steamAppIdFilePath = Path.Combine(workingDirectory, SteamAppIdFile); - if (launchConfigurationWithCredentials.SteamSupport) + if (launchConfigurationWithCredentials.SteamSupport || isSteamLogin) { await File.WriteAllTextAsync(steamAppIdFilePath, SteamAppId, cancellationToken); scopedLogger.LogDebug( diff --git a/Daybreak.Core/Services/Credentials/CredentialManager.cs b/Daybreak.Core/Services/Credentials/CredentialManager.cs index b5713005..d32aee39 100644 --- a/Daybreak.Core/Services/Credentials/CredentialManager.cs +++ b/Daybreak.Core/Services/Credentials/CredentialManager.cs @@ -26,6 +26,12 @@ internal sealed class CredentialManager( public bool TryGetCredentialsByIdentifier(string identifier, out LoginCredentials? loginCredentials) { loginCredentials = default; + if (string.Equals(identifier, LoginCredentials.SteamLoginIdentifier, StringComparison.Ordinal)) + { + loginCredentials = LoginCredentials.SteamLogin; + return true; + } + if (this.GetCredentialList().FirstOrDefault(l => l.Identifier == identifier) is LoginCredentials foundCredentials) { loginCredentials = foundCredentials; @@ -56,6 +62,7 @@ public void StoreCredentials(List loginCredentials) this.logger.LogDebug("Storing credentials"); var options = this.liveOptions.CurrentValue; options.ProtectedLoginCredentials = [.. loginCredentials + .Where(c => !c.IsSteamLogin) .Select(this.ProtectCredentials) .OfType()]; this.optionsProvider.SaveOption(options); diff --git a/Daybreak.Core/Views/LaunchConfigurationsView.razor.cs b/Daybreak.Core/Views/LaunchConfigurationsView.razor.cs index a8868977..19a7f63b 100644 --- a/Daybreak.Core/Views/LaunchConfigurationsView.razor.cs +++ b/Daybreak.Core/Views/LaunchConfigurationsView.razor.cs @@ -30,6 +30,7 @@ public sealed class LaunchConfigurationsViewModel( public override ValueTask ParametersSet(LaunchConfigurationsView view, CancellationToken cancellationToken) { this.Credentials.ClearAnd().AddRange(this.credentialManager.GetCredentialList()); + this.Credentials.Add(LoginCredentials.SteamLogin); this.LaunchConfigurations.ClearAnd().AddRange(this.launchConfigurationService.GetLaunchConfigurations()); this.Executables.ClearAnd().AddRange(this.guildWarsExecutableManager.GetExecutableList()).Add(string.Empty); return base.ParametersSet(view, cancellationToken); diff --git a/Daybreak.Core/Views/LaunchView.razor.cs b/Daybreak.Core/Views/LaunchView.razor.cs index 65425906..2ad5d413 100644 --- a/Daybreak.Core/Views/LaunchView.razor.cs +++ b/Daybreak.Core/Views/LaunchView.razor.cs @@ -507,9 +507,12 @@ launcherViewContext.AppContext is not null cancellationToken ); - // If the API is available but it does not belong to the desired user, return null + // If the API is available but it does not belong to the desired user, return null. + // Steam login configurations use a virtual credential whose username is not the account + // email, so the ownership check is skipped for them. if ( maybeApiContext is not null + && launcherViewContext.Configuration.Credentials.IsSteamLogin is false && await maybeApiContext.GetLoginInfo(cancellationToken) is LoginInfo loginInfo && loginInfo.Email != launcherViewContext.Configuration.Credentials.Username ) diff --git a/Daybreak.Linux/Configuration/LinuxPlatformConfiguration.cs b/Daybreak.Linux/Configuration/LinuxPlatformConfiguration.cs index 4cf42969..ec582d38 100644 --- a/Daybreak.Linux/Configuration/LinuxPlatformConfiguration.cs +++ b/Daybreak.Linux/Configuration/LinuxPlatformConfiguration.cs @@ -55,6 +55,7 @@ public override void RegisterServices(IServiceCollection services) services.AddSingleton(); services.AddScoped(); services.AddScoped(); + services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); services.AddScoped(); 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.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.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..e0fa4f3e 100644 --- a/Daybreak.Windows/Configuration/WindowsPlatformConfiguration.cs +++ b/Daybreak.Windows/Configuration/WindowsPlatformConfiguration.cs @@ -72,6 +72,7 @@ public override void RegisterServices(IServiceCollection services) services.AddScoped(); services.AddScoped(); services.AddScoped(); + services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); services.AddScoped(); 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; + } +} From d64116ee45d1de8cd13b3dd337a50145fd41d59c Mon Sep 17 00:00:00 2001 From: Macocian Alexandru Victor Date: Sun, 2 Aug 2026 14:09:40 +0200 Subject: [PATCH 3/5] Notifications show an animation for their expiration (#1600) --- Daybreak.Core/Models/NotificationWrapper.cs | 9 ++++ .../Views/Components/NotificationArea.razor | 7 ++- .../Components/NotificationArea.razor.css | 43 +++++++++++++++++-- 3 files changed, 54 insertions(+), 5 deletions(-) diff --git a/Daybreak.Core/Models/NotificationWrapper.cs b/Daybreak.Core/Models/NotificationWrapper.cs index 7b1404c0..60ebc65d 100644 --- a/Daybreak.Core/Models/NotificationWrapper.cs +++ b/Daybreak.Core/Models/NotificationWrapper.cs @@ -4,5 +4,14 @@ namespace Daybreak.Models; public sealed class NotificationWrapper { public required Notification Notification { get; init; } + public DateTime StartTime { get; set; } public DateTime ExpirationTime { get; set; } + + /// + /// Incremented every time the notification lifetime is prolonged so the UI can + /// restart the expiration progress animation. + /// + public int LifetimeGeneration { get; set; } + + public double LifetimeMilliseconds => Math.Max(0, (this.ExpirationTime - this.StartTime).TotalMilliseconds); } diff --git a/Daybreak.Core/Views/Components/NotificationArea.razor b/Daybreak.Core/Views/Components/NotificationArea.razor index 50aeb2d5..c76022d9 100644 --- a/Daybreak.Core/Views/Components/NotificationArea.razor +++ b/Daybreak.Core/Views/Components/NotificationArea.razor @@ -25,6 +25,9 @@ {

@notificationTuple.Notification.Description

} +
} @@ -72,7 +75,7 @@ try { await this.notificationLock.WaitAsync(cancellationToken); - this.notifications.Add(new NotificationWrapper { Notification = notification, ExpirationTime = notification.ExpirationTime }); + this.notifications.Add(new NotificationWrapper { Notification = notification, StartTime = DateTime.UtcNow, ExpirationTime = notification.ExpirationTime }); } catch (OperationCanceledException) { @@ -167,7 +170,9 @@ private void ExtendNotification(NotificationWrapper notification) { + notification.StartTime = DateTime.UtcNow; notification.ExpirationTime = DateTime.UtcNow + NotificationExtension; + notification.LifetimeGeneration++; } private async Task OpenNofitication(NotificationWrapper notification) diff --git a/Daybreak.Core/Views/Components/NotificationArea.razor.css b/Daybreak.Core/Views/Components/NotificationArea.razor.css index 8a3a8e15..7bb2807c 100644 --- a/Daybreak.Core/Views/Components/NotificationArea.razor.css +++ b/Daybreak.Core/Views/Components/NotificationArea.razor.css @@ -99,21 +99,25 @@ /* Notification level styling */ .notification-item.level-information { - border-left: 4px solid var(--accent-fill-rest); + --notification-accent: var(--accent-fill-rest); + border-left: 4px solid var(--notification-accent); } .notification-item.level-warning { - border-left: 4px solid #ff9500; + --notification-accent: #ff9500; + border-left: 4px solid var(--notification-accent); } .notification-item.level-error, .notification-item.level-critical { - border-left: 4px solid #d13438; + --notification-accent: #d13438; + border-left: 4px solid var(--notification-accent); } .notification-item.level-debug, .notification-item.level-trace { - border-left: 4px solid var(--neutral-stroke-accessible); + --notification-accent: var(--neutral-stroke-accessible); + border-left: 4px solid var(--notification-accent); } .notification-item[class*="level-"]:hover { @@ -121,6 +125,32 @@ cursor: pointer; } +/* Expiration progress line animating from start time to expiration */ +.notification-progress { + position: absolute; + left: 0; + bottom: 0; + height: 3px; + width: 100%; + background: var(--notification-accent, var(--accent-fill-rest)); + transform: scaleX(0); + transform-origin: left center; + animation-name: notificationProgress; + animation-timing-function: linear; + animation-fill-mode: forwards; + animation-iteration-count: 1; + pointer-events: none; +} + +@keyframes notificationProgress { + from { + transform: scaleX(0); + } + to { + transform: scaleX(1); + } +} + @keyframes slideInFromRight { from { transform: translateX(100%); @@ -179,4 +209,9 @@ animation: none; opacity: 0; } + + .notification-progress { + animation: none; + transform: scaleX(1); + } } \ No newline at end of file From b8d927c58e781edfdab791fe6c1efb17b3b4d263 Mon Sep 17 00:00:00 2001 From: Macocian Alexandru Victor Date: Sun, 2 Aug 2026 14:39:34 +0200 Subject: [PATCH 4/5] Improve interaction with system shell (#1601) --- .../Services/ReShade/ReShadeService.cs | 6 ++- Daybreak.Core/Views/App.razor.cs | 14 ++--- Daybreak.Core/Views/PluginsView.razor.cs | 8 +-- .../Views/VersionManagementView.razor | 20 ++++--- .../Views/VersionManagementView.razor.cs | 10 ++-- .../Views/VersionManagementView.razor.css | 52 ++++++++++++++++--- .../LinuxPlatformConfiguration.cs | 3 ++ .../Services/Shell/ShellExecutor.cs | 45 ++++++++++++++++ .../Services/Shell/IShellExecutor.cs | 21 ++++++++ .../WindowsPlatformConfiguration.cs | 3 ++ .../Services/Shell/ShellExecutor.cs | 51 ++++++++++++++++++ 11 files changed, 203 insertions(+), 30 deletions(-) create mode 100644 Daybreak.Linux/Services/Shell/ShellExecutor.cs create mode 100644 Daybreak.Shared/Services/Shell/IShellExecutor.cs create mode 100644 Daybreak.Windows/Services/Shell/ShellExecutor.cs diff --git a/Daybreak.Core/Services/ReShade/ReShadeService.cs b/Daybreak.Core/Services/ReShade/ReShadeService.cs index cbdc84bb..dab5b27e 100644 --- a/Daybreak.Core/Services/ReShade/ReShadeService.cs +++ b/Daybreak.Core/Services/ReShade/ReShadeService.cs @@ -10,6 +10,7 @@ using Daybreak.Shared.Services.Notifications; using Daybreak.Shared.Services.Options; using Daybreak.Shared.Services.ReShade; +using Daybreak.Shared.Services.Shell; using Daybreak.Shared.Utils; using Daybreak.Views.Mods; using HtmlAgilityPack; @@ -18,7 +19,6 @@ using Microsoft.Extensions.Options; using System.Core.Extensions; using System.Data; -using System.Diagnostics; using System.Extensions; using System.Extensions.Core; using System.IO.Compression; @@ -34,6 +34,7 @@ internal sealed class ReShadeService( IHttpClient httpClient, IDownloadService downloadService, IViewManager viewManager, + IShellExecutor shellExecutor, ILogger logger) : IReShadeService { private const string PackagesIniUrl = "https://raw.githubusercontent.com/crosire/reshade-shaders/list/EffectPackages.ini"; @@ -68,6 +69,7 @@ internal sealed class ReShadeService( private readonly IHttpClient httpClient = httpClient.ThrowIfNull(); private readonly IDownloadService downloadService = downloadService.ThrowIfNull(); private readonly IViewManager viewManager = viewManager.ThrowIfNull(); + private readonly IShellExecutor shellExecutor = shellExecutor.ThrowIfNull(); private readonly ILogger logger = logger.ThrowIfNull(); public string Name => "ReShade"; @@ -220,7 +222,7 @@ public Task OnGuildWarsStartingDisabled(GuildWarsStartingDisabledContext guildWa public void OpenReShadeFolder() { - Process.Start("explorer.exe", ReShadePath); + this.shellExecutor.OpenPath(ReShadePath); } public async Task> GetStockPackages(CancellationToken cancellationToken) diff --git a/Daybreak.Core/Views/App.razor.cs b/Daybreak.Core/Views/App.razor.cs index 754750ca..9f36802a 100644 --- a/Daybreak.Core/Views/App.razor.cs +++ b/Daybreak.Core/Views/App.razor.cs @@ -8,6 +8,7 @@ using Daybreak.Shared.Services.Notifications; using Daybreak.Shared.Services.Options; using Daybreak.Shared.Services.Privilege; +using Daybreak.Shared.Services.Shell; using Daybreak.Shared.Services.Themes; using Daybreak.Shared.Services.Updater; using Daybreak.Shared.Services.Window; @@ -17,7 +18,6 @@ using Microsoft.JSInterop; using Photino.Blazor; using System.Core.Extensions; -using System.Diagnostics; using System.Extensions; using TrailBlazr.Services; @@ -39,6 +39,7 @@ public sealed class AppViewModel private readonly JSConsoleInterop jsConsoleInterop; private readonly INotificationService notificationService; private readonly IWindowManipulationService windowManipulationService; + private readonly IShellExecutor shellExecutor; private readonly ILogger logger; private SemaphoreSlim themeChangeSemaphore = new(1, 1); @@ -89,6 +90,7 @@ public AppViewModel( INotificationProducer notificationProducer, INotificationService notificationService, IWindowManipulationService windowManipulationService, + IShellExecutor shellExecutor, ILogger logger) { this.keyboardHookService = keyboardHookService.ThrowIfNull(); @@ -104,6 +106,7 @@ public AppViewModel( this.NotificationProducer = notificationProducer.ThrowIfNull(); this.notificationService = notificationService.ThrowIfNull(); this.windowManipulationService = windowManipulationService.ThrowIfNull(); + this.shellExecutor = shellExecutor.ThrowIfNull(); this.logger = logger.ThrowIfNull(); @@ -215,14 +218,7 @@ public void CloseNavigationMenu() public void OpenIssues() { - try - { - Process.Start(new ProcessStartInfo { FileName = IssueUrl, UseShellExecute = true }); - } - catch (Exception ex) - { - this.logger.LogError(ex, "Encountered exception while opening issues page"); - } + this.shellExecutor.OpenUrl(IssueUrl); } public void OpenSynchronizationView() diff --git a/Daybreak.Core/Views/PluginsView.razor.cs b/Daybreak.Core/Views/PluginsView.razor.cs index b120be11..003119d7 100644 --- a/Daybreak.Core/Views/PluginsView.razor.cs +++ b/Daybreak.Core/Views/PluginsView.razor.cs @@ -1,8 +1,8 @@ using Daybreak.Shared.Models.Plugins; using Daybreak.Shared.Services.ApplicationLauncher; using Daybreak.Shared.Services.Plugins; +using Daybreak.Shared.Services.Shell; using Photino.NET; -using System.Diagnostics; using System.Extensions; using TrailBlazr.Services; using TrailBlazr.ViewModels; @@ -12,13 +12,15 @@ public sealed class PluginsViewModel( PhotinoWindow window, IApplicationLauncher applicationLauncher, IViewManager viewManager, - IPluginsService pluginsService) + IPluginsService pluginsService, + IShellExecutor shellExecutor) : ViewModelBase { private readonly PhotinoWindow window = window; private readonly IApplicationLauncher applicationLauncher = applicationLauncher; private readonly IViewManager viewManager = viewManager; private readonly IPluginsService pluginsService = pluginsService; + private readonly IShellExecutor shellExecutor = shellExecutor; public List AvailablePlugins { get; init; } = []; public bool CanSave { get; private set; } = false; @@ -71,7 +73,7 @@ public async void LoadPluginsFromDisk() public void NavigateToPlugin(AvailablePlugin plugin) { - Process.Start("explorer.exe", plugin.Path); + this.shellExecutor.OpenPath(plugin.Path); } private void UpdatePlugins() diff --git a/Daybreak.Core/Views/VersionManagementView.razor b/Daybreak.Core/Views/VersionManagementView.razor index 8c5c316f..61df3c3e 100644 --- a/Daybreak.Core/Views/VersionManagementView.razor +++ b/Daybreak.Core/Views/VersionManagementView.razor @@ -1,15 +1,23 @@ 
-
-
- @version.ToString() + Context="version" + ItemSize="56"> +
+
+
+ +
+ @version.ToString()
-
+
+ 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 ec582d38..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; @@ -61,6 +63,7 @@ public override void RegisterServices(IServiceCollection services) services.AddScoped(); services.AddSingleton(); services.AddSingleton(); + services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); 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/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.Windows/Configuration/WindowsPlatformConfiguration.cs b/Daybreak.Windows/Configuration/WindowsPlatformConfiguration.cs index e0fa4f3e..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; @@ -78,6 +80,7 @@ public override void RegisterServices(IServiceCollection services) services.AddScoped(); services.AddSingleton(); services.AddSingleton(); + services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); 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); + } + } +} From 698378a37a36b4f98471a92aecaa14ee8c10dd31 Mon Sep 17 00:00:00 2001 From: Macocian Alexandru Victor Date: Sun, 2 Aug 2026 14:56:15 +0200 Subject: [PATCH 5/5] Update documentation with Steam support (#1603) --- Daybreak.wiki | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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