Configuration scoped modlist (#1423) (Closes #1225)

* Mod selection for each launch configuration (Closes #1225)

* Store enabled mods with the launched application
This commit is contained in:
2026-02-11 11:56:28 -08:00
parent 176365b4a4
commit 47369851f2
27 changed files with 365 additions and 231 deletions
@@ -0,0 +1,13 @@
using Daybreak.Shared.Attributes;
using System.Text.Json.Serialization;
namespace Daybreak.Configuration.Options;
[OptionsName(Name = "Daybreak API")]
[OptionsIgnore]
public sealed class DaybreakApiOptions
{
[JsonPropertyName(nameof(Enabled))]
[OptionName(Name = "Enabled", Description = "If true, the Daybreak API is injected into Guild Wars, enabling extended functionality such as loading builds, character switching, and the focus view")]
public bool Enabled { get; set; } = false;
}
@@ -8,10 +8,6 @@ namespace Daybreak.Configuration.Options;
[OptionsIgnore]
public sealed class FocusViewOptions
{
[JsonPropertyName(nameof(Enabled))]
[OptionName(Name = "Enabled", Description = "If true, the focus view is enabled, showing live information from the game")]
public bool Enabled { get; set; } = false;
[JsonPropertyName(nameof(ExperienceDisplay))]
[OptionName(Name = "Experience Display Mode", Description = "Sets how should the experience display show the information")]
public ExperienceDisplay ExperienceDisplay { get; set; }
@@ -334,6 +334,7 @@ public class ProjectConfiguration : PluginConfigurationBase
optionsProducer.RegisterOptions<SynchronizationOptions>();
optionsProducer.RegisterOptions<FocusViewOptions>();
optionsProducer.RegisterOptions<DaybreakApiOptions>();
optionsProducer.RegisterOptions<ToolboxOptions>();
optionsProducer.RegisterOptions<UModOptions>();
+1
View File
@@ -10,6 +10,7 @@ public sealed class ModListEntry
public required bool IsVisible { get; init; }
public required bool CanManage { get; init; }
public required bool CanUninstall { get; init; }
public required bool CanDisable { get; init; }
public bool IsEnabled { get; set; }
public bool IsInstalled { get; set; }
@@ -24,7 +24,7 @@ public sealed class DaybreakApiService(
IStubInjector stubInjector,
INotificationService notificationService,
IHttpClient<ScopedApiContext> scopedApiClient,
IOptionsMonitor<FocusViewOptions> liveUpdateableOptions,
IOptionsMonitor<DaybreakApiOptions> daybreakApiOptions,
ILogger<DaybreakApiService> logger,
ILogger<ScopedApiContext> scopedApiLogger)
: IDaybreakApiService
@@ -42,7 +42,7 @@ public sealed class DaybreakApiService(
private readonly IStubInjector stubInjector = stubInjector.ThrowIfNull();
private readonly INotificationService notificationService = notificationService.ThrowIfNull();
private readonly IHttpClient<ScopedApiContext> scopedApiClient = scopedApiClient.ThrowIfNull();
private readonly IOptionsMonitor<FocusViewOptions> liveUpdateableOptions = liveUpdateableOptions.ThrowIfNull();
private readonly IOptionsMonitor<DaybreakApiOptions> daybreakApiOptions = daybreakApiOptions.ThrowIfNull();
private readonly ILogger<DaybreakApiService> logger = logger.ThrowIfNull();
private readonly ILogger<ScopedApiContext> scopedApiLogger = scopedApiLogger.ThrowIfNull();
@@ -52,10 +52,10 @@ public sealed class DaybreakApiService(
public bool IsEnabled
{
get => this.liveUpdateableOptions.CurrentValue.Enabled;
get => this.daybreakApiOptions.CurrentValue.Enabled;
set
{
var options = this.liveUpdateableOptions.CurrentValue;
var options = this.daybreakApiOptions.CurrentValue;
options.Enabled = value;
this.optionsProvider.SaveOption(options);
}
@@ -65,6 +65,7 @@ public sealed class DaybreakApiService(
public bool IsVisible => true;
public bool CanCustomManage => false;
public bool CanUninstall => false;
public bool CanDisable => true;
public IProgressAsyncOperation<bool> PerformUninstallation(CancellationToken cancellationToken)
{
@@ -17,12 +17,10 @@ using Daybreak.Shared.Services.Privilege;
using Daybreak.Views;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using Photino.NET;
namespace Daybreak.Services.ApplicationLauncher;
internal sealed class ApplicationLauncher(
PhotinoWindow photinoWindow,
IDaybreakInjector daybreakInjector,
IGuildWarsExecutableManager guildWarsExecutableManager,
INotificationService notificationService,
@@ -40,7 +38,6 @@ internal sealed class ApplicationLauncher(
private static readonly TimeSpan LaunchTimeout = TimeSpan.FromMinutes(1);
private readonly PhotinoWindow photinoWindow = photinoWindow.ThrowIfNull();
private readonly IDaybreakInjector daybreakInjector = daybreakInjector.ThrowIfNull();
private readonly IGuildWarsExecutableManager guildWarsExecutableManager =
guildWarsExecutableManager.ThrowIfNull();
@@ -71,7 +68,7 @@ internal sealed class ApplicationLauncher(
cancellationToken,
timeout.Token
);
var gwProcess = await this.LaunchGuildwarsProcess(
var (gwProcess, enabledMods) = await this.LaunchGuildwarsProcess(
launchConfigurationWithCredentials,
cancellation.Token
);
@@ -85,6 +82,7 @@ internal sealed class ApplicationLauncher(
LaunchConfiguration = launchConfigurationWithCredentials,
GuildWarsProcess = gwProcess,
ProcessId = (uint)gwProcess.Id,
EnabledMods = enabledMods,
};
}
@@ -103,7 +101,7 @@ internal sealed class ApplicationLauncher(
this.daybreakRestartingService.RestartDaybreakAsNormalUser();
}
private async Task<Process?> LaunchGuildwarsProcess(
private async Task<(Process? Process, IReadOnlyList<IModService> EnabledMods)> LaunchGuildwarsProcess(
LaunchConfigurationWithCredentials launchConfigurationWithCredentials,
CancellationToken cancellationToken
)
@@ -137,7 +135,7 @@ internal sealed class ApplicationLauncher(
title: "Can not launch Guild Wars",
description: "No available executables found. All executables are currently in use"
);
return default;
return (default, []);
}
executable = firstAvailableExecutable;
@@ -178,11 +176,15 @@ internal sealed class ApplicationLauncher(
var enabledMods = this
.modsManager.GetMods()
.Where(m => m.IsEnabled && m.IsInstalled)
.Where(m => launchConfigurationWithCredentials.CustomModLoadoutEnabled
? m.IsInstalled && (!m.CanDisable || launchConfigurationWithCredentials.EnabledMods?.Contains(m.Name) is true)
: m.IsInstalled && m.IsEnabled)
.ToList();
var disabledMods = this
.modsManager.GetMods()
.Where(m => !m.IsEnabled && m.IsInstalled)
.Where(m => launchConfigurationWithCredentials.CustomModLoadoutEnabled
? m.IsInstalled && (!m.CanDisable || launchConfigurationWithCredentials.EnabledMods?.Contains(m.Name) is not true)
: m.IsInstalled && !m.IsEnabled)
.ToList();
if (this.launcherOptions.CurrentValue.CancelLaunchOutOfDateMods)
@@ -269,7 +271,7 @@ internal sealed class ApplicationLauncher(
title: $"{mod.Name} exception",
description: $"Mod encountered exception of type {e.GetType().Name} while processing {nameof(mod.OnGuildWarsStartingDisabled)}"
);
return default;
return (default, []);
}
}
@@ -293,7 +295,7 @@ internal sealed class ApplicationLauncher(
title: $"{mod.Name} canceled startup",
description: $"Mod canceled the startup during {nameof(mod.OnGuildWarsStarting)}"
);
return default;
return (default, []);
}
}
catch (TaskCanceledException)
@@ -311,7 +313,7 @@ internal sealed class ApplicationLauncher(
title: $"{mod.Name} exception",
description: $"Mod encountered exception of type {e.GetType().Name} while processing {nameof(mod.OnGuildWarsStarting)}"
);
return default;
return (default, []);
}
}
@@ -331,7 +333,7 @@ internal sealed class ApplicationLauncher(
title: "Failed to launch Guild Wars",
description: $"Injector failed to launch Guild Wars with launch result {launchResult}. Check logs for details"
);
return default;
return (default, []);
}
var threadHandle = launchResult.ThreadHandle;
@@ -347,7 +349,7 @@ internal sealed class ApplicationLauncher(
title: "Failed to launch Guild Wars",
description: "Injector returned invalid process or thread handle. Check logs for details"
);
return default;
return (default, []);
}
// Reset launch context with the launched process
@@ -386,7 +388,7 @@ internal sealed class ApplicationLauncher(
ProcessId = (uint)pId,
}
);
return default;
return (default, []);
}
}
catch (TaskCanceledException)
@@ -412,7 +414,7 @@ internal sealed class ApplicationLauncher(
title: $"{mod.Name} exception",
description: $"Mod encountered exception of type {e.GetType().Name} while processing {nameof(mod.OnGuildWarsCreated)}"
);
return default;
return (default, []);
}
}
@@ -427,7 +429,7 @@ internal sealed class ApplicationLauncher(
title: "Failed to launch Guild Wars",
description: $"Injector failed to resume Guild Wars with resume result {resumeResult}. Check logs for details"
);
return default;
return (default, []);
}
var sw = Stopwatch.StartNew();
@@ -443,7 +445,7 @@ internal sealed class ApplicationLauncher(
title: "Guild Wars process not ready",
description: "Guild Wars process exited or timed out before becoming ready. Please check logs for details"
);
return default;
return (default, []);
}
/*
@@ -485,11 +487,11 @@ internal sealed class ApplicationLauncher(
title: $"{mod.Name} exception",
description: $"Mod encountered exception of type {e.GetType().Name} while processing {nameof(mod.OnGuildWarsStarted)}"
);
return default;
return (default, []);
}
}
return process;
return (process, enabledMods);
}
public GuildWarsApplicationLaunchContext? GetGuildwarsProcess(
@@ -57,6 +57,7 @@ internal sealed class DirectSongService(
public bool IsVisible => true;
public bool CanCustomManage => false;
public bool CanUninstall => true;
public bool CanDisable => true;
public bool IsEnabled
{
get => this.options.CurrentValue.Enabled;
@@ -47,6 +47,7 @@ internal sealed class GuildWarsVersionChecker(
private readonly ILogger<GuildWarsVersionChecker> logger = logger.ThrowIfNull();
public bool CanUninstall => false;
public bool CanDisable => true;
public IProgressAsyncOperation<bool> PerformUninstallation(CancellationToken cancellationToken)
{
@@ -111,6 +111,8 @@ internal sealed class LaunchConfigurationService(
config.Executable = launchConfigurationWithCredentials.ExecutablePath;
config.Arguments = launchConfigurationWithCredentials.Arguments;
config.SteamSupport = launchConfigurationWithCredentials.SteamSupport;
config.EnabledMods = launchConfigurationWithCredentials.EnabledMods;
config.CustomModLoadoutEnabled = launchConfigurationWithCredentials.CustomModLoadoutEnabled;
var options = this.liveUpdateableOptions.CurrentValue;
options.LaunchConfigurations = configs;
this.optionsProvider.SaveOption(options);
@@ -124,7 +126,9 @@ internal sealed class LaunchConfigurationService(
Executable = launchConfigurationWithCredentials.ExecutablePath,
Identifier = launchConfigurationWithCredentials.Identifier,
Arguments = launchConfigurationWithCredentials.Arguments,
SteamSupport = launchConfigurationWithCredentials.SteamSupport
SteamSupport = launchConfigurationWithCredentials.SteamSupport,
EnabledMods = launchConfigurationWithCredentials.EnabledMods,
CustomModLoadoutEnabled = launchConfigurationWithCredentials.CustomModLoadoutEnabled
});
var optionsNew = this.liveUpdateableOptions.CurrentValue;
optionsNew.LaunchConfigurations = configs;
@@ -152,7 +156,9 @@ internal sealed class LaunchConfigurationService(
ExecutablePath = launchConfiguration.Executable,
Arguments = launchConfiguration.Arguments,
Name = launchConfiguration.Name,
SteamSupport = launchConfiguration.SteamSupport ?? true
SteamSupport = launchConfiguration.SteamSupport ?? true,
EnabledMods = launchConfiguration.EnabledMods,
CustomModLoadoutEnabled = launchConfiguration.CustomModLoadoutEnabled ?? false
};
}
}
@@ -99,6 +99,7 @@ internal sealed class ReShadeService(
File.Exists(ReShadeLogPath) &&
File.Exists(ConfigIniPath);
public bool CanUninstall => true;
public bool CanDisable => true;
public IProgressAsyncOperation<bool> PerformUninstallation(CancellationToken cancellationToken)
{
@@ -57,6 +57,7 @@ internal sealed class ToolboxService(
public bool IsVisible => true;
public bool CanCustomManage => false;
public bool CanUninstall => true;
public bool CanDisable => true;
public bool IsEnabled
{
get => this.toolboxOptions.CurrentValue.Enabled;
@@ -64,6 +64,7 @@ internal sealed class UModService(
public bool IsVisible => true;
public bool CanCustomManage => true;
public bool CanUninstall => true;
public bool CanDisable => true;
public bool IsEnabled
{
get => this.uModOptions.CurrentValue.Enabled;
+106 -109
View File
@@ -1,117 +1,114 @@
<div class="container">
<div class="title-bar-additional-btns-left">
<button class="title-bar-btn" @onclick="this.ViewModel.CreateNewLaunchConfiguration" title="Add new launch configuration">
<FluentIcon Value="new Microsoft.FluentUI.AspNetCore.Components.Icons.Regular.Size16.Add()" />
</button>
<div class="title-bar-additional-btns-left">
<button class="title-bar-btn" @onclick="this.ViewModel.CreateNewLaunchConfiguration"
title="Add new launch configuration">
<FluentIcon Value="new Microsoft.FluentUI.AspNetCore.Components.Icons.Regular.Size16.Add()" />
</button>
</div>
<div class="content backdrop-panel">
<div class="option-view-title">
<h3>Launch Configurations</h3>
</div>
<div class="content backdrop-panel">
<div class="option-view-title">
<h3>Launch Configurations</h3>
</div>
<div class="launch-configs-list">
@foreach (var config in this.ViewModel.LaunchConfigurations)
{
<div class="launch-configs-row">
<div class="launch-config-identifier">
<div class="launch-config-identifier-label">
<FluentLabel>Identifier</FluentLabel>
</div>
<div class="launch-config-identifier-field">
<FluentTextField Value="@config.Identifier"
ReadOnly="true"
Spellcheck="false"
Placeholder="Launch config identifier">
<FluentIcon Value="@(new Microsoft.FluentUI.AspNetCore.Components.Icons.Regular.Size16.Delete())"
@onclick="@(() => this.ViewModel.DeleteLaunchConfiguration(config))"
slot="end"
Color="Color.Neutral"
Title="Delete launch configuration" />
</FluentTextField>
</div>
</div>
<div class="launch-config-name">
<div class="launch-config-name-label">
<FluentLabel>Name</FluentLabel>
</div>
<div class="launch-config-name-field">
<FluentTextField Value="@config.Name"
ValueChanged="@((newValue) => this.ViewModel.CustomNameChanged(config, newValue))"
Spellcheck="false"
Immediate="true"
ImmediateDelay="500"
InputMode="InputMode.Text"
AutoComplete="false"
Placeholder="Custom name" />
</div>
</div>
<div class="launch-config-executable">
<div class="launch-config-executable-label">
<FluentLabel>Executable</FluentLabel>
</div>
<div class="launch-config-executable-field">
<FluentSelect TOption="string"
Multiple="false"
Width="100%"
Position="SelectPosition.Below"
Items="@this.ViewModel.Executables"
SelectedOption="@config.ExecutablePath"
SelectedOptionChanged="@((newValue) => this.ViewModel.ExecutableChanged(config, newValue))">
<OptionTemplate>
<div title="@(context)">
@(string.IsNullOrWhiteSpace(context) ? "Any Executable" : context)
</div>
</OptionTemplate>
</FluentSelect>
</div>
</div>
<div class="launch-config-credentials">
<div class="launch-config-credentials-label">
<FluentLabel>Credentials</FluentLabel>
</div>
<div class="launch-config-credentials-field">
<FluentSelect TOption="string"
Multiple="false"
Width="100%"
Position="SelectPosition.Below"
Items="@this.ViewModel.Credentials.Select(c => c.Identifier ?? string.Empty)"
SelectedOption="@(config.Credentials?.Identifier ?? string.Empty)"
SelectedOptionChanged="@((newValue) => this.ViewModel.CredentialsChanged(config, newValue))">
<OptionTemplate>
@(this.ViewModel.Credentials.FirstOrDefault(c => c.Identifier == context)?.Username ?? "Unknown")
</OptionTemplate>
</FluentSelect>
</div>
</div>
<div class="launch-config-args">
<div class="launch-config-args-label">
<FluentLabel>Custom Arguments</FluentLabel>
</div>
<div class="launch-config-args-field">
<FluentTextField Value="@config.Arguments"
ValueChanged="@((newValue) => this.ViewModel.CustomArgsChanged(config, newValue))"
Spellcheck="false"
Immediate="true"
ImmediateDelay="500"
InputMode="InputMode.Text"
AutoComplete="false"
Placeholder="Launch arguments"/>
</div>
</div>
<div class="launch-config-steam">
<div class="launch-config-steam-label">
<FluentLabel>Steam support</FluentLabel>
</div>
<div class="launch-config-steam-field">
<FluentCheckbox Value="@config.SteamSupport"
ValueChanged="@((newValue) => this.ViewModel.SteamSupportChanged(config, newValue))" />
</div>
</div>
</div>
}
<div class="launch-configs-list">
@foreach (var config in this.ViewModel.LaunchConfigurations)
{
<div class="launch-configs-row">
<div class="launch-config-identifier">
<div class="launch-config-identifier-label">
<FluentLabel>Identifier</FluentLabel>
</div>
<div class="launch-config-identifier-field">
<FluentTextField Value="@config.Identifier" ReadOnly="true" Spellcheck="false"
Placeholder="Launch config identifier">
<FluentIcon Value="@(new Microsoft.FluentUI.AspNetCore.Components.Icons.Regular.Size16.Delete())"
@onclick="@(() => this.ViewModel.DeleteLaunchConfiguration(config))" slot="end" Color="Color.Neutral"
Title="Delete launch configuration" />
</FluentTextField>
</div>
</div>
<div class="launch-config-name">
<div class="launch-config-name-label">
<FluentLabel>Name</FluentLabel>
</div>
<div class="launch-config-name-field">
<FluentTextField Value="@config.Name"
ValueChanged="@((newValue) => this.ViewModel.CustomNameChanged(config, newValue))" Spellcheck="false"
Immediate="true" ImmediateDelay="500" InputMode="InputMode.Text" AutoComplete="false"
Placeholder="Custom name" />
</div>
</div>
<div class="launch-config-executable">
<div class="launch-config-executable-label">
<FluentLabel>Executable</FluentLabel>
</div>
<div class="launch-config-executable-field">
<FluentSelect TOption="string" Multiple="false" Width="100%" Position="SelectPosition.Below"
Items="@this.ViewModel.Executables" SelectedOption="@config.ExecutablePath"
SelectedOptionChanged="@((newValue) => this.ViewModel.ExecutableChanged(config, newValue))">
<OptionTemplate>
<div title="@(context)">
@(string.IsNullOrWhiteSpace(context) ? "Any Executable" : context)
</div>
</OptionTemplate>
</FluentSelect>
</div>
</div>
<div class="launch-config-credentials">
<div class="launch-config-credentials-label">
<FluentLabel>Credentials</FluentLabel>
</div>
<div class="launch-config-credentials-field">
<FluentSelect TOption="string" Multiple="false" Width="100%" Position="SelectPosition.Below"
Items="@this.ViewModel.Credentials.Select(c => c.Identifier ?? string.Empty)"
SelectedOption="@(config.Credentials?.Identifier ?? string.Empty)"
SelectedOptionChanged="@((newValue) => this.ViewModel.CredentialsChanged(config, newValue))">
<OptionTemplate>
@(this.ViewModel.Credentials.FirstOrDefault(c => c.Identifier == context)?.Username ?? "Unknown")
</OptionTemplate>
</FluentSelect>
</div>
</div>
<div class="launch-config-args">
<div class="launch-config-args-label">
<FluentLabel>Custom Arguments</FluentLabel>
</div>
<div class="launch-config-args-field">
<FluentTextField Value="@config.Arguments"
ValueChanged="@((newValue) => this.ViewModel.CustomArgsChanged(config, newValue))" Spellcheck="false"
Immediate="true" ImmediateDelay="500" InputMode="InputMode.Text" AutoComplete="false"
Placeholder="Launch arguments" />
</div>
</div>
<div class="launch-config-steam">
<div class="launch-config-steam-label">
<FluentLabel>Steam support</FluentLabel>
</div>
<div class="launch-config-steam-field">
<FluentCheckbox Value="@config.SteamSupport"
ValueChanged="@((newValue) => this.ViewModel.SteamSupportChanged(config, newValue))" />
</div>
</div>
<div class="launch-config-custom-mods">
<div class="launch-config-custom-mods-label">
<FluentLabel>Custom mod loadout</FluentLabel>
</div>
<div class="launch-config-custom-mods-field">
<FluentCheckbox Value="@config.CustomModLoadoutEnabled"
ValueChanged="@((newValue) => this.ViewModel.CustomModLoadoutChanged(config, newValue))" />
<FluentButton Appearance="Appearance.Neutral"
@onclick="@(() => this.ViewModel.ManageCustomMods(config))"
Disabled="@(!config.CustomModLoadoutEnabled)"
Title="Manage custom mods for this launch configuration">
Manage Mods
</FluentButton>
</div>
</div>
</div>
}
</div>
</div>
</div>
@using Daybreak.Shared.Models;
@page "/launch-configurations"
@inherits ViewBase<LaunchConfigurationsView, LaunchConfigurationsViewModel>
@inherits ViewBase<LaunchConfigurationsView, LaunchConfigurationsViewModel>
@@ -4,18 +4,22 @@ using Daybreak.Shared.Services.Credentials;
using Daybreak.Shared.Services.ExecutableManagement;
using Daybreak.Shared.Services.LaunchConfigurations;
using System.Extensions;
using TrailBlazr.Services;
using TrailBlazr.ViewModels;
namespace Daybreak.Views;
public sealed class LaunchConfigurationsViewModel(
ILaunchConfigurationService launchConfigurationService,
ICredentialManager credentialManager,
IGuildWarsExecutableManager guildWarsExecutableManager)
IGuildWarsExecutableManager guildWarsExecutableManager,
IViewManager viewManager)
: ViewModelBase<LaunchConfigurationsViewModel, LaunchConfigurationsView>
{
private readonly ILaunchConfigurationService launchConfigurationService = launchConfigurationService;
private readonly ICredentialManager credentialManager = credentialManager;
private readonly IGuildWarsExecutableManager guildWarsExecutableManager = guildWarsExecutableManager;
private readonly IViewManager viewManager = viewManager;
public List<string> Executables { get; } = [];
public List<LoginCredentials> Credentials { get; } = [];
@@ -83,4 +87,15 @@ public sealed class LaunchConfigurationsViewModel(
configuration.SteamSupport = isEnabled;
this.launchConfigurationService.SaveConfiguration(configuration);
}
public void CustomModLoadoutChanged(LaunchConfigurationWithCredentials configuration, bool isEnabled)
{
configuration.CustomModLoadoutEnabled = isEnabled;
this.launchConfigurationService.SaveConfiguration(configuration);
}
public void ManageCustomMods(LaunchConfigurationWithCredentials configuration)
{
this.viewManager.ShowView<ModsView>((nameof(ModsView.LaunchConfigurationIdentifier), configuration.Identifier ?? throw new InvalidOperationException("Managed configuration identifier cannot be null")));
}
}
@@ -51,7 +51,8 @@
.launch-config-credentials,
.launch-config-name,
.launch-config-args,
.launch-config-steam {
.launch-config-steam,
.launch-config-custom-mods {
display: flex;
flex-direction: row;
width: 100%;
@@ -65,7 +66,8 @@
.launch-config-credentials-label,
.launch-config-name-label,
.launch-config-args-label,
.launch-config-steam-label {
.launch-config-steam-label,
.launch-config-custom-mods-label {
width: 250px;
}
@@ -74,8 +76,13 @@
.launch-config-credentials-field,
.launch-config-name-field,
.launch-config-args-field,
.launch-config-steam-field {
.launch-config-steam-field,
.launch-config-custom-mods-field {
width: 50%;
display: flex;
flex-direction: row;
align-items: center;
gap: 10px;
}
/* Limit dropdown height to prevent container overflow */
@@ -110,7 +117,8 @@
.launch-config-credentials-label ::deep *,
.launch-config-name-label ::deep *,
.launch-config-args-label ::deep *,
.launch-config-steam-label ::deep * {
.launch-config-steam-label ::deep *,
.launch-config-custom-mods-label ::deep * {
font-size: var(--font-size-large) !important;
}
@@ -119,7 +127,8 @@
.launch-config-credentials-field ::deep fluent-text-field,
.launch-config-name-field ::deep fluent-text-field,
.launch-config-args-field ::deep fluent-text-field,
.launch-config-steam-field ::deep fluent-text-field {
.launch-config-steam-field ::deep fluent-text-field,
.launch-config-custom-mods-field ::deep fluent-text-field {
width: 100%;
box-sizing: border-box !important;
font-size: var(--font-size-large);
@@ -130,9 +139,10 @@
.launch-config-credentials-field ::deep fluent-select,
.launch-config-name-field ::deep fluent-select,
.launch-config-args-field ::deep fluent-select,
.launch-config-steam-field ::deep fluent-select {
.launch-config-steam-field ::deep fluent-select,
.launch-config-custom-mods-field ::deep fluent-select {
width: 100%;
box-sizing: border-box !important;
font-size: var(--font-size-large) !important;
--type-ramp-base-font-size: var(--font-size-large);
}
}
+10 -16
View File
@@ -3,7 +3,6 @@ using System.ComponentModel;
using System.Core.Extensions;
using System.Diagnostics;
using System.Runtime.CompilerServices;
using Daybreak.Configuration.Options;
using Daybreak.Shared.Models;
using Daybreak.Shared.Models.Api;
using Daybreak.Shared.Models.LaunchConfigurations;
@@ -13,7 +12,6 @@ using Daybreak.Shared.Services.ApplicationLauncher;
using Daybreak.Shared.Services.LaunchConfigurations;
using Daybreak.Shared.Services.Notifications;
using Daybreak.Shared.Services.Onboarding;
using Microsoft.Extensions.Options;
using TrailBlazr.Services;
using TrailBlazr.ViewModels;
@@ -25,8 +23,7 @@ public sealed class LaunchViewModel(
IDaybreakApiService daybreakApiService,
ILaunchConfigurationService launchConfigurationService,
IOnboardingService onboardingService,
IApplicationLauncher applicationLauncher,
IOptionsMonitor<FocusViewOptions> focusViewOptions
IApplicationLauncher applicationLauncher
) : ViewModelBase<LaunchViewModel, LaunchView>, INotifyPropertyChanged, IDisposable
{
private static readonly TimeSpan LaunchTimeout = TimeSpan.FromSeconds(10);
@@ -38,8 +35,6 @@ public sealed class LaunchViewModel(
launchConfigurationService.ThrowIfNull();
private readonly IOnboardingService onboardingService = onboardingService.ThrowIfNull();
private readonly IApplicationLauncher applicationLauncher = applicationLauncher.ThrowIfNull();
private readonly IOptionsMonitor<FocusViewOptions> focusViewOptions =
focusViewOptions.ThrowIfNull();
private CancellationTokenSource? cancellationTokenSource;
private string? autoLaunchConfigurationId;
@@ -476,11 +471,11 @@ public sealed class LaunchViewModel(
}
else
{
// Game running with API mod - can attach or kill based on focus view setting
// Game running with API mod available - can attach to use FocusView
launcherViewContext.GameRunning = true;
launcherViewContext.CanLaunch = false;
launcherViewContext.CanAttach = this.focusViewOptions.CurrentValue.Enabled;
launcherViewContext.CanKill = !this.focusViewOptions.CurrentValue.Enabled;
launcherViewContext.CanAttach = true;
launcherViewContext.CanKill = false;
}
}
@@ -546,7 +541,8 @@ public sealed class LaunchViewModel(
CancellationToken cancellationToken
)
{
if (!this.focusViewOptions.CurrentValue.Enabled)
// Only attach if Daybreak API was enabled for this launch
if (!context.EnabledMods.OfType<IDaybreakApiService>().Any())
{
return;
}
@@ -593,11 +589,7 @@ public sealed class LaunchViewModel(
return;
}
if (!this.focusViewOptions.CurrentValue.Enabled)
{
return;
}
// If we have an apiContext, the Daybreak API is running and we can attach
using var notificationToken = this.notificationService.NotifyInformation(
title: "Attaching to Guild Wars process...",
description: "Attempting to attach to Guild Wars process"
@@ -677,7 +669,9 @@ public sealed class LaunchViewModel(
this.launchConfigurationService.SetLastLaunchConfigurationWithCredentials(
launcherViewContext.Configuration
);
if (!this.focusViewOptions.CurrentValue.Enabled)
// Only attach to focus view if Daybreak API was enabled for this launch
if (!launchedContext.EnabledMods.OfType<IDaybreakApiService>().Any())
{
return;
}
+73 -60
View File
@@ -1,66 +1,79 @@
<div class="stretch-container backdrop-panel">
<div class="mods-container">
@foreach (var mod in this.ViewModel.Mods)
{
if (!mod.IsVisible)
{
continue;
}
<div class="view-title">
<h3>
@if (this.ViewModel.LaunchConfiguration is not null)
{
@($"Mods - {this.ViewModel.LaunchConfiguration.Name ?? this.ViewModel.LaunchConfiguration.Identifier}")
}
else
{
@("Mods")
}
</h3>
</div>
<div class="mod-row">
<div class="mod-row-left">
<div class="mod-name-label">@mod.Name</div>
<div class="mod-description-label">@mod.Description</div>
</div>
<div class="mod-row-right">
<div class="mod-toggle @((mod.IsInstalled ? string.Empty : "disabled"))">
<FluentLabel class="label">@(mod.IsEnabled ? "Enabled" : "Disabled")</FluentLabel>
<FluentSwitch Value="@(mod.IsEnabled)"
@onchange="() => this.ViewModel.ToggleMod(mod)" />
</div>
@if (mod.Loading)
{
<SpinnerWidget IsLoading="true" />
}
else if (!mod.IsInstalled)
{
<div class="mod-action-button"
@onclick="(() => this.ViewModel.InstallMod(mod))">
Install
</div>
}
else if (mod.CanUpdate)
{
<div class="mod-action-button"
@onclick="@(() => this.ViewModel.UpdateMod(mod))">
Update
</div>
}
else if (mod.IsEnabled && mod.CanManage)
{
<div class="mod-action-button"
@onclick="@(() => this.ViewModel.ManageMod(mod))">
Manage
</div>
}
else if (mod.CanUninstall)
{
<div class="mod-action-button"
@onclick="@(() => this.ViewModel.UninstallMod(mod))">
Uninstall
</div>
}
else
{
<div class="mod-action-button-disabled">
Installed
</div>
}
</div>
<div class="mods-container">
@foreach (var mod in this.ViewModel.Mods)
{
if (!mod.IsVisible)
{
continue;
}
<div class="mod-row">
<div class="mod-row-left">
<div class="mod-name-label">@mod.Name</div>
<div class="mod-description-label">@mod.Description</div>
</div>
<div class="mod-row-right">
<div class="mod-toggle @((mod.IsInstalled && mod.CanDisable ? string.Empty : "disabled"))">
<FluentLabel class="label">@(mod.IsEnabled ? "Enabled" : "Disabled")</FluentLabel>
<FluentSwitch Value="@(mod.IsEnabled)" Disabled="@(!mod.CanDisable)" @onchange="() => this.ViewModel.ToggleMod(mod)" />
</div>
@if (mod.Loading)
{
<SpinnerWidget IsLoading="true" />
}
else if (!mod.IsInstalled)
{
<div class="mod-action-button" @onclick="(() => this.ViewModel.InstallMod(mod))">
Install
</div>
}
</div>
}
else if (mod.CanUpdate)
{
<div class="mod-action-button" @onclick="@(() => this.ViewModel.UpdateMod(mod))">
Update
</div>
}
else if (mod.IsEnabled && mod.CanManage)
{
<div class="mod-action-button" @onclick="@(() => this.ViewModel.ManageMod(mod))">
Manage
</div>
}
else if (mod.CanUninstall)
{
<div class="mod-action-button" @onclick="@(() => this.ViewModel.UninstallMod(mod))">
Uninstall
</div>
}
else
{
<div class="mod-action-button-disabled">
Installed
</div>
}
</div>
</div>
}
</div>
</div>
@page "/mods"
@inherits ViewBase<ModsView, ModsViewModel>
@inherits ViewBase<ModsView, ModsViewModel>
@code
{
[Parameter]
public string? LaunchConfigurationIdentifier { get; set; }
}
+51 -7
View File
@@ -1,5 +1,7 @@
using System.Extensions.Core;
using Daybreak.Models;
using Daybreak.Shared.Models.LaunchConfigurations;
using Daybreak.Shared.Services.LaunchConfigurations;
using Daybreak.Shared.Services.Mods;
using Daybreak.Shared.Services.Notifications;
using Microsoft.Extensions.Logging;
@@ -7,25 +9,29 @@ using TrailBlazr.Services;
using TrailBlazr.ViewModels;
namespace Daybreak.Views;
public sealed class ModsViewModel(
INotificationService notificationService,
IViewManager viewManager,
IModsManager modsManager,
ILaunchConfigurationService launchConfigurationService,
ILogger<ModsViewModel> logger)
: ViewModelBase<ModsViewModel, ModsView>
{
private readonly INotificationService notificationService = notificationService;
private readonly IViewManager viewManager = viewManager;
private readonly IModsManager modService = modsManager;
private readonly ILaunchConfigurationService launchConfigurationService = launchConfigurationService;
private readonly ILogger<ModsViewModel> logger = logger;
private CancellationTokenSource? cts;
public LaunchConfigurationWithCredentials? LaunchConfiguration { get; private set; }
public IEnumerable<ModListEntry> Mods { get; private set; } = [];
public override ValueTask ParametersSet(ModsView view, CancellationToken cancellationToken)
{
this.Mods = [.. this.modService.GetMods().OrderBy(m => m.Name)
var globalMods = this.modService.GetMods().OrderBy(m => m.Name)
.Select(m => new ModListEntry
{
Name = m.Name,
@@ -36,9 +42,26 @@ public sealed class ModsViewModel(
CanManage = m.CanCustomManage,
IsInstalled = m.IsInstalled,
CanUninstall = m.CanUninstall,
CanDisable = m.CanDisable,
CanUpdate = false,
Loading = true,
})];
}).ToList();
if (this.launchConfigurationService.GetLaunchConfigurations().FirstOrDefault(l => l.Identifier == view.LaunchConfigurationIdentifier) is LaunchConfigurationWithCredentials config)
{
this.LaunchConfiguration = config;
foreach (var mod in globalMods)
{
// Mods that cannot be disabled are always enabled
mod.IsEnabled = !mod.CanDisable || config.EnabledMods?.Any(m => m == mod.Name) is true;
}
}
else
{
this.LaunchConfiguration = null;
}
this.Mods = globalMods;
this.viewManager.ShowViewRequested += this.ViewManager_ShowViewRequested;
this.cts?.Cancel();
this.cts?.Dispose();
@@ -49,13 +72,34 @@ public sealed class ModsViewModel(
public void ToggleMod(ModListEntry mod)
{
if (!mod.IsInstalled)
if (!mod.IsInstalled || !mod.CanDisable)
{
return;
}
mod.IsEnabled = !mod.IsEnabled;
mod.ModService.IsEnabled = mod.IsEnabled;
if (this.LaunchConfiguration is not null)
{
if (mod.IsEnabled)
{
this.LaunchConfiguration.EnabledMods ??= [];
if (!this.LaunchConfiguration.EnabledMods.Any(m => m == mod.Name))
{
this.LaunchConfiguration.EnabledMods.Add(mod.Name);
}
}
else
{
this.LaunchConfiguration.EnabledMods?.Remove(mod.Name);
}
this.launchConfigurationService.SaveConfiguration(this.LaunchConfiguration);
}
else
{
mod.ModService.IsEnabled = mod.IsEnabled;
}
this.RefreshView();
}
@@ -110,7 +154,7 @@ public sealed class ModsViewModel(
if (!await mod.ModService.PerformUninstallation(this.cts?.Token ?? CancellationToken.None))
{
this.notificationService.NotifyError($"{mod.Name} uninstallation failed", $"{mod.Name} has failed to uninstall. Please check logs for more details.");
}
mod.Loading = false;
@@ -133,7 +177,7 @@ public sealed class ModsViewModel(
private async ValueTask CheckForUpdates(CancellationToken cancellationToken)
{
foreach(var mod in this.Mods)
foreach (var mod in this.Mods)
{
mod.Loading = true;
}
@@ -152,7 +196,7 @@ public sealed class ModsViewModel(
mod.CanUpdate = false;
}
}
catch(Exception ex)
catch (Exception ex)
{
mod.CanUpdate = false;
var modName = mod.Name ?? "Unknown mod";
+2 -1
View File
@@ -2,7 +2,8 @@
gap: 1rem;
padding: 1rem;
overflow-y: auto;
margin-top: 40px;
flex: 1;
min-height: 0;
}
.mod-row {
+7
View File
@@ -133,6 +133,13 @@ html, body {
background: color-mix(in srgb, var(--neutral-fill-strong-hover) 80%, transparent);
}
.view-title {
text-align: center;
margin-bottom: 2rem;
color: var(--neutral-foreground-rest);
font-size: var(--font-size-large);
}
.option-view-title {
text-align: center;
margin-bottom: 2rem;
@@ -69,6 +69,9 @@ public sealed class WinePrefixManager(
/// <inheritdoc />
public bool CanUninstall => false;
/// <inheritdoc />
public bool CanDisable => false;
/// <inheritdoc />
public IProgressAsyncOperation<bool> PerformInstallation(CancellationToken cancellationToken)
{
@@ -1,4 +1,5 @@
using System.Diagnostics;
using Daybreak.Shared.Services.Mods;
namespace Daybreak.Shared.Models.LaunchConfigurations;
@@ -8,6 +9,12 @@ public sealed record GuildWarsApplicationLaunchContext : IEquatable<GuildWarsApp
public required Process GuildWarsProcess { get; init; }
public required uint ProcessId { get; init; }
/// <summary>
/// The list of mod services that were enabled when this process was launched.
/// This is used to determine which mods are active for this specific instance.
/// </summary>
public IReadOnlyList<IModService> EnabledMods { get; init; } = [];
public GuildWarsApplicationLaunchContext()
{
}
@@ -21,4 +21,10 @@ public sealed class LaunchConfiguration
[JsonPropertyName(nameof(SteamSupport))]
public bool? SteamSupport { get; set; } = true;
[JsonPropertyName(nameof(EnabledMods))]
public List<string>? EnabledMods { get; set; }
[JsonPropertyName(nameof(CustomModLoadoutEnabled))]
public bool? CustomModLoadoutEnabled { get; set; }
}
@@ -8,6 +8,8 @@ public sealed class LaunchConfigurationWithCredentials : IEquatable<LaunchConfig
public string? Arguments { get; set; }
public bool SteamSupport { get; set; } = true;
public LoginCredentials? Credentials { get; set; }
public List<string>? EnabledMods { get; set; }
public bool CustomModLoadoutEnabled { get; set; }
public bool Equals(LaunchConfigurationWithCredentials? other)
{
@@ -27,7 +29,10 @@ public sealed class LaunchConfigurationWithCredentials : IEquatable<LaunchConfig
(this.Name is not null && other.Name is not null && string.Equals(this.Name, other.Name, StringComparison.Ordinal))) &&
this.Credentials?.Equals(other.Credentials) is true &&
this.SteamSupport == other.SteamSupport;
this.SteamSupport == other.SteamSupport &&
this.EnabledMods?.SequenceEqual(other.EnabledMods ?? []) is true &&
this.CustomModLoadoutEnabled == other.CustomModLoadoutEnabled;
}
public override bool Equals(object? obj)
@@ -40,6 +40,11 @@ public interface IModService
/// </summary>
bool CanUninstall { get; }
/// <summary>
/// Dictates if the mod can be disabled by the user. If false, the mod is always enabled when installed.
/// </summary>
bool CanDisable { get; }
/// <summary>
/// Called by the mod manager to perform installation steps for the mod.
/// </summary>
@@ -48,6 +48,7 @@ internal sealed class GuildwarsScreenPlacer(
public bool IsInstalled => true;
public bool CanUninstall => false;
public bool CanDisable => true;
public IProgressAsyncOperation<bool> PerformUninstallation(CancellationToken cancellationToken)
{
@@ -27,6 +27,7 @@ public sealed class SimpleNotificationMod(
public bool IsVisible { get; } = true;
public bool CanCustomManage { get; } = false;
public bool CanUninstall { get; } = false;
public bool CanDisable { get; } = true;
public IEnumerable<string> GetCustomArguments() => [];