Compare commits

..

6 commits

Author SHA1 Message Date
Pat Hartl
f08fd24a14 Release notes for 2.1.9
All checks were successful
Publish Documentation Updates / dispatch (push) Successful in 9s
2026-07-27 20:09:00 -05:00
Pat Hartl
da94ff8438 Fix detection of primary display's resolution on some Linux multi-display configurations
Some checks failed
LANCommander Release / prep (push) Failing after 49s
LANCommander Release / build_server_linux_arm64 (push) Has been skipped
LANCommander Release / build_server_linux_x64 (push) Has been skipped
LANCommander Release / build_server_osx_arm64 (push) Has been skipped
LANCommander Release / build_server_osx_x64 (push) Has been skipped
LANCommander Release / build_server_win_arm64 (push) Has been skipped
LANCommander Release / build_server_win_x64 (push) Has been skipped
LANCommander Release / build_launcher_linux_arm64 (push) Has been skipped
LANCommander Release / build_launcher_linux_x64 (push) Has been skipped
LANCommander Release / build_launcher_osx_arm64 (push) Has been skipped
LANCommander Release / build_launcher_osx_x64 (push) Has been skipped
LANCommander Release / build_launcher_win_arm64 (push) Has been skipped
LANCommander Release / build_launcher_win_x64 (push) Has been skipped
LANCommander Release / build_packager (push) Has been skipped
LANCommander Release / build_release (push) Has been skipped
2026-07-27 00:26:12 -05:00
Pat Hartl
41bcfa908d Unify path resolution server-wide 2026-07-24 18:00:32 -05:00
Pat Hartl
2ca1db535c Fix app path resolution for server, add better tests 2026-07-24 17:35:58 -05:00
Pat Hartl
3eae04abdd Better handle bypass execution policy for scripts 2026-07-24 00:57:29 -05:00
Pat Hartl
2b10b55b38 Create abstraction around elevated process launching for better testing 2026-07-23 21:31:47 -05:00
231 changed files with 1777 additions and 27841 deletions

View file

@ -7,32 +7,9 @@ on:
- "LANCommander.Documentation/**"
jobs:
publish:
dispatch:
runs-on: ubuntu-latest
permissions:
contents: write
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup .NET
uses: actions/setup-dotnet@v4
with:
dotnet-version: "10.0.x"
- name: Regenerate plugin API reference
run: dotnet run --project LANCommander.PluginDocsGenerator
- name: Commit regenerated API reference
run: |
git config user.name "github-actions[bot]"
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
if ! git diff --quiet -- "LANCommander.Documentation/Plugins/API Reference.md"; then
git add "LANCommander.Documentation/Plugins/API Reference.md"
git commit -m "docs: regenerate plugin API reference [skip ci]"
git push
fi
- name: Dispatch to Docusaurus repo
env:
DISPATCH_TOKEN: ${{ secrets.DOCUMENTATION_DISPATCH_TOKEN }}
@ -45,7 +22,7 @@ jobs:
"event_type": "docs-sources-updated",
"client_payload": {
"repo": "'"${GITHUB_REPOSITORY}"'",
"sha": "'"$(git rev-parse HEAD)"'",
"sha": "'"${GITHUB_SHA}"'",
"ref": "'"${GITHUB_REF}"'"
}
}'

View file

@ -16,8 +16,8 @@
<PackageVersion Include="Notify.NET" Version="1.1.0" />
<PackageVersion Include="Svrooij.PowerShell.DI" Version="1.3.4" />
</ItemGroup>
<ItemGroup Label="Mapperly">
<PackageVersion Include="Riok.Mapperly" Version="4.3.1" />
<ItemGroup Label="AutoMapper">
<PackageVersion Include="AutoMapper" Version="14.0.0" />
</ItemGroup>
<ItemGroup Label="AntDesign">
<PackageVersion Include="AntDesign" Version="1.5.0" />

View file

@ -27,5 +27,4 @@ This site serves as the main documentation platform for the project. As such, it
- [Launcher](/Launcher/Overview)
- [Packager](/Packager/Overview)
- [Scripting](/Scripting/Overview)
- [SDK Documentation](/SDK/Overview)
- [Plugin Development](/Plugins/Overview)
- [SDK Documentation](/SDK/Overview)

View file

@ -1,415 +0,0 @@
---
title: API Reference
sidebar_label: API Reference
sidebar_position: 4
---
{/* This file is generated by LANCommander.PluginDocsGenerator. Do not edit by hand. */}
{/* Regenerate with: dotnet run --project LANCommander.PluginDocsGenerator */}
# Plugin API Reference
This reference is generated directly from the plugin contract assemblies and their XML
documentation comments, so it always reflects the extension surface of the installed version.
Types are grouped by namespace. Interfaces you implement in a plugin are listed first within
each group.
## `LANCommander.SDK.Plugins`
### IPlugin
`interface``LANCommander.SDK.Plugins.IPlugin`
The entry point contract every LANCommander plugin implements. Plugins are discovered from the host's `Plugins` drop-in folder and loaded once at startup.
**Properties**
- `string Id { get; }`
- Stable, globally unique identifier (e.g. "com.acme.myplugin").
- `string Name { get; }`
- Human readable display name.
- `string Version { get; }`
- Plugin version (SemVer recommended).
- `string Author { get; }`
- Plugin author.
**Methods**
- `void ConfigureServices(IServiceCollection services)`
- Registers the plugin's own services into the host's DI container. Called during host startup before the service provider is built, so implementations must only register services and must not attempt to resolve them.
- `Task InitializeAsync(PluginContext context, CancellationToken cancellationToken)`
- Asynchronous startup hook, invoked after the host's service provider is built. Use this to resolve services, subscribe to lifecycle events, register UI extensions, etc.
### IPluginEventBus
`interface``LANCommander.SDK.Plugins.IPluginEventBus`
A minimal in-process, strongly-typed event aggregator that lets plugins react to host lifecycle events (game install/launch/uninstall, login, etc.). Registered as a singleton in both hosts.
**Methods**
- `IDisposable Subscribe<TEvent>(Func<TEvent, CancellationToken, Task> handler)`
- Subscribes a handler to events of type `TEvent`.
- `Task PublishAsync<TEvent>(TEvent event, CancellationToken cancellationToken)`
- Publishes an event to all subscribed handlers. Each handler is awaited and isolated so a throwing handler cannot break the publisher or other handlers.
### IPluginPowerShellExtension
`interface``LANCommander.SDK.Plugins.IPluginPowerShellExtension`
Implemented by plugins that want to add PowerShell cmdlets or script modules into the LANCommander runspace. Register the implementation in `ConfigureServices`; the SDK's PowerShell runspace picks up all registered extensions when a script is executed.
**Methods**
- `IEnumerable<Type> GetCmdletTypes()`
- Returns cmdlet types (classes decorated with `[Cmdlet]`) to register into each runspace.
- `IEnumerable<string> GetModulePaths()`
- Returns absolute paths to PowerShell script modules (.psm1/.psd1) shipped with the plugin that should be imported into each runspace.
### LANCommanderPluginAttribute
`attribute``LANCommander.SDK.Plugins.LANCommanderPluginAttribute`
Assembly-level attribute that marks an assembly as a LANCommander plugin and declares its entry point and compatibility metadata. This is the primary discovery mechanism used by the loader.
**Properties**
- `Type EntryPoint { get; }`
- The concrete type implementing `IPlugin` that serves as the entry point.
- `string Id { get; set; }`
- Optional override for the plugin id; when null the loader falls back to the instance's `Id`.
- `string MinHostVersion { get; set; }`
- Minimum compatible host (SDK) version, inclusive. Null means no lower bound.
- `string MaxHostVersion { get; set; }`
- Maximum compatible host (SDK) version, inclusive. Null means no upper bound.
- `PluginHost Hosts { get; set; }`
- The hosts this plugin supports. Defaults to both server and launcher.
### PluginBootstrap
`class``LANCommander.SDK.Plugins.PluginBootstrap`
Convenience helper that centralizes plugin discovery so every host wires it identically. Call `ConfigurePlugins` as the last step while populating the service collection (before building the provider), then call `InitializeAllAsync` on the returned loader after the provider is built.
**Methods**
- `PluginLoaderService ConfigurePlugins(IServiceCollection services, PluginHost host)`
- Discovers plugins for `host` from `<config>/Plugins`, lets each register its services into `services`, and registers the loader as a singleton so the same instance can drive Phase 2 initialization.
### PluginContext
`class``LANCommander.SDK.Plugins.PluginContext`
Runtime context handed to a plugin during `InitializeAsync`.
**Properties**
- `PluginHost Host { get; init; }`
- The host the plugin is running inside (a single value, never a flags combination).
- `IServiceProvider Services { get; init; }`
- The fully built host service provider (scoped per plugin during initialization).
- `string PluginDirectory { get; init; }`
- Absolute path to the folder the plugin was loaded from.
- `ILogger Logger { get; init; }`
- Logger scoped to the plugin.
### PluginEventBus
`class``LANCommander.SDK.Plugins.PluginEventBus`
**Methods**
- `IDisposable Subscribe<TEvent>(Func<TEvent, CancellationToken, Task> handler)`
- `Task PublishAsync<TEvent>(TEvent event, CancellationToken cancellationToken)`
### PluginLoadContext
`class``LANCommander.SDK.Plugins.PluginLoadContext`
An isolated `AssemblyLoadContext` for a single plugin. Uses an `AssemblyDependencyResolver` to resolve the plugin's private dependencies while deferring host-provided assemblies (the SDK, DI abstractions, Avalonia, etc.) to the default context so that shared types keep a single identity across the ALC boundary.
### PluginLoaderService
`class``LANCommander.SDK.Plugins.PluginLoaderService`
Discovers, loads, and initializes plugins from a drop-in folder. Split into two phases to match the "build the DI container once" constraint: `DiscoverAndConfigure` runs while the host is still populating its `IServiceCollection` (before the provider is built).`InitializeAllAsync` runs after the provider has been built.
**Properties**
- `IReadOnlyList<PluginManifest> LoadedPlugins { get; }`
- Plugins successfully loaded and configured during discovery.
**Methods**
- `void DiscoverAndConfigure(IServiceCollection services, PluginHost host, string pluginsRoot, string hostVersion, ILogger logger)`
- Phase 1: scans `pluginsRoot` for plugins, loads each into its own `PluginLoadContext`, applies host + version gates, instantiates the entry point, and lets it register services. A failure in one plugin never aborts the batch.
- `Task InitializeAllAsync(IServiceProvider serviceProvider, CancellationToken cancellationToken)`
- Phase 2: invokes `InitializeAsync` for every loaded plugin, each within its own DI scope. A failure in one plugin never aborts the others.
### PluginManifest
`class``LANCommander.SDK.Plugins.PluginManifest`
Parsed metadata describing a discovered plugin, derived from its `LANCommanderPluginAttribute`.
**Properties**
- `string Id { get; init; }`
- `Type EntryPoint { get; init; }`
- `string MinHostVersion { get; init; }`
- `string MaxHostVersion { get; init; }`
- `PluginHost Hosts { get; init; }`
- `Assembly Assembly { get; init; }`
- The assembly the plugin was loaded from.
- `string Directory { get; init; }`
- Absolute path to the folder the plugin was loaded from.
**Methods**
- `PluginManifest FromAttribute(LANCommanderPluginAttribute attribute, Assembly assembly, string directory)`
- Builds a manifest from an assembly-level plugin attribute.
### PluginHost
`enum``LANCommander.SDK.Plugins.PluginHost`
Identifies which LANCommander host a plugin targets. Used both as a single value (the host a plugin is being loaded into) and as a flags set (the hosts a plugin declares support for).
| Value | Description |
| --- | --- |
| `None` = `0` | |
| `Server` = `1` | |
| `Launcher` = `2` | |
## `LANCommander.SDK.Plugins.Events`
### GameAfterExitEvent
`record``LANCommander.SDK.Plugins.Events.GameAfterExitEvent`
Raised immediately after a launched game process exits.
**Properties**
- `Guid GameId { get; init; }`
- `string InstallDirectory { get; init; }`
### GameBeforeLaunchEvent
`record``LANCommander.SDK.Plugins.Events.GameBeforeLaunchEvent`
Raised immediately before a game's executable is launched.
**Properties**
- `Guid GameId { get; init; }`
- `string InstallDirectory { get; init; }`
- `string Action { get; init; }`
### GameInstallFailedEvent
`record``LANCommander.SDK.Plugins.Events.GameInstallFailedEvent`
Raised when a game install fails.
**Properties**
- `Guid GameId { get; init; }`
- `string InstallDirectory { get; init; }`
### GameInstalledEvent
`record``LANCommander.SDK.Plugins.Events.GameInstalledEvent`
Raised after a game has finished installing.
**Properties**
- `Guid GameId { get; init; }`
- `string InstallDirectory { get; init; }`
### GameInstallingEvent
`record``LANCommander.SDK.Plugins.Events.GameInstallingEvent`
Raised just before a game install begins.
**Properties**
- `Guid GameId { get; init; }`
- `string InstallDirectory { get; init; }`
### GameUninstalledEvent
`record``LANCommander.SDK.Plugins.Events.GameUninstalledEvent`
Raised after a game has finished uninstalling.
**Properties**
- `Guid GameId { get; init; }`
### GameUninstallingEvent
`record``LANCommander.SDK.Plugins.Events.GameUninstallingEvent`
Raised just before a game is uninstalled.
**Properties**
- `Guid GameId { get; init; }`
- `string InstallDirectory { get; init; }`
### InstallQueueChangedEvent
`record``LANCommander.SDK.Plugins.Events.InstallQueueChangedEvent`
Raised whenever the install/download queue changes.
### UserLoggedInEvent
`record``LANCommander.SDK.Plugins.Events.UserLoggedInEvent`
Raised after a user successfully logs in.
**Properties**
- `Guid UserId { get; init; }`
- `string UserName { get; init; }`
### UserLoggedOutEvent
`record``LANCommander.SDK.Plugins.Events.UserLoggedOutEvent`
Raised after a user logs out.
**Properties**
- `Guid UserId { get; init; }`
- `string UserName { get; init; }`
## `LANCommander.Launcher.Plugins.Extensions`
### IContextMenuExtension
`interface``LANCommander.Launcher.Plugins.Extensions.IContextMenuExtension`
Adds items to a game's context menu. Implementations are resolved from DI and their items appended to the consolidated game menu shown on covers and list rows.
**Properties**
- `int Order { get; }`
- Relative position among extension items; lower values appear first.
**Methods**
- `IEnumerable<Control> BuildMenuItems(Guid gameId)`
- Builds the menu items shown for the given game (typically `MenuItem`s).
### IFooterExtension
`interface``LANCommander.Launcher.Plugins.Extensions.IFooterExtension`
Adds a control to the launcher shell's footer. Implementations are resolved from DI and rendered, ordered by `Order`, alongside the built-in footer items.
**Properties**
- `int Order { get; }`
- Relative position among extension items; lower values appear first.
**Methods**
- `Control BuildContent()`
- Builds the control rendered in the footer.
### IGameDetailTabExtension
`interface``LANCommander.Launcher.Plugins.Extensions.IGameDetailTabExtension`
Adds an additional tab to a game's detail view. Implementations are resolved from DI and appended, ordered by `Order`, after the built-in tabs.
**Properties**
- `string Header { get; }`
- Header shown on the tab.
- `int Order { get; }`
- Relative position among extension tabs; lower values appear first.
**Methods**
- `Control BuildContent(Guid gameId)`
- Builds the control rendered inside the tab for the given game.
### INavigationPageExtension
`interface``LANCommander.Launcher.Plugins.Extensions.INavigationPageExtension`
Adds a top-level navigable destination reachable from the launcher shell. The view model is registered with the `IViewRegistry` so the shell's content control can render the associated view when navigated to.
**Properties**
- `string Label { get; }`
- Label shown for the navigation entry.
- `int Order { get; }`
- Relative position among extension destinations; lower values appear first.
- `Type ViewModelType { get; }`
- The view model type used both as the navigation target and the registry key.
**Methods**
- `PluginViewModelBase CreateViewModel()`
- Creates the view model instance shown when the destination is activated.
- `Control BuildView()`
- Builds the control that renders `ViewModelType`.
### ISettingsPageExtension
`interface``LANCommander.Launcher.Plugins.Extensions.ISettingsPageExtension`
Adds an additional section to the launcher's settings page. Implementations are resolved from DI and appended, ordered by `Order`, beneath the built-in settings sections.
**Properties**
- `string Title { get; }`
- Heading shown for the section.
- `int Order { get; }`
- Relative position among extension sections; lower values appear first.
**Methods**
- `Control BuildContent()`
- Builds the control rendered inside the section.
## `LANCommander.Launcher.Plugins`
### IViewRegistry
`interface``LANCommander.Launcher.Plugins.IViewRegistry`
Maps view model types to the Avalonia controls that render them. Seeded at startup with the launcher's built-in mappings and extended at runtime by plugins that add navigable views. Consumers apply `AsDataTemplate` to a `ContentControl` so content is resolved by view model type, replacing the previously hard-coded inline XAML data templates.
**Methods**
- `void Register(Type viewModelType, Func<Control> factory)`
- Register a control factory for the given view model type.
- `void Register<TViewModel>(Func<Control> factory)`
- Register a control factory for `TViewModel`.
- `IDataTemplate AsDataTemplate()`
- Build an `IDataTemplate` backed by the current registrations. Matching prefers the most-derived registered type, preserving the launcher's existing rule that DepotGameDetailViewModel resolves before its GameDetailViewModel base.
### PluginViewModelBase
`class``LANCommander.Launcher.Plugins.PluginViewModelBase`
Base type for view models supplied by plugins. Lives in this project (rather than the launcher's ViewModels assembly) so plugins can derive from it without taking a dependency on the launcher application itself, avoiding a circular reference.
### ViewRegistry
`class``LANCommander.Launcher.Plugins.ViewRegistry`
**Methods**
- `void Register(Type viewModelType, Func<Control> factory)`
- `void Register<TViewModel>(Func<Control> factory)`
- `IDataTemplate AsDataTemplate()`

View file

@ -1,208 +0,0 @@
---
sidebar_label: Extension Points
sidebar_position: 3
---
# Extension Points
This page is a tour of everything a plugin can extend, with a short example for each. For exact
signatures and every available type, see the [API Reference](/Plugins/API%20Reference), which is
generated directly from the source.
## The registration pattern
Almost every extension point follows the same shape: implement an interface, then register your
implementation in [`IPlugin.ConfigureServices`](/Plugins/API%20Reference#iplugin). The host resolves all
registered implementations of a given interface and wires them in automatically.
```csharp
public void ConfigureServices(IServiceCollection services)
{
services.AddSingleton<ISettingsPageExtension, MySettingsExtension>();
services.AddSingleton<IContextMenuExtension, MyContextMenuExtension>();
services.AddSingleton<IPluginPowerShellExtension, MyPowerShellExtension>();
}
```
Where a UI extension point exposes an `Order` property, implementations are sorted ascending, lower
values appear first, and rendered alongside the built-in items.
## Launcher UI extensions
The launcher exposes five UI extension points, all in the
`LANCommander.Launcher.Plugins.Extensions` namespace. Each one builds an Avalonia `Control`.
| Interface | What it adds |
| --- | --- |
| [`INavigationPageExtension`](/Plugins/API%20Reference#inavigationpageextension) | A top-level navigable page reachable from the shell. |
| [`ISettingsPageExtension`](/Plugins/API%20Reference#isettingspageextension) | A section on the settings page. |
| [`IGameDetailTabExtension`](/Plugins/API%20Reference#igamedetailtabextension) | A tab on a game's detail view. |
| [`IContextMenuExtension`](/Plugins/API%20Reference#icontextmenuextension) | Items on a game's context menu. |
| [`IFooterExtension`](/Plugins/API%20Reference#ifooterextension) | A widget in the shell footer. |
:::tip Build controls in code, not XAML
Because plugins load in their own `AssemblyLoadContext`, Avalonia's compiled-XAML asset resolution
(`avares://`) does not reliably resolve across the boundary. The recommended authoring path for plugin
views is to build controls in code, as the examples below do. This is exactly what the reference plugin
does.
:::
### Example: a settings section
```csharp
using Avalonia.Controls;
using Avalonia.Layout;
using LANCommander.Launcher.Plugins.Extensions;
public sealed class MySettingsExtension : ISettingsPageExtension
{
public string Title => "My Plugin";
public int Order => 0;
public Control BuildContent()
{
var panel = new StackPanel { Spacing = 8 };
panel.Children.Add(new TextBlock
{
Text = "This settings section was added by my plugin.",
TextWrapping = Avalonia.Media.TextWrapping.Wrap,
});
panel.Children.Add(new CheckBox
{
Content = "Enable my feature",
HorizontalAlignment = HorizontalAlignment.Left,
});
return panel;
}
}
```
### Example: game context menu items
`IContextMenuExtension` receives the id of the game the menu was opened for and returns any number of
controls (typically `MenuItem`s) to append:
```csharp
public sealed class MyContextMenuExtension : IContextMenuExtension
{
public int Order => 0;
public IEnumerable<Control> BuildMenuItems(Guid gameId)
{
var item = new MenuItem { Header = "Do something with this game" };
item.Click += (_, _) => { /* ... */ };
yield return item;
}
}
```
### Example: a navigation page
A navigation page pairs a view model (deriving from
[`PluginViewModelBase`](/Plugins/API%20Reference#pluginviewmodelbase)) with a control that renders it.
The view model type doubles as the registry key used by the shell's content control.
```csharp
public sealed class MyPageExtension : INavigationPageExtension
{
public string Label => "My Page";
public int Order => 100;
public Type ViewModelType => typeof(MyPageViewModel);
public PluginViewModelBase CreateViewModel() => new MyPageViewModel();
public Control BuildView() => new TextBlock { Text = "Hello from my page" };
}
```
## Server metadata providers
On the server, a plugin can contribute additional metadata providers by registering an
`IMetadataProvider` (from `LANCommander.Server.Services`). Registered providers are picked up by the
server's provider enumeration and used alongside the built-in ones.
```csharp
public void ConfigureServices(IServiceCollection services)
{
services.AddSingleton<IMetadataProvider, MyMetadataProvider>();
}
```
## PowerShell extensions
LANCommander runs installs and other tasks through an embedded PowerShell runtime. A plugin can add its
own cmdlets and script modules by implementing
[`IPluginPowerShellExtension`](/Plugins/API%20Reference#ipluginpowershellextension). Registered
extensions are picked up whenever a script runspace is created, in both hosts.
```csharp
using System.Management.Automation;
using LANCommander.SDK.Plugins;
// The cmdlet itself.
[Cmdlet(VerbsCommon.Get, "MyGreeting")]
public sealed class GetMyGreetingCmdlet : PSCmdlet
{
[Parameter(Position = 0)]
public string Name { get; set; } = "World";
protected override void ProcessRecord() => WriteObject($"Hello, {Name}!");
}
// The extension that exposes it to the runspace.
public sealed class MyPowerShellExtension : IPluginPowerShellExtension
{
public IEnumerable<Type> GetCmdletTypes() => new[] { typeof(GetMyGreetingCmdlet) };
public IEnumerable<string> GetModulePaths() => Array.Empty<string>();
}
```
Once registered, `Get-MyGreeting` is callable from any LANCommander script. To ship script modules
(`.psm1`/`.psd1`) instead of (or in addition to) cmdlets, return their absolute paths from
`GetModulePaths()`. The plugin directory is available to you via `PluginContext.PluginDirectory`.
## Lifecycle events
The [`IPluginEventBus`](/Plugins/API%20Reference#iplugineventbus) is an in-process, strongly typed
event aggregator registered as a singleton in both hosts. Resolve it in `InitializeAsync` and subscribe
to the events you care about. `Subscribe` returns an `IDisposable`; keep it and dispose it to
unsubscribe.
```csharp
public Task InitializeAsync(PluginContext context, CancellationToken cancellationToken)
{
var events = context.Services.GetRequiredService<IPluginEventBus>();
events.Subscribe<GameInstalledEvent>((evt, ct) =>
{
context.Logger.LogInformation("Installed {GameId} to {Dir}", evt.GameId, evt.InstallDirectory);
return Task.CompletedTask;
});
return Task.CompletedTask;
}
```
Handlers are awaited and isolated: a throwing handler cannot break the publisher or other subscribers.
The events published today live in the `LANCommander.SDK.Plugins.Events` namespace:
| Event | Raised when |
| --- | --- |
| `GameInstallingEvent` | A game install is about to begin. |
| `GameInstalledEvent` | A game has finished installing. |
| `GameInstallFailedEvent` | A game install fails. |
| `GameUninstallingEvent` | A game is about to be uninstalled. |
| `GameUninstalledEvent` | A game has finished uninstalling. |
| `GameBeforeLaunchEvent` | A game's executable is about to launch. |
| `GameAfterExitEvent` | A launched game process has exited. |
| `InstallQueueChangedEvent` | The install/download queue changes. |
| `UserLoggedInEvent` | A user successfully logs in. |
| `UserLoggedOutEvent` | A user logs out. |
See the [API Reference](/Plugins/API%20Reference#lancommandersdkpluginsevents) for the exact payload of
each event.

View file

@ -1,161 +0,0 @@
---
sidebar_label: Getting Started
sidebar_position: 2
---
# Getting Started
This guide walks through building a minimal plugin from an empty project to a working drop-in that the
launcher loads at startup. If you'd rather read finished code, the repository ships a complete reference
plugin under `LANCommander.SamplePlugin` that exercises every extension point described here.
## Prerequisites
- The .NET 10 SDK.
- A local checkout of the LANCommander source, or a package/binary reference to the contract assemblies
listed below. The framework binds plugins against the host's already-loaded assemblies, so you build
against the same contract assemblies the host ships.
## 1. Create a class library
A plugin is an ordinary class library targeting `net10.0`:
```bash
dotnet new classlib -n MyCompany.MyPlugin -f net10.0
```
## 2. Reference the contract assemblies
Reference the LANCommander assemblies that expose the extension points you need. Reference them with
`Private=false` so your plugin binds against the host's already-loaded copies at runtime rather than
shipping (and loading) its own duplicates:
```xml
<ItemGroup>
<!-- Core plugin contracts, events, and the PowerShell extension point. -->
<ProjectReference Include="..\LANCommander.SDK\LANCommander.SDK.csproj" Private="false" />
<!-- Launcher UI extension points (only needed if you extend the launcher UI). -->
<ProjectReference Include="..\LANCommander.Launcher.Plugins\LANCommander.Launcher.Plugins.csproj" Private="false" />
<!-- Server contracts such as IMetadataProvider (only needed for server extensions). -->
<ProjectReference Include="..\LANCommander.Server.Services\LANCommander.Server.Services.csproj" Private="false" />
</ItemGroup>
```
:::note
`Private=false` keeps the contract assemblies out of your plugin's output folder. This is important:
the framework preserves type identity across the plugin's load context by deferring these shared
assemblies to the host. Your plugin's *own* private dependencies (NuGet packages, helper libraries)
should ship normally so they land next to your plugin's DLL.
:::
## 3. Implement the entry point
Every plugin has a single entry point that implements
[`IPlugin`](/Plugins/API%20Reference#iplugin). The two lifecycle methods map directly onto the host's
"build the DI container once" model:
- **`ConfigureServices`** runs while the host is still populating its service collection, *before* the
provider is built. Only register services here — do not resolve them.
- **`InitializeAsync`** runs *after* the provider is built. Resolve services, subscribe to events, and do
any asynchronous startup work here. You receive a
[`PluginContext`](/Plugins/API%20Reference#plugincontext) with the host identity, a scoped service
provider, the plugin's directory, and a scoped logger.
```csharp
using LANCommander.SDK.Plugins;
using LANCommander.SDK.Plugins.Events;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
namespace MyCompany.MyPlugin;
public sealed class MyPlugin : IPlugin
{
public string Id => "com.mycompany.myplugin";
public string Name => "My Plugin";
public string Version => "1.0.0";
public string Author => "My Company";
private IDisposable? _launchSubscription;
public void ConfigureServices(IServiceCollection services)
{
// Register anything you'll resolve later, or any extension point implementations.
// e.g. services.AddSingleton<ISettingsPageExtension, MySettingsExtension>();
}
public Task InitializeAsync(PluginContext context, CancellationToken cancellationToken)
{
var events = context.Services.GetRequiredService<IPluginEventBus>();
_launchSubscription = events.Subscribe<GameBeforeLaunchEvent>((evt, ct) =>
{
context.Logger.LogInformation("Game {GameId} is about to launch", evt.GameId);
return Task.CompletedTask;
});
context.Logger.LogInformation("{Name} initialized on host {Host}", Name, context.Host);
return Task.CompletedTask;
}
}
```
## 4. Mark the assembly as a plugin
Discovery is driven by an assembly-level
[`[LANCommanderPlugin]`](/Plugins/API%20Reference#lancommanderpluginattribute) attribute. It names the
entry point and declares compatibility metadata. Place it anywhere in your project (a common choice is
above the `namespace` declaration in your entry point file):
```csharp
using LANCommander.SDK.Plugins;
[assembly: LANCommanderPlugin(
typeof(MyCompany.MyPlugin.MyPlugin),
Id = "com.mycompany.myplugin",
MinHostVersion = "1.1.0",
Hosts = PluginHost.Server | PluginHost.Launcher)]
```
- **`Id`** is optional; when omitted the loader falls back to the entry point's `IPlugin.Id`.
- **`MinHostVersion` / `MaxHostVersion`** are optional SemVer bounds (inclusive). A plugin outside the
host's version range is skipped.
- **`Hosts`** declares which hosts the plugin supports. Defaults to both server and launcher.
## 5. Build and deploy
Build your plugin and copy its output into a subfolder of LANCommander's `Plugins` directory. That
directory lives inside LANCommander's data folder — `Data/Plugins` next to the executable, or under your
user profile's application data if the install directory is not writable:
```
Data/
└── Plugins/
└── MyCompany.MyPlugin/
├── MyCompany.MyPlugin.dll
├── MyCompany.MyPlugin.deps.json
└── (your private dependencies)
```
The loader prefers an assembly named after the folder (`MyCompany.MyPlugin.dll` in the example above),
so naming the folder after your main assembly is the most reliable convention.
## 6. Verify it loaded
Start the host and check the logs. A successful load emits an entry like:
```
Loaded plugin 'My Plugin' (com.mycompany.myplugin) v1.0.0 by My Company
```
If your plugin does not appear, the logs will explain why — a missing `[LANCommanderPlugin]` attribute,
an incompatible host version, a host it does not target, or an exception thrown during
`ConfigureServices`. Every failure is isolated and logged rather than crashing the host.
## Next steps
Now that your plugin loads, head to [Extension Points](/Plugins/Extension%20Points) to add real
functionality, or browse the [API Reference](/Plugins/API%20Reference) for the complete surface.

View file

@ -1,50 +0,0 @@
---
sidebar_label: Overview
sidebar_position: 1
---
# Plugin Development
LANCommander ships a plugin framework that lets you extend both the **server** and the **launcher**
without modifying the core applications. A plugin is a standard .NET class library that is discovered
from a drop-in folder at startup, loaded in isolation, and given the opportunity to register services
and hook into the host.
Plugins can, among other things:
- Add new pages, settings sections, game detail tabs, context menu items, and footer widgets to the
launcher UI.
- Register additional metadata providers on the server.
- Add custom PowerShell cmdlets and modules to the scripting runtime used during installs.
- React to host lifecycle events such as game install, launch, exit, and user login.
## How it works
The framework is intentionally small and built around a few ideas:
- **Discovery by convention.** At startup each host scans its `Plugins` drop-in folder. Every
subfolder is treated as a candidate plugin; the loader looks for an assembly named after the folder
(or the single assembly that ships a `.deps.json`) and reads its
[`[LANCommanderPlugin]`](/Plugins/API%20Reference#lancommanderpluginattribute) assembly attribute.
- **Host targeting.** A plugin declares which hosts it supports (`Server`, `Launcher`, or both). A
plugin that does not target the current host is skipped.
- **Version gating.** A plugin can declare a minimum and/or maximum compatible host version. Incompatible
plugins are skipped with a warning rather than loaded.
- **Isolation.** Each plugin is loaded into its own
[`AssemblyLoadContext`](/Plugins/API%20Reference#pluginloadcontext) so its private dependencies do not
collide with the host or with other plugins. Contract assemblies shared with the host (the SDK, DI
abstractions, Avalonia) are deferred to the host so shared types keep a single identity.
- **Two-phase lifecycle.** Because the host builds its dependency injection container exactly once,
plugins participate in two phases: they register services first, then run an asynchronous
initialization hook after the container is built. See [Getting Started](/Plugins/Getting%20Started)
for details.
- **Fault isolation.** A plugin that throws during discovery, configuration, or initialization is
logged and skipped. A misbehaving plugin cannot crash the host.
## Where to go next
- **[Getting Started](/Plugins/Getting%20Started)** — build, package, and deploy your first plugin.
- **[Extension Points](/Plugins/Extension%20Points)** — a tour of everything a plugin can extend, with
examples.
- **[API Reference](/Plugins/API%20Reference)** — the full plugin surface, generated directly from the
source.

View file

@ -9,7 +9,7 @@ import ContributorGrid from '@site/src/components/ContributorGrid';
# LANCommander 2.1.0 Release Notes
:::tip Latest Version
This page covers the full LANCommander 2.1 series. The latest patch is **2.1.8** — see [Patch Updates](#patch-updates) below for what's changed since the initial release.
This page covers the full LANCommander 2.1 series. The latest patch is **2.1.9** — see [Patch Updates](#patch-updates) below for what's changed since the initial release.
:::
LANCommander 2.1.0 is a landmark release that touches virtually every part of the platform. A brand new launcher built on Avalonia, a standalone packager application, a C++ SDK powering a legacy Win32 launcher, major server improvements, and the launch of LANCommander HQ all come together in what has been the most ambitious update cycle to date.
@ -586,10 +586,37 @@ Actions, scripts, and save paths can now be scoped to a specific runtime platfor
</details>
### 2.1.9
<details>
<summary>View 2.1.9 patch notes</summary>
#### Improvements
- Path resolution has been unified across the server so that storage paths are resolved consistently everywhere. A migration aligns existing settings storage paths automatically on upgrade.
- Depot queries have been optimized for better performance.
- Server notifications now use a shorter timeout so a slow or unreachable server no longer holds up the launcher.
- Updated SharpCompress to the latest version. This should resolve most extraction issues for games with large archives.
#### Bug Fixes
- Fixed detection of the primary display's resolution on some Linux multi-display configurations.
- Fixed application path resolution on the server, correcting how saves, media, archives, and updates are located.
- Improved handling of the bypass execution policy for scripts.
- Fixed installation of wine32 and winetricks.
<ReleaseDownloads release="v2.1.9" />
</details>
## Downloads
<ReleaseDownloads release="v2.1.9" />
<details>
<summary>View 2.1.8 downloads</summary>
<ReleaseDownloads release="v2.1.8" />
</details>
<details>
<summary>View 2.1.7 downloads</summary>
@ -648,4 +675,4 @@ Actions, scripts, and save paths can now be scoped to a specific runtime platfor
## Contributors
<ContributorGrid from="v2.0.2" to="v2.1.8" />
<ContributorGrid from="v2.0.2" to="v2.1.9" />

View file

@ -0,0 +1,32 @@
---
title: 2.1.9
---
import ReleaseDownloads from '@site/src/components/ReleaseDownloads';
import ContributorGrid from '@site/src/components/ContributorGrid';
# LANCommander 2.1.9 Release Notes
:::info Full Release Notes
This is a patch release for the **2.1 series**. For the complete feature overview and all patch notes, visit the [LANCommander 2.1.0 release notes](./2.1.0).
:::
## Improvements
- Path resolution has been unified across the server so that storage paths are resolved consistently everywhere. A migration aligns existing settings storage paths automatically on upgrade.
- Depot queries have been optimized for better performance.
- Server notifications now use a shorter timeout so a slow or unreachable server no longer holds up the launcher.
- Updated SharpCompress to the latest version. This should resolve most extraction issues for games with large archives.
## Bug Fixes
- Fixed detection of the primary display's resolution on some Linux multi-display configurations.
- Fixed application path resolution on the server, correcting how saves, media, archives, and updates are located.
- Improved handling of the bypass execution policy for scripts.
- Fixed installation of wine32 and winetricks.
## Downloads
<ReleaseDownloads release="v2.1.9" />
## Contributors
<ContributorGrid from="v2.1.8" to="v2.1.9" />

View file

@ -17,7 +17,6 @@ namespace LANCommander.Launcher.Models
public DateTime QueuedOn { get; set; }
public DateTime? CompletedOn { get; set; }
public bool IsUpdate { get; set; }
public SDK.Models.GameVersion? TargetVersion { get; set; }
public bool State {
get
{

View file

@ -1,16 +0,0 @@
using Avalonia.Controls;
namespace LANCommander.Launcher.Plugins.Extensions;
/// <summary>
/// Adds items to a game's context menu. Implementations are resolved from DI and their items
/// appended to the consolidated game menu shown on covers and list rows.
/// </summary>
public interface IContextMenuExtension
{
/// <summary>Relative position among extension items; lower values appear first.</summary>
int Order { get; }
/// <summary>Builds the menu items shown for the given game (typically <see cref="MenuItem"/>s).</summary>
IEnumerable<Control> BuildMenuItems(Guid gameId);
}

View file

@ -1,16 +0,0 @@
using Avalonia.Controls;
namespace LANCommander.Launcher.Plugins.Extensions;
/// <summary>
/// Adds a control to the launcher shell's footer. Implementations are resolved from DI and
/// rendered, ordered by <see cref="Order"/>, alongside the built-in footer items.
/// </summary>
public interface IFooterExtension
{
/// <summary>Relative position among extension items; lower values appear first.</summary>
int Order { get; }
/// <summary>Builds the control rendered in the footer.</summary>
Control BuildContent();
}

View file

@ -1,19 +0,0 @@
using Avalonia.Controls;
namespace LANCommander.Launcher.Plugins.Extensions;
/// <summary>
/// Adds an additional tab to a game's detail view. Implementations are resolved from DI and
/// appended, ordered by <see cref="Order"/>, after the built-in tabs.
/// </summary>
public interface IGameDetailTabExtension
{
/// <summary>Header shown on the tab.</summary>
string Header { get; }
/// <summary>Relative position among extension tabs; lower values appear first.</summary>
int Order { get; }
/// <summary>Builds the control rendered inside the tab for the given game.</summary>
Control BuildContent(Guid gameId);
}

View file

@ -1,26 +0,0 @@
using Avalonia.Controls;
namespace LANCommander.Launcher.Plugins.Extensions;
/// <summary>
/// Adds a top-level navigable destination reachable from the launcher shell. The view model
/// is registered with the <see cref="IViewRegistry"/> so the shell's content control can render the
/// associated view when navigated to.
/// </summary>
public interface INavigationPageExtension
{
/// <summary>Label shown for the navigation entry.</summary>
string Label { get; }
/// <summary>Relative position among extension destinations; lower values appear first.</summary>
int Order { get; }
/// <summary>The view model type used both as the navigation target and the registry key.</summary>
Type ViewModelType { get; }
/// <summary>Creates the view model instance shown when the destination is activated.</summary>
PluginViewModelBase CreateViewModel();
/// <summary>Builds the control that renders <see cref="ViewModelType"/>.</summary>
Control BuildView();
}

View file

@ -1,19 +0,0 @@
using Avalonia.Controls;
namespace LANCommander.Launcher.Plugins.Extensions;
/// <summary>
/// Adds an additional section to the launcher's settings page. Implementations are resolved
/// from DI and appended, ordered by <see cref="Order"/>, beneath the built-in settings sections.
/// </summary>
public interface ISettingsPageExtension
{
/// <summary>Heading shown for the section.</summary>
string Title { get; }
/// <summary>Relative position among extension sections; lower values appear first.</summary>
int Order { get; }
/// <summary>Builds the control rendered inside the section.</summary>
Control BuildContent();
}

View file

@ -1,26 +0,0 @@
using Avalonia.Controls;
using Avalonia.Controls.Templates;
namespace LANCommander.Launcher.Plugins;
/// <summary>
/// Maps view model types to the Avalonia controls that render them. Seeded at startup with the
/// launcher's built-in mappings and extended at runtime by plugins that add navigable views.
/// Consumers apply <see cref="AsDataTemplate"/> to a <see cref="ContentControl"/> so content is
/// resolved by view model type, replacing the previously hard-coded inline XAML data templates.
/// </summary>
public interface IViewRegistry
{
/// <summary>Register a control factory for the given view model type.</summary>
void Register(Type viewModelType, Func<Control> factory);
/// <summary>Register a control factory for <typeparamref name="TViewModel"/>.</summary>
void Register<TViewModel>(Func<Control> factory);
/// <summary>
/// Build an <see cref="IDataTemplate"/> backed by the current registrations. Matching prefers
/// the most-derived registered type, preserving the launcher's existing rule that
/// DepotGameDetailViewModel resolves before its GameDetailViewModel base.
/// </summary>
IDataTemplate AsDataTemplate();
}

View file

@ -1,22 +0,0 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<!-- Emit XML docs so the plugin API reference can be generated from source comments. -->
<GenerateDocumentationFile>true</GenerateDocumentationFile>
<!-- Don't fail/spam the build for members without XML comments. -->
<NoWarn>$(NoWarn);CS1591</NoWarn>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Avalonia" />
<PackageReference Include="CommunityToolkit.Mvvm" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\LANCommander.SDK\LANCommander.SDK.csproj" />
</ItemGroup>
</Project>

View file

@ -1,12 +0,0 @@
using CommunityToolkit.Mvvm.ComponentModel;
namespace LANCommander.Launcher.Plugins;
/// <summary>
/// Base type for view models supplied by plugins. Lives in this project (rather than the
/// launcher's ViewModels assembly) so plugins can derive from it without taking a dependency
/// on the launcher application itself, avoiding a circular reference.
/// </summary>
public abstract class PluginViewModelBase : ObservableObject
{
}

View file

@ -1,58 +0,0 @@
using Avalonia.Controls;
using Avalonia.Controls.Templates;
namespace LANCommander.Launcher.Plugins;
/// <inheritdoc cref="IViewRegistry" />
public sealed class ViewRegistry : IViewRegistry
{
private readonly List<Registration> _registrations = new();
public void Register(Type viewModelType, Func<Control> factory)
{
ArgumentNullException.ThrowIfNull(viewModelType);
ArgumentNullException.ThrowIfNull(factory);
_registrations.Add(new Registration(viewModelType, factory));
}
public void Register<TViewModel>(Func<Control> factory) => Register(typeof(TViewModel), factory);
public IDataTemplate AsDataTemplate() => new RegistryDataTemplate(_registrations);
private readonly record struct Registration(Type ViewModelType, Func<Control> Factory);
/// <summary>
/// A live view over the registry's registrations. Because it holds the same list instance the
/// registry appends to, plugin registrations made after this template is attached are still
/// picked up.
/// </summary>
private sealed class RegistryDataTemplate(IReadOnlyList<Registration> registrations) : IDataTemplate
{
public bool Match(object? data) => data != null && FindFactory(data.GetType()) != null;
public Control? Build(object? data) => data == null ? null : FindFactory(data.GetType())?.Invoke();
private Func<Control>? FindFactory(Type dataType)
{
Type? bestType = null;
Func<Control>? bestFactory = null;
foreach (var registration in registrations)
{
if (!registration.ViewModelType.IsAssignableFrom(dataType))
continue;
// Prefer the most-derived match: a candidate wins if the current best is one of its
// base types (i.e. the candidate is more specific), or if there is no best yet.
if (bestType == null || bestType.IsAssignableFrom(registration.ViewModelType))
{
bestType = registration.ViewModelType;
bestFactory = registration.Factory;
}
}
return bestFactory;
}
}
}

View file

@ -35,6 +35,8 @@ namespace LANCommander.Launcher.Services.Extensions
services.AddSingleton<KeepAliveService>();
#endregion
services.AddSingleton<ICurrentProcessInfo, CurrentProcessInfo>();
services.AddSingleton<IElevatedProcessLauncher, ElevatedProcessLauncher>();
services.AddSingleton<IScriptInterceptor, ElevatedScriptInterceptor>();
services.AddSingleton<ScriptDebugger>();
services.AddSingleton<IScriptDebugger>(sp =>

View file

@ -4,8 +4,6 @@ using LANCommander.Launcher.Models;
using LANCommander.SDK;
using LANCommander.SDK.Extensions;
using LANCommander.SDK.Helpers;
using LANCommander.SDK.Plugins;
using LANCommander.SDK.Plugins.Events;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.DependencyInjection;
@ -24,7 +22,6 @@ namespace LANCommander.Launcher.Services
ToolService toolService,
ToolClient toolClient,
IConnectionClient connectionClient,
IPluginEventBus pluginEventBus,
IServiceProvider serviceProvider) : BaseDatabaseService<Game>(dbContext, logger)
{
public Dictionary<Guid, Process> RunningProcesses = new Dictionary<Guid, Process>();
@ -52,7 +49,6 @@ namespace LANCommander.Launcher.Services
try
{
OnUninstall?.Invoke(game);
await pluginEventBus.PublishAsync(new GameUninstallingEvent(game.Id, game.InstallDirectory));
var installService = serviceProvider.GetService<InstallService>();
installService?.ClearCompleted(game.Id);
@ -98,7 +94,6 @@ namespace LANCommander.Launcher.Services
await UpdateAsync(game);
OnUninstallComplete?.Invoke(game);
await pluginEventBus.PublishAsync(new GameUninstalledEvent(game.Id));
operation.Complete();
}

View file

@ -8,8 +8,6 @@ using Microsoft.Extensions.Logging;
using System.Collections.ObjectModel;
using System.Diagnostics;
using LANCommander.SDK.Models;
using LANCommander.SDK.Plugins;
using LANCommander.SDK.Plugins.Events;
using LANCommander.SDK.Services;
using Microsoft.EntityFrameworkCore;
using Game = LANCommander.Launcher.Data.Models.Game;
@ -26,7 +24,6 @@ namespace LANCommander.Launcher.Services
private readonly RedistributableClient _redistributableClient;
private readonly ToolClient _toolClient;
private readonly MediaClient _mediaClient;
private readonly IPluginEventBus _pluginEventBus;
private Stopwatch Stopwatch { get; set; }
@ -67,8 +64,7 @@ namespace LANCommander.Launcher.Services
GameClient gameClient,
RedistributableClient redistributableClient,
ToolClient toolClient,
MediaClient mediaClient,
IPluginEventBus pluginEventBus) : base(logger)
MediaClient mediaClient) : base(logger)
{
_gameService = gameService;
_toolService = toolService;
@ -77,16 +73,6 @@ namespace LANCommander.Launcher.Services
_redistributableClient = redistributableClient;
_toolClient = toolClient;
_mediaClient = mediaClient;
_pluginEventBus = pluginEventBus;
// Bridge existing lifecycle events to the plugin event bus so plugins can react without
// touching every internal call site.
OnInstallComplete += game =>
_pluginEventBus.PublishAsync(new GameInstalledEvent(game.Id, game.InstallDirectory ?? string.Empty));
OnInstallFail += game =>
_pluginEventBus.PublishAsync(new GameInstallFailedEvent(game.Id, game.InstallDirectory));
OnQueueChanged += () =>
_pluginEventBus.PublishAsync(new InstallQueueChangedEvent());
Stopwatch = new Stopwatch();
@ -582,13 +568,6 @@ namespace LANCommander.Launcher.Services
return;
}
if (queueItem.TargetVersion != null)
{
await SwitchToVersion(queueItem, localGame, remoteGame);
await Next();
return;
}
if (localGame.Installed && !queueItem.DependsOnId.HasValue
&& !string.IsNullOrEmpty(localGame.InstallDirectory)
&& ManifestHelper.Exists(localGame.InstallDirectory, localGame.Id))
@ -749,8 +728,6 @@ namespace LANCommander.Launcher.Services
currentItem.Status = InstallStatus.Downloading;
OnQueueChanged?.Invoke();
await _pluginEventBus.PublishAsync(new GameInstallingEvent(localGame.Id, currentItem.InstallDirectory));
try
{
// Build a plan item from the queue item's tasks
@ -966,136 +943,6 @@ namespace LANCommander.Launcher.Services
}
}
/// <summary>
/// Queues an explicit install/rollback of an already-installed game to a specific version.
/// The switch runs through the download queue (so it shows progress and supports cancel)
/// and is processed by <see cref="SwitchToVersion"/>.
/// </summary>
public async Task AddVersionSwitchAsync(Game localGame, SDK.Models.GameVersion version)
{
ArgumentNullException.ThrowIfNull(localGame);
ArgumentNullException.ThrowIfNull(version);
if (version.ArchiveId == null || version.ArchiveId == Guid.Empty)
throw new InstallException("The selected version has no archive to install.");
if (string.IsNullOrWhiteSpace(localGame.InstallDirectory))
throw new InstallException("The game is not installed.");
var remoteGame = await _gameClient.GetAsync(localGame.Id);
if (remoteGame == null)
throw new InstallException($"Could not fetch game info for game {localGame.Id}");
// Drop any settled (non-active) history for this game so the switch shows as a fresh item.
var staleItems = Queue.Where(i => !i.State && i.Id == localGame.Id).ToList();
foreach (var staleItem in staleItems)
Queue.Remove(staleItem);
// If a switch/install for this game is already in flight, don't queue a duplicate.
if (Queue.Any(i => i.Id == localGame.Id
&& i.Status.ValueIsIn(InstallStatus.Queued, InstallStatus.Starting, InstallStatus.Downloading)))
{
Logger?.LogInformation("[InstallQueue] AddVersionSwitch: Game {GameId} already has an active queue item, skipping", localGame.Id);
return;
}
var queueItem = new InstallQueueGame(remoteGame)
{
InstallDirectory = localGame.InstallDirectory,
Version = version.Version,
TargetVersion = version,
IsUpdate = !string.IsNullOrWhiteSpace(localGame.InstalledVersion)
&& version.Version != localGame.InstalledVersion,
};
Queue.Add(queueItem);
_pendingNotificationRoots.Add(localGame.Id);
Logger?.LogInformation("[InstallQueue] AddVersionSwitch: Queued switch of {Title} ({Id}) to version {Version}",
localGame.Title, localGame.Id, version.Version);
if (!Queue.Any(i => i.State))
{
queueItem.Status = InstallStatus.Starting;
await Next();
}
OnQueueChanged?.Invoke();
}
/// <summary>
/// Applies an explicit version switch queue item. Downloads the target version's full
/// archive, extracts it over the install directory, then writes the version-scoped manifest
/// and scripts so on-disk config matches the chosen version. Updates the local
/// InstalledVersion. Cancellable via the queue item's cancellation token.
/// </summary>
private async Task SwitchToVersion(InstallQueueGame currentItem, Game localGame, SDK.Models.Game remoteGame)
{
var version = currentItem.TargetVersion;
using (var operation = Logger.BeginOperation("Switching game {GameTitle} ({GameId}) to version {Version}", localGame.Title, localGame.Id, version.Version))
{
Logger?.LogInformation("[InstallQueue] SwitchToVersion: Switching {Title} ({Id}) from {InstalledVersion} to {TargetVersion} (archive {ArchiveId})",
localGame.Title, localGame.Id, localGame.InstalledVersion, version.Version, version.ArchiveId);
currentItem.Status = InstallStatus.Downloading;
OnQueueChanged?.Invoke();
try
{
var success = await _gameClient.ApplyUpdateArchiveAsync(version.ArchiveId.Value, localGame.Id, localGame.InstallDirectory, currentItem.CancellationToken.Token);
if (!success)
throw new InstallCanceledException("Version switch was canceled");
// Write the version-scoped manifest and scripts so on-disk config matches the chosen version.
await _gameClient.RefreshManifestAndScriptsAsync(localGame.InstallDirectory, localGame.Id, version.Id);
localGame.InstalledVersion = version.Version;
await _gameService.UpdateAsync(localGame);
}
catch (InstallCanceledException)
{
Logger?.LogError("Version switch canceled, removing from queue");
Queue.Remove(currentItem);
return;
}
catch (InstallException ex)
{
Logger?.LogError(ex, "An error occurred during version switch");
currentItem.Status = InstallStatus.Failed;
OnQueueChanged?.Invoke();
OnInstallFail?.Invoke(localGame);
return;
}
catch (Exception ex)
{
Logger?.LogError(ex, "An unknown error occurred during version switch");
currentItem.Status = InstallStatus.Failed;
OnQueueChanged?.Invoke();
OnInstallFail?.Invoke(localGame);
return;
}
currentItem.CompletedOn = DateTime.Now;
currentItem.Status = InstallStatus.Complete;
currentItem.Progress = 1;
currentItem.BytesDownloaded = currentItem.TotalBytes;
OnQueueChanged?.Invoke();
Logger?.LogInformation("[InstallQueue] SwitchToVersion: Completed switch of {Title} ({Id}) to version {Version}",
localGame.Title, localGame.Id, version.Version);
OnInstallComplete?.Invoke(localGame);
operation.Complete();
}
}
public async Task Install(InstallQueueTool currentItem, Tool localTool, SDK.Models.Tool remoteTool)
{
using (var operation = Logger.BeginOperation("Installing tool {ToolName} ({ToolId})", localTool.Name, localTool.Id))

View file

@ -0,0 +1,26 @@
using System.Diagnostics;
using System.Runtime.InteropServices;
using System.Security.Principal;
namespace LANCommander.Launcher.Services;
public class CurrentProcessInfo : ICurrentProcessInfo
{
public string ExecutablePath => Process.GetCurrentProcess().MainModule!.FileName;
public bool IsElevated
{
get
{
if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
{
using var identity = WindowsIdentity.GetCurrent();
var principal = new WindowsPrincipal(identity);
return principal.IsInRole(WindowsBuiltInRole.Administrator);
}
return Environment.UserName == "root";
}
}
}

View file

@ -0,0 +1,22 @@
using System.Diagnostics;
using System.Threading.Tasks;
namespace LANCommander.Launcher.Services;
public class ElevatedProcessLauncher : IElevatedProcessLauncher
{
public async Task LaunchAndWaitAsync(ElevatedProcessRequest request)
{
using var process = new Process();
process.StartInfo.FileName = request.FileName;
process.StartInfo.Verb = "runas";
process.StartInfo.UseShellExecute = true;
process.StartInfo.WorkingDirectory = request.WorkingDirectory;
process.StartInfo.Arguments = request.Arguments;
process.Start();
await process.WaitForExitAsync();
}
}

View file

@ -1,35 +1,19 @@
using System.Diagnostics;
using System.Runtime.InteropServices;
using System.Security.Principal;
using CommandLine;
using LANCommander.Launcher.Models;
using LANCommander.SDK;
using LANCommander.SDK.Enums;
using LANCommander.SDK.PowerShell;
namespace LANCommander.Launcher.Services;
public class ElevatedScriptInterceptor : IScriptInterceptor
public class ElevatedScriptInterceptor(
ICurrentProcessInfo currentProcessInfo,
IElevatedProcessLauncher processLauncher) : IScriptInterceptor
{
public async Task<bool> ExecuteAsync(PowerShellScript script)
{
try
{
bool isElevated = false;
if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
{
var identity = WindowsIdentity.GetCurrent();
var principal = new WindowsPrincipal(identity);
isElevated = principal.IsInRole(WindowsBuiltInRole.Administrator);
}
else
{
isElevated = Environment.UserName == "root";
}
if (script.RunAsAdmin && !isElevated)
if (script.RunAsAdmin && !currentProcessInfo.IsElevated)
{
var manifest = script.Variables.GetValue<SDK.Models.Manifest.Game>("GameManifest");
@ -50,28 +34,26 @@ public class ElevatedScriptInterceptor : IScriptInterceptor
}
var arguments = Parser.Default.FormatCommandLine(options);
var path = Process.GetCurrentProcess().MainModule!.FileName;
var process = new Process();
process.StartInfo.FileName = path;
process.StartInfo.Verb = "runas";
process.StartInfo.UseShellExecute = true;
process.StartInfo.WorkingDirectory = script.WorkingDirectory;
process.StartInfo.Arguments = arguments;
process.Start();
await process.WaitForExitAsync();
// Re-launch this launcher as a minimal, elevated process that runs just this script
// (with all its runtime parameters) and then exits. Wait until it has finished before
// reporting the script as handled so the caller doesn't continue prematurely.
await processLauncher.LaunchAndWaitAsync(new ElevatedProcessRequest
{
FileName = currentProcessInfo.ExecutablePath,
Arguments = arguments,
WorkingDirectory = script.WorkingDirectory,
});
return true;
}
}
catch (Exception ex)
catch (Exception)
{
// Not running as admin
// Unable to determine elevation state or launch the elevated process; fall back to
// running the script in-process.
}
return false;
}
}
}

View file

@ -0,0 +1,21 @@
namespace LANCommander.Launcher.Services;
/// <summary>
/// Exposes information about the currently running launcher process that the
/// <see cref="ElevatedScriptInterceptor"/> needs in order to decide whether a script must be
/// re-launched with elevated privileges. Abstracted so the elevation decision can be tested without
/// depending on the real process token.
/// </summary>
public interface ICurrentProcessInfo
{
/// <summary>
/// The full path to the executable backing the current process. This is the "minimal launcher"
/// that gets re-invoked (elevated) to actually run the script.
/// </summary>
string ExecutablePath { get; }
/// <summary>
/// True if the current process is already running with administrator/root privileges.
/// </summary>
bool IsElevated { get; }
}

View file

@ -0,0 +1,32 @@
using System.Threading.Tasks;
namespace LANCommander.Launcher.Services;
/// <summary>
/// Describes how to re-launch the launcher as a minimal, elevated process that runs a single script
/// with the supplied runtime parameters and then exits.
/// </summary>
public class ElevatedProcessRequest
{
/// <summary>The launcher executable to invoke elevated.</summary>
public required string FileName { get; init; }
/// <summary>The formatted command line (RunScript verb + options) passed to the elevated process.</summary>
public required string Arguments { get; init; }
/// <summary>The working directory the elevated script should run in.</summary>
public string? WorkingDirectory { get; init; }
}
/// <summary>
/// Launches an elevated process and waits for it to finish. Abstracted so the interceptor's
/// wait-for-completion behavior can be tested without spawning a real UAC-elevated process.
/// </summary>
public interface IElevatedProcessLauncher
{
/// <summary>
/// Starts the elevated process described by <paramref name="request"/> and completes only once
/// that process has exited.
/// </summary>
Task LaunchAndWaitAsync(ElevatedProcessRequest request);
}

View file

@ -0,0 +1,229 @@
using System;
using System.Collections.Generic;
using System.Threading.Tasks;
using LANCommander.Launcher.Services;
using LANCommander.SDK.Abstractions;
using LANCommander.SDK.Enums;
using LANCommander.SDK.PowerShell;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using Xunit;
using SdkSettings = LANCommander.SDK.Models.Settings;
using ManifestGame = LANCommander.SDK.Models.Manifest.Game;
namespace LANCommander.Launcher.Tests.Tests;
/// <summary>
/// Verifies the admin-elevation path for launcher scripts. When a script is flagged
/// <c>#Requires -RunAsAdministrator</c> and the launcher is not already elevated, the interceptor
/// must re-launch the launcher as a minimal elevated process, pass it every runtime parameter the
/// script needs, wait until that process exits, and only then report the script as handled. In every
/// other case (no admin required, already elevated, or a failure) it must fall through so the script
/// runs in-process.
/// </summary>
public class ElevatedScriptInterceptorTests
{
private static PowerShellScript CreateScript(ScriptType type)
{
var services = new ServiceCollection();
services.AddLogging();
services.AddSingleton<ISettingsProvider, FakeSettingsProvider>();
var provider = services.BuildServiceProvider();
return new PowerShellScript(provider, type, Options.Create(new SdkSettings()));
}
[Fact]
public async Task NonAdminScript_ReturnsFalse_AndDoesNotLaunchElevatedProcess()
{
var processInfo = new FakeCurrentProcessInfo { IsElevated = false };
var launcher = new RecordingElevatedProcessLauncher();
var interceptor = new ElevatedScriptInterceptor(processInfo, launcher);
var script = CreateScript(ScriptType.Install);
script.AddVariable("GameManifest", new ManifestGame { Id = Guid.NewGuid() });
script.AddVariable("InstallDirectory", "InstallDir");
// Note: not calling AsAdmin() — script does not require elevation.
var handled = await interceptor.ExecuteAsync(script);
Assert.False(handled);
Assert.Equal(0, launcher.LaunchCount);
}
[Fact]
public async Task AdminScript_WhenAlreadyElevated_ReturnsFalse_AndDoesNotLaunchElevatedProcess()
{
var processInfo = new FakeCurrentProcessInfo { IsElevated = true };
var launcher = new RecordingElevatedProcessLauncher();
var interceptor = new ElevatedScriptInterceptor(processInfo, launcher);
var script = CreateScript(ScriptType.Install).AsAdmin();
script.AddVariable("GameManifest", new ManifestGame { Id = Guid.NewGuid() });
script.AddVariable("InstallDirectory", "InstallDir");
var handled = await interceptor.ExecuteAsync(script);
Assert.False(handled);
Assert.Equal(0, launcher.LaunchCount);
}
[Fact]
public async Task AdminScript_WhenNotElevated_LaunchesMinimalLauncherWithRunAsParametersAndWaits()
{
var gameId = Guid.NewGuid();
var processInfo = new FakeCurrentProcessInfo
{
IsElevated = false,
ExecutablePath = @"C:\LANCommander\LANCommander.Launcher.exe",
};
var launcher = new RecordingElevatedProcessLauncher();
var interceptor = new ElevatedScriptInterceptor(processInfo, launcher);
var script = CreateScript(ScriptType.Install).AsAdmin().UseWorkingDirectory("WorkDir");
script.AddVariable("GameManifest", new ManifestGame { Id = gameId });
script.AddVariable("InstallDirectory", "InstallDir");
var handled = await interceptor.ExecuteAsync(script);
Assert.True(handled);
Assert.Equal(1, launcher.LaunchCount);
var request = Assert.Single(launcher.Requests);
// Re-launches this same launcher executable as the elevated process.
Assert.Equal(processInfo.ExecutablePath, request.FileName);
// Preserves the working directory so the elevated script runs in the right place.
Assert.Equal("WorkDir", request.WorkingDirectory);
// Passes the RunScript verb plus every parameter the elevated process needs to run the script.
Assert.Contains("RunScript", request.Arguments);
Assert.Contains(gameId.ToString(), request.Arguments);
Assert.Contains("InstallDir", request.Arguments);
Assert.Contains(ScriptType.Install.ToString(), request.Arguments);
// The interceptor must not report the script handled until the elevated process has exited.
Assert.True(launcher.CompletedBeforeReturn);
}
[Fact]
public async Task KeyChangeScript_ForwardsAllocatedKeyToElevatedProcess()
{
var processInfo = new FakeCurrentProcessInfo { IsElevated = false };
var launcher = new RecordingElevatedProcessLauncher();
var interceptor = new ElevatedScriptInterceptor(processInfo, launcher);
var script = CreateScript(ScriptType.KeyChange).AsAdmin();
script.AddVariable("GameManifest", new ManifestGame { Id = Guid.NewGuid() });
script.AddVariable("InstallDirectory", "InstallDir");
script.AddVariable("AllocatedKey", "KEY-12345");
var handled = await interceptor.ExecuteAsync(script);
Assert.True(handled);
var request = Assert.Single(launcher.Requests);
Assert.Contains(ScriptType.KeyChange.ToString(), request.Arguments);
Assert.Contains("KEY-12345", request.Arguments);
}
[Fact]
public async Task NameChangeScript_ForwardsOldAndNewAliasesToElevatedProcess()
{
var processInfo = new FakeCurrentProcessInfo { IsElevated = false };
var launcher = new RecordingElevatedProcessLauncher();
var interceptor = new ElevatedScriptInterceptor(processInfo, launcher);
var script = CreateScript(ScriptType.NameChange).AsAdmin();
script.AddVariable("GameManifest", new ManifestGame { Id = Guid.NewGuid() });
script.AddVariable("InstallDirectory", "InstallDir");
script.AddVariable("OldPlayerAlias", "OldAlias");
script.AddVariable("NewPlayerAlias", "NewAlias");
var handled = await interceptor.ExecuteAsync(script);
Assert.True(handled);
var request = Assert.Single(launcher.Requests);
Assert.Contains(ScriptType.NameChange.ToString(), request.Arguments);
Assert.Contains("OldAlias", request.Arguments);
Assert.Contains("NewAlias", request.Arguments);
}
[Fact]
public async Task WhenElevationCheckThrows_ReturnsFalse_SoScriptRunsInProcess()
{
var processInfo = new ThrowingCurrentProcessInfo();
var launcher = new RecordingElevatedProcessLauncher();
var interceptor = new ElevatedScriptInterceptor(processInfo, launcher);
var script = CreateScript(ScriptType.Install).AsAdmin();
script.AddVariable("GameManifest", new ManifestGame { Id = Guid.NewGuid() });
script.AddVariable("InstallDirectory", "InstallDir");
var handled = await interceptor.ExecuteAsync(script);
Assert.False(handled);
Assert.Equal(0, launcher.LaunchCount);
}
[Fact]
public async Task WhenElevatedLaunchFails_ReturnsFalse_SoScriptRunsInProcess()
{
var processInfo = new FakeCurrentProcessInfo { IsElevated = false };
var launcher = new RecordingElevatedProcessLauncher { ThrowOnLaunch = true };
var interceptor = new ElevatedScriptInterceptor(processInfo, launcher);
var script = CreateScript(ScriptType.Install).AsAdmin();
script.AddVariable("GameManifest", new ManifestGame { Id = Guid.NewGuid() });
script.AddVariable("InstallDirectory", "InstallDir");
var handled = await interceptor.ExecuteAsync(script);
Assert.False(handled);
}
private sealed class FakeCurrentProcessInfo : ICurrentProcessInfo
{
public string ExecutablePath { get; init; } = @"C:\LANCommander\LANCommander.Launcher.exe";
public bool IsElevated { get; init; }
}
private sealed class ThrowingCurrentProcessInfo : ICurrentProcessInfo
{
public string ExecutablePath => throw new InvalidOperationException("path unavailable");
public bool IsElevated => throw new InvalidOperationException("cannot determine elevation");
}
private sealed class RecordingElevatedProcessLauncher : IElevatedProcessLauncher
{
public List<ElevatedProcessRequest> Requests { get; } = new();
public int LaunchCount => Requests.Count;
public bool ThrowOnLaunch { get; init; }
/// <summary>Set once the (awaited) launch has fully completed. Proves the caller waited.</summary>
public bool CompletedBeforeReturn { get; private set; }
public async Task LaunchAndWaitAsync(ElevatedProcessRequest request)
{
Requests.Add(request);
if (ThrowOnLaunch)
throw new InvalidOperationException("elevated launch failed");
// Simulate the elevated process running for a moment; if the interceptor did not await
// this, CompletedBeforeReturn would still be false when ExecuteAsync returns.
await Task.Delay(20);
CompletedBeforeReturn = true;
}
}
private sealed class FakeSettingsProvider : ISettingsProvider
{
public SdkSettings CurrentValue { get; } = new();
public void Update(Action<SdkSettings> patch) => patch(CurrentValue);
}
}

View file

@ -9,7 +9,6 @@ using Avalonia.Data.Core.Plugins;
using Avalonia.Markup.Xaml;
using LANCommander.Launcher.Input;
using LANCommander.Launcher.Helpers;
using LANCommander.Launcher.Plugins;
using LANCommander.Launcher.Services;
using LANCommander.Launcher.ViewModels;
using LANCommander.Launcher.Views;
@ -195,16 +194,6 @@ public partial class App : Application
_logger?.LogInformation("Initializing view model...");
await mainViewModel.InitializeAsync().ConfigureAwait(false);
_logger?.LogInformation("View model initialized, application ready");
// Initialize plugins now that the service provider and core services are ready.
await Services!.GetRequiredService<LANCommander.SDK.Plugins.PluginLoaderService>()
.InitializeAllAsync(Services!).ConfigureAwait(false);
// Register plugin navigable views with the shared registry so the shell's
// content control can render them. The registry's data template reads its registration
// list live, so mappings added here are picked up even though the template was attached
// when the shell view was constructed.
RegisterPluginNavigationViews();
}
catch (Exception ex)
{
@ -213,29 +202,6 @@ public partial class App : Application
}
}
private static void RegisterPluginNavigationViews()
{
if (Services is null)
return;
var registry = Services.GetService<IViewRegistry>();
if (registry is null)
return;
foreach (var extension in Services.GetServices<Plugins.Extensions.INavigationPageExtension>())
{
try
{
registry.Register(extension.ViewModelType, extension.BuildView);
}
catch (Exception ex)
{
_logger?.LogWarning(ex, "Could not register navigation view for plugin extension {Extension}", extension.GetType().FullName);
}
}
}
private static void ConfigureServices(IServiceCollection services)
{
// Configure logging to console and file
@ -299,37 +265,6 @@ public partial class App : Application
services.AddSingleton<TaskbarProgressService>();
services.AddSingleton<SingleInstanceService>();
services.AddSingleton<INavigationService, NavigationService>();
// View registry: seed the built-in view model -> view mappings that were previously declared
// as inline DataTemplates in MainWindow.axaml / ShellView.axaml. Plugins may append further
// mappings during initialization. Ordering preserves the DepotGameDetailViewModel-before-
// GameDetailViewModel rule via most-derived-first matching in ViewRegistry.
services.AddSingleton<IViewRegistry>(_ =>
{
var registry = new ViewRegistry();
// App-level shell hosted in MainWindow's ContentControl
registry.Register<SplashViewModel>(() => new SplashView());
registry.Register<ServerSelectionViewModel>(() => new ServerSelectionView());
registry.Register<LoginViewModel>(() => new LoginView());
registry.Register<ShellViewModel>(() => new ShellView());
// Shell content hosted in ShellView's TransitioningContentControl
registry.Register<DepotViewModel>(() => new DepotView());
registry.Register<DepotBrowseViewModel>(() => new DepotBrowseView());
registry.Register<DepotGameDetailViewModel>(() => new GameDetailView());
registry.Register<GamesListViewModel>(() => new GamesListView());
registry.Register<LibraryViewModel>(() => new GamesListView());
registry.Register<GameDetailViewModel>(() => new GameDetailView());
registry.Register<SettingsViewModel>(() => new SettingsView());
registry.Register<DownloadQueueViewModel>(() => new DownloadQueuePageView());
return registry;
});
// Plugin framework: discover drop-in plugins and let them register services. Must be the last
// registration step because the service provider is built immediately after this method returns.
LANCommander.SDK.Plugins.PluginBootstrap.ConfigurePlugins(services, LANCommander.SDK.Plugins.PluginHost.Launcher);
}
private static OSPlatform GetOSPlatform()

View file

@ -1,7 +1,5 @@
using System;
using System.Collections.Generic;
using System.Collections.Specialized;
using System.Linq;
using System.Runtime.CompilerServices;
using Avalonia;
using Avalonia.Controls;
@ -10,10 +8,8 @@ using Avalonia.Data.Converters;
using Avalonia.Input;
using Avalonia.Threading;
using Avalonia.VisualTree;
using LANCommander.Launcher.Plugins.Extensions;
using LANCommander.Launcher.ViewModels;
using LANCommander.Launcher.ViewModels.Components;
using Microsoft.Extensions.DependencyInjection;
namespace LANCommander.Launcher.Controls;
@ -200,70 +196,18 @@ public static class GameContextMenu
items.Add(Item("Browse Files", vm, "BrowseFilesCommand", visiblePath: "IsInstalled"));
items.Add(Item("View Manual", vm, "OpenFirstManualCommand", visiblePath: "HasManuals"));
items.Add(Item("Modify", vm, "ModifyCommand", visiblePath: "IsInstalled"));
items.Add(Item("Select Version...", vm, "SelectVersionCommand", visiblePath: "IsInstalled"));
items.Add(Separator(vm, "IsInstalled"));
items.Add(Item("Verify Files", vm, "VerifyFilesCommand", visiblePath: "IsInstalled", enabledPath: "IsVerifyingFiles", enabledInvert: true));
items.Add(Item("Uninstall", vm, "UninstallCommand", visiblePath: "IsInstalled", enabledPath: "IsUninstalling", enabledInvert: true));
items.Add(Item("Add to Library", vm, "AddToLibraryCommand", visiblePath: "IsInLibrary", visibleInvert: true));
items.Add(Item("Remove from Library", vm, "RemoveFromLibraryCommand", enabledPath: "IsInLibrary"));
AppendPluginItems(items, vm.GameId);
flyout.Items.Clear();
foreach (var item in items)
flyout.Items.Add(item);
}
/// <summary>
/// Appends items added by plugins (via <see cref="IContextMenuExtension"/>) after the
/// built-in items, separated by a divider. A failing extension is skipped so the core menu
/// still renders.
/// </summary>
private static void AppendPluginItems(List<Control> items, Guid gameId)
{
var extensions = App.Services?
.GetServices<IContextMenuExtension>()
.OrderBy(c => c.Order)
.ToList();
if (extensions == null || extensions.Count == 0)
return;
var added = false;
foreach (var extension in extensions)
{
IEnumerable<Control>? extensionItems;
try
{
extensionItems = extension.BuildMenuItems(gameId);
}
catch
{
continue;
}
if (extensionItems == null)
continue;
foreach (var control in extensionItems)
{
if (control == null)
continue;
if (!added)
{
items.Add(new Separator());
added = true;
}
items.Add(control);
}
}
}
private static MenuItem Item(
string header,
GameActionBarViewModel vm,

View file

@ -41,7 +41,6 @@
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\LANCommander.Launcher.Plugins\LANCommander.Launcher.Plugins.csproj" />
<ProjectReference Include="..\LANCommander.Launcher.Services\LANCommander.Launcher.Services.csproj" />
<ProjectReference Include="..\LANCommander.Launcher.Settings\LANCommander.Launcher.Settings.csproj" />
</ItemGroup>

View file

@ -14,7 +14,6 @@ using LANCommander.Launcher.Services;
using LANCommander.Launcher.Services.Extensions;
using LANCommander.SDK;
using LANCommander.SDK.Extensions;
using LANCommander.SDK.Plugins;
using LANCommander.SDK.Services;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Configuration;
@ -107,14 +106,8 @@ class Program
services.AddSingleton<InstallService>();
services.AddSingleton<SingleInstanceService>();
// Plugin framework: discover drop-in plugins and let them register services before the
// provider is built. UI extensions register harmlessly but are never resolved in headless mode.
var pluginLoader = PluginBootstrap.ConfigurePlugins(services, PluginHost.Launcher);
var serviceProvider = services.BuildServiceProvider();
await pluginLoader.InitializeAllAsync(serviceProvider).ConfigureAwait(false);
if (settings.Debug.EnableScriptDebugging)
{
HeadlessServiceProvider = serviceProvider;

View file

@ -1207,108 +1207,6 @@ public partial class GameActionBarViewModel : ViewModelBase, IDisposable
}
}
[RelayCommand]
private async Task SelectVersionAsync()
{
if (!IsInstalled || IsInstalling) return;
try
{
using var scope = _serviceProvider.CreateScope();
var gameService = scope.ServiceProvider.GetRequiredService<GameService>();
var gameClient = scope.ServiceProvider.GetRequiredService<GameClient>();
var installService = scope.ServiceProvider.GetRequiredService<InstallService>();
var localGame = await gameService.GetAsync(GameId);
if (localGame == null)
throw new InvalidOperationException("Game not found in local database");
var versions = (await gameClient.GetVersionsAsync(GameId))?.ToList() ?? [];
// Only versions that carry an archive can be installed or rolled back to.
var installable = versions
.Where(v => v.ArchiveId.HasValue && v.ArchiveId.Value != Guid.Empty)
.ToList();
if (installable.Count == 0)
{
await Views.AlertOverlay.ShowAsync("No Versions Available", "This game has no downloadable versions.");
return;
}
var installedVersion = installable.FirstOrDefault(v => v.Version == localGame.InstalledVersion);
var installedSortOrder = installedVersion?.SortOrder;
var versionsVm = new GameVersionsViewModel
{
DialogTitle = $"{Title} — Versions",
};
foreach (var version in installable)
{
var isInstalled = version.Version == localGame.InstalledVersion;
var isNewer = installedSortOrder.HasValue && version.SortOrder > installedSortOrder.Value;
versionsVm.Versions.Add(new GameVersionItemViewModel(version, isInstalled, isNewer));
}
var tcs = new System.Threading.Tasks.TaskCompletionSource<SDK.Models.GameVersion?>();
await Dispatcher.UIThread.InvokeAsync(() =>
{
var overlay = new Views.GameVersionsOverlay
{
DataContext = versionsVm,
HorizontalAlignment = global::Avalonia.Layout.HorizontalAlignment.Stretch,
VerticalAlignment = global::Avalonia.Layout.VerticalAlignment.Stretch,
};
overlay.VersionSelected += (_, v) => tcs.TrySetResult(v);
var mainWindow = (Application.Current?.ApplicationLifetime
as IClassicDesktopStyleApplicationLifetime)?.MainWindow;
var layer = OverlayLayer.GetOverlayLayer(mainWindow);
if (layer is not null)
{
overlay.Bind(global::Avalonia.Layout.Layoutable.WidthProperty, new Binding("Bounds.Width") { Source = layer });
overlay.Bind(global::Avalonia.Layout.Layoutable.HeightProperty, new Binding("Bounds.Height") { Source = layer });
layer.Children.Add(overlay);
}
else
{
tcs.TrySetResult(null);
}
});
var selected = await tcs.Task;
if (selected == null)
return;
IsInstalling = true;
StatusMessage = $"Preparing to switch to version {selected.Version}...";
_logger.LogInformation("Queuing switch of game {GameId} ({Title}) to version {Version}", GameId, Title, selected.Version);
await installService.AddVersionSwitchAsync(localGame, selected);
StatusMessage = "Added to download queue";
InstallRequested?.Invoke(this, EventArgs.Empty);
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to switch version for game {GameId} ({Title})", GameId, Title);
StatusMessage = $"Failed to switch version: {ex.Message}";
await Views.AlertOverlay.ShowAsync("Failed to Switch Version", ex.Message);
}
finally
{
IsInstalling = false;
}
}
[RelayCommand]
private async Task UninstallAsync()
{

View file

@ -1,55 +0,0 @@
using System;
using System.Collections.ObjectModel;
using ByteSizeLib;
using CommunityToolkit.Mvvm.ComponentModel;
namespace LANCommander.Launcher.ViewModels;
/// <summary>
/// ViewModel for the version picker overlay. Lists every downloadable version for a game so the
/// user can install or roll back to a specific one, along with its changelog and download size.
/// </summary>
public partial class GameVersionsViewModel : ViewModelBase
{
[ObservableProperty]
private string _dialogTitle = string.Empty;
[ObservableProperty]
private ObservableCollection<GameVersionItemViewModel> _versions = new();
}
public partial class GameVersionItemViewModel : ViewModelBase
{
public SDK.Models.GameVersion Version { get; }
public string VersionLabel => string.IsNullOrWhiteSpace(Version.Version) ? "(unversioned)" : Version.Version;
public string ChangelogText => Version.Changelog ?? string.Empty;
public bool HasChangelog => !string.IsNullOrWhiteSpace(Version.Changelog);
public string SizeText => Version.CompressedSize > 0
? ByteSize.FromBytes(Version.CompressedSize).ToString("0.##")
: string.Empty;
public bool HasSize => Version.CompressedSize > 0;
public bool CreatedOnKnown => Version.CreatedOn != default;
public string CreatedOnText => Version.CreatedOn.ToLocalTime().ToString("MMM d, yyyy");
/// <summary>True when this version matches the game's currently installed version.</summary>
public bool IsInstalled { get; }
/// <summary>Only versions that carry an archive and aren't already installed can be switched to.</summary>
public bool IsInstallable => !IsInstalled
&& Version.ArchiveId.HasValue
&& Version.ArchiveId.Value != Guid.Empty;
/// <summary>Label for the action button: "Update" for a newer version, "Roll Back" for an older one.</summary>
public string ButtonText { get; }
public GameVersionItemViewModel(SDK.Models.GameVersion version, bool isInstalled, bool isNewerThanInstalled)
{
Version = version;
IsInstalled = isInstalled;
ButtonText = isNewerThanInstalled ? "Update" : "Roll Back";
}
}

View file

@ -7,67 +7,14 @@ using global::Avalonia.VisualTree;
using LANCommander.Launcher.Controls;
using LANCommander.Launcher.ViewModels;
using LANCommander.Launcher.ViewModels.Components;
using Microsoft.Extensions.DependencyInjection;
namespace LANCommander.Launcher.Views;
public partial class GameDetailView : UserControl
{
private bool _pluginTabsAdded;
public GameDetailView()
{
InitializeComponent();
DataContextChanged += (_, _) => AppendPluginTabs();
}
/// <summary>
/// Appends plugin detail sections (via <see cref="Plugins.Extensions.IGameDetailTabExtension"/>) to
/// the bottom of the left column once a game is bound. A failing extension is skipped so the
/// built-in detail content still renders.
/// </summary>
private void AppendPluginTabs()
{
if (_pluginTabsAdded || DataContext is not GameDetailViewModel detailVm)
return;
var extensions = App.Services?
.GetServices<Plugins.Extensions.IGameDetailTabExtension>()
.OrderBy(c => c.Order)
.ToList();
if (extensions == null || extensions.Count == 0)
return;
_pluginTabsAdded = true;
foreach (var extension in extensions)
{
Control content;
try
{
content = extension.BuildContent(detailVm.Id);
}
catch
{
continue;
}
var header = new TextBlock
{
Text = extension.Header,
FontWeight = global::Avalonia.Media.FontWeight.SemiBold,
FontSize = 16,
};
var stack = new StackPanel { Spacing = 8 };
stack.Children.Add(header);
stack.Children.Add(content);
LeftContent.Children.Add(stack);
}
}
/// <summary>

View file

@ -1,96 +0,0 @@
<UserControl xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:vm="using:LANCommander.Launcher.ViewModels"
xmlns:controls="using:LANCommander.Launcher.Controls"
x:Class="LANCommander.Launcher.Views.GameVersionsOverlay"
x:DataType="vm:GameVersionsViewModel"
controls:AutoFocus.IsEnabled="True">
<!-- Full-window dim backdrop -->
<Grid Background="#AA000000">
<!-- Centered card -->
<Border Background="{DynamicResource SystemControlBackgroundChromeMediumBrush}"
Width="480"
MaxHeight="620"
HorizontalAlignment="Center"
VerticalAlignment="Center"
BoxShadow="0 8 40 8 #88000000">
<StackPanel Spacing="0">
<!-- Header -->
<Border Padding="20,16,20,8">
<TextBlock FontSize="15" FontWeight="SemiBold"
HorizontalAlignment="Center"
TextTrimming="CharacterEllipsis"
Text="{Binding DialogTitle}" />
</Border>
<!-- Version list -->
<Border Padding="12,4,12,0">
<ScrollViewer MaxHeight="460"
HorizontalScrollBarVisibility="Disabled"
VerticalScrollBarVisibility="Auto">
<ItemsControl ItemsSource="{Binding Versions}">
<ItemsControl.ItemTemplate>
<DataTemplate x:DataType="vm:GameVersionItemViewModel">
<Border Background="{DynamicResource SystemControlBackgroundChromeMediumLowBrush}"
Padding="12,10"
Margin="0,0,0,6"
CornerRadius="4">
<StackPanel Spacing="6">
<DockPanel>
<StackPanel DockPanel.Dock="Right"
Orientation="Horizontal"
Spacing="8"
VerticalAlignment="Center">
<TextBlock Text="Installed"
FontSize="11"
Opacity="0.55"
VerticalAlignment="Center"
IsVisible="{Binding IsInstalled}" />
<Button Content="{Binding ButtonText}"
Classes="Primary"
Click="Install_Click"
IsVisible="{Binding IsInstallable}" />
</StackPanel>
<StackPanel Spacing="2">
<TextBlock Text="{Binding VersionLabel}"
FontWeight="SemiBold"
FontSize="14" />
<TextBlock Text="{Binding CreatedOnText}"
FontSize="11"
Opacity="0.55"
IsVisible="{Binding CreatedOnKnown}" />
</StackPanel>
</DockPanel>
<TextBlock Text="{Binding ChangelogText}"
FontSize="12"
TextWrapping="Wrap"
IsVisible="{Binding HasChangelog}" />
<TextBlock FontSize="11"
Opacity="0.55"
IsVisible="{Binding HasSize}">
<Run Text="Download:" />
<Run Text="{Binding SizeText}" />
</TextBlock>
</StackPanel>
</Border>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</ScrollViewer>
</Border>
<!-- Footer -->
<Border Padding="20,12,20,16" Classes="DialogFooter">
<Button Content="Close"
HorizontalAlignment="Right"
Click="Close_Click" />
</Border>
</StackPanel>
</Border>
</Grid>
</UserControl>

View file

@ -1,33 +0,0 @@
using System;
using Avalonia.Controls;
using Avalonia.Controls.Primitives;
using Avalonia.Interactivity;
using LANCommander.Launcher.ViewModels;
namespace LANCommander.Launcher.Views;
public partial class GameVersionsOverlay : UserControl
{
/// <summary>Raised when the overlay closes. Carries the chosen version, or null when dismissed.</summary>
public event EventHandler<SDK.Models.GameVersion?>? VersionSelected;
public GameVersionsOverlay()
{
InitializeComponent();
}
private void Install_Click(object? sender, RoutedEventArgs e)
{
if (sender is Button { DataContext: GameVersionItemViewModel item })
Close(item.Version);
}
private void Close_Click(object? sender, RoutedEventArgs e) => Close(null);
private void Close(SDK.Models.GameVersion? version)
{
var layer = OverlayLayer.GetOverlayLayer(this);
VersionSelected?.Invoke(this, version);
layer?.Children.Remove(this);
}
}

View file

@ -40,9 +40,23 @@
<Border x:Name="ResizeSE" Grid.Row="2" Grid.Column="2" Cursor="BottomRightCorner" Background="Transparent" PointerPressed="ResizeGrip_PointerPressed" />
</Grid>
<!-- Main Content (fills full window height). Data templates are supplied in code-behind from
the IViewRegistry so plugins can add view mappings. -->
<ContentControl x:Name="MainContent" Content="{Binding CurrentView}" ZIndex="0" />
<!-- Main Content (fills full window height) -->
<ContentControl Content="{Binding CurrentView}" ZIndex="0">
<ContentControl.DataTemplates>
<DataTemplate DataType="vm:SplashViewModel">
<views:SplashView />
</DataTemplate>
<DataTemplate DataType="vm:ServerSelectionViewModel">
<views:ServerSelectionView />
</DataTemplate>
<DataTemplate DataType="vm:LoginViewModel">
<views:LoginView />
</DataTemplate>
<DataTemplate DataType="vm:ShellViewModel">
<views:ShellView />
</DataTemplate>
</ContentControl.DataTemplates>
</ContentControl>
<!-- Floating Title Bar (overlays content) -->
<Grid VerticalAlignment="Top"

View file

@ -2,9 +2,7 @@ using System;
using Avalonia.Controls;
using Avalonia.Input;
using Avalonia.Interactivity;
using LANCommander.Launcher.Plugins;
using LANCommander.Launcher.ViewModels;
using Microsoft.Extensions.DependencyInjection;
namespace LANCommander.Launcher.Views;
@ -20,12 +18,6 @@ public partial class MainWindow : Window
{
InitializeComponent();
// Content templates come from the shared view registry (seeded with the built-in mappings and
// extendable by plugins) rather than inline XAML DataTemplates.
var registry = App.Services?.GetService<IViewRegistry>();
if (registry != null)
MainContent.DataTemplates.Add(registry.AsDataTemplate());
Closing += (_, e) =>
{
// Hide to the system tray instead of closing; the app keeps running.

View file

@ -35,7 +35,7 @@
<!-- Content -->
<ScrollViewer Grid.Row="1" HorizontalScrollBarVisibility="Disabled" VerticalScrollBarVisibility="Auto">
<StackPanel x:Name="SectionsPanel" Spacing="24" Margin="24">
<StackPanel Spacing="24" Margin="24">
<!-- Status Message -->
<TextBlock Text="{Binding StatusMessage}"

View file

@ -1,9 +1,4 @@
using System.Linq;
using Avalonia.Controls;
using Avalonia.Layout;
using Avalonia.Media;
using LANCommander.Launcher.Plugins.Extensions;
using Microsoft.Extensions.DependencyInjection;
namespace LANCommander.Launcher.Views;
@ -12,61 +7,5 @@ public partial class SettingsView : UserControl
public SettingsView()
{
InitializeComponent();
AppendPluginSections();
}
/// <summary>
/// Appends any plugin settings sections beneath the built-in sections, styled to
/// match the surrounding cards so extensions look native.
/// </summary>
private void AppendPluginSections()
{
var extensions = App.Services?
.GetServices<ISettingsPageExtension>()
.OrderBy(c => c.Order)
.ToList();
if (extensions == null || extensions.Count == 0)
return;
foreach (var extension in extensions)
{
Control content;
try
{
content = extension.BuildContent();
}
catch
{
// A misbehaving plugin must not break the settings page.
continue;
}
var header = new TextBlock
{
Text = extension.Title,
FontWeight = FontWeight.SemiBold,
FontSize = 16,
};
var stack = new StackPanel { Spacing = 12 };
stack.Children.Add(header);
stack.Children.Add(content);
var card = new Border
{
Padding = new Avalonia.Thickness(16),
CornerRadius = new Avalonia.CornerRadius(8),
Child = stack,
HorizontalAlignment = HorizontalAlignment.Stretch,
};
if (this.TryFindResource("SystemControlBackgroundChromeMediumLowBrush", out var brush) && brush is IBrush background)
card.Background = background;
SectionsPanel.Children.Add(card);
}
}
}

View file

@ -19,14 +19,39 @@
<Grid RowDefinitions="*,Auto">
<!-- ── Main Content ──────────────────────────────────────────────
Data templates are supplied in code-behind from the IViewRegistry so plugins can
add navigable views. Most-derived-first matching preserves the
DepotGameDetailViewModel-before-GameDetailViewModel resolution rule. -->
<TransitioningContentControl x:Name="ContentHost" Grid.Row="0" Content="{Binding ContentView}">
<!-- ── Main Content ──────────────────────────────────────────── -->
<TransitioningContentControl Grid.Row="0" Content="{Binding ContentView}">
<TransitioningContentControl.PageTransition>
<controls:FadeInPageTransition Duration="0:0:0.2" />
</TransitioningContentControl.PageTransition>
<TransitioningContentControl.DataTemplates>
<DataTemplate DataType="vm:DepotViewModel">
<views:DepotView />
</DataTemplate>
<DataTemplate DataType="vm:DepotBrowseViewModel">
<views:DepotBrowseView />
</DataTemplate>
<!-- DepotGameDetailViewModel must appear before GameDetailViewModel
so the more-specific type is matched first. -->
<DataTemplate DataType="vm:DepotGameDetailViewModel">
<views:GameDetailView />
</DataTemplate>
<DataTemplate DataType="vm:GamesListViewModel">
<views:GamesListView />
</DataTemplate>
<DataTemplate DataType="vm:LibraryViewModel">
<views:GamesListView />
</DataTemplate>
<DataTemplate DataType="vm:GameDetailViewModel">
<views:GameDetailView />
</DataTemplate>
<DataTemplate DataType="vm:SettingsViewModel">
<views:SettingsView />
</DataTemplate>
<DataTemplate DataType="vm:DownloadQueueViewModel">
<views:DownloadQueuePageView />
</DataTemplate>
</TransitioningContentControl.DataTemplates>
</TransitioningContentControl>
<!-- ── Loading overlay (shown during async navigation) ───── -->
@ -161,10 +186,7 @@
</StackPanel>
</Panel>
<StackPanel Grid.Column="2" Orientation="Horizontal" Spacing="4" HorizontalAlignment="Right">
<!-- Plugin footer items, ordered in code-behind -->
<StackPanel x:Name="FooterPluginItems" Orientation="Horizontal" Spacing="4" VerticalAlignment="Center" />
<Panel Grid.Column="2" HorizontalAlignment="Right">
<!-- Chat button -->
<Button Command="{Binding OpenChatCommand}"
Classes="Text">
@ -180,7 +202,7 @@
</Border>
</StackPanel>
</Button>
</StackPanel>
</Panel>
</Grid>
</Border>

View file

@ -1,9 +1,7 @@
using System;
using System.Linq;
using Avalonia.Controls;
using Avalonia.Input;
using Avalonia.Threading;
using LANCommander.Launcher.Plugins.Extensions;
using LANCommander.Launcher.Services;
using LANCommander.Launcher.ViewModels;
using Microsoft.Extensions.DependencyInjection;
@ -21,14 +19,6 @@ public partial class ShellView : UserControl
{
InitializeComponent();
// Content templates come from the shared view registry (seeded with the built-in mappings and
// extendable by plugins) rather than inline XAML DataTemplates.
var registry = App.Services?.GetService<LANCommander.Launcher.Plugins.IViewRegistry>();
if (registry != null)
ContentHost.DataTemplates.Add(registry.AsDataTemplate());
AppendFooterExtensions();
KeyDown += OnKeyDown;
DataContextChanged += (_, _) =>
@ -44,34 +34,6 @@ public partial class ShellView : UserControl
};
}
/// <summary>
/// Renders any plugin footer controls (via <see cref="IFooterExtension"/>) to the
/// left of the chat button, ordered by their declared <c>Order</c>. A failing extension is
/// skipped so the built-in footer still renders.
/// </summary>
private void AppendFooterExtensions()
{
var extensions = App.Services?
.GetServices<IFooterExtension>()
.OrderBy(c => c.Order)
.ToList();
if (extensions == null || extensions.Count == 0)
return;
foreach (var extension in extensions)
{
try
{
FooterPluginItems.Children.Add(extension.BuildContent());
}
catch
{
// A misbehaving plugin must not break the shell footer.
}
}
}
private void OnKeyDown(object? sender, KeyEventArgs e)
{
if (e.Key == Key.Escape && !e.Handled)

View file

@ -1,21 +0,0 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<!-- Utility only; not part of the shipped app build graph. -->
<IsPackable>false</IsPackable>
</PropertyGroup>
<!--
References the plugin contract assemblies so their compiled types + generated XML docs land in
this tool's output folder, where the generator reflects over them to build the API reference.
-->
<ItemGroup>
<ProjectReference Include="..\LANCommander.SDK\LANCommander.SDK.csproj" />
<ProjectReference Include="..\LANCommander.Launcher.Plugins\LANCommander.Launcher.Plugins.csproj" />
</ItemGroup>
</Project>

View file

@ -1,206 +0,0 @@
using System.Reflection;
using System.Text;
using LANCommander.Launcher.Plugins.Extensions;
using LANCommander.PluginDocsGenerator;
using LANCommander.SDK.Plugins;
// Generates the plugin API reference for the documentation site directly from the plugin contract
// assemblies and their XML doc comments. Run with:
// dotnet run --project LANCommander.PluginDocsGenerator [output-path]
// If no output path is supplied, the generator writes to LANCommander.Documentation/Plugins/API Reference.md.
// Anchor types pull in the two assemblies that make up the public plugin surface.
var assemblies = new[]
{
typeof(IPlugin).Assembly, // LANCommander.SDK (LANCommander.SDK.Plugins.*)
typeof(INavigationPageExtension).Assembly, // LANCommander.Launcher.Plugins.*
};
// Only types in these namespaces are considered part of the plugin surface.
string[] namespacePrefixes =
{
"LANCommander.SDK.Plugins",
"LANCommander.Launcher.Plugins",
};
// Fixed ordering so the reference reads top-down from "what you implement" to host internals.
string[] namespaceOrder =
{
"LANCommander.SDK.Plugins",
"LANCommander.SDK.Plugins.Events",
"LANCommander.Launcher.Plugins.Extensions",
"LANCommander.Launcher.Plugins",
};
var docs = new XmlDocLookup(assemblies);
var types = assemblies
.SelectMany(a => a.GetExportedTypes())
.Where(t => t.Namespace is not null && namespacePrefixes.Any(p => t.Namespace == p || t.Namespace.StartsWith(p + ".")))
.Where(t => !t.IsNested)
.ToList();
var sb = new StringBuilder();
sb.AppendLine("---");
sb.AppendLine("title: API Reference");
sb.AppendLine("sidebar_label: API Reference");
sb.AppendLine("sidebar_position: 4");
sb.AppendLine("---");
sb.AppendLine();
sb.AppendLine("{/* This file is generated by LANCommander.PluginDocsGenerator. Do not edit by hand. */}");
sb.AppendLine("{/* Regenerate with: dotnet run --project LANCommander.PluginDocsGenerator */}");
sb.AppendLine();
sb.AppendLine("# Plugin API Reference");
sb.AppendLine();
sb.AppendLine("This reference is generated directly from the plugin contract assemblies and their XML");
sb.AppendLine("documentation comments, so it always reflects the extension surface of the installed version.");
sb.AppendLine("Types are grouped by namespace. Interfaces you implement in a plugin are listed first within");
sb.AppendLine("each group.");
sb.AppendLine();
foreach (var ns in types.Select(t => t.Namespace!).Distinct().OrderBy(NamespaceRank).ThenBy(n => n))
{
sb.AppendLine($"## `{ns}`");
sb.AppendLine();
var nsTypes = types
.Where(t => t.Namespace == ns)
.OrderBy(KindRank)
.ThenBy(t => t.Name, StringComparer.Ordinal);
foreach (var type in nsTypes)
WriteType(sb, type, docs);
}
var outputPath = args.Length > 0 ? args[0] : ResolveDefaultOutputPath();
Directory.CreateDirectory(Path.GetDirectoryName(outputPath)!);
File.WriteAllText(outputPath, sb.ToString());
Console.WriteLine($"Wrote {types.Count} types to {outputPath}");
return;
int NamespaceRank(string ns)
{
var index = Array.IndexOf(namespaceOrder, ns);
return index < 0 ? int.MaxValue : index;
}
static int KindRank(Type t) => t switch
{
{ IsInterface: true } => 0,
{ IsEnum: true } => 3,
{ IsValueType: true } => 2,
_ => 1,
};
static void WriteType(StringBuilder sb, Type type, XmlDocLookup docs)
{
sb.AppendLine($"### {type.Name}");
sb.AppendLine();
sb.AppendLine($"`{Kind(type)}` — `{type.FullName}`");
sb.AppendLine();
var summary = docs.GetSummary(XmlId.ForType(type));
if (summary is not null)
{
sb.AppendLine(summary);
sb.AppendLine();
}
if (type.IsEnum)
{
WriteEnumMembers(sb, type, docs);
return;
}
WriteProperties(sb, type, docs);
WriteMethods(sb, type, docs);
}
static void WriteEnumMembers(StringBuilder sb, Type type, XmlDocLookup docs)
{
var fields = type.GetFields(BindingFlags.Public | BindingFlags.Static);
if (fields.Length == 0)
return;
sb.AppendLine("| Value | Description |");
sb.AppendLine("| --- | --- |");
foreach (var field in fields)
{
var summary = docs.GetSummary(XmlId.ForField(field))?.Replace("\n", " ") ?? "";
sb.AppendLine($"| `{field.Name}` = `{Convert.ToInt64(field.GetRawConstantValue())}` | {summary} |");
}
sb.AppendLine();
}
static void WriteProperties(StringBuilder sb, Type type, XmlDocLookup docs)
{
var properties = type
.GetProperties(BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly)
.Where(p => p.GetIndexParameters().Length == 0)
.OrderBy(p => p.MetadataToken)
.ToList();
if (properties.Count == 0)
return;
sb.AppendLine("**Properties**");
sb.AppendLine();
foreach (var property in properties)
{
sb.AppendLine($"- `{Signatures.Property(property)}`");
var summary = docs.GetSummary(XmlId.ForProperty(property));
if (summary is not null)
sb.AppendLine($" - {summary.Replace("\n", " ")}");
}
sb.AppendLine();
}
static void WriteMethods(StringBuilder sb, Type type, XmlDocLookup docs)
{
var methods = type
.GetMethods(BindingFlags.Public | BindingFlags.Instance | BindingFlags.Static | BindingFlags.DeclaredOnly)
.Where(m => !m.IsSpecialName) // drop property/event accessors, operators
.Where(m => m.DeclaringType != typeof(object))
.Where(m => m.Name is not ("Equals" or "GetHashCode" or "ToString" or "Deconstruct" or "PrintMembers"))
.Where(m => !m.Name.StartsWith('<')) // drop compiler-generated (e.g. records)
.OrderBy(m => m.MetadataToken)
.ToList();
if (methods.Count == 0)
return;
sb.AppendLine("**Methods**");
sb.AppendLine();
foreach (var method in methods)
{
sb.AppendLine($"- `{Signatures.Method(method)}`");
var summary = docs.GetSummary(XmlId.ForMethod(method));
if (summary is not null)
sb.AppendLine($" - {summary.Replace("\n", " ")}");
}
sb.AppendLine();
}
static string Kind(Type t)
{
if (t.IsInterface) return "interface";
if (t.IsEnum) return "enum";
if (t.IsValueType) return "struct";
if (typeof(Attribute).IsAssignableFrom(t)) return "attribute";
if (t.GetMembers(BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance).Any(m => m.Name == "<Clone>$"))
return "record";
return "class";
}
static string ResolveDefaultOutputPath()
{
var dir = new DirectoryInfo(AppContext.BaseDirectory);
while (dir is not null && !Directory.Exists(Path.Combine(dir.FullName, "LANCommander.Documentation")))
dir = dir.Parent;
if (dir is null)
throw new InvalidOperationException("Could not locate the LANCommander.Documentation directory. Pass an output path explicitly.");
return Path.Combine(dir.FullName, "LANCommander.Documentation", "Plugins", "API Reference.md");
}

View file

@ -1,85 +0,0 @@
using System.Reflection;
namespace LANCommander.PluginDocsGenerator;
/// <summary>
/// Renders human-readable C#-style signatures for members, used in the API reference output.
/// </summary>
internal static class Signatures
{
public static string Property(PropertyInfo property)
{
var accessors = property switch
{
{ CanRead: true, SetMethod.IsPublic: true } when IsInit(property) => "{ get; init; }",
{ CanRead: true, SetMethod.IsPublic: true } => "{ get; set; }",
{ CanRead: true } => "{ get; }",
_ => "{ set; }",
};
return $"{Friendly(property.PropertyType)} {property.Name} {accessors}";
}
public static string Method(MethodInfo method)
{
var generics = method.IsGenericMethodDefinition
? "<" + string.Join(", ", method.GetGenericArguments().Select(a => a.Name)) + ">"
: "";
var parameters = string.Join(", ", method.GetParameters().Select(p => $"{Friendly(p.ParameterType)} {p.Name}"));
return $"{Friendly(method.ReturnType)} {method.Name}{generics}({parameters})";
}
private static bool IsInit(PropertyInfo property)
{
var setMethod = property.SetMethod;
return setMethod is not null
&& setMethod.ReturnParameter.GetRequiredCustomModifiers()
.Any(m => m.FullName == "System.Runtime.CompilerServices.IsExternalInit");
}
public static string Friendly(Type type)
{
if (Nullable.GetUnderlyingType(type) is { } underlying)
return Friendly(underlying) + "?";
if (type.IsByRef)
return Friendly(type.GetElementType()!);
if (type.IsArray)
return Friendly(type.GetElementType()!) + "[]";
if (type.IsGenericParameter)
return type.Name;
if (type.IsGenericType)
{
var name = type.Name.Split('`')[0];
var args = string.Join(", ", type.GetGenericArguments().Select(Friendly));
return $"{name}<{args}>";
}
return Aliases.TryGetValue(type.FullName ?? "", out var alias) ? alias : type.Name;
}
private static readonly Dictionary<string, string> Aliases = new()
{
["System.Void"] = "void",
["System.Object"] = "object",
["System.String"] = "string",
["System.Boolean"] = "bool",
["System.Byte"] = "byte",
["System.SByte"] = "sbyte",
["System.Char"] = "char",
["System.Int16"] = "short",
["System.UInt16"] = "ushort",
["System.Int32"] = "int",
["System.UInt32"] = "uint",
["System.Int64"] = "long",
["System.UInt64"] = "ulong",
["System.Single"] = "float",
["System.Double"] = "double",
["System.Decimal"] = "decimal",
};
}

View file

@ -1,97 +0,0 @@
using System.Reflection;
using System.Text;
using System.Xml.Linq;
namespace LANCommander.PluginDocsGenerator;
/// <summary>
/// Loads the XML documentation files that sit alongside the given assemblies and exposes their
/// &lt;summary&gt; text keyed by XML documentation member id.
/// </summary>
internal sealed class XmlDocLookup
{
private readonly Dictionary<string, string> _summaries = new(StringComparer.Ordinal);
public XmlDocLookup(IEnumerable<Assembly> assemblies)
{
foreach (var assembly in assemblies)
{
var xmlPath = Path.ChangeExtension(assembly.Location, ".xml");
if (!File.Exists(xmlPath))
continue;
foreach (var member in XDocument.Load(xmlPath).Descendants("member"))
{
var name = member.Attribute("name")?.Value;
var summary = member.Element("summary");
if (name is null || summary is null)
continue;
_summaries[name] = Render(summary);
}
}
}
public string? GetSummary(string memberId) => _summaries.TryGetValue(memberId, out var value) ? value : null;
// Flattens a <summary> element into plain text, resolving the common inline doc tags.
private static string Render(XElement summary)
{
var sb = new StringBuilder();
RenderNodes(summary, sb);
// Collapse the incidental whitespace/indentation that XML doc comments carry.
var text = string.Join(" ", sb.ToString().Split((char[]?)null, StringSplitOptions.RemoveEmptyEntries));
return text.Trim();
}
private static void RenderNodes(XElement element, StringBuilder sb)
{
foreach (var node in element.Nodes())
{
switch (node)
{
case XText text:
sb.Append(text.Value);
break;
case XElement child:
RenderElement(child, sb);
break;
}
}
}
private static void RenderElement(XElement element, StringBuilder sb)
{
switch (element.Name.LocalName)
{
case "see":
case "seealso":
var reference = element.Attribute("cref")?.Value ?? element.Attribute("href")?.Value;
sb.Append('`').Append(ShortName(reference)).Append('`');
break;
case "paramref":
case "typeparamref":
sb.Append('`').Append(element.Attribute("name")?.Value).Append('`');
break;
case "c":
sb.Append('`').Append(element.Value).Append('`');
break;
default:
RenderNodes(element, sb);
break;
}
}
// "T:LANCommander.SDK.Plugins.IPlugin" -> "IPlugin", "IPlugin.InitializeAsync" -> "InitializeAsync".
private static string ShortName(string? cref)
{
if (string.IsNullOrEmpty(cref))
return "";
var withoutPrefix = cref.Length > 1 && cref[1] == ':' ? cref[2..] : cref;
var withoutParameters = withoutPrefix.Split('(')[0];
var segments = withoutParameters.Split('.');
return segments[^1];
}
}

View file

@ -1,60 +0,0 @@
using System.Reflection;
using System.Text;
namespace LANCommander.PluginDocsGenerator;
/// <summary>
/// Builds the member identifiers used in .NET XML documentation files (e.g. "M:Namespace.Type.Method(System.Int32)")
/// from reflection, so summaries can be looked up for a given <see cref="MemberInfo"/>.
/// See ECMA-334 / the C# spec "Processing the documentation file" for the ID string format.
/// </summary>
internal static class XmlId
{
public static string ForType(Type type) => "T:" + TypeName(type);
public static string ForField(FieldInfo field) => "F:" + TypeName(field.DeclaringType!) + "." + field.Name;
public static string ForProperty(PropertyInfo property) => "P:" + TypeName(property.DeclaringType!) + "." + property.Name;
public static string ForMethod(MethodBase method)
{
var sb = new StringBuilder("M:");
sb.Append(TypeName(method.DeclaringType!));
sb.Append('.');
sb.Append(method.Name.Replace('.', '#')); // constructors: ".ctor" -> "#ctor"
if (method is MethodInfo { IsGenericMethodDefinition: true } gm)
sb.Append("``").Append(gm.GetGenericArguments().Length);
var parameters = method.GetParameters();
if (parameters.Length > 0)
sb.Append('(').Append(string.Join(",", parameters.Select(p => ParameterName(p.ParameterType)))).Append(')');
return sb.ToString();
}
// Full name of a type as it appears in a T: reference (nested '+' -> '.').
private static string TypeName(Type type) => (type.FullName ?? type.Namespace + "." + type.Name).Replace('+', '.');
// Encoding of a type when used as a method parameter.
private static string ParameterName(Type type)
{
if (type.IsByRef)
return ParameterName(type.GetElementType()!) + "@";
if (type.IsArray)
return ParameterName(type.GetElementType()!) + "[]";
if (type.IsGenericParameter)
return (type.DeclaringMethod is not null ? "``" : "`") + type.GenericParameterPosition;
if (type.IsGenericType)
{
var definition = type.GetGenericTypeDefinition().FullName!.Split('`')[0].Replace('+', '.');
var args = type.GetGenericArguments().Select(ParameterName);
return definition + "{" + string.Join(",", args) + "}";
}
return TypeName(type);
}
}

View file

@ -0,0 +1,102 @@
using LANCommander.SDK.Helpers;
namespace LANCommander.SDK.Tests;
public class AppPathsTests
{
// ── ResolveStorageLocationPath: rooted paths ─────────────────────────────
[Fact]
public void ResolveStorageLocationPath_RootedPath_ReturnedAsIs()
{
var rooted = Path.GetTempPath().TrimEnd(Path.DirectorySeparatorChar);
var resolved = AppPaths.ResolveStorageLocationPath(rooted);
Assert.Equal(rooted, resolved);
}
[Fact]
public void ResolveStorageLocationPath_RootedPathWithSegments_CombinesUnderRoot()
{
var rooted = Path.GetTempPath().TrimEnd(Path.DirectorySeparatorChar);
var resolved = AppPaths.ResolveStorageLocationPath(rooted, "user", "game", "save");
Assert.Equal(Path.Combine(rooted, "user", "game", "save"), resolved);
}
// ── ResolveStorageLocationPath: relative paths anchor to the config dir ───
[Fact]
public void ResolveStorageLocationPath_RelativePath_AnchoredToConfigDirectory()
{
var resolved = AppPaths.ResolveStorageLocationPath("Saves");
Assert.Equal(Path.Combine(AppPaths.GetConfigDirectory(), "Saves"), resolved);
}
[Fact]
public void ResolveStorageLocationPath_RelativePathWithSegments_AnchoredToConfigDirectory()
{
var resolved = AppPaths.ResolveStorageLocationPath("Saves", "user", "game", "save");
Assert.Equal(
Path.Combine(AppPaths.GetConfigDirectory(), "Saves", "user", "game", "save"),
resolved);
}
/// <summary>
/// Regression guard for the reported bug: writes and reads of the same save both went through two
/// different resolvers that disagreed for relative storage paths (one anchored to the working
/// directory, the other to the config directory). Every consumer must now resolve identically.
/// </summary>
[Fact]
public void ResolveStorageLocationPath_SameRelativeInput_IsDeterministicAcrossCallers()
{
var writer = AppPaths.ResolveStorageLocationPath("Saves", "user", "game", "save");
var reader = AppPaths.ResolveStorageLocationPath("Saves", "user", "game", "save");
Assert.Equal(writer, reader);
}
[Theory]
[InlineData(null)]
[InlineData("")]
[InlineData(" ")]
public void ResolveStorageLocationPath_NullOrWhitespacePath_Throws(string? path)
{
Assert.Throws<ArgumentException>(() => AppPaths.ResolveStorageLocationPath(path!));
}
// ── GetConfigDirectory ───────────────────────────────────────────────────
[Fact]
public void GetConfigDirectory_ReturnsAbsoluteExistingDirectory()
{
var configDir = AppPaths.GetConfigDirectory();
Assert.True(Path.IsPathRooted(configDir));
Assert.True(Directory.Exists(configDir));
}
/// <summary>
/// With no override, the data root is a "Data" folder under the current working directory when writable.
/// </summary>
[Fact]
public void GetConfigDirectory_AnchoredToWorkingDirectory()
{
// Skip when an operator override or a read-only working directory changes the anchor.
if (!string.IsNullOrWhiteSpace(Environment.GetEnvironmentVariable(AppPaths.DataDirectoryEnvironmentVariable)))
return;
var workingDir = Directory.GetCurrentDirectory();
if (!DirectoryHelper.IsDirectoryWritable(workingDir))
return;
var configDir = Path.GetFullPath(AppPaths.GetConfigDirectory());
Assert.Equal(Path.GetFullPath(Path.Combine(workingDir, "Data")), configDir);
}
}

View file

@ -148,7 +148,7 @@ public class GameInstallationSharedDirectoryTests : IDisposable
/// resolution and local file verification, so they are safe to leave null.
/// </summary>
private static GameClient CreateClient() =>
new(null!, null!, null!, null!, null!, null!, null!, null!, null!, null!, null!, null!, null!);
new(null!, null!, null!, null!, null!, null!, null!, null!, null!, null!, null!, null!);
private static void InstallGameFiles(string installDirectory)
{

View file

@ -1,78 +0,0 @@
using System;
using System.Threading;
using System.Threading.Tasks;
using LANCommander.SDK.Plugins;
using Microsoft.Extensions.Logging.Abstractions;
namespace LANCommander.SDK.Tests.Plugins;
public class PluginEventBusTests
{
private sealed record SampleEvent(int Value);
private sealed record OtherEvent(string Name);
private static PluginEventBus CreateBus() => new(NullLogger<PluginEventBus>.Instance);
[Fact]
public async Task PublishAsync_InvokesSubscribedHandler()
{
var bus = CreateBus();
var received = 0;
bus.Subscribe<SampleEvent>((e, _) => { received = e.Value; return Task.CompletedTask; });
await bus.PublishAsync(new SampleEvent(42));
Assert.Equal(42, received);
}
[Fact]
public async Task PublishAsync_OnlyInvokesHandlersForMatchingType()
{
var bus = CreateBus();
var sampleCalled = false;
bus.Subscribe<SampleEvent>((_, _) => { sampleCalled = true; return Task.CompletedTask; });
await bus.PublishAsync(new OtherEvent("nope"));
Assert.False(sampleCalled);
}
[Fact]
public async Task PublishAsync_IsolatesThrowingHandlers()
{
var bus = CreateBus();
var secondCalled = false;
bus.Subscribe<SampleEvent>((_, _) => throw new InvalidOperationException("boom"));
bus.Subscribe<SampleEvent>((_, _) => { secondCalled = true; return Task.CompletedTask; });
// Must not throw, and the second handler must still run.
await bus.PublishAsync(new SampleEvent(1));
Assert.True(secondCalled);
}
[Fact]
public async Task Dispose_Unsubscribes()
{
var bus = CreateBus();
var count = 0;
var subscription = bus.Subscribe<SampleEvent>((_, _) => { count++; return Task.CompletedTask; });
await bus.PublishAsync(new SampleEvent(1));
subscription.Dispose();
await bus.PublishAsync(new SampleEvent(1));
Assert.Equal(1, count);
}
[Fact]
public async Task PublishAsync_NoSubscribers_DoesNothing()
{
var bus = CreateBus();
await bus.PublishAsync(new SampleEvent(1)); // should simply return
}
}

View file

@ -1,28 +0,0 @@
using LANCommander.SDK.Plugins;
namespace LANCommander.SDK.Tests.Plugins;
public class PluginVersionGateTests
{
[Theory]
[InlineData("1.1.0", null, null, true)] // no bounds → compatible
[InlineData("1.1.0", "1.0.0", null, true)] // above min
[InlineData("1.1.0", "1.1.0", null, true)] // equal to min (inclusive)
[InlineData("1.0.0", "1.1.0", null, false)] // below min
[InlineData("1.5.0", null, "2.0.0", true)] // below max
[InlineData("2.0.0", null, "2.0.0", true)] // equal to max (inclusive)
[InlineData("2.1.0", null, "2.0.0", false)] // above max
[InlineData("1.5.0", "1.0.0", "2.0.0", true)] // within range
[InlineData("0.9.0", "1.0.0", "2.0.0", false)] // below range
[InlineData("2.5.0", "1.0.0", "2.0.0", false)] // above range
public void IsVersionCompatible_EvaluatesBounds(string hostVersion, string? min, string? max, bool expected)
{
Assert.Equal(expected, PluginLoaderService.IsVersionCompatible(hostVersion, min, max));
}
[Fact]
public void IsVersionCompatible_UnparseableHostVersion_DoesNotBlock()
{
Assert.True(PluginLoaderService.IsVersionCompatible("not-a-version", "1.0.0", "2.0.0"));
}
}

View file

@ -0,0 +1,89 @@
using System;
using System.IO;
using System.Threading.Tasks;
using LANCommander.SDK.Abstractions;
using LANCommander.SDK.Enums;
using LANCommander.SDK.PowerShell;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Options;
using SdkSettings = LANCommander.SDK.Models.Settings;
namespace LANCommander.SDK.Tests.PowerShell;
public class PowerShellScriptExecutionTests : IDisposable
{
private readonly string _workingDirectory;
public PowerShellScriptExecutionTests()
{
_workingDirectory = Path.Combine(Path.GetTempPath(), $"lc-ps-exec-{Guid.NewGuid():N}");
Directory.CreateDirectory(_workingDirectory);
}
public void Dispose()
{
if (Directory.Exists(_workingDirectory))
Directory.Delete(_workingDirectory, true);
}
private static PowerShellScript CreateScript(ScriptType type = ScriptType.Install)
{
var services = new ServiceCollection();
services.AddLogging();
services.AddSingleton<ISettingsProvider, FakeSettingsProvider>();
var provider = services.BuildServiceProvider();
return new PowerShellScript(provider, type, Options.Create(new SdkSettings()));
}
[Fact]
public async Task ExecuteAsync_RunsUnsignedInlineScript_AndReturnsValue()
{
var script = CreateScript()
.UseWorkingDirectory(_workingDirectory)
.UseInline("$Return = 42");
var result = await script.ExecuteAsync<int>();
// Reaching a real returned value proves the runspace opened (ExecutionPolicy.Bypass applied on
// Windows) and the script executed rather than being silently skipped.
Assert.Equal(42, result);
}
[Fact]
public async Task ExecuteAsync_ExecutesScriptSideEffects_InWorkingDirectory()
{
var markerPath = Path.Combine(_workingDirectory, "marker.txt");
var script = CreateScript()
.UseWorkingDirectory(_workingDirectory)
.UseInline("Set-Content -Path (Join-Path $WorkingDirectory 'marker.txt') -Value 'ran'");
await script.ExecuteAsync<int>();
Assert.True(File.Exists(markerPath), "The script's side effect did not run — the script was skipped.");
Assert.Equal("ran", (await File.ReadAllTextAsync(markerPath)).Trim());
}
[Fact]
public async Task ExecuteAsync_PassesVariablesIntoScript()
{
var script = CreateScript()
.UseWorkingDirectory(_workingDirectory)
.AddVariable("Multiplier", 7)
.UseInline("$Return = $Multiplier * 6");
var result = await script.ExecuteAsync<int>();
Assert.Equal(42, result);
}
private sealed class FakeSettingsProvider : ISettingsProvider
{
public SdkSettings CurrentValue { get; } = new();
public void Update(Action<SdkSettings> patch) => patch(CurrentValue);
}
}

View file

@ -1,6 +1,8 @@
using System;
using System.IO;
using System.Linq;
using System.Reflection;
using System.Runtime.InteropServices;
using LANCommander.SDK.Helpers;
namespace LANCommander.SDK;
@ -9,6 +11,8 @@ public static class AppPaths
{
private static string _configDirectory = String.Empty;
public const string DataDirectoryEnvironmentVariable = "LANCOMMANDER_DATA_DIR";
/// <summary>
/// Builds a full path under the application's config directory.
/// </summary>
@ -17,9 +21,33 @@ public static class AppPaths
public static string GetConfigPath(params string[] paths)
=> Path.Combine(GetConfigDirectory(), Path.Combine(paths));
/// <summary>
/// Resolves a storage location path to an absolute path using a single, consistent rule so that
/// every consumer (saves, media, archives, ...) resolves the same way: rooted paths are used as-is,
/// while relative paths are resolved beneath the config directory (i.e. next to the server binary).
/// </summary>
/// <param name="storageLocationPath">The configured storage location path (absolute or relative).</param>
/// <param name="segments">Additional path segments appended to the resolved storage location.</param>
/// <returns>The absolute path to the storage location (plus any appended segments).</returns>
public static string ResolveStorageLocationPath(string storageLocationPath, params string[] segments)
{
if (String.IsNullOrWhiteSpace(storageLocationPath))
throw new ArgumentException("A storage location path must be provided.", nameof(storageLocationPath));
var root = Path.IsPathRooted(storageLocationPath)
? storageLocationPath
: Path.Combine(GetConfigDirectory(), storageLocationPath);
return segments is { Length: > 0 }
? Path.Combine(new[] { root }.Concat(segments).ToArray())
: root;
}
/// <summary>
/// Locates (and creates if necessary) the directory in which application data will be stored.
/// Prefers the current working directory when writable; otherwise falls back to the user's local application data.
/// Resolution order: the <see cref="DataDirectoryEnvironmentVariable"/> override if set; otherwise a
/// "Data" folder under the current working directory when writable; otherwise a "Data" folder under
/// the current user's platform-native application data directory.
/// </summary>
/// <returns>The resolved config directory path.</returns>
public static string GetConfigDirectory()
@ -27,36 +55,57 @@ public static class AppPaths
if (!String.IsNullOrWhiteSpace(_configDirectory))
return _configDirectory;
var baseDirectory = Directory.GetCurrentDirectory();
var overrideDirectory = Environment.GetEnvironmentVariable(DataDirectoryEnvironmentVariable);
if (DirectoryHelper.IsDirectoryWritable(baseDirectory))
_configDirectory = baseDirectory;
if (!String.IsNullOrWhiteSpace(overrideDirectory))
{
// Operator-specified data root is used verbatim (no implicit "Data" subfolder).
_configDirectory = Path.GetFullPath(overrideDirectory);
}
else
_configDirectory = GetAppDataPath();
_configDirectory = Path.Combine(_configDirectory, "Data");
{
var baseDirectory = Directory.GetCurrentDirectory();
_configDirectory = DirectoryHelper.IsDirectoryWritable(baseDirectory)
? Path.Combine(baseDirectory, "Data")
: Path.Combine(GetAppDataPath(), "Data");
}
if (!Directory.Exists(_configDirectory))
Directory.CreateDirectory(_configDirectory);
return _configDirectory;
}
/// <summary>
/// Gets (and creates if necessary) the base local application data directory for the current user,
/// scoped by the entry assembly's company and product metadata.
/// Gets (and creates if necessary) the base per-user application data directory for the current user,
/// scoped by the entry assembly's company and product metadata. Uses the platform-native convention:
/// <c>%LOCALAPPDATA%</c> on Windows, <c>~/Library/Application Support</c> on macOS, and
/// <c>$XDG_DATA_HOME</c> (<c>~/.local/share</c>) on Linux.
/// </summary>
/// <returns>The local application data path for this application.</returns>
/// <returns>The application data path for this application.</returns>
public static string GetAppDataPath()
{
var (company, product) = GetCompanyAndProduct();
var userRoot = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData);
var appDataPath = Path.Combine(userRoot, company, product);
string userRoot;
if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX))
// .NET maps LocalApplicationData to ~/.local/share on macOS; use the native location instead.
userRoot = Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.UserProfile),
"Library", "Application Support");
else
// Windows: %LOCALAPPDATA%. Linux: $XDG_DATA_HOME or ~/.local/share.
userRoot = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData);
var appDataPath = Path.Combine(new[] { userRoot, company, product }
.Where(segment => !String.IsNullOrWhiteSpace(segment))
.ToArray()!);
if (!Directory.Exists(appDataPath))
Directory.CreateDirectory(appDataPath);
return appDataPath;
}

View file

@ -9,8 +9,6 @@ using LANCommander.SDK.Exceptions;
using LANCommander.SDK.Extensions;
using LANCommander.SDK.Factories;
using LANCommander.SDK.Models;
using LANCommander.SDK.Plugins;
using LANCommander.SDK.Plugins.Events;
using LANCommander.SDK.Providers;
using Microsoft.Extensions.Logging;
using AuthenticationProvider = LANCommander.SDK.Models.AuthenticationProvider;
@ -24,8 +22,7 @@ public class AuthenticationClient(
ISettingsProvider settingsProvider,
ApiRequestFactory apiRequestFactory,
IConnectionClient connectionClient,
ProfileClient profileClient,
IPluginEventBus pluginEventBus)
ProfileClient profileClient)
{
public async Task<AuthToken> AuthenticateAsync(string username, string password, Uri serverAddress)
{
@ -68,18 +65,6 @@ public class AuthenticationClient(
await configRefresher.RefreshAsync();
try
{
var profile = await profileClient.GetAsync(forceLoad: true);
if (profile != null)
await pluginEventBus.PublishAsync(new UserLoggedInEvent(profile.Id, profile.UserName));
}
catch (Exception ex)
{
logger?.LogWarning(ex, "Could not publish login event for user {UserName}", username);
}
return token;
case HttpStatusCode.Forbidden:
@ -103,17 +88,6 @@ public class AuthenticationClient(
public async Task LogoutAsync()
{
User loggedOutUser = null;
try
{
loggedOutUser = await profileClient.GetAsync();
}
catch
{
// Profile may be unavailable if the server is offline; logout still proceeds.
}
try
{
await apiRequestFactory
@ -132,18 +106,6 @@ public class AuthenticationClient(
tokenProvider.SetToken(null);
profileClient.ClearCache();
if (loggedOutUser != null)
{
try
{
await pluginEventBus.PublishAsync(new UserLoggedOutEvent(loggedOutUser.Id, loggedOutUser.UserName));
}
catch (Exception ex)
{
logger?.LogWarning(ex, "Could not publish logout event for user {UserName}", loggedOutUser.UserName);
}
}
}
public async Task RegisterAsync(string username, string password, string passwordConfirmation)

View file

@ -18,8 +18,6 @@ using System.Threading;
using System.Threading.Tasks;
using LANCommander.SDK.Abstractions;
using LANCommander.SDK.Factories;
using LANCommander.SDK.Plugins;
using LANCommander.SDK.Plugins.Events;
using Action = System.Action;
namespace LANCommander.SDK.Services
@ -76,8 +74,7 @@ namespace LANCommander.SDK.Services
ScriptClient scriptClient,
ProfileClient profileClient,
LobbyClient lobbyClient,
ToolClient toolClient,
IPluginEventBus pluginEventBus)
ToolClient toolClient)
{
public delegate void OnArchiveEntryExtractionProgressHandler(object sender, ArchiveEntryExtractionProgressArgs e);
public event OnArchiveEntryExtractionProgressHandler OnArchiveEntryExtractionProgress;
@ -133,26 +130,6 @@ namespace LANCommander.SDK.Services
.GetAsync<Models.Manifest.Game>();
}
public async Task<Models.Manifest.Game> GetManifestAsync(Guid id, Guid versionId)
{
return await apiRequestFactory
.Create()
.UseAuthenticationToken()
.UseVersioning()
.UseRoute($"/api/Games/{id}/Versions/{versionId}/Manifest")
.GetAsync<Models.Manifest.Game>();
}
public async Task<IEnumerable<Models.GameVersion>> GetVersionsAsync(Guid id)
{
return await apiRequestFactory
.Create()
.UseAuthenticationToken()
.UseVersioning()
.UseRoute($"/api/Games/{id}/Versions")
.GetAsync<IEnumerable<Models.GameVersion>>();
}
public async Task<ICollection<Models.Manifest.Game>> GetManifestsAsync(string installDirectory, Guid id)
{
var manifests = new List<Models.Manifest.Game>();
@ -331,16 +308,6 @@ namespace LANCommander.SDK.Services
.GetAsync<IEnumerable<Script>>();
}
public async Task<IEnumerable<Script>> GetScriptsAsync(Guid id, Guid versionId)
{
return await apiRequestFactory
.Create()
.UseAuthenticationToken()
.UseVersioning()
.UseRoute($"/api/Games/{id}/Versions/{versionId}/Scripts")
.GetAsync<IEnumerable<Script>>();
}
public async Task<bool> CheckForUpdateAsync(Guid id, string currentVersion)
{
return await apiRequestFactory
@ -1842,29 +1809,6 @@ namespace LANCommander.SDK.Services
}
}
/// <summary>
/// Refreshes the on-disk manifest and scripts for an installed game using a specific version,
/// writing the version-scoped manifest and its scripts to the game's install directory. Used
/// when installing or rolling back to a particular version so the local config matches exactly.
/// </summary>
public async Task RefreshManifestAndScriptsAsync(string installDirectory, Guid gameId, Guid versionId)
{
logger?.LogTrace("Refreshing version {VersionId} manifest and scripts for game {GameId} in {InstallDirectory}", versionId, gameId, installDirectory);
var manifest = await GetManifestAsync(gameId, versionId);
await ManifestHelper.WriteAsync(manifest, installDirectory);
var scripts = await GetScriptsAsync(gameId, versionId);
if (scripts != null && scripts.Any())
{
var game = new Game { Id = gameId };
foreach (var script in scripts)
await ScriptHelper.SaveScriptAsync(game, script, installDirectory);
}
}
private async Task<Models.Manifest.Game> WriteManifestAsync(string installDirectory, Game game)
{
logger?.LogTrace($"Retrieving game manifest for game {game.Title} with id {game.Id}");
@ -2429,8 +2373,6 @@ namespace LANCommander.SDK.Services
}
#endregion
await pluginEventBus.PublishAsync(new GameBeforeLaunchEvent(gameId, installDirectory, action?.Name));
Task heartbeatTask = null;
try
@ -2520,8 +2462,6 @@ namespace LANCommander.SDK.Services
}
#endregion
}
await pluginEventBus.PublishAsync(new GameAfterExitEvent(gameId, installDirectory));
}
}

View file

@ -2,7 +2,6 @@ using LANCommander.SDK.Abstractions;
using LANCommander.SDK.Clients;
using LANCommander.SDK.Factories;
using LANCommander.SDK.Models;
using LANCommander.SDK.Plugins;
using LANCommander.SDK.PowerShell;
using LANCommander.SDK.Providers;
using LANCommander.SDK.Rpc.Client;
@ -61,11 +60,8 @@ public static class IServiceCollectionExtensions
services.AddSingleton<MigrationHistoryService>();
services.AddSingleton<MigrationService>();
services.TryAddSingleton<IChatClient, ChatClient>();
// Plugin framework: in-process event bus shared by both hosts so plugins can react to lifecycle events.
services.TryAddSingleton<IPluginEventBus, PluginEventBus>();
return services;
}

View file

@ -115,15 +115,20 @@ namespace LANCommander.SDK.Helpers
// ── Linux helpers ─────────────────────────────────────────────────────────
/// <summary>
/// Parses <c>xrandr</c> output to find the active resolution and refresh rate.
/// Works on X11 and XWayland.
/// Parses <c>xrandr</c> output to find the primary display's active resolution
/// and refresh rate. Works on X11 and XWayland.
///
/// We deliberately parse the primary output's connected line rather than the
/// "Screen 0: ... current W x H" summary, because that summary reports the
/// combined bounding box of all displays in a multi-monitor setup.
///
/// Example xrandr output:
/// <code>
/// Screen 0: minimum 320 x 200, current 1920 x 1080, maximum 16384 x 16384
/// DP-1 connected primary 1920x1080+0+0 ...
/// 1920x1080 60.00*+ 50.00 59.94
/// 1280x720 60.00 59.94
/// Screen 0: minimum 16 x 16, current 4480 x 1440, maximum 32767 x 32767
/// DP-1 connected 1920x1200+2560+0 ...
/// 1920x1200 59.88*+
/// DP-3 connected primary 2560x1440+0+0 ...
/// 2560x1440 164.85*+
/// </code>
/// </summary>
private static bool TryGetScreenFromXrandr(out Bounds bounds, out int refreshRate, out int bitsPerPixel)
@ -138,24 +143,50 @@ namespace LANCommander.SDK.Helpers
if (string.IsNullOrWhiteSpace(output))
return false;
// "Screen 0: ... current 1920 x 1080 ..."
var screenMatch = Regex.Match(output, @"current\s+(\d+)\s*x\s*(\d+)");
if (!screenMatch.Success)
var lines = output.Split('\n');
var connectedLine = @"^\S+\s+connected(\s+primary)?\s+(\d+)x(\d+)\+\d+\+\d+";
var primaryIndex = -1;
var fallbackIndex = -1;
for (var i = 0; i < lines.Length; i++)
{
var match = Regex.Match(lines[i], connectedLine);
if (!match.Success)
continue;
if (match.Groups[1].Success && primaryIndex == -1)
primaryIndex = i;
if (fallbackIndex == -1)
fallbackIndex = i;
}
var displayIndex = primaryIndex != -1 ? primaryIndex : fallbackIndex;
if (displayIndex == -1)
return false;
bounds.Width = int.Parse(screenMatch.Groups[1].Value);
bounds.Height = int.Parse(screenMatch.Groups[2].Value);
var displayMatch = Regex.Match(lines[displayIndex], connectedLine);
bounds.Width = int.Parse(displayMatch.Groups[2].Value);
bounds.Height = int.Parse(displayMatch.Groups[3].Value);
// A mode line looks like: " 1920x1080 60.00*+ 50.00 59.94"
// The active refresh rate is the one immediately followed by '*'.
var refreshMatch = Regex.Match(output, @"(\d+\.\d+)\*");
if (refreshMatch.Success &&
float.TryParse(refreshMatch.Groups[1].Value,
System.Globalization.NumberStyles.Float,
System.Globalization.CultureInfo.InvariantCulture,
out var rate))
for (var i = displayIndex + 1; i < lines.Length; i++)
{
refreshRate = (int)Math.Round(rate);
// A non-indented, non-empty line starts the next output's block.
if (lines[i].Length > 0 && !char.IsWhiteSpace(lines[i][0]))
break;
var refreshMatch = Regex.Match(lines[i], @"(\d+\.\d+)\*");
if (refreshMatch.Success &&
float.TryParse(refreshMatch.Groups[1].Value,
System.Globalization.NumberStyles.Float,
System.Globalization.CultureInfo.InvariantCulture,
out var rate))
{
refreshRate = (int)Math.Round(rate);
break;
}
}
return bounds.Width > 0 && bounds.Height > 0;

View file

@ -1,5 +1,6 @@
using System.IO;
using System.Text.RegularExpressions;
using AutoMapper;
namespace LANCommander.SDK.Helpers;

View file

@ -13,10 +13,6 @@
<RepositoryType>git</RepositoryType>
<PackageTags>lancommander</PackageTags>
<PackageLicenseExpression>MIT</PackageLicenseExpression>
<!-- Emit XML docs so the plugin API reference can be generated from source comments. -->
<GenerateDocumentationFile>true</GenerateDocumentationFile>
<!-- Don't fail/spam the build for members without XML comments. -->
<NoWarn>$(NoWarn);CS1591</NoWarn>
</PropertyGroup>
<ItemGroup>
@ -31,7 +27,6 @@
<PackageReference Include="Microsoft.Extensions.Hosting.Abstractions" />
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" />
<PackageReference Include="Microsoft.Extensions.Logging.Configuration" />
<PackageReference Include="Microsoft.Extensions.Logging.Console" />
<PackageReference Include="Microsoft.Extensions.Options.ConfigurationExtensions" />
<PackageReference Include="Microsoft.PowerShell.Commands.Diagnostics" />
<PackageReference Include="Microsoft.PowerShell.SDK" />

View file

@ -1,24 +0,0 @@
using System;
namespace LANCommander.SDK.Models
{
public class GameVersion : BaseModel
{
public string Version { get; set; }
public string Changelog { get; set; }
public int SortOrder { get; set; }
public Guid GameId { get; set; }
/// <summary>
/// The id of the archive attached to this version, if one has been uploaded. Null when the
/// version exists only to hold config (Scripts, Actions, SavePaths) without a build.
/// </summary>
public Guid? ArchiveId { get; set; }
public long CompressedSize { get; set; }
public long UncompressedSize { get; set; }
}
}

View file

@ -1,9 +0,0 @@
using System;
namespace LANCommander.SDK.Plugins.Events;
/// <summary>Raised after a user successfully logs in.</summary>
public sealed record UserLoggedInEvent(Guid UserId, string UserName);
/// <summary>Raised after a user logs out.</summary>
public sealed record UserLoggedOutEvent(Guid UserId, string UserName);

View file

@ -1,27 +0,0 @@
using System;
namespace LANCommander.SDK.Plugins.Events;
/// <summary>Raised just before a game install begins.</summary>
public sealed record GameInstallingEvent(Guid GameId, string? InstallDirectory);
/// <summary>Raised after a game has finished installing.</summary>
public sealed record GameInstalledEvent(Guid GameId, string InstallDirectory);
/// <summary>Raised when a game install fails.</summary>
public sealed record GameInstallFailedEvent(Guid GameId, string? InstallDirectory);
/// <summary>Raised just before a game is uninstalled.</summary>
public sealed record GameUninstallingEvent(Guid GameId, string? InstallDirectory);
/// <summary>Raised after a game has finished uninstalling.</summary>
public sealed record GameUninstalledEvent(Guid GameId);
/// <summary>Raised immediately before a game's executable is launched.</summary>
public sealed record GameBeforeLaunchEvent(Guid GameId, string InstallDirectory, string? Action);
/// <summary>Raised immediately after a launched game process exits.</summary>
public sealed record GameAfterExitEvent(Guid GameId, string InstallDirectory);
/// <summary>Raised whenever the install/download queue changes.</summary>
public sealed record InstallQueueChangedEvent;

View file

@ -1,37 +0,0 @@
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.DependencyInjection;
namespace LANCommander.SDK.Plugins;
/// <summary>
/// The entry point contract every LANCommander plugin implements. Plugins are discovered
/// from the host's <c>Plugins</c> drop-in folder and loaded once at startup.
/// </summary>
public interface IPlugin
{
/// <summary>Stable, globally unique identifier (e.g. "com.acme.myplugin").</summary>
string Id { get; }
/// <summary>Human readable display name.</summary>
string Name { get; }
/// <summary>Plugin version (SemVer recommended).</summary>
string Version { get; }
/// <summary>Plugin author.</summary>
string Author { get; }
/// <summary>
/// Registers the plugin's own services into the host's DI container. Called during host
/// startup <b>before</b> the service provider is built, so implementations must only register
/// services and must not attempt to resolve them.
/// </summary>
void ConfigureServices(IServiceCollection services);
/// <summary>
/// Asynchronous startup hook, invoked <b>after</b> the host's service provider is built. Use this
/// to resolve services, subscribe to lifecycle events, register UI extensions, etc.
/// </summary>
Task InitializeAsync(PluginContext context, CancellationToken cancellationToken);
}

View file

@ -1,24 +0,0 @@
using System;
using System.Threading;
using System.Threading.Tasks;
namespace LANCommander.SDK.Plugins;
/// <summary>
/// A minimal in-process, strongly-typed event aggregator that lets plugins react to host lifecycle
/// events (game install/launch/uninstall, login, etc.). Registered as a singleton in both hosts.
/// </summary>
public interface IPluginEventBus
{
/// <summary>
/// Subscribes a handler to events of type <typeparamref name="TEvent"/>.
/// </summary>
/// <returns>A token that unsubscribes the handler when disposed.</returns>
IDisposable Subscribe<TEvent>(Func<TEvent, CancellationToken, Task> handler);
/// <summary>
/// Publishes an event to all subscribed handlers. Each handler is awaited and isolated so a
/// throwing handler cannot break the publisher or other handlers.
/// </summary>
Task PublishAsync<TEvent>(TEvent @event, CancellationToken cancellationToken = default);
}

View file

@ -1,23 +0,0 @@
using System;
using System.Collections.Generic;
namespace LANCommander.SDK.Plugins;
/// <summary>
/// Implemented by plugins that want to add PowerShell cmdlets or script modules into the
/// LANCommander runspace. Register the implementation in <see cref="IPlugin.ConfigureServices"/>;
/// the SDK's PowerShell runspace picks up all registered extensions when a script is executed.
/// </summary>
public interface IPluginPowerShellExtension
{
/// <summary>
/// Returns cmdlet types (classes decorated with <c>[Cmdlet]</c>) to register into each runspace.
/// </summary>
IEnumerable<Type> GetCmdletTypes();
/// <summary>
/// Returns absolute paths to PowerShell script modules (.psm1/.psd1) shipped with the plugin that
/// should be imported into each runspace.
/// </summary>
IEnumerable<string> GetModulePaths();
}

View file

@ -1,35 +0,0 @@
using System;
namespace LANCommander.SDK.Plugins;
/// <summary>
/// Assembly-level attribute that marks an assembly as a LANCommander plugin and declares its
/// entry point and compatibility metadata. This is the primary discovery mechanism used by the loader.
/// </summary>
/// <example>
/// [assembly: LANCommanderPlugin(typeof(MyPlugin), Id = "com.acme.myplugin",
/// MinHostVersion = "1.1.0", Hosts = PluginHost.Server | PluginHost.Launcher)]
/// </example>
[AttributeUsage(AttributeTargets.Assembly, AllowMultiple = false)]
public sealed class LANCommanderPluginAttribute : Attribute
{
/// <summary>The concrete type implementing <see cref="IPlugin"/> that serves as the entry point.</summary>
public Type EntryPoint { get; }
/// <summary>Optional override for the plugin id; when null the loader falls back to the instance's <see cref="IPlugin.Id"/>.</summary>
public string? Id { get; set; }
/// <summary>Minimum compatible host (SDK) version, inclusive. Null means no lower bound.</summary>
public string? MinHostVersion { get; set; }
/// <summary>Maximum compatible host (SDK) version, inclusive. Null means no upper bound.</summary>
public string? MaxHostVersion { get; set; }
/// <summary>The hosts this plugin supports. Defaults to both server and launcher.</summary>
public PluginHost Hosts { get; set; } = PluginHost.Server | PluginHost.Launcher;
public LANCommanderPluginAttribute(Type entryPoint)
{
EntryPoint = entryPoint;
}
}

View file

@ -1,42 +0,0 @@
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using LANCommander.SDK.Helpers;
namespace LANCommander.SDK.Plugins;
/// <summary>
/// Convenience helper that centralizes plugin discovery so every host wires it identically.
/// Call <see cref="ConfigurePlugins"/> as the last step while populating the service collection
/// (before building the provider), then call <see cref="PluginLoaderService.InitializeAllAsync"/>
/// on the returned loader after the provider is built.
/// </summary>
public static class PluginBootstrap
{
/// <summary>Name of the drop-in folder under the host's config directory.</summary>
public const string PluginsFolderName = "Plugins";
/// <summary>
/// Discovers plugins for <paramref name="host"/> from <c>&lt;config&gt;/Plugins</c>, lets each register
/// its services into <paramref name="services"/>, and registers the loader as a singleton so the
/// same instance can drive Phase 2 initialization.
/// </summary>
public static PluginLoaderService ConfigurePlugins(IServiceCollection services, PluginHost host)
{
var loader = new PluginLoaderService();
// Discovery runs before the host's provider exists, so use a throwaway logger factory just for
// discovery diagnostics. Phase 2 uses the host's real logger factory.
using (var loggerFactory = LoggerFactory.Create(builder => builder.AddSimpleConsole()))
{
var logger = loggerFactory.CreateLogger("LANCommander.Plugins");
var pluginsRoot = AppPaths.GetConfigPath(PluginsFolderName);
var hostVersion = VersionHelper.GetCurrentVersion().ToString();
loader.DiscoverAndConfigure(services, host, pluginsRoot, hostVersion, logger);
}
services.AddSingleton(loader);
return loader;
}
}

View file

@ -1,22 +0,0 @@
using System;
using Microsoft.Extensions.Logging;
namespace LANCommander.SDK.Plugins;
/// <summary>
/// Runtime context handed to a plugin during <see cref="IPlugin.InitializeAsync"/>.
/// </summary>
public sealed class PluginContext
{
/// <summary>The host the plugin is running inside (a single value, never a flags combination).</summary>
public PluginHost Host { get; init; }
/// <summary>The fully built host service provider (scoped per plugin during initialization).</summary>
public IServiceProvider Services { get; init; } = default!;
/// <summary>Absolute path to the folder the plugin was loaded from.</summary>
public string PluginDirectory { get; init; } = string.Empty;
/// <summary>Logger scoped to the plugin.</summary>
public ILogger Logger { get; init; } = default!;
}

View file

@ -1,75 +0,0 @@
using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Logging;
namespace LANCommander.SDK.Plugins;
/// <inheritdoc cref="IPluginEventBus"/>
public sealed class PluginEventBus : IPluginEventBus
{
private readonly ILogger<PluginEventBus> _logger;
private readonly ConcurrentDictionary<Type, List<object>> _handlers = new();
private readonly object _lock = new();
public PluginEventBus(ILogger<PluginEventBus> logger)
{
_logger = logger;
}
public IDisposable Subscribe<TEvent>(Func<TEvent, CancellationToken, Task> handler)
{
if (handler is null)
throw new ArgumentNullException(nameof(handler));
var list = _handlers.GetOrAdd(typeof(TEvent), _ => new List<object>());
lock (_lock)
list.Add(handler);
return new Subscription(() =>
{
lock (_lock)
list.Remove(handler);
});
}
public async Task PublishAsync<TEvent>(TEvent @event, CancellationToken cancellationToken = default)
{
if (!_handlers.TryGetValue(typeof(TEvent), out var list))
return;
object[] snapshot;
lock (_lock)
snapshot = list.ToArray();
foreach (var entry in snapshot)
{
var handler = (Func<TEvent, CancellationToken, Task>)entry;
try
{
await handler(@event, cancellationToken).ConfigureAwait(false);
}
catch (Exception ex)
{
_logger.LogError(ex, "A plugin handler for {EventType} threw an exception", typeof(TEvent).Name);
}
}
}
private sealed class Subscription : IDisposable
{
private Action? _unsubscribe;
public Subscription(Action unsubscribe) => _unsubscribe = unsubscribe;
public void Dispose()
{
_unsubscribe?.Invoke();
_unsubscribe = null;
}
}
}

View file

@ -1,15 +0,0 @@
using System;
namespace LANCommander.SDK.Plugins;
/// <summary>
/// Identifies which LANCommander host a plugin targets. Used both as a single value
/// (the host a plugin is being loaded into) and as a flags set (the hosts a plugin declares support for).
/// </summary>
[Flags]
public enum PluginHost
{
None = 0,
Server = 1 << 0,
Launcher = 1 << 1,
}

View file

@ -1,72 +0,0 @@
using System;
using System.Collections.Generic;
using System.Reflection;
using System.Runtime.Loader;
namespace LANCommander.SDK.Plugins;
/// <summary>
/// An isolated <see cref="AssemblyLoadContext"/> for a single plugin. Uses an
/// <see cref="AssemblyDependencyResolver"/> to resolve the plugin's private dependencies while
/// deferring host-provided assemblies (the SDK, DI abstractions, Avalonia, etc.) to the default
/// context so that shared types keep a single identity across the ALC boundary.
/// </summary>
public sealed class PluginLoadContext : AssemblyLoadContext
{
private readonly AssemblyDependencyResolver _resolver;
/// <summary>
/// Simple assembly names that must always resolve to the host's already-loaded copy so that
/// contract types (<see cref="IPlugin"/>, DI, event bus, UI contracts) share identity. Matched
/// as a prefix so, e.g., all Avalonia.* assemblies are covered.
/// </summary>
private static readonly string[] SharedAssemblyPrefixes =
{
"LANCommander.SDK",
"LANCommander.Launcher.Plugins",
"LANCommander.Server.Services",
"Microsoft.Extensions.DependencyInjection",
"Microsoft.Extensions.Logging",
"Microsoft.Extensions.Hosting",
"Microsoft.Extensions.Options",
"Microsoft.Extensions.Configuration",
"Avalonia",
"CommunityToolkit.Mvvm",
"System.Management.Automation",
};
public PluginLoadContext(string mainAssemblyPath)
: base(name: $"Plugin:{System.IO.Path.GetFileNameWithoutExtension(mainAssemblyPath)}", isCollectible: false)
{
_resolver = new AssemblyDependencyResolver(mainAssemblyPath);
}
protected override Assembly? Load(AssemblyName assemblyName)
{
if (IsShared(assemblyName.Name))
return null; // defer to the default context for shared/host-provided assemblies
var path = _resolver.ResolveAssemblyToPath(assemblyName);
return path is null ? null : LoadFromAssemblyPath(path);
}
protected override IntPtr LoadUnmanagedDll(string unmanagedDllName)
{
var path = _resolver.ResolveUnmanagedDllToPath(unmanagedDllName);
return path is null ? IntPtr.Zero : LoadUnmanagedDllFromPath(path);
}
private static bool IsShared(string? simpleName)
{
if (string.IsNullOrEmpty(simpleName))
return false;
foreach (var prefix in SharedAssemblyPrefixes)
{
if (simpleName.StartsWith(prefix, StringComparison.OrdinalIgnoreCase))
return true;
}
return false;
}
}

View file

@ -1,191 +0,0 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Reflection;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using Semver;
namespace LANCommander.SDK.Plugins;
/// <summary>
/// Discovers, loads, and initializes plugins from a drop-in folder. Split into two phases to match
/// the "build the DI container once" constraint:
/// <list type="number">
/// <item><see cref="DiscoverAndConfigure"/> runs while the host is still populating its
/// <see cref="IServiceCollection"/> (before the provider is built).</item>
/// <item><see cref="InitializeAllAsync"/> runs after the provider has been built.</item>
/// </list>
/// </summary>
public sealed class PluginLoaderService
{
private readonly List<LoadedPlugin> _loaded = new();
private PluginHost _host;
/// <summary>Plugins successfully loaded and configured during discovery.</summary>
public IReadOnlyList<PluginManifest> LoadedPlugins => _loaded.Select(p => p.Manifest).ToList();
/// <summary>
/// Phase 1: scans <paramref name="pluginsRoot"/> for plugins, loads each into its own
/// <see cref="PluginLoadContext"/>, applies host + version gates, instantiates the entry point,
/// and lets it register services. A failure in one plugin never aborts the batch.
/// </summary>
public void DiscoverAndConfigure(
IServiceCollection services,
PluginHost host,
string pluginsRoot,
string hostVersion,
ILogger logger)
{
_host = host;
if (!Directory.Exists(pluginsRoot))
{
logger.LogInformation("Plugins directory '{PluginsRoot}' does not exist; no plugins loaded", pluginsRoot);
return;
}
foreach (var directory in Directory.GetDirectories(pluginsRoot))
{
try
{
TryLoadPlugin(services, host, directory, hostVersion, logger);
}
catch (Exception ex)
{
logger.LogError(ex, "Failed to load plugin from '{Directory}'", directory);
}
}
}
private void TryLoadPlugin(
IServiceCollection services,
PluginHost host,
string directory,
string hostVersion,
ILogger logger)
{
var mainAssemblyPath = ResolveMainAssemblyPath(directory);
if (mainAssemblyPath is null)
{
logger.LogWarning("No plugin assembly found in '{Directory}' (expected '{Name}.dll' or a single assembly with a .deps.json)", directory, new DirectoryInfo(directory).Name);
return;
}
var context = new PluginLoadContext(mainAssemblyPath);
var assembly = context.LoadFromAssemblyPath(mainAssemblyPath);
var attribute = assembly.GetCustomAttribute<LANCommanderPluginAttribute>();
if (attribute is null)
{
logger.LogWarning("Assembly '{Assembly}' is missing a [LANCommanderPlugin] attribute; skipping", assembly.FullName);
return;
}
var manifest = PluginManifest.FromAttribute(attribute, assembly, directory);
if ((manifest.Hosts & host) == 0)
{
logger.LogDebug("Plugin '{Id}' does not target host {Host}; skipping", manifest.Id, host);
return;
}
if (!IsVersionCompatible(hostVersion, manifest.MinHostVersion, manifest.MaxHostVersion))
{
logger.LogWarning(
"Plugin '{Id}' is incompatible with host version {HostVersion} (requires {Min}..{Max}); skipping",
manifest.Id, hostVersion, manifest.MinHostVersion ?? "*", manifest.MaxHostVersion ?? "*");
return;
}
if (Activator.CreateInstance(manifest.EntryPoint) is not IPlugin plugin)
{
logger.LogWarning("Entry point '{EntryPoint}' for plugin '{Id}' does not implement IPlugin; skipping", manifest.EntryPoint.FullName, manifest.Id);
return;
}
try
{
plugin.ConfigureServices(services);
}
catch (Exception ex)
{
logger.LogError(ex, "Plugin '{Id}' threw during ConfigureServices; skipping", plugin.Id);
return;
}
_loaded.Add(new LoadedPlugin(plugin, manifest));
logger.LogInformation("Loaded plugin '{Name}' ({Id}) v{Version} by {Author}", plugin.Name, plugin.Id, plugin.Version, plugin.Author);
}
/// <summary>
/// Phase 2: invokes <see cref="IPlugin.InitializeAsync"/> for every loaded plugin, each within its
/// own DI scope. A failure in one plugin never aborts the others.
/// </summary>
public async Task InitializeAllAsync(IServiceProvider serviceProvider, CancellationToken cancellationToken = default)
{
var loggerFactory = serviceProvider.GetRequiredService<ILoggerFactory>();
foreach (var loaded in _loaded)
{
var logger = loggerFactory.CreateLogger($"Plugin:{loaded.Plugin.Id}");
try
{
using var scope = serviceProvider.CreateScope();
var context = new PluginContext
{
Host = _host,
Services = scope.ServiceProvider,
PluginDirectory = loaded.Manifest.Directory,
Logger = logger,
};
await loaded.Plugin.InitializeAsync(context, cancellationToken).ConfigureAwait(false);
}
catch (Exception ex)
{
logger.LogError(ex, "Plugin '{Id}' threw during InitializeAsync", loaded.Plugin.Id);
}
}
}
private static string? ResolveMainAssemblyPath(string directory)
{
// Preferred convention: an assembly named after the plugin folder.
var conventional = Path.Combine(directory, $"{new DirectoryInfo(directory).Name}.dll");
if (File.Exists(conventional))
return conventional;
// Fallback: the single assembly in the folder that ships a .deps.json (i.e. the main project output).
var candidates = Directory.GetFiles(directory, "*.dll")
.Where(dll => File.Exists(Path.ChangeExtension(dll, ".deps.json")))
.ToArray();
return candidates.Length == 1 ? candidates[0] : null;
}
internal static bool IsVersionCompatible(string hostVersion, string? min, string? max)
{
if (!SemVersion.TryParse(hostVersion, SemVersionStyles.Any, out var host))
return true; // can't evaluate the host version, so don't block
if (min is not null && SemVersion.TryParse(min, SemVersionStyles.Any, out var minVersion)
&& host.ComparePrecedenceTo(minVersion) < 0)
return false;
if (max is not null && SemVersion.TryParse(max, SemVersionStyles.Any, out var maxVersion)
&& host.ComparePrecedenceTo(maxVersion) > 0)
return false;
return true;
}
private readonly record struct LoadedPlugin(IPlugin Plugin, PluginManifest Manifest);
}

View file

@ -1,35 +0,0 @@
using System;
using System.Reflection;
namespace LANCommander.SDK.Plugins;
/// <summary>
/// Parsed metadata describing a discovered plugin, derived from its <see cref="LANCommanderPluginAttribute"/>.
/// </summary>
public sealed class PluginManifest
{
public string Id { get; init; } = string.Empty;
public Type EntryPoint { get; init; } = default!;
public string? MinHostVersion { get; init; }
public string? MaxHostVersion { get; init; }
public PluginHost Hosts { get; init; } = PluginHost.Server | PluginHost.Launcher;
/// <summary>The assembly the plugin was loaded from.</summary>
public Assembly Assembly { get; init; } = default!;
/// <summary>Absolute path to the folder the plugin was loaded from.</summary>
public string Directory { get; init; } = string.Empty;
/// <summary>Builds a manifest from an assembly-level plugin attribute.</summary>
public static PluginManifest FromAttribute(LANCommanderPluginAttribute attribute, Assembly assembly, string directory)
=> new()
{
Id = attribute.Id ?? attribute.EntryPoint.FullName ?? attribute.EntryPoint.Name,
EntryPoint = attribute.EntryPoint,
MinHostVersion = attribute.MinHostVersion,
MaxHostVersion = attribute.MaxHostVersion,
Hosts = attribute.Hosts,
Assembly = assembly,
Directory = directory,
};
}

View file

@ -3,17 +3,14 @@ using Microsoft.Extensions.Logging;
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Management.Automation;
using System.Management.Automation.Runspaces;
using System.Reflection;
using System.Runtime.InteropServices;
using System.Text;
using System.Text.RegularExpressions;
using System.Threading.Tasks;
using LANCommander.SDK.Abstractions;
using LANCommander.SDK.Factories;
using LANCommander.SDK.Plugins;
using LANCommander.SDK.PowerShell.Extensions;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Options;
@ -173,55 +170,89 @@ namespace LANCommander.SDK.PowerShell
return Regex.IsMatch(Contents, pattern);
}
public async Task<T> ExecuteAsync<T>()
/// <summary>
/// Builds the runspace configuration. When <paramref name="bypassExecutionPolicy"/> is set we
/// prefer an execution policy of <see cref="Microsoft.PowerShell.ExecutionPolicy.Bypass"/> so
/// unsigned game scripts run without prompting.
/// </summary>
private InitialSessionState CreateSessionState(bool bypassExecutionPolicy)
{
T result = default;
var initialSessionState = InitialSessionState.CreateDefault();
if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
if (bypassExecutionPolicy)
initialSessionState.ExecutionPolicy = Microsoft.PowerShell.ExecutionPolicy.Bypass;
initialSessionState.AddCustomCmdlets();
RegisterPluginCmdlets(initialSessionState);
return initialSessionState;
}
/// <summary>
/// Opens a PowerShell runspace, preferring an execution policy of Bypass on Windows. Applying a
/// process-scope Bypass during <see cref="Runspace.Open"/> can throw on machines where the
/// execution policy is locked down by Group Policy; in that case we fall back to opening the
/// runspace with the system default policy so script execution is never silently skipped.
/// </summary>
private Runspace OpenRunspace()
{
var bypassExecutionPolicy = RuntimeInformation.IsOSPlatform(OSPlatform.Windows);
var runspace = RunspaceFactory.CreateRunspace(CreateSessionState(bypassExecutionPolicy));
try
{
runspace.Open();
return runspace;
}
catch (Exception ex) when (bypassExecutionPolicy)
{
Logger?.LogWarning(ex, "Failed to open PowerShell runspace with ExecutionPolicy.Bypass; retrying with the system default execution policy");
runspace.Dispose();
var fallback = RunspaceFactory.CreateRunspace(CreateSessionState(false));
fallback.Open();
return fallback;
}
}
public async Task<T> ExecuteAsync<T>()
{
T result = default;
DisableWow64Redirection();
using (Runspace runspace = RunspaceFactory.CreateRunspace(initialSessionState))
using (Runspace runspace = OpenRunspace())
{
runspace.Open();
var modulesPath = AppPaths.GetConfigPath("Modules");
var moduleSources = new List<string>();
if (Directory.Exists(modulesPath))
moduleSources.AddRange(Directory.GetDirectories(modulesPath));
moduleSources.AddRange(GetPluginModulePaths());
foreach (var moduleDirectory in moduleSources)
{
try
foreach (var moduleDirectory in Directory.GetDirectories(modulesPath))
{
using var import = System.Management.Automation.PowerShell.Create();
try
{
using var import = System.Management.Automation.PowerShell.Create();
import.Runspace = runspace;
import.AddCommand("Import-Module")
.AddParameter("Name", moduleDirectory)
.AddParameter("ErrorAction", "Stop");
import.Invoke();
import.Runspace = runspace;
import.AddCommand("Import-Module")
.AddParameter("Name", moduleDirectory)
.AddParameter("ErrorAction", "Stop");
import.Invoke();
if (import.HadErrors)
foreach (var error in import.Streams.Error)
Logger.LogWarning("Failed to load module {ModuleDirectory}: {ErrorMessage}", moduleDirectory, error.Exception?.Message);
}
catch (Exception ex)
{
Logger.LogWarning(ex, "Failed to load module {ModuleDirectory}", moduleDirectory);
if (import.HadErrors)
foreach (var error in import.Streams.Error)
Logger.LogWarning("Failed to load module {ModuleDirectory}: {ErrorMessage}", moduleDirectory, error.Exception?.Message);
}
catch (Exception ex)
{
Logger.LogWarning(ex, "Failed to load module {ModuleDirectory}", moduleDirectory);
}
}
}
@ -356,96 +387,6 @@ namespace LANCommander.SDK.PowerShell
return result;
}
private void RegisterPluginCmdlets(InitialSessionState initialSessionState)
{
IEnumerable<IPluginPowerShellExtension> extensions;
try
{
extensions = ServiceProvider.GetServices<IPluginPowerShellExtension>();
}
catch (Exception ex)
{
Logger?.LogWarning(ex, "Could not resolve plugin PowerShell extensions");
return;
}
foreach (var extension in extensions)
{
IEnumerable<Type> cmdletTypes;
try
{
cmdletTypes = extension.GetCmdletTypes() ?? Enumerable.Empty<Type>();
}
catch (Exception ex)
{
Logger?.LogWarning(ex, "Plugin PowerShell extension {Extension} failed to enumerate cmdlet types", extension.GetType().FullName);
continue;
}
foreach (var cmdletType in cmdletTypes)
{
try
{
var attribute = cmdletType.GetCustomAttribute<CmdletAttribute>();
if (attribute == null)
{
Logger?.LogWarning("Plugin cmdlet type {CmdletType} is missing a [Cmdlet] attribute and was skipped", cmdletType.FullName);
continue;
}
var cmdletName = $"{attribute.VerbName}-{attribute.NounName}";
initialSessionState.Commands.Add(new SessionStateCmdletEntry(cmdletName, cmdletType, null));
Logger?.LogDebug("Registered plugin cmdlet {CmdletName} from {CmdletType}", cmdletName, cmdletType.FullName);
}
catch (Exception ex)
{
Logger?.LogWarning(ex, "Could not register plugin cmdlet {CmdletType}", cmdletType.FullName);
}
}
}
}
private IEnumerable<string> GetPluginModulePaths()
{
IEnumerable<IPluginPowerShellExtension> extensions;
try
{
extensions = ServiceProvider.GetServices<IPluginPowerShellExtension>();
}
catch (Exception ex)
{
Logger?.LogWarning(ex, "Could not resolve plugin PowerShell extensions");
yield break;
}
foreach (var extension in extensions)
{
IEnumerable<string> modulePaths;
try
{
modulePaths = extension.GetModulePaths() ?? Enumerable.Empty<string>();
}
catch (Exception ex)
{
Logger?.LogWarning(ex, "Plugin PowerShell extension {Extension} failed to enumerate module paths", extension.GetType().FullName);
continue;
}
foreach (var modulePath in modulePaths)
{
if (!string.IsNullOrWhiteSpace(modulePath))
yield return modulePath;
}
}
}
private static T ConvertResult<T>(object value)
{
// Unwrap PSObject wrapper

View file

@ -1,22 +0,0 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<!-- Not part of the app build graph; built on demand and dropped into <config>/Plugins/. -->
<IsPackable>false</IsPackable>
</PropertyGroup>
<!--
Host-provided contract assemblies are referenced with Private=false so the plugin binds against
the host's already-loaded copies at runtime (preserving type identity across the plugin's
AssemblyLoadContext) rather than shipping its own copies in the drop-in folder.
-->
<ItemGroup>
<ProjectReference Include="..\LANCommander.SDK\LANCommander.SDK.csproj" Private="false" />
<ProjectReference Include="..\LANCommander.Launcher.Plugins\LANCommander.Launcher.Plugins.csproj" Private="false" />
<ProjectReference Include="..\LANCommander.Server.Services\LANCommander.Server.Services.csproj" Private="false" />
</ItemGroup>
</Project>

View file

@ -1,20 +0,0 @@
using System.Management.Automation;
namespace LANCommander.SamplePlugin;
/// <summary>
/// A cmdlet added by the sample plugin. Once registered via
/// <see cref="SamplePowerShellExtension"/> it is callable from any LANCommander script as
/// <c>Get-SampleGreeting -Name "World"</c>.
/// </summary>
[Cmdlet(VerbsCommon.Get, "SampleGreeting")]
public sealed class SampleGreetingCmdlet : PSCmdlet
{
[Parameter(Position = 0)]
public string Name { get; set; } = "World";
protected override void ProcessRecord()
{
WriteObject($"Hello, {Name}, from the LANCommander sample plugin!");
}
}

View file

@ -1,20 +0,0 @@
using LANCommander.SDK.Models.Manifest;
using LANCommander.Server.Services.Providers.Metadata;
namespace LANCommander.SamplePlugin;
/// <summary>
/// Minimal metadata provider added by the sample plugin. Returns no results; its purpose is to
/// prove that a plugin-registered <see cref="IMetadataProvider"/> is picked up by the server's
/// provider enumeration.
/// </summary>
public sealed class SampleMetadataProvider : IMetadataProvider
{
public string ProviderName => "Sample Plugin Provider";
public Task<MetadataSearchResultsCollection<Game>?> SearchGamesAsync(string input, int limit = 10, int offset = 0)
=> Task.FromResult<MetadataSearchResultsCollection<Game>?>(
new MetadataSearchResultsCollection<Game>(new List<MetadataSearchResult<Game>>(), More: false, limit, offset));
public Task<Game?> GetGameAsync(string gameId) => Task.FromResult<Game?>(null);
}

View file

@ -1,56 +0,0 @@
using LANCommander.Launcher.Plugins.Extensions;
using LANCommander.SamplePlugin;
using LANCommander.SDK.Plugins;
using LANCommander.SDK.Plugins.Events;
using LANCommander.Server.Services.Providers.Metadata;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
[assembly: LANCommanderPlugin(typeof(SamplePlugin), Id = "com.lancommander.sampleplugin",
Hosts = PluginHost.Server | PluginHost.Launcher)]
namespace LANCommander.SamplePlugin;
/// <summary>
/// Reference plugin exercising the framework's extension points: a server metadata provider, a
/// launcher settings section, a PowerShell cmdlet, and a lifecycle event subscription. Used as the
/// end-to-end smoke test for the plugin framework.
/// </summary>
public sealed class SamplePlugin : IPlugin
{
public string Id => "com.lancommander.sampleplugin";
public string Name => "LANCommander Sample Plugin";
public string Version => "1.0.0";
public string Author => "LANCommander";
private IDisposable? _launchSubscription;
public void ConfigureServices(IServiceCollection services)
{
// Server: add an additional metadata provider that appears in the host's enumeration.
services.AddSingleton<IMetadataProvider, SampleMetadataProvider>();
// Launcher: add a settings section rendered by a code-built control.
services.AddSingleton<ISettingsPageExtension, SampleSettingsExtension>();
// Both hosts: add a PowerShell cmdlet callable from any script.
services.AddSingleton<IPluginPowerShellExtension, SamplePowerShellExtension>();
}
public Task InitializeAsync(PluginContext context, CancellationToken cancellationToken)
{
// Subscribe to a lifecycle event; logs whenever a game is about to launch.
var eventBus = context.Services.GetRequiredService<IPluginEventBus>();
_launchSubscription = eventBus.Subscribe<GameBeforeLaunchEvent>((evt, ct) =>
{
context.Logger.LogInformation(
"[SamplePlugin] Game {GameId} is about to launch (action: {Action})", evt.GameId, evt.Action);
return Task.CompletedTask;
});
context.Logger.LogInformation("[SamplePlugin] Initialized on host {Host}", context.Host);
return Task.CompletedTask;
}
}

View file

@ -1,14 +0,0 @@
using LANCommander.SDK.Plugins;
namespace LANCommander.SamplePlugin;
/// <summary>
/// Registers the sample plugin's cmdlets (and optionally PowerShell modules) into the runspace used
/// by LANCommander scripts.
/// </summary>
public sealed class SamplePowerShellExtension : IPluginPowerShellExtension
{
public IEnumerable<Type> GetCmdletTypes() => new[] { typeof(SampleGreetingCmdlet) };
public IEnumerable<string> GetModulePaths() => Array.Empty<string>();
}

View file

@ -1,37 +0,0 @@
using Avalonia.Controls;
using Avalonia.Layout;
using LANCommander.Launcher.Plugins.Extensions;
namespace LANCommander.SamplePlugin;
/// <summary>
/// Adds a settings section built entirely in code (no XAML / avares assets), which is the
/// recommended authoring path for plugin views since it avoids Avalonia asset resolution across the
/// plugin's AssemblyLoadContext.
/// </summary>
public sealed class SampleSettingsExtension : ISettingsPageExtension
{
public string Title => "Sample Plugin";
public int Order => 0;
public Control BuildContent()
{
var panel = new StackPanel { Spacing = 8 };
panel.Children.Add(new TextBlock
{
Text = "This settings section was added by the sample plugin.",
TextWrapping = Avalonia.Media.TextWrapping.Wrap,
Opacity = 0.7,
});
panel.Children.Add(new CheckBox
{
Content = "Enable sample feature",
HorizontalAlignment = HorizontalAlignment.Left,
});
return panel;
}
}

View file

@ -1,202 +0,0 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace LANCommander.Server.Data.MySQL.Migrations
{
/// <inheritdoc />
public partial class AddGameVersions : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<Guid>(
name: "GameVersionId",
table: "Scripts",
type: "char(36)",
nullable: true,
collation: "ascii_general_ci");
migrationBuilder.AddColumn<Guid>(
name: "GameVersionId",
table: "SavePaths",
type: "char(36)",
nullable: true,
collation: "ascii_general_ci");
migrationBuilder.AddColumn<Guid>(
name: "GameVersionId",
table: "Archive",
type: "char(36)",
nullable: true,
collation: "ascii_general_ci");
migrationBuilder.AddColumn<Guid>(
name: "GameVersionId",
table: "Actions",
type: "char(36)",
nullable: true,
collation: "ascii_general_ci");
migrationBuilder.CreateTable(
name: "GameVersions",
columns: table => new
{
Id = table.Column<Guid>(type: "char(36)", nullable: false, collation: "ascii_general_ci"),
Version = table.Column<string>(type: "longtext", nullable: false)
.Annotation("MySql:CharSet", "utf8mb4"),
Changelog = table.Column<string>(type: "longtext", nullable: true)
.Annotation("MySql:CharSet", "utf8mb4"),
SortOrder = table.Column<int>(type: "int", nullable: false),
GameId = table.Column<Guid>(type: "char(36)", nullable: false, collation: "ascii_general_ci"),
CreatedOn = table.Column<DateTime>(type: "datetime(6)", nullable: false),
CreatedById = table.Column<Guid>(type: "char(36)", nullable: true, collation: "ascii_general_ci"),
UpdatedOn = table.Column<DateTime>(type: "datetime(6)", nullable: false),
UpdatedById = table.Column<Guid>(type: "char(36)", nullable: true, collation: "ascii_general_ci")
},
constraints: table =>
{
table.PrimaryKey("PK_GameVersions", x => x.Id);
table.ForeignKey(
name: "FK_GameVersions_Games_GameId",
column: x => x.GameId,
principalTable: "Games",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
table.ForeignKey(
name: "FK_GameVersions_Users_CreatedById",
column: x => x.CreatedById,
principalTable: "Users",
principalColumn: "Id",
onDelete: ReferentialAction.SetNull);
table.ForeignKey(
name: "FK_GameVersions_Users_UpdatedById",
column: x => x.UpdatedById,
principalTable: "Users",
principalColumn: "Id",
onDelete: ReferentialAction.SetNull);
})
.Annotation("MySql:CharSet", "utf8mb4");
migrationBuilder.CreateIndex(
name: "IX_Scripts_GameVersionId",
table: "Scripts",
column: "GameVersionId");
migrationBuilder.CreateIndex(
name: "IX_SavePaths_GameVersionId",
table: "SavePaths",
column: "GameVersionId");
migrationBuilder.CreateIndex(
name: "IX_Archive_GameVersionId",
table: "Archive",
column: "GameVersionId",
unique: true);
migrationBuilder.CreateIndex(
name: "IX_Actions_GameVersionId",
table: "Actions",
column: "GameVersionId");
migrationBuilder.CreateIndex(
name: "IX_GameVersions_CreatedById",
table: "GameVersions",
column: "CreatedById");
migrationBuilder.CreateIndex(
name: "IX_GameVersions_GameId",
table: "GameVersions",
column: "GameId");
migrationBuilder.CreateIndex(
name: "IX_GameVersions_UpdatedById",
table: "GameVersions",
column: "UpdatedById");
migrationBuilder.AddForeignKey(
name: "FK_Actions_GameVersions_GameVersionId",
table: "Actions",
column: "GameVersionId",
principalTable: "GameVersions",
principalColumn: "Id");
migrationBuilder.AddForeignKey(
name: "FK_Archive_GameVersions_GameVersionId",
table: "Archive",
column: "GameVersionId",
principalTable: "GameVersions",
principalColumn: "Id");
migrationBuilder.AddForeignKey(
name: "FK_SavePaths_GameVersions_GameVersionId",
table: "SavePaths",
column: "GameVersionId",
principalTable: "GameVersions",
principalColumn: "Id");
migrationBuilder.AddForeignKey(
name: "FK_Scripts_GameVersions_GameVersionId",
table: "Scripts",
column: "GameVersionId",
principalTable: "GameVersions",
principalColumn: "Id");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropForeignKey(
name: "FK_Actions_GameVersions_GameVersionId",
table: "Actions");
migrationBuilder.DropForeignKey(
name: "FK_Archive_GameVersions_GameVersionId",
table: "Archive");
migrationBuilder.DropForeignKey(
name: "FK_SavePaths_GameVersions_GameVersionId",
table: "SavePaths");
migrationBuilder.DropForeignKey(
name: "FK_Scripts_GameVersions_GameVersionId",
table: "Scripts");
migrationBuilder.DropTable(
name: "GameVersions");
migrationBuilder.DropIndex(
name: "IX_Scripts_GameVersionId",
table: "Scripts");
migrationBuilder.DropIndex(
name: "IX_SavePaths_GameVersionId",
table: "SavePaths");
migrationBuilder.DropIndex(
name: "IX_Archive_GameVersionId",
table: "Archive");
migrationBuilder.DropIndex(
name: "IX_Actions_GameVersionId",
table: "Actions");
migrationBuilder.DropColumn(
name: "GameVersionId",
table: "Scripts");
migrationBuilder.DropColumn(
name: "GameVersionId",
table: "SavePaths");
migrationBuilder.DropColumn(
name: "GameVersionId",
table: "Archive");
migrationBuilder.DropColumn(
name: "GameVersionId",
table: "Actions");
}
}
}

View file

@ -1,29 +0,0 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace LANCommander.Server.Data.MySQL.Migrations
{
/// <inheritdoc />
public partial class RemoveArchiveChangelog : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "Changelog",
table: "Archive");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<string>(
name: "Changelog",
table: "Archive",
type: "longtext",
nullable: true)
.Annotation("MySql:CharSet", "utf8mb4");
}
}
}

View file

@ -193,9 +193,6 @@ namespace LANCommander.Server.Data.MySQL.Migrations
b.Property<Guid?>("GameId")
.HasColumnType("char(36)");
b.Property<Guid?>("GameVersionId")
.HasColumnType("char(36)");
b.Property<string>("Name")
.IsRequired()
.HasColumnType("longtext");
@ -236,8 +233,6 @@ namespace LANCommander.Server.Data.MySQL.Migrations
b.HasIndex("GameId");
b.HasIndex("GameVersionId");
b.HasIndex("ServerId");
b.HasIndex("ToolId");
@ -253,6 +248,9 @@ namespace LANCommander.Server.Data.MySQL.Migrations
.ValueGeneratedOnAdd()
.HasColumnType("char(36)");
b.Property<string>("Changelog")
.HasColumnType("longtext");
b.Property<long>("CompressedSize")
.HasColumnType("bigint");
@ -265,9 +263,6 @@ namespace LANCommander.Server.Data.MySQL.Migrations
b.Property<Guid?>("GameId")
.HasColumnType("char(36)");
b.Property<Guid?>("GameVersionId")
.HasColumnType("char(36)");
b.Property<Guid?>("LastVersionId")
.HasColumnType("char(36)");
@ -303,9 +298,6 @@ namespace LANCommander.Server.Data.MySQL.Migrations
b.HasIndex("GameId");
b.HasIndex("GameVersionId")
.IsUnique();
b.HasIndex("LastVersionId");
b.HasIndex("RedistributableId");
@ -736,48 +728,6 @@ namespace LANCommander.Server.Data.MySQL.Migrations
b.ToTable("GameSaves");
});
modelBuilder.Entity("LANCommander.Server.Data.Models.GameVersion", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("char(36)");
b.Property<string>("Changelog")
.HasColumnType("longtext");
b.Property<Guid?>("CreatedById")
.HasColumnType("char(36)");
b.Property<DateTime>("CreatedOn")
.HasColumnType("datetime(6)");
b.Property<Guid>("GameId")
.HasColumnType("char(36)");
b.Property<int>("SortOrder")
.HasColumnType("int");
b.Property<Guid?>("UpdatedById")
.HasColumnType("char(36)");
b.Property<DateTime>("UpdatedOn")
.HasColumnType("datetime(6)");
b.Property<string>("Version")
.IsRequired()
.HasColumnType("longtext");
b.HasKey("Id");
b.HasIndex("CreatedById");
b.HasIndex("GameId");
b.HasIndex("UpdatedById");
b.ToTable("GameVersions");
});
modelBuilder.Entity("LANCommander.Server.Data.Models.Genre", b =>
{
b.Property<Guid>("Id")
@ -1373,9 +1323,6 @@ namespace LANCommander.Server.Data.MySQL.Migrations
b.Property<Guid?>("GameId")
.HasColumnType("char(36)");
b.Property<Guid?>("GameVersionId")
.HasColumnType("char(36)");
b.Property<bool>("IsRegex")
.HasColumnType("tinyint(1)");
@ -1404,8 +1351,6 @@ namespace LANCommander.Server.Data.MySQL.Migrations
b.HasIndex("GameId");
b.HasIndex("GameVersionId");
b.HasIndex("UpdatedById");
b.ToTable("SavePaths");
@ -1433,9 +1378,6 @@ namespace LANCommander.Server.Data.MySQL.Migrations
b.Property<Guid?>("GameId")
.HasColumnType("char(36)");
b.Property<Guid?>("GameVersionId")
.HasColumnType("char(36)");
b.Property<string>("Name")
.IsRequired()
.HasColumnType("longtext");
@ -1470,8 +1412,6 @@ namespace LANCommander.Server.Data.MySQL.Migrations
b.HasIndex("GameId");
b.HasIndex("GameVersionId");
b.HasIndex("RedistributableId");
b.HasIndex("ServerId");
@ -2249,11 +2189,6 @@ namespace LANCommander.Server.Data.MySQL.Migrations
.HasForeignKey("GameId")
.OnDelete(DeleteBehavior.Cascade);
b.HasOne("LANCommander.Server.Data.Models.GameVersion", "GameVersion")
.WithMany("Actions")
.HasForeignKey("GameVersionId")
.OnDelete(DeleteBehavior.ClientCascade);
b.HasOne("LANCommander.Server.Data.Models.Server", "Server")
.WithMany("Actions")
.HasForeignKey("ServerId")
@ -2273,8 +2208,6 @@ namespace LANCommander.Server.Data.MySQL.Migrations
b.Navigation("Game");
b.Navigation("GameVersion");
b.Navigation("Server");
b.Navigation("Tool");
@ -2294,11 +2227,6 @@ namespace LANCommander.Server.Data.MySQL.Migrations
.HasForeignKey("GameId")
.OnDelete(DeleteBehavior.Cascade);
b.HasOne("LANCommander.Server.Data.Models.GameVersion", "GameVersion")
.WithOne("Archive")
.HasForeignKey("LANCommander.Server.Data.Models.Archive", "GameVersionId")
.OnDelete(DeleteBehavior.ClientCascade);
b.HasOne("LANCommander.Server.Data.Models.Archive", "LastVersion")
.WithMany()
.HasForeignKey("LastVersionId");
@ -2328,8 +2256,6 @@ namespace LANCommander.Server.Data.MySQL.Migrations
b.Navigation("Game");
b.Navigation("GameVersion");
b.Navigation("LastVersion");
b.Navigation("Redistributable");
@ -2597,31 +2523,6 @@ namespace LANCommander.Server.Data.MySQL.Migrations
b.Navigation("User");
});
modelBuilder.Entity("LANCommander.Server.Data.Models.GameVersion", b =>
{
b.HasOne("LANCommander.Server.Data.Models.User", "CreatedBy")
.WithMany()
.HasForeignKey("CreatedById")
.OnDelete(DeleteBehavior.SetNull);
b.HasOne("LANCommander.Server.Data.Models.Game", "Game")
.WithMany("Versions")
.HasForeignKey("GameId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("LANCommander.Server.Data.Models.User", "UpdatedBy")
.WithMany()
.HasForeignKey("UpdatedById")
.OnDelete(DeleteBehavior.SetNull);
b.Navigation("CreatedBy");
b.Navigation("Game");
b.Navigation("UpdatedBy");
});
modelBuilder.Entity("LANCommander.Server.Data.Models.Genre", b =>
{
b.HasOne("LANCommander.Server.Data.Models.User", "CreatedBy")
@ -2953,11 +2854,6 @@ namespace LANCommander.Server.Data.MySQL.Migrations
.HasForeignKey("GameId")
.OnDelete(DeleteBehavior.Cascade);
b.HasOne("LANCommander.Server.Data.Models.GameVersion", "GameVersion")
.WithMany("SavePaths")
.HasForeignKey("GameVersionId")
.OnDelete(DeleteBehavior.ClientCascade);
b.HasOne("LANCommander.Server.Data.Models.User", "UpdatedBy")
.WithMany()
.HasForeignKey("UpdatedById")
@ -2967,8 +2863,6 @@ namespace LANCommander.Server.Data.MySQL.Migrations
b.Navigation("Game");
b.Navigation("GameVersion");
b.Navigation("UpdatedBy");
});
@ -2984,11 +2878,6 @@ namespace LANCommander.Server.Data.MySQL.Migrations
.HasForeignKey("GameId")
.OnDelete(DeleteBehavior.Cascade);
b.HasOne("LANCommander.Server.Data.Models.GameVersion", "GameVersion")
.WithMany("Scripts")
.HasForeignKey("GameVersionId")
.OnDelete(DeleteBehavior.ClientCascade);
b.HasOne("LANCommander.Server.Data.Models.Redistributable", "Redistributable")
.WithMany("Scripts")
.HasForeignKey("RedistributableId")
@ -3013,8 +2902,6 @@ namespace LANCommander.Server.Data.MySQL.Migrations
b.Navigation("Game");
b.Navigation("GameVersion");
b.Navigation("Redistributable");
b.Navigation("Server");
@ -3356,19 +3243,6 @@ namespace LANCommander.Server.Data.MySQL.Migrations
b.Navigation("Scripts");
b.Navigation("Servers");
b.Navigation("Versions");
});
modelBuilder.Entity("LANCommander.Server.Data.Models.GameVersion", b =>
{
b.Navigation("Actions");
b.Navigation("Archive");
b.Navigation("SavePaths");
b.Navigation("Scripts");
});
modelBuilder.Entity("LANCommander.Server.Data.Models.Media", b =>

View file

@ -1,195 +0,0 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace LANCommander.Server.Data.PostgreSQL.Migrations
{
/// <inheritdoc />
public partial class AddGameVersions : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<Guid>(
name: "GameVersionId",
table: "Scripts",
type: "uuid",
nullable: true);
migrationBuilder.AddColumn<Guid>(
name: "GameVersionId",
table: "SavePaths",
type: "uuid",
nullable: true);
migrationBuilder.AddColumn<Guid>(
name: "GameVersionId",
table: "Archive",
type: "uuid",
nullable: true);
migrationBuilder.AddColumn<Guid>(
name: "GameVersionId",
table: "Actions",
type: "uuid",
nullable: true);
migrationBuilder.CreateTable(
name: "GameVersions",
columns: table => new
{
Id = table.Column<Guid>(type: "uuid", nullable: false),
Version = table.Column<string>(type: "text", nullable: false),
Changelog = table.Column<string>(type: "text", nullable: true),
SortOrder = table.Column<int>(type: "integer", nullable: false),
GameId = table.Column<Guid>(type: "uuid", nullable: false),
CreatedOn = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
CreatedById = table.Column<Guid>(type: "uuid", nullable: true),
UpdatedOn = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
UpdatedById = table.Column<Guid>(type: "uuid", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_GameVersions", x => x.Id);
table.ForeignKey(
name: "FK_GameVersions_Games_GameId",
column: x => x.GameId,
principalTable: "Games",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
table.ForeignKey(
name: "FK_GameVersions_Users_CreatedById",
column: x => x.CreatedById,
principalTable: "Users",
principalColumn: "Id",
onDelete: ReferentialAction.SetNull);
table.ForeignKey(
name: "FK_GameVersions_Users_UpdatedById",
column: x => x.UpdatedById,
principalTable: "Users",
principalColumn: "Id",
onDelete: ReferentialAction.SetNull);
});
migrationBuilder.CreateIndex(
name: "IX_Scripts_GameVersionId",
table: "Scripts",
column: "GameVersionId");
migrationBuilder.CreateIndex(
name: "IX_SavePaths_GameVersionId",
table: "SavePaths",
column: "GameVersionId");
migrationBuilder.CreateIndex(
name: "IX_Archive_GameVersionId",
table: "Archive",
column: "GameVersionId",
unique: true);
migrationBuilder.CreateIndex(
name: "IX_Actions_GameVersionId",
table: "Actions",
column: "GameVersionId");
migrationBuilder.CreateIndex(
name: "IX_GameVersions_CreatedById",
table: "GameVersions",
column: "CreatedById");
migrationBuilder.CreateIndex(
name: "IX_GameVersions_GameId",
table: "GameVersions",
column: "GameId");
migrationBuilder.CreateIndex(
name: "IX_GameVersions_UpdatedById",
table: "GameVersions",
column: "UpdatedById");
migrationBuilder.AddForeignKey(
name: "FK_Actions_GameVersions_GameVersionId",
table: "Actions",
column: "GameVersionId",
principalTable: "GameVersions",
principalColumn: "Id");
migrationBuilder.AddForeignKey(
name: "FK_Archive_GameVersions_GameVersionId",
table: "Archive",
column: "GameVersionId",
principalTable: "GameVersions",
principalColumn: "Id");
migrationBuilder.AddForeignKey(
name: "FK_SavePaths_GameVersions_GameVersionId",
table: "SavePaths",
column: "GameVersionId",
principalTable: "GameVersions",
principalColumn: "Id");
migrationBuilder.AddForeignKey(
name: "FK_Scripts_GameVersions_GameVersionId",
table: "Scripts",
column: "GameVersionId",
principalTable: "GameVersions",
principalColumn: "Id");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropForeignKey(
name: "FK_Actions_GameVersions_GameVersionId",
table: "Actions");
migrationBuilder.DropForeignKey(
name: "FK_Archive_GameVersions_GameVersionId",
table: "Archive");
migrationBuilder.DropForeignKey(
name: "FK_SavePaths_GameVersions_GameVersionId",
table: "SavePaths");
migrationBuilder.DropForeignKey(
name: "FK_Scripts_GameVersions_GameVersionId",
table: "Scripts");
migrationBuilder.DropTable(
name: "GameVersions");
migrationBuilder.DropIndex(
name: "IX_Scripts_GameVersionId",
table: "Scripts");
migrationBuilder.DropIndex(
name: "IX_SavePaths_GameVersionId",
table: "SavePaths");
migrationBuilder.DropIndex(
name: "IX_Archive_GameVersionId",
table: "Archive");
migrationBuilder.DropIndex(
name: "IX_Actions_GameVersionId",
table: "Actions");
migrationBuilder.DropColumn(
name: "GameVersionId",
table: "Scripts");
migrationBuilder.DropColumn(
name: "GameVersionId",
table: "SavePaths");
migrationBuilder.DropColumn(
name: "GameVersionId",
table: "Archive");
migrationBuilder.DropColumn(
name: "GameVersionId",
table: "Actions");
}
}
}

View file

@ -1,28 +0,0 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace LANCommander.Server.Data.PostgreSQL.Migrations
{
/// <inheritdoc />
public partial class RemoveArchiveChangelog : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "Changelog",
table: "Archive");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<string>(
name: "Changelog",
table: "Archive",
type: "text",
nullable: true);
}
}
}

View file

@ -193,9 +193,6 @@ namespace LANCommander.Server.Data.PostgreSQL.Migrations
b.Property<Guid?>("GameId")
.HasColumnType("uuid");
b.Property<Guid?>("GameVersionId")
.HasColumnType("uuid");
b.Property<string>("Name")
.IsRequired()
.HasColumnType("text");
@ -236,8 +233,6 @@ namespace LANCommander.Server.Data.PostgreSQL.Migrations
b.HasIndex("GameId");
b.HasIndex("GameVersionId");
b.HasIndex("ServerId");
b.HasIndex("ToolId");
@ -253,6 +248,9 @@ namespace LANCommander.Server.Data.PostgreSQL.Migrations
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<string>("Changelog")
.HasColumnType("text");
b.Property<long>("CompressedSize")
.HasColumnType("bigint");
@ -265,9 +263,6 @@ namespace LANCommander.Server.Data.PostgreSQL.Migrations
b.Property<Guid?>("GameId")
.HasColumnType("uuid");
b.Property<Guid?>("GameVersionId")
.HasColumnType("uuid");
b.Property<Guid?>("LastVersionId")
.HasColumnType("uuid");
@ -303,9 +298,6 @@ namespace LANCommander.Server.Data.PostgreSQL.Migrations
b.HasIndex("GameId");
b.HasIndex("GameVersionId")
.IsUnique();
b.HasIndex("LastVersionId");
b.HasIndex("RedistributableId");
@ -736,48 +728,6 @@ namespace LANCommander.Server.Data.PostgreSQL.Migrations
b.ToTable("GameSaves");
});
modelBuilder.Entity("LANCommander.Server.Data.Models.GameVersion", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<string>("Changelog")
.HasColumnType("text");
b.Property<Guid?>("CreatedById")
.HasColumnType("uuid");
b.Property<DateTime>("CreatedOn")
.HasColumnType("timestamp with time zone");
b.Property<Guid>("GameId")
.HasColumnType("uuid");
b.Property<int>("SortOrder")
.HasColumnType("integer");
b.Property<Guid?>("UpdatedById")
.HasColumnType("uuid");
b.Property<DateTime>("UpdatedOn")
.HasColumnType("timestamp with time zone");
b.Property<string>("Version")
.IsRequired()
.HasColumnType("text");
b.HasKey("Id");
b.HasIndex("CreatedById");
b.HasIndex("GameId");
b.HasIndex("UpdatedById");
b.ToTable("GameVersions");
});
modelBuilder.Entity("LANCommander.Server.Data.Models.Genre", b =>
{
b.Property<Guid>("Id")
@ -1373,9 +1323,6 @@ namespace LANCommander.Server.Data.PostgreSQL.Migrations
b.Property<Guid?>("GameId")
.HasColumnType("uuid");
b.Property<Guid?>("GameVersionId")
.HasColumnType("uuid");
b.Property<bool>("IsRegex")
.HasColumnType("boolean");
@ -1404,8 +1351,6 @@ namespace LANCommander.Server.Data.PostgreSQL.Migrations
b.HasIndex("GameId");
b.HasIndex("GameVersionId");
b.HasIndex("UpdatedById");
b.ToTable("SavePaths");
@ -1433,9 +1378,6 @@ namespace LANCommander.Server.Data.PostgreSQL.Migrations
b.Property<Guid?>("GameId")
.HasColumnType("uuid");
b.Property<Guid?>("GameVersionId")
.HasColumnType("uuid");
b.Property<string>("Name")
.IsRequired()
.HasColumnType("text");
@ -1470,8 +1412,6 @@ namespace LANCommander.Server.Data.PostgreSQL.Migrations
b.HasIndex("GameId");
b.HasIndex("GameVersionId");
b.HasIndex("RedistributableId");
b.HasIndex("ServerId");
@ -2249,11 +2189,6 @@ namespace LANCommander.Server.Data.PostgreSQL.Migrations
.HasForeignKey("GameId")
.OnDelete(DeleteBehavior.Cascade);
b.HasOne("LANCommander.Server.Data.Models.GameVersion", "GameVersion")
.WithMany("Actions")
.HasForeignKey("GameVersionId")
.OnDelete(DeleteBehavior.ClientCascade);
b.HasOne("LANCommander.Server.Data.Models.Server", "Server")
.WithMany("Actions")
.HasForeignKey("ServerId")
@ -2273,8 +2208,6 @@ namespace LANCommander.Server.Data.PostgreSQL.Migrations
b.Navigation("Game");
b.Navigation("GameVersion");
b.Navigation("Server");
b.Navigation("Tool");
@ -2294,11 +2227,6 @@ namespace LANCommander.Server.Data.PostgreSQL.Migrations
.HasForeignKey("GameId")
.OnDelete(DeleteBehavior.Cascade);
b.HasOne("LANCommander.Server.Data.Models.GameVersion", "GameVersion")
.WithOne("Archive")
.HasForeignKey("LANCommander.Server.Data.Models.Archive", "GameVersionId")
.OnDelete(DeleteBehavior.ClientCascade);
b.HasOne("LANCommander.Server.Data.Models.Archive", "LastVersion")
.WithMany()
.HasForeignKey("LastVersionId");
@ -2328,8 +2256,6 @@ namespace LANCommander.Server.Data.PostgreSQL.Migrations
b.Navigation("Game");
b.Navigation("GameVersion");
b.Navigation("LastVersion");
b.Navigation("Redistributable");
@ -2597,31 +2523,6 @@ namespace LANCommander.Server.Data.PostgreSQL.Migrations
b.Navigation("User");
});
modelBuilder.Entity("LANCommander.Server.Data.Models.GameVersion", b =>
{
b.HasOne("LANCommander.Server.Data.Models.User", "CreatedBy")
.WithMany()
.HasForeignKey("CreatedById")
.OnDelete(DeleteBehavior.SetNull);
b.HasOne("LANCommander.Server.Data.Models.Game", "Game")
.WithMany("Versions")
.HasForeignKey("GameId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("LANCommander.Server.Data.Models.User", "UpdatedBy")
.WithMany()
.HasForeignKey("UpdatedById")
.OnDelete(DeleteBehavior.SetNull);
b.Navigation("CreatedBy");
b.Navigation("Game");
b.Navigation("UpdatedBy");
});
modelBuilder.Entity("LANCommander.Server.Data.Models.Genre", b =>
{
b.HasOne("LANCommander.Server.Data.Models.User", "CreatedBy")
@ -2953,11 +2854,6 @@ namespace LANCommander.Server.Data.PostgreSQL.Migrations
.HasForeignKey("GameId")
.OnDelete(DeleteBehavior.Cascade);
b.HasOne("LANCommander.Server.Data.Models.GameVersion", "GameVersion")
.WithMany("SavePaths")
.HasForeignKey("GameVersionId")
.OnDelete(DeleteBehavior.ClientCascade);
b.HasOne("LANCommander.Server.Data.Models.User", "UpdatedBy")
.WithMany()
.HasForeignKey("UpdatedById")
@ -2967,8 +2863,6 @@ namespace LANCommander.Server.Data.PostgreSQL.Migrations
b.Navigation("Game");
b.Navigation("GameVersion");
b.Navigation("UpdatedBy");
});
@ -2984,11 +2878,6 @@ namespace LANCommander.Server.Data.PostgreSQL.Migrations
.HasForeignKey("GameId")
.OnDelete(DeleteBehavior.Cascade);
b.HasOne("LANCommander.Server.Data.Models.GameVersion", "GameVersion")
.WithMany("Scripts")
.HasForeignKey("GameVersionId")
.OnDelete(DeleteBehavior.ClientCascade);
b.HasOne("LANCommander.Server.Data.Models.Redistributable", "Redistributable")
.WithMany("Scripts")
.HasForeignKey("RedistributableId")
@ -3013,8 +2902,6 @@ namespace LANCommander.Server.Data.PostgreSQL.Migrations
b.Navigation("Game");
b.Navigation("GameVersion");
b.Navigation("Redistributable");
b.Navigation("Server");
@ -3356,19 +3243,6 @@ namespace LANCommander.Server.Data.PostgreSQL.Migrations
b.Navigation("Scripts");
b.Navigation("Servers");
b.Navigation("Versions");
});
modelBuilder.Entity("LANCommander.Server.Data.Models.GameVersion", b =>
{
b.Navigation("Actions");
b.Navigation("Archive");
b.Navigation("SavePaths");
b.Navigation("Scripts");
});
modelBuilder.Entity("LANCommander.Server.Data.Models.Media", b =>

View file

@ -1,195 +0,0 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace LANCommander.Migrations
{
/// <inheritdoc />
public partial class AddGameVersions : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<Guid>(
name: "GameVersionId",
table: "Scripts",
type: "TEXT",
nullable: true);
migrationBuilder.AddColumn<Guid>(
name: "GameVersionId",
table: "SavePaths",
type: "TEXT",
nullable: true);
migrationBuilder.AddColumn<Guid>(
name: "GameVersionId",
table: "Archive",
type: "TEXT",
nullable: true);
migrationBuilder.AddColumn<Guid>(
name: "GameVersionId",
table: "Actions",
type: "TEXT",
nullable: true);
migrationBuilder.CreateTable(
name: "GameVersions",
columns: table => new
{
Id = table.Column<Guid>(type: "TEXT", nullable: false),
Version = table.Column<string>(type: "TEXT", nullable: false),
Changelog = table.Column<string>(type: "TEXT", nullable: true),
SortOrder = table.Column<int>(type: "INTEGER", nullable: false),
GameId = table.Column<Guid>(type: "TEXT", nullable: false),
CreatedOn = table.Column<DateTime>(type: "TEXT", nullable: false),
CreatedById = table.Column<Guid>(type: "TEXT", nullable: true),
UpdatedOn = table.Column<DateTime>(type: "TEXT", nullable: false),
UpdatedById = table.Column<Guid>(type: "TEXT", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_GameVersions", x => x.Id);
table.ForeignKey(
name: "FK_GameVersions_Games_GameId",
column: x => x.GameId,
principalTable: "Games",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
table.ForeignKey(
name: "FK_GameVersions_Users_CreatedById",
column: x => x.CreatedById,
principalTable: "Users",
principalColumn: "Id",
onDelete: ReferentialAction.SetNull);
table.ForeignKey(
name: "FK_GameVersions_Users_UpdatedById",
column: x => x.UpdatedById,
principalTable: "Users",
principalColumn: "Id",
onDelete: ReferentialAction.SetNull);
});
migrationBuilder.CreateIndex(
name: "IX_Scripts_GameVersionId",
table: "Scripts",
column: "GameVersionId");
migrationBuilder.CreateIndex(
name: "IX_SavePaths_GameVersionId",
table: "SavePaths",
column: "GameVersionId");
migrationBuilder.CreateIndex(
name: "IX_Archive_GameVersionId",
table: "Archive",
column: "GameVersionId",
unique: true);
migrationBuilder.CreateIndex(
name: "IX_Actions_GameVersionId",
table: "Actions",
column: "GameVersionId");
migrationBuilder.CreateIndex(
name: "IX_GameVersions_CreatedById",
table: "GameVersions",
column: "CreatedById");
migrationBuilder.CreateIndex(
name: "IX_GameVersions_GameId",
table: "GameVersions",
column: "GameId");
migrationBuilder.CreateIndex(
name: "IX_GameVersions_UpdatedById",
table: "GameVersions",
column: "UpdatedById");
migrationBuilder.AddForeignKey(
name: "FK_Actions_GameVersions_GameVersionId",
table: "Actions",
column: "GameVersionId",
principalTable: "GameVersions",
principalColumn: "Id");
migrationBuilder.AddForeignKey(
name: "FK_Archive_GameVersions_GameVersionId",
table: "Archive",
column: "GameVersionId",
principalTable: "GameVersions",
principalColumn: "Id");
migrationBuilder.AddForeignKey(
name: "FK_SavePaths_GameVersions_GameVersionId",
table: "SavePaths",
column: "GameVersionId",
principalTable: "GameVersions",
principalColumn: "Id");
migrationBuilder.AddForeignKey(
name: "FK_Scripts_GameVersions_GameVersionId",
table: "Scripts",
column: "GameVersionId",
principalTable: "GameVersions",
principalColumn: "Id");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropForeignKey(
name: "FK_Actions_GameVersions_GameVersionId",
table: "Actions");
migrationBuilder.DropForeignKey(
name: "FK_Archive_GameVersions_GameVersionId",
table: "Archive");
migrationBuilder.DropForeignKey(
name: "FK_SavePaths_GameVersions_GameVersionId",
table: "SavePaths");
migrationBuilder.DropForeignKey(
name: "FK_Scripts_GameVersions_GameVersionId",
table: "Scripts");
migrationBuilder.DropTable(
name: "GameVersions");
migrationBuilder.DropIndex(
name: "IX_Scripts_GameVersionId",
table: "Scripts");
migrationBuilder.DropIndex(
name: "IX_SavePaths_GameVersionId",
table: "SavePaths");
migrationBuilder.DropIndex(
name: "IX_Archive_GameVersionId",
table: "Archive");
migrationBuilder.DropIndex(
name: "IX_Actions_GameVersionId",
table: "Actions");
migrationBuilder.DropColumn(
name: "GameVersionId",
table: "Scripts");
migrationBuilder.DropColumn(
name: "GameVersionId",
table: "SavePaths");
migrationBuilder.DropColumn(
name: "GameVersionId",
table: "Archive");
migrationBuilder.DropColumn(
name: "GameVersionId",
table: "Actions");
}
}
}

View file

@ -1,28 +0,0 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace LANCommander.Migrations
{
/// <inheritdoc />
public partial class RemoveArchiveChangelog : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "Changelog",
table: "Archive");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<string>(
name: "Changelog",
table: "Archive",
type: "TEXT",
nullable: true);
}
}
}

View file

@ -188,9 +188,6 @@ namespace LANCommander.Migrations
b.Property<Guid?>("GameId")
.HasColumnType("TEXT");
b.Property<Guid?>("GameVersionId")
.HasColumnType("TEXT");
b.Property<string>("Name")
.IsRequired()
.HasColumnType("TEXT");
@ -231,8 +228,6 @@ namespace LANCommander.Migrations
b.HasIndex("GameId");
b.HasIndex("GameVersionId");
b.HasIndex("ServerId");
b.HasIndex("ToolId");
@ -248,6 +243,9 @@ namespace LANCommander.Migrations
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<string>("Changelog")
.HasColumnType("TEXT");
b.Property<long>("CompressedSize")
.HasColumnType("INTEGER");
@ -260,9 +258,6 @@ namespace LANCommander.Migrations
b.Property<Guid?>("GameId")
.HasColumnType("TEXT");
b.Property<Guid?>("GameVersionId")
.HasColumnType("TEXT");
b.Property<Guid?>("LastVersionId")
.HasColumnType("TEXT");
@ -298,9 +293,6 @@ namespace LANCommander.Migrations
b.HasIndex("GameId");
b.HasIndex("GameVersionId")
.IsUnique();
b.HasIndex("LastVersionId");
b.HasIndex("RedistributableId");
@ -731,48 +723,6 @@ namespace LANCommander.Migrations
b.ToTable("GameSaves");
});
modelBuilder.Entity("LANCommander.Server.Data.Models.GameVersion", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<string>("Changelog")
.HasColumnType("TEXT");
b.Property<Guid?>("CreatedById")
.HasColumnType("TEXT");
b.Property<DateTime>("CreatedOn")
.HasColumnType("TEXT");
b.Property<Guid>("GameId")
.HasColumnType("TEXT");
b.Property<int>("SortOrder")
.HasColumnType("INTEGER");
b.Property<Guid?>("UpdatedById")
.HasColumnType("TEXT");
b.Property<DateTime>("UpdatedOn")
.HasColumnType("TEXT");
b.Property<string>("Version")
.IsRequired()
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("CreatedById");
b.HasIndex("GameId");
b.HasIndex("UpdatedById");
b.ToTable("GameVersions");
});
modelBuilder.Entity("LANCommander.Server.Data.Models.Genre", b =>
{
b.Property<Guid>("Id")
@ -1366,9 +1316,6 @@ namespace LANCommander.Migrations
b.Property<Guid?>("GameId")
.HasColumnType("TEXT");
b.Property<Guid?>("GameVersionId")
.HasColumnType("TEXT");
b.Property<bool>("IsRegex")
.HasColumnType("INTEGER");
@ -1397,8 +1344,6 @@ namespace LANCommander.Migrations
b.HasIndex("GameId");
b.HasIndex("GameVersionId");
b.HasIndex("UpdatedById");
b.ToTable("SavePaths");
@ -1426,9 +1371,6 @@ namespace LANCommander.Migrations
b.Property<Guid?>("GameId")
.HasColumnType("TEXT");
b.Property<Guid?>("GameVersionId")
.HasColumnType("TEXT");
b.Property<string>("Name")
.IsRequired()
.HasColumnType("TEXT");
@ -1463,8 +1405,6 @@ namespace LANCommander.Migrations
b.HasIndex("GameId");
b.HasIndex("GameVersionId");
b.HasIndex("RedistributableId");
b.HasIndex("ServerId");
@ -2240,11 +2180,6 @@ namespace LANCommander.Migrations
.HasForeignKey("GameId")
.OnDelete(DeleteBehavior.Cascade);
b.HasOne("LANCommander.Server.Data.Models.GameVersion", "GameVersion")
.WithMany("Actions")
.HasForeignKey("GameVersionId")
.OnDelete(DeleteBehavior.ClientCascade);
b.HasOne("LANCommander.Server.Data.Models.Server", "Server")
.WithMany("Actions")
.HasForeignKey("ServerId")
@ -2264,8 +2199,6 @@ namespace LANCommander.Migrations
b.Navigation("Game");
b.Navigation("GameVersion");
b.Navigation("Server");
b.Navigation("Tool");
@ -2285,11 +2218,6 @@ namespace LANCommander.Migrations
.HasForeignKey("GameId")
.OnDelete(DeleteBehavior.Cascade);
b.HasOne("LANCommander.Server.Data.Models.GameVersion", "GameVersion")
.WithOne("Archive")
.HasForeignKey("LANCommander.Server.Data.Models.Archive", "GameVersionId")
.OnDelete(DeleteBehavior.ClientCascade);
b.HasOne("LANCommander.Server.Data.Models.Archive", "LastVersion")
.WithMany()
.HasForeignKey("LastVersionId");
@ -2319,8 +2247,6 @@ namespace LANCommander.Migrations
b.Navigation("Game");
b.Navigation("GameVersion");
b.Navigation("LastVersion");
b.Navigation("Redistributable");
@ -2588,31 +2514,6 @@ namespace LANCommander.Migrations
b.Navigation("User");
});
modelBuilder.Entity("LANCommander.Server.Data.Models.GameVersion", b =>
{
b.HasOne("LANCommander.Server.Data.Models.User", "CreatedBy")
.WithMany()
.HasForeignKey("CreatedById")
.OnDelete(DeleteBehavior.SetNull);
b.HasOne("LANCommander.Server.Data.Models.Game", "Game")
.WithMany("Versions")
.HasForeignKey("GameId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("LANCommander.Server.Data.Models.User", "UpdatedBy")
.WithMany()
.HasForeignKey("UpdatedById")
.OnDelete(DeleteBehavior.SetNull);
b.Navigation("CreatedBy");
b.Navigation("Game");
b.Navigation("UpdatedBy");
});
modelBuilder.Entity("LANCommander.Server.Data.Models.Genre", b =>
{
b.HasOne("LANCommander.Server.Data.Models.User", "CreatedBy")
@ -2944,11 +2845,6 @@ namespace LANCommander.Migrations
.HasForeignKey("GameId")
.OnDelete(DeleteBehavior.Cascade);
b.HasOne("LANCommander.Server.Data.Models.GameVersion", "GameVersion")
.WithMany("SavePaths")
.HasForeignKey("GameVersionId")
.OnDelete(DeleteBehavior.ClientCascade);
b.HasOne("LANCommander.Server.Data.Models.User", "UpdatedBy")
.WithMany()
.HasForeignKey("UpdatedById")
@ -2958,8 +2854,6 @@ namespace LANCommander.Migrations
b.Navigation("Game");
b.Navigation("GameVersion");
b.Navigation("UpdatedBy");
});
@ -2975,11 +2869,6 @@ namespace LANCommander.Migrations
.HasForeignKey("GameId")
.OnDelete(DeleteBehavior.Cascade);
b.HasOne("LANCommander.Server.Data.Models.GameVersion", "GameVersion")
.WithMany("Scripts")
.HasForeignKey("GameVersionId")
.OnDelete(DeleteBehavior.ClientCascade);
b.HasOne("LANCommander.Server.Data.Models.Redistributable", "Redistributable")
.WithMany("Scripts")
.HasForeignKey("RedistributableId")
@ -3004,8 +2893,6 @@ namespace LANCommander.Migrations
b.Navigation("Game");
b.Navigation("GameVersion");
b.Navigation("Redistributable");
b.Navigation("Server");
@ -3347,19 +3234,6 @@ namespace LANCommander.Migrations
b.Navigation("Scripts");
b.Navigation("Servers");
b.Navigation("Versions");
});
modelBuilder.Entity("LANCommander.Server.Data.Models.GameVersion", b =>
{
b.Navigation("Actions");
b.Navigation("Archive");
b.Navigation("SavePaths");
b.Navigation("Scripts");
});
modelBuilder.Entity("LANCommander.Server.Data.Models.Media", b =>

View file

@ -75,7 +75,6 @@ namespace LANCommander.Server.Data
builder.ConfigureBaseRelationships<Collection>();
builder.ConfigureBaseRelationships<Company>();
builder.ConfigureBaseRelationships<Game>();
builder.ConfigureBaseRelationships<GameVersion>();
builder.ConfigureBaseRelationships<GameSave>();
builder.ConfigureBaseRelationships<Genre>();
builder.ConfigureBaseRelationships<Key>();
@ -265,43 +264,6 @@ namespace LANCommander.Server.Data
.OnDelete(DeleteBehavior.Cascade);
#endregion
#region Game Version Relationships
builder.Entity<Game>()
.HasMany(g => g.Versions)
.WithOne(v => v.Game)
.IsRequired(true)
.OnDelete(DeleteBehavior.Cascade);
// GameVersion owns the version-scoped config. The same Archive/Script/Action/SavePath
// rows are also referenced by the Game (dual-write during the transition), which already
// declares a cascade path. A second cascade path here would be rejected by MySQL
// (multiple cascade paths), so the GameVersion side uses ClientCascade instead.
builder.Entity<GameVersion>()
.HasOne(v => v.Archive)
.WithOne(a => a.GameVersion)
.HasForeignKey<Archive>(a => a.GameVersionId)
.IsRequired(false)
.OnDelete(DeleteBehavior.ClientCascade);
builder.Entity<GameVersion>()
.HasMany(v => v.Scripts)
.WithOne(s => s.GameVersion)
.IsRequired(false)
.OnDelete(DeleteBehavior.ClientCascade);
builder.Entity<GameVersion>()
.HasMany(v => v.Actions)
.WithOne(a => a.GameVersion)
.IsRequired(false)
.OnDelete(DeleteBehavior.ClientCascade);
builder.Entity<GameVersion>()
.HasMany(v => v.SavePaths)
.WithOne(p => p.GameVersion)
.IsRequired(false)
.OnDelete(DeleteBehavior.ClientCascade);
#endregion
#region Media Relationships
builder.Entity<Media>()
.HasOne(m => m.Thumbnail)
@ -566,8 +528,6 @@ namespace LANCommander.Server.Data
public DbSet<Game>? Games { get; set; }
public DbSet<GameVersion>? GameVersions { get; set; }
public DbSet<Genre>? Genres { get; set; }
public DbSet<Category>? Categories { get; set; }

View file

@ -1,4 +1,5 @@
using LANCommander.Server.Data.Models;
using AutoMapper.QueryableExtensions;
using LANCommander.Server.Data.Models;
using Microsoft.EntityFrameworkCore;
using System;
using System.Collections.Generic;

View file

@ -7,6 +7,7 @@
</PropertyGroup>
<ItemGroup>
<PackageReference Include="AutoMapper" />
<PackageReference Include="Microsoft.AspNetCore.Http.Abstractions" />
<PackageReference Include="Microsoft.AspNetCore.Identity.EntityFrameworkCore" />
<PackageReference Include="Microsoft.EntityFrameworkCore" />

View file

@ -33,11 +33,5 @@ namespace LANCommander.Server.Data.Models
[ForeignKey(nameof(ToolId))]
[InverseProperty("Actions")]
public Tool? Tool { get; set; }
public Guid? GameVersionId { get; set; }
[JsonIgnore]
[ForeignKey(nameof(GameVersionId))]
[InverseProperty(nameof(GameVersion.Actions))]
public GameVersion? GameVersion { get; set; }
}
}

Some files were not shown because too many files have changed in this diff Show more