diff --git a/.github/workflows/LANCommander.Documentation.yml b/.github/workflows/LANCommander.Documentation.yml
index 27d15188..6bc3628d 100644
--- a/.github/workflows/LANCommander.Documentation.yml
+++ b/.github/workflows/LANCommander.Documentation.yml
@@ -7,9 +7,32 @@ on:
- "LANCommander.Documentation/**"
jobs:
- dispatch:
+ publish:
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 }}
@@ -22,7 +45,7 @@ jobs:
"event_type": "docs-sources-updated",
"client_payload": {
"repo": "'"${GITHUB_REPOSITORY}"'",
- "sha": "'"${GITHUB_SHA}"'",
+ "sha": "'"$(git rev-parse HEAD)"'",
"ref": "'"${GITHUB_REF}"'"
}
}'
diff --git a/Directory.Packages.props b/Directory.Packages.props
index 085b603b..33c60ee9 100644
--- a/Directory.Packages.props
+++ b/Directory.Packages.props
@@ -16,8 +16,8 @@
-
-
+
+
diff --git a/LANCommander.Documentation/Overview.md b/LANCommander.Documentation/Overview.md
index 9968fb3c..2170adff 100644
--- a/LANCommander.Documentation/Overview.md
+++ b/LANCommander.Documentation/Overview.md
@@ -27,4 +27,5 @@ 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)
\ No newline at end of file
+- [SDK Documentation](/SDK/Overview)
+- [Plugin Development](/Plugins/Overview)
\ No newline at end of file
diff --git a/LANCommander.Documentation/Plugins/API Reference.md b/LANCommander.Documentation/Plugins/API Reference.md
new file mode 100644
index 00000000..cf1d66de
--- /dev/null
+++ b/LANCommander.Documentation/Plugins/API Reference.md
@@ -0,0 +1,415 @@
+---
+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(Func handler)`
+ - Subscribes a handler to events of type `TEvent`.
+- `Task PublishAsync(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 GetCmdletTypes()`
+ - Returns cmdlet types (classes decorated with `[Cmdlet]`) to register into each runspace.
+- `IEnumerable 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 `/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(Func handler)`
+- `Task PublishAsync(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 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 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 factory)`
+ - Register a control factory for the given view model type.
+- `void Register(Func 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 factory)`
+- `void Register(Func factory)`
+- `IDataTemplate AsDataTemplate()`
+
diff --git a/LANCommander.Documentation/Plugins/Extension Points.md b/LANCommander.Documentation/Plugins/Extension Points.md
new file mode 100644
index 00000000..20a5612f
--- /dev/null
+++ b/LANCommander.Documentation/Plugins/Extension Points.md
@@ -0,0 +1,208 @@
+---
+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();
+ services.AddSingleton();
+ services.AddSingleton();
+}
+```
+
+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 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();
+}
+```
+
+## 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 GetCmdletTypes() => new[] { typeof(GetMyGreetingCmdlet) };
+
+ public IEnumerable GetModulePaths() => Array.Empty();
+}
+```
+
+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();
+
+ events.Subscribe((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.
diff --git a/LANCommander.Documentation/Plugins/Getting Started.md b/LANCommander.Documentation/Plugins/Getting Started.md
new file mode 100644
index 00000000..c363bc17
--- /dev/null
+++ b/LANCommander.Documentation/Plugins/Getting Started.md
@@ -0,0 +1,161 @@
+---
+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
+
+
+
+
+
+
+
+
+
+
+```
+
+:::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();
+ }
+
+ public Task InitializeAsync(PluginContext context, CancellationToken cancellationToken)
+ {
+ var events = context.Services.GetRequiredService();
+
+ _launchSubscription = events.Subscribe((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.
diff --git a/LANCommander.Documentation/Plugins/Overview.md b/LANCommander.Documentation/Plugins/Overview.md
new file mode 100644
index 00000000..800d63aa
--- /dev/null
+++ b/LANCommander.Documentation/Plugins/Overview.md
@@ -0,0 +1,50 @@
+---
+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.
diff --git a/LANCommander.Documentation/Releases/2.1.0.mdx b/LANCommander.Documentation/Releases/2.1.0.mdx
index 2e80e1e7..653ae378 100644
--- a/LANCommander.Documentation/Releases/2.1.0.mdx
+++ b/LANCommander.Documentation/Releases/2.1.0.mdx
@@ -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.9** — 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.8** — 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,37 +586,10 @@ Actions, scripts, and save paths can now be scoped to a specific runtime platfor
-### 2.1.9
-
-View 2.1.9 patch notes
-
-#### 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
-
-
-
-View 2.1.8 downloads
-
-
-
View 2.1.7 downloads
@@ -675,4 +648,4 @@ Actions, scripts, and save paths can now be scoped to a specific runtime platfor
## Contributors
-
+
diff --git a/LANCommander.Documentation/Releases/2.1.9.mdx b/LANCommander.Documentation/Releases/2.1.9.mdx
deleted file mode 100644
index cf305a44..00000000
--- a/LANCommander.Documentation/Releases/2.1.9.mdx
+++ /dev/null
@@ -1,32 +0,0 @@
----
-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
-
-
-
-## Contributors
-
-
diff --git a/LANCommander.Launcher.Models/InstallQueueGame.cs b/LANCommander.Launcher.Models/InstallQueueGame.cs
index 8ee0ced2..f80d6bff 100644
--- a/LANCommander.Launcher.Models/InstallQueueGame.cs
+++ b/LANCommander.Launcher.Models/InstallQueueGame.cs
@@ -17,6 +17,7 @@ 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
{
diff --git a/LANCommander.Launcher.Plugins/Extensions/IContextMenuExtension.cs b/LANCommander.Launcher.Plugins/Extensions/IContextMenuExtension.cs
new file mode 100644
index 00000000..784c3599
--- /dev/null
+++ b/LANCommander.Launcher.Plugins/Extensions/IContextMenuExtension.cs
@@ -0,0 +1,16 @@
+using Avalonia.Controls;
+
+namespace LANCommander.Launcher.Plugins.Extensions;
+
+///
+/// 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.
+///
+public interface IContextMenuExtension
+{
+ /// Relative position among extension items; lower values appear first.
+ int Order { get; }
+
+ /// Builds the menu items shown for the given game (typically s).
+ IEnumerable BuildMenuItems(Guid gameId);
+}
diff --git a/LANCommander.Launcher.Plugins/Extensions/IFooterExtension.cs b/LANCommander.Launcher.Plugins/Extensions/IFooterExtension.cs
new file mode 100644
index 00000000..9239d34f
--- /dev/null
+++ b/LANCommander.Launcher.Plugins/Extensions/IFooterExtension.cs
@@ -0,0 +1,16 @@
+using Avalonia.Controls;
+
+namespace LANCommander.Launcher.Plugins.Extensions;
+
+///
+/// Adds a control to the launcher shell's footer. Implementations are resolved from DI and
+/// rendered, ordered by , alongside the built-in footer items.
+///
+public interface IFooterExtension
+{
+ /// Relative position among extension items; lower values appear first.
+ int Order { get; }
+
+ /// Builds the control rendered in the footer.
+ Control BuildContent();
+}
diff --git a/LANCommander.Launcher.Plugins/Extensions/IGameDetailTabExtension.cs b/LANCommander.Launcher.Plugins/Extensions/IGameDetailTabExtension.cs
new file mode 100644
index 00000000..ca97d5b3
--- /dev/null
+++ b/LANCommander.Launcher.Plugins/Extensions/IGameDetailTabExtension.cs
@@ -0,0 +1,19 @@
+using Avalonia.Controls;
+
+namespace LANCommander.Launcher.Plugins.Extensions;
+
+///
+/// Adds an additional tab to a game's detail view. Implementations are resolved from DI and
+/// appended, ordered by , after the built-in tabs.
+///
+public interface IGameDetailTabExtension
+{
+ /// Header shown on the tab.
+ string Header { get; }
+
+ /// Relative position among extension tabs; lower values appear first.
+ int Order { get; }
+
+ /// Builds the control rendered inside the tab for the given game.
+ Control BuildContent(Guid gameId);
+}
diff --git a/LANCommander.Launcher.Plugins/Extensions/INavigationPageExtension.cs b/LANCommander.Launcher.Plugins/Extensions/INavigationPageExtension.cs
new file mode 100644
index 00000000..57a498d9
--- /dev/null
+++ b/LANCommander.Launcher.Plugins/Extensions/INavigationPageExtension.cs
@@ -0,0 +1,26 @@
+using Avalonia.Controls;
+
+namespace LANCommander.Launcher.Plugins.Extensions;
+
+///
+/// Adds a top-level navigable destination reachable from the launcher shell. The view model
+/// is registered with the so the shell's content control can render the
+/// associated view when navigated to.
+///
+public interface INavigationPageExtension
+{
+ /// Label shown for the navigation entry.
+ string Label { get; }
+
+ /// Relative position among extension destinations; lower values appear first.
+ int Order { get; }
+
+ /// The view model type used both as the navigation target and the registry key.
+ Type ViewModelType { get; }
+
+ /// Creates the view model instance shown when the destination is activated.
+ PluginViewModelBase CreateViewModel();
+
+ /// Builds the control that renders .
+ Control BuildView();
+}
diff --git a/LANCommander.Launcher.Plugins/Extensions/ISettingsPageExtension.cs b/LANCommander.Launcher.Plugins/Extensions/ISettingsPageExtension.cs
new file mode 100644
index 00000000..18bf3df0
--- /dev/null
+++ b/LANCommander.Launcher.Plugins/Extensions/ISettingsPageExtension.cs
@@ -0,0 +1,19 @@
+using Avalonia.Controls;
+
+namespace LANCommander.Launcher.Plugins.Extensions;
+
+///
+/// Adds an additional section to the launcher's settings page. Implementations are resolved
+/// from DI and appended, ordered by , beneath the built-in settings sections.
+///
+public interface ISettingsPageExtension
+{
+ /// Heading shown for the section.
+ string Title { get; }
+
+ /// Relative position among extension sections; lower values appear first.
+ int Order { get; }
+
+ /// Builds the control rendered inside the section.
+ Control BuildContent();
+}
diff --git a/LANCommander.Launcher.Plugins/IViewRegistry.cs b/LANCommander.Launcher.Plugins/IViewRegistry.cs
new file mode 100644
index 00000000..6555ec27
--- /dev/null
+++ b/LANCommander.Launcher.Plugins/IViewRegistry.cs
@@ -0,0 +1,26 @@
+using Avalonia.Controls;
+using Avalonia.Controls.Templates;
+
+namespace LANCommander.Launcher.Plugins;
+
+///
+/// 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 to a so content is
+/// resolved by view model type, replacing the previously hard-coded inline XAML data templates.
+///
+public interface IViewRegistry
+{
+ /// Register a control factory for the given view model type.
+ void Register(Type viewModelType, Func factory);
+
+ /// Register a control factory for .
+ void Register(Func factory);
+
+ ///
+ /// Build an 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.
+ ///
+ IDataTemplate AsDataTemplate();
+}
diff --git a/LANCommander.Launcher.Plugins/LANCommander.Launcher.Plugins.csproj b/LANCommander.Launcher.Plugins/LANCommander.Launcher.Plugins.csproj
new file mode 100644
index 00000000..2dfc962f
--- /dev/null
+++ b/LANCommander.Launcher.Plugins/LANCommander.Launcher.Plugins.csproj
@@ -0,0 +1,22 @@
+
+
+
+ net10.0
+ enable
+ enable
+
+ true
+
+ $(NoWarn);CS1591
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/LANCommander.Launcher.Plugins/PluginViewModelBase.cs b/LANCommander.Launcher.Plugins/PluginViewModelBase.cs
new file mode 100644
index 00000000..ffa9d911
--- /dev/null
+++ b/LANCommander.Launcher.Plugins/PluginViewModelBase.cs
@@ -0,0 +1,12 @@
+using CommunityToolkit.Mvvm.ComponentModel;
+
+namespace LANCommander.Launcher.Plugins;
+
+///
+/// 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.
+///
+public abstract class PluginViewModelBase : ObservableObject
+{
+}
diff --git a/LANCommander.Launcher.Plugins/ViewRegistry.cs b/LANCommander.Launcher.Plugins/ViewRegistry.cs
new file mode 100644
index 00000000..7cc36f27
--- /dev/null
+++ b/LANCommander.Launcher.Plugins/ViewRegistry.cs
@@ -0,0 +1,58 @@
+using Avalonia.Controls;
+using Avalonia.Controls.Templates;
+
+namespace LANCommander.Launcher.Plugins;
+
+///
+public sealed class ViewRegistry : IViewRegistry
+{
+ private readonly List _registrations = new();
+
+ public void Register(Type viewModelType, Func factory)
+ {
+ ArgumentNullException.ThrowIfNull(viewModelType);
+ ArgumentNullException.ThrowIfNull(factory);
+
+ _registrations.Add(new Registration(viewModelType, factory));
+ }
+
+ public void Register(Func factory) => Register(typeof(TViewModel), factory);
+
+ public IDataTemplate AsDataTemplate() => new RegistryDataTemplate(_registrations);
+
+ private readonly record struct Registration(Type ViewModelType, Func Factory);
+
+ ///
+ /// 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.
+ ///
+ private sealed class RegistryDataTemplate(IReadOnlyList 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? FindFactory(Type dataType)
+ {
+ Type? bestType = null;
+ Func? 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;
+ }
+ }
+}
diff --git a/LANCommander.Launcher.Services/Extensions/ServiceCollectionExtensions.cs b/LANCommander.Launcher.Services/Extensions/ServiceCollectionExtensions.cs
index 74382799..6e20a892 100644
--- a/LANCommander.Launcher.Services/Extensions/ServiceCollectionExtensions.cs
+++ b/LANCommander.Launcher.Services/Extensions/ServiceCollectionExtensions.cs
@@ -35,8 +35,6 @@ namespace LANCommander.Launcher.Services.Extensions
services.AddSingleton();
#endregion
- services.AddSingleton();
- services.AddSingleton();
services.AddSingleton();
services.AddSingleton();
services.AddSingleton(sp =>
diff --git a/LANCommander.Launcher.Services/GameService.cs b/LANCommander.Launcher.Services/GameService.cs
index 20bb66e6..8f3fb1ad 100644
--- a/LANCommander.Launcher.Services/GameService.cs
+++ b/LANCommander.Launcher.Services/GameService.cs
@@ -4,6 +4,8 @@ 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;
@@ -22,6 +24,7 @@ namespace LANCommander.Launcher.Services
ToolService toolService,
ToolClient toolClient,
IConnectionClient connectionClient,
+ IPluginEventBus pluginEventBus,
IServiceProvider serviceProvider) : BaseDatabaseService(dbContext, logger)
{
public Dictionary RunningProcesses = new Dictionary();
@@ -49,6 +52,7 @@ namespace LANCommander.Launcher.Services
try
{
OnUninstall?.Invoke(game);
+ await pluginEventBus.PublishAsync(new GameUninstallingEvent(game.Id, game.InstallDirectory));
var installService = serviceProvider.GetService();
installService?.ClearCompleted(game.Id);
@@ -94,6 +98,7 @@ namespace LANCommander.Launcher.Services
await UpdateAsync(game);
OnUninstallComplete?.Invoke(game);
+ await pluginEventBus.PublishAsync(new GameUninstalledEvent(game.Id));
operation.Complete();
}
diff --git a/LANCommander.Launcher.Services/InstallService.cs b/LANCommander.Launcher.Services/InstallService.cs
index bcc1e4f3..0516c627 100644
--- a/LANCommander.Launcher.Services/InstallService.cs
+++ b/LANCommander.Launcher.Services/InstallService.cs
@@ -8,6 +8,8 @@ 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;
@@ -24,6 +26,7 @@ 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; }
@@ -64,7 +67,8 @@ namespace LANCommander.Launcher.Services
GameClient gameClient,
RedistributableClient redistributableClient,
ToolClient toolClient,
- MediaClient mediaClient) : base(logger)
+ MediaClient mediaClient,
+ IPluginEventBus pluginEventBus) : base(logger)
{
_gameService = gameService;
_toolService = toolService;
@@ -73,6 +77,16 @@ 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();
@@ -568,6 +582,13 @@ 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))
@@ -728,6 +749,8 @@ 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
@@ -943,6 +966,136 @@ namespace LANCommander.Launcher.Services
}
}
+ ///
+ /// 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 .
+ ///
+ 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();
+ }
+
+ ///
+ /// 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.
+ ///
+ 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))
diff --git a/LANCommander.Launcher.Services/PowerShell/CurrentProcessInfo.cs b/LANCommander.Launcher.Services/PowerShell/CurrentProcessInfo.cs
deleted file mode 100644
index f805d7ed..00000000
--- a/LANCommander.Launcher.Services/PowerShell/CurrentProcessInfo.cs
+++ /dev/null
@@ -1,26 +0,0 @@
-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";
- }
- }
-}
diff --git a/LANCommander.Launcher.Services/PowerShell/ElevatedProcessLauncher.cs b/LANCommander.Launcher.Services/PowerShell/ElevatedProcessLauncher.cs
deleted file mode 100644
index 2c9e8136..00000000
--- a/LANCommander.Launcher.Services/PowerShell/ElevatedProcessLauncher.cs
+++ /dev/null
@@ -1,22 +0,0 @@
-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();
- }
-}
diff --git a/LANCommander.Launcher.Services/PowerShell/ElevatedScriptInterceptor.cs b/LANCommander.Launcher.Services/PowerShell/ElevatedScriptInterceptor.cs
index 3f65a3b2..8f4bfc40 100644
--- a/LANCommander.Launcher.Services/PowerShell/ElevatedScriptInterceptor.cs
+++ b/LANCommander.Launcher.Services/PowerShell/ElevatedScriptInterceptor.cs
@@ -1,19 +1,35 @@
+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(
- ICurrentProcessInfo currentProcessInfo,
- IElevatedProcessLauncher processLauncher) : IScriptInterceptor
+public class ElevatedScriptInterceptor : IScriptInterceptor
{
public async Task ExecuteAsync(PowerShellScript script)
{
try
{
- if (script.RunAsAdmin && !currentProcessInfo.IsElevated)
+ 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)
{
var manifest = script.Variables.GetValue("GameManifest");
@@ -34,26 +50,28 @@ public class ElevatedScriptInterceptor(
}
var arguments = Parser.Default.FormatCommandLine(options);
+ var path = Process.GetCurrentProcess().MainModule!.FileName;
- // 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,
- });
+ 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();
return true;
}
}
- catch (Exception)
+ catch (Exception ex)
{
- // Unable to determine elevation state or launch the elevated process; fall back to
- // running the script in-process.
+ // Not running as admin
}
return false;
}
-}
+}
\ No newline at end of file
diff --git a/LANCommander.Launcher.Services/PowerShell/ICurrentProcessInfo.cs b/LANCommander.Launcher.Services/PowerShell/ICurrentProcessInfo.cs
deleted file mode 100644
index 89359668..00000000
--- a/LANCommander.Launcher.Services/PowerShell/ICurrentProcessInfo.cs
+++ /dev/null
@@ -1,21 +0,0 @@
-namespace LANCommander.Launcher.Services;
-
-///
-/// Exposes information about the currently running launcher process that the
-/// 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.
-///
-public interface ICurrentProcessInfo
-{
- ///
- /// 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.
- ///
- string ExecutablePath { get; }
-
- ///
- /// True if the current process is already running with administrator/root privileges.
- ///
- bool IsElevated { get; }
-}
diff --git a/LANCommander.Launcher.Services/PowerShell/IElevatedProcessLauncher.cs b/LANCommander.Launcher.Services/PowerShell/IElevatedProcessLauncher.cs
deleted file mode 100644
index 4e91be3a..00000000
--- a/LANCommander.Launcher.Services/PowerShell/IElevatedProcessLauncher.cs
+++ /dev/null
@@ -1,32 +0,0 @@
-using System.Threading.Tasks;
-
-namespace LANCommander.Launcher.Services;
-
-///
-/// 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.
-///
-public class ElevatedProcessRequest
-{
- /// The launcher executable to invoke elevated.
- public required string FileName { get; init; }
-
- /// The formatted command line (RunScript verb + options) passed to the elevated process.
- public required string Arguments { get; init; }
-
- /// The working directory the elevated script should run in.
- public string? WorkingDirectory { get; init; }
-}
-
-///
-/// 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.
-///
-public interface IElevatedProcessLauncher
-{
- ///
- /// Starts the elevated process described by and completes only once
- /// that process has exited.
- ///
- Task LaunchAndWaitAsync(ElevatedProcessRequest request);
-}
diff --git a/LANCommander.Launcher.Tests/Tests/ElevatedScriptInterceptorTests.cs b/LANCommander.Launcher.Tests/Tests/ElevatedScriptInterceptorTests.cs
deleted file mode 100644
index b676d143..00000000
--- a/LANCommander.Launcher.Tests/Tests/ElevatedScriptInterceptorTests.cs
+++ /dev/null
@@ -1,229 +0,0 @@
-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;
-
-///
-/// Verifies the admin-elevation path for launcher scripts. When a script is flagged
-/// #Requires -RunAsAdministrator 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.
-///
-public class ElevatedScriptInterceptorTests
-{
- private static PowerShellScript CreateScript(ScriptType type)
- {
- var services = new ServiceCollection();
-
- services.AddLogging();
- services.AddSingleton();
-
- 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 Requests { get; } = new();
- public int LaunchCount => Requests.Count;
- public bool ThrowOnLaunch { get; init; }
-
- /// Set once the (awaited) launch has fully completed. Proves the caller waited.
- 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 patch) => patch(CurrentValue);
- }
-}
diff --git a/LANCommander.Launcher/App.axaml.cs b/LANCommander.Launcher/App.axaml.cs
index 6ea33b9a..84873233 100644
--- a/LANCommander.Launcher/App.axaml.cs
+++ b/LANCommander.Launcher/App.axaml.cs
@@ -9,6 +9,7 @@ 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;
@@ -194,6 +195,16 @@ 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()
+ .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)
{
@@ -202,6 +213,29 @@ public partial class App : Application
}
}
+ private static void RegisterPluginNavigationViews()
+ {
+ if (Services is null)
+ return;
+
+ var registry = Services.GetService();
+
+ if (registry is null)
+ return;
+
+ foreach (var extension in Services.GetServices())
+ {
+ 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
@@ -265,6 +299,37 @@ public partial class App : Application
services.AddSingleton();
services.AddSingleton();
services.AddSingleton();
+
+ // 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(_ =>
+ {
+ var registry = new ViewRegistry();
+
+ // App-level shell hosted in MainWindow's ContentControl
+ registry.Register(() => new SplashView());
+ registry.Register(() => new ServerSelectionView());
+ registry.Register(() => new LoginView());
+ registry.Register(() => new ShellView());
+
+ // Shell content hosted in ShellView's TransitioningContentControl
+ registry.Register(() => new DepotView());
+ registry.Register(() => new DepotBrowseView());
+ registry.Register(() => new GameDetailView());
+ registry.Register(() => new GamesListView());
+ registry.Register(() => new GamesListView());
+ registry.Register(() => new GameDetailView());
+ registry.Register(() => new SettingsView());
+ registry.Register(() => 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()
diff --git a/LANCommander.Launcher/Controls/GameContextMenu.cs b/LANCommander.Launcher/Controls/GameContextMenu.cs
index e6e99733..20064aba 100644
--- a/LANCommander.Launcher/Controls/GameContextMenu.cs
+++ b/LANCommander.Launcher/Controls/GameContextMenu.cs
@@ -1,5 +1,7 @@
+using System;
using System.Collections.Generic;
using System.Collections.Specialized;
+using System.Linq;
using System.Runtime.CompilerServices;
using Avalonia;
using Avalonia.Controls;
@@ -8,8 +10,10 @@ 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;
@@ -196,18 +200,70 @@ 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);
}
+ ///
+ /// Appends items added by plugins (via ) after the
+ /// built-in items, separated by a divider. A failing extension is skipped so the core menu
+ /// still renders.
+ ///
+ private static void AppendPluginItems(List items, Guid gameId)
+ {
+ var extensions = App.Services?
+ .GetServices()
+ .OrderBy(c => c.Order)
+ .ToList();
+
+ if (extensions == null || extensions.Count == 0)
+ return;
+
+ var added = false;
+
+ foreach (var extension in extensions)
+ {
+ IEnumerable? 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,
diff --git a/LANCommander.Launcher/LANCommander.Launcher.csproj b/LANCommander.Launcher/LANCommander.Launcher.csproj
index 9a52d8ab..1ac25723 100644
--- a/LANCommander.Launcher/LANCommander.Launcher.csproj
+++ b/LANCommander.Launcher/LANCommander.Launcher.csproj
@@ -41,6 +41,7 @@
+
diff --git a/LANCommander.Launcher/Program.cs b/LANCommander.Launcher/Program.cs
index 7af8d6d9..8e93767c 100644
--- a/LANCommander.Launcher/Program.cs
+++ b/LANCommander.Launcher/Program.cs
@@ -14,6 +14,7 @@ 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;
@@ -106,8 +107,14 @@ class Program
services.AddSingleton();
services.AddSingleton();
+ // 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;
diff --git a/LANCommander.Launcher/ViewModels/Components/GameActionBarViewModel.cs b/LANCommander.Launcher/ViewModels/Components/GameActionBarViewModel.cs
index 562b11df..dc8b3f73 100644
--- a/LANCommander.Launcher/ViewModels/Components/GameActionBarViewModel.cs
+++ b/LANCommander.Launcher/ViewModels/Components/GameActionBarViewModel.cs
@@ -1207,6 +1207,108 @@ 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();
+ var gameClient = scope.ServiceProvider.GetRequiredService();
+ var installService = scope.ServiceProvider.GetRequiredService();
+
+ 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();
+
+ 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()
{
diff --git a/LANCommander.Launcher/ViewModels/GameVersionsViewModel.cs b/LANCommander.Launcher/ViewModels/GameVersionsViewModel.cs
new file mode 100644
index 00000000..7d9c7292
--- /dev/null
+++ b/LANCommander.Launcher/ViewModels/GameVersionsViewModel.cs
@@ -0,0 +1,55 @@
+using System;
+using System.Collections.ObjectModel;
+using ByteSizeLib;
+using CommunityToolkit.Mvvm.ComponentModel;
+
+namespace LANCommander.Launcher.ViewModels;
+
+///
+/// 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.
+///
+public partial class GameVersionsViewModel : ViewModelBase
+{
+ [ObservableProperty]
+ private string _dialogTitle = string.Empty;
+
+ [ObservableProperty]
+ private ObservableCollection _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");
+
+ /// True when this version matches the game's currently installed version.
+ public bool IsInstalled { get; }
+
+ /// Only versions that carry an archive and aren't already installed can be switched to.
+ public bool IsInstallable => !IsInstalled
+ && Version.ArchiveId.HasValue
+ && Version.ArchiveId.Value != Guid.Empty;
+
+ /// Label for the action button: "Update" for a newer version, "Roll Back" for an older one.
+ public string ButtonText { get; }
+
+ public GameVersionItemViewModel(SDK.Models.GameVersion version, bool isInstalled, bool isNewerThanInstalled)
+ {
+ Version = version;
+ IsInstalled = isInstalled;
+ ButtonText = isNewerThanInstalled ? "Update" : "Roll Back";
+ }
+}
diff --git a/LANCommander.Launcher/Views/GameDetailView.axaml.cs b/LANCommander.Launcher/Views/GameDetailView.axaml.cs
index 72bb137f..82e9f468 100644
--- a/LANCommander.Launcher/Views/GameDetailView.axaml.cs
+++ b/LANCommander.Launcher/Views/GameDetailView.axaml.cs
@@ -7,14 +7,67 @@ 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();
+ }
+
+ ///
+ /// Appends plugin detail sections (via ) 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.
+ ///
+ private void AppendPluginTabs()
+ {
+ if (_pluginTabsAdded || DataContext is not GameDetailViewModel detailVm)
+ return;
+
+ var extensions = App.Services?
+ .GetServices()
+ .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);
+ }
}
///
diff --git a/LANCommander.Launcher/Views/GameVersionsOverlay.axaml b/LANCommander.Launcher/Views/GameVersionsOverlay.axaml
new file mode 100644
index 00000000..83eca77d
--- /dev/null
+++ b/LANCommander.Launcher/Views/GameVersionsOverlay.axaml
@@ -0,0 +1,96 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/LANCommander.Launcher/Views/GameVersionsOverlay.axaml.cs b/LANCommander.Launcher/Views/GameVersionsOverlay.axaml.cs
new file mode 100644
index 00000000..17cb1f9d
--- /dev/null
+++ b/LANCommander.Launcher/Views/GameVersionsOverlay.axaml.cs
@@ -0,0 +1,33 @@
+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
+{
+ /// Raised when the overlay closes. Carries the chosen version, or null when dismissed.
+ public event EventHandler? 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);
+ }
+}
diff --git a/LANCommander.Launcher/Views/MainWindow.axaml b/LANCommander.Launcher/Views/MainWindow.axaml
index 74886613..a840e24d 100644
--- a/LANCommander.Launcher/Views/MainWindow.axaml
+++ b/LANCommander.Launcher/Views/MainWindow.axaml
@@ -40,23 +40,9 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+ ();
+ if (registry != null)
+ MainContent.DataTemplates.Add(registry.AsDataTemplate());
+
Closing += (_, e) =>
{
// Hide to the system tray instead of closing; the app keeps running.
diff --git a/LANCommander.Launcher/Views/SettingsView.axaml b/LANCommander.Launcher/Views/SettingsView.axaml
index a0014de5..7d174ed4 100644
--- a/LANCommander.Launcher/Views/SettingsView.axaml
+++ b/LANCommander.Launcher/Views/SettingsView.axaml
@@ -35,7 +35,7 @@
-
+
+ /// Appends any plugin settings sections beneath the built-in sections, styled to
+ /// match the surrounding cards so extensions look native.
+ ///
+ private void AppendPluginSections()
+ {
+ var extensions = App.Services?
+ .GetServices()
+ .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);
+ }
}
}
diff --git a/LANCommander.Launcher/Views/ShellView.axaml b/LANCommander.Launcher/Views/ShellView.axaml
index 3066b6ce..1db5bdb6 100644
--- a/LANCommander.Launcher/Views/ShellView.axaml
+++ b/LANCommander.Launcher/Views/ShellView.axaml
@@ -19,39 +19,14 @@
-
-
+
+
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
@@ -186,7 +161,10 @@
-
+
+
+
+
-
+
diff --git a/LANCommander.Launcher/Views/ShellView.axaml.cs b/LANCommander.Launcher/Views/ShellView.axaml.cs
index 84599b42..f0e9c923 100644
--- a/LANCommander.Launcher/Views/ShellView.axaml.cs
+++ b/LANCommander.Launcher/Views/ShellView.axaml.cs
@@ -1,7 +1,9 @@
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;
@@ -19,6 +21,14 @@ 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();
+ if (registry != null)
+ ContentHost.DataTemplates.Add(registry.AsDataTemplate());
+
+ AppendFooterExtensions();
+
KeyDown += OnKeyDown;
DataContextChanged += (_, _) =>
@@ -34,6 +44,34 @@ public partial class ShellView : UserControl
};
}
+ ///
+ /// Renders any plugin footer controls (via ) to the
+ /// left of the chat button, ordered by their declared Order. A failing extension is
+ /// skipped so the built-in footer still renders.
+ ///
+ private void AppendFooterExtensions()
+ {
+ var extensions = App.Services?
+ .GetServices()
+ .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)
diff --git a/LANCommander.PluginDocsGenerator/LANCommander.PluginDocsGenerator.csproj b/LANCommander.PluginDocsGenerator/LANCommander.PluginDocsGenerator.csproj
new file mode 100644
index 00000000..85043f54
--- /dev/null
+++ b/LANCommander.PluginDocsGenerator/LANCommander.PluginDocsGenerator.csproj
@@ -0,0 +1,21 @@
+
+
+
+ Exe
+ net10.0
+ enable
+ enable
+
+ false
+
+
+
+
+
+
+
+
+
diff --git a/LANCommander.PluginDocsGenerator/Program.cs b/LANCommander.PluginDocsGenerator/Program.cs
new file mode 100644
index 00000000..a2ba7160
--- /dev/null
+++ b/LANCommander.PluginDocsGenerator/Program.cs
@@ -0,0 +1,206 @@
+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 == "$"))
+ 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");
+}
diff --git a/LANCommander.PluginDocsGenerator/Signatures.cs b/LANCommander.PluginDocsGenerator/Signatures.cs
new file mode 100644
index 00000000..6e2be054
--- /dev/null
+++ b/LANCommander.PluginDocsGenerator/Signatures.cs
@@ -0,0 +1,85 @@
+using System.Reflection;
+
+namespace LANCommander.PluginDocsGenerator;
+
+///
+/// Renders human-readable C#-style signatures for members, used in the API reference output.
+///
+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 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",
+ };
+}
diff --git a/LANCommander.PluginDocsGenerator/XmlDocLookup.cs b/LANCommander.PluginDocsGenerator/XmlDocLookup.cs
new file mode 100644
index 00000000..8d2bdb22
--- /dev/null
+++ b/LANCommander.PluginDocsGenerator/XmlDocLookup.cs
@@ -0,0 +1,97 @@
+using System.Reflection;
+using System.Text;
+using System.Xml.Linq;
+
+namespace LANCommander.PluginDocsGenerator;
+
+///
+/// Loads the XML documentation files that sit alongside the given assemblies and exposes their
+/// <summary> text keyed by XML documentation member id.
+///
+internal sealed class XmlDocLookup
+{
+ private readonly Dictionary _summaries = new(StringComparer.Ordinal);
+
+ public XmlDocLookup(IEnumerable 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 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];
+ }
+}
diff --git a/LANCommander.PluginDocsGenerator/XmlId.cs b/LANCommander.PluginDocsGenerator/XmlId.cs
new file mode 100644
index 00000000..b20d82a3
--- /dev/null
+++ b/LANCommander.PluginDocsGenerator/XmlId.cs
@@ -0,0 +1,60 @@
+using System.Reflection;
+using System.Text;
+
+namespace LANCommander.PluginDocsGenerator;
+
+///
+/// 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 ECMA-334 / the C# spec "Processing the documentation file" for the ID string format.
+///
+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);
+ }
+}
diff --git a/LANCommander.SDK.Tests/AppPathsTests.cs b/LANCommander.SDK.Tests/AppPathsTests.cs
deleted file mode 100644
index e7671c54..00000000
--- a/LANCommander.SDK.Tests/AppPathsTests.cs
+++ /dev/null
@@ -1,102 +0,0 @@
-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);
- }
-
- ///
- /// 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.
- ///
- [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(() => AppPaths.ResolveStorageLocationPath(path!));
- }
-
- // ── GetConfigDirectory ───────────────────────────────────────────────────
-
- [Fact]
- public void GetConfigDirectory_ReturnsAbsoluteExistingDirectory()
- {
- var configDir = AppPaths.GetConfigDirectory();
-
- Assert.True(Path.IsPathRooted(configDir));
- Assert.True(Directory.Exists(configDir));
- }
-
- ///
- /// With no override, the data root is a "Data" folder under the current working directory when writable.
- ///
- [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);
- }
-}
diff --git a/LANCommander.SDK.Tests/Install/GameInstallationSharedDirectoryTests.cs b/LANCommander.SDK.Tests/Install/GameInstallationSharedDirectoryTests.cs
index de127a7f..392b357c 100644
--- a/LANCommander.SDK.Tests/Install/GameInstallationSharedDirectoryTests.cs
+++ b/LANCommander.SDK.Tests/Install/GameInstallationSharedDirectoryTests.cs
@@ -148,7 +148,7 @@ public class GameInstallationSharedDirectoryTests : IDisposable
/// resolution and local file verification, so they are safe to leave null.
///
private static GameClient CreateClient() =>
- new(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!, null!);
private static void InstallGameFiles(string installDirectory)
{
diff --git a/LANCommander.SDK.Tests/Plugins/PluginEventBusTests.cs b/LANCommander.SDK.Tests/Plugins/PluginEventBusTests.cs
new file mode 100644
index 00000000..a58aeb92
--- /dev/null
+++ b/LANCommander.SDK.Tests/Plugins/PluginEventBusTests.cs
@@ -0,0 +1,78 @@
+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.Instance);
+
+ [Fact]
+ public async Task PublishAsync_InvokesSubscribedHandler()
+ {
+ var bus = CreateBus();
+ var received = 0;
+
+ bus.Subscribe((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((_, _) => { 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((_, _) => throw new InvalidOperationException("boom"));
+ bus.Subscribe((_, _) => { 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((_, _) => { 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
+ }
+}
diff --git a/LANCommander.SDK.Tests/Plugins/PluginVersionGateTests.cs b/LANCommander.SDK.Tests/Plugins/PluginVersionGateTests.cs
new file mode 100644
index 00000000..674fd2be
--- /dev/null
+++ b/LANCommander.SDK.Tests/Plugins/PluginVersionGateTests.cs
@@ -0,0 +1,28 @@
+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"));
+ }
+}
diff --git a/LANCommander.SDK.Tests/PowerShell/PowerShellScriptExecutionTests.cs b/LANCommander.SDK.Tests/PowerShell/PowerShellScriptExecutionTests.cs
deleted file mode 100644
index 0d7de7f7..00000000
--- a/LANCommander.SDK.Tests/PowerShell/PowerShellScriptExecutionTests.cs
+++ /dev/null
@@ -1,89 +0,0 @@
-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();
-
- 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();
-
- // 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();
-
- 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();
-
- Assert.Equal(42, result);
- }
-
- private sealed class FakeSettingsProvider : ISettingsProvider
- {
- public SdkSettings CurrentValue { get; } = new();
-
- public void Update(Action patch) => patch(CurrentValue);
- }
-}
diff --git a/LANCommander.SDK/AppPaths.cs b/LANCommander.SDK/AppPaths.cs
index 381901c3..9989440d 100644
--- a/LANCommander.SDK/AppPaths.cs
+++ b/LANCommander.SDK/AppPaths.cs
@@ -1,8 +1,6 @@
using System;
using System.IO;
-using System.Linq;
using System.Reflection;
-using System.Runtime.InteropServices;
using LANCommander.SDK.Helpers;
namespace LANCommander.SDK;
@@ -11,8 +9,6 @@ public static class AppPaths
{
private static string _configDirectory = String.Empty;
- public const string DataDirectoryEnvironmentVariable = "LANCOMMANDER_DATA_DIR";
-
///
/// Builds a full path under the application's config directory.
///
@@ -21,33 +17,9 @@ public static class AppPaths
public static string GetConfigPath(params string[] paths)
=> Path.Combine(GetConfigDirectory(), Path.Combine(paths));
- ///
- /// 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).
- ///
- /// The configured storage location path (absolute or relative).
- /// Additional path segments appended to the resolved storage location.
- /// The absolute path to the storage location (plus any appended segments).
- 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;
- }
-
///
/// Locates (and creates if necessary) the directory in which application data will be stored.
- /// Resolution order: the 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.
+ /// Prefers the current working directory when writable; otherwise falls back to the user's local application data.
///
/// The resolved config directory path.
public static string GetConfigDirectory()
@@ -55,57 +27,36 @@ public static class AppPaths
if (!String.IsNullOrWhiteSpace(_configDirectory))
return _configDirectory;
- var overrideDirectory = Environment.GetEnvironmentVariable(DataDirectoryEnvironmentVariable);
+ var baseDirectory = Directory.GetCurrentDirectory();
- if (!String.IsNullOrWhiteSpace(overrideDirectory))
- {
- // Operator-specified data root is used verbatim (no implicit "Data" subfolder).
- _configDirectory = Path.GetFullPath(overrideDirectory);
- }
+ if (DirectoryHelper.IsDirectoryWritable(baseDirectory))
+ _configDirectory = baseDirectory;
else
- {
- var baseDirectory = Directory.GetCurrentDirectory();
-
- _configDirectory = DirectoryHelper.IsDirectoryWritable(baseDirectory)
- ? Path.Combine(baseDirectory, "Data")
- : Path.Combine(GetAppDataPath(), "Data");
- }
-
+ _configDirectory = GetAppDataPath();
+
+ _configDirectory = Path.Combine(_configDirectory, "Data");
+
if (!Directory.Exists(_configDirectory))
Directory.CreateDirectory(_configDirectory);
-
+
return _configDirectory;
}
///
- /// 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:
- /// %LOCALAPPDATA% on Windows, ~/Library/Application Support on macOS, and
- /// $XDG_DATA_HOME (~/.local/share) on Linux.
+ /// 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.
///
- /// The application data path for this application.
+ /// The local application data path for this application.
public static string GetAppDataPath()
{
var (company, product) = GetCompanyAndProduct();
-
- 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()!);
-
+ var userRoot = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData);
+
+ var appDataPath = Path.Combine(userRoot, company, product);
+
if (!Directory.Exists(appDataPath))
Directory.CreateDirectory(appDataPath);
-
+
return appDataPath;
}
diff --git a/LANCommander.SDK/Clients/AuthenticationClient.cs b/LANCommander.SDK/Clients/AuthenticationClient.cs
index 26b99e03..2071bca1 100644
--- a/LANCommander.SDK/Clients/AuthenticationClient.cs
+++ b/LANCommander.SDK/Clients/AuthenticationClient.cs
@@ -9,6 +9,8 @@ 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;
@@ -22,7 +24,8 @@ public class AuthenticationClient(
ISettingsProvider settingsProvider,
ApiRequestFactory apiRequestFactory,
IConnectionClient connectionClient,
- ProfileClient profileClient)
+ ProfileClient profileClient,
+ IPluginEventBus pluginEventBus)
{
public async Task AuthenticateAsync(string username, string password, Uri serverAddress)
{
@@ -65,6 +68,18 @@ 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:
@@ -88,6 +103,17 @@ 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
@@ -106,6 +132,18 @@ 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)
diff --git a/LANCommander.SDK/Clients/GameClient.cs b/LANCommander.SDK/Clients/GameClient.cs
index dcc52f5e..f2801460 100644
--- a/LANCommander.SDK/Clients/GameClient.cs
+++ b/LANCommander.SDK/Clients/GameClient.cs
@@ -18,6 +18,8 @@ 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
@@ -74,7 +76,8 @@ namespace LANCommander.SDK.Services
ScriptClient scriptClient,
ProfileClient profileClient,
LobbyClient lobbyClient,
- ToolClient toolClient)
+ ToolClient toolClient,
+ IPluginEventBus pluginEventBus)
{
public delegate void OnArchiveEntryExtractionProgressHandler(object sender, ArchiveEntryExtractionProgressArgs e);
public event OnArchiveEntryExtractionProgressHandler OnArchiveEntryExtractionProgress;
@@ -130,6 +133,26 @@ namespace LANCommander.SDK.Services
.GetAsync();
}
+ public async Task GetManifestAsync(Guid id, Guid versionId)
+ {
+ return await apiRequestFactory
+ .Create()
+ .UseAuthenticationToken()
+ .UseVersioning()
+ .UseRoute($"/api/Games/{id}/Versions/{versionId}/Manifest")
+ .GetAsync();
+ }
+
+ public async Task> GetVersionsAsync(Guid id)
+ {
+ return await apiRequestFactory
+ .Create()
+ .UseAuthenticationToken()
+ .UseVersioning()
+ .UseRoute($"/api/Games/{id}/Versions")
+ .GetAsync>();
+ }
+
public async Task> GetManifestsAsync(string installDirectory, Guid id)
{
var manifests = new List();
@@ -308,6 +331,16 @@ namespace LANCommander.SDK.Services
.GetAsync>();
}
+ public async Task> GetScriptsAsync(Guid id, Guid versionId)
+ {
+ return await apiRequestFactory
+ .Create()
+ .UseAuthenticationToken()
+ .UseVersioning()
+ .UseRoute($"/api/Games/{id}/Versions/{versionId}/Scripts")
+ .GetAsync>();
+ }
+
public async Task CheckForUpdateAsync(Guid id, string currentVersion)
{
return await apiRequestFactory
@@ -1809,6 +1842,29 @@ namespace LANCommander.SDK.Services
}
}
+ ///
+ /// 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.
+ ///
+ 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 WriteManifestAsync(string installDirectory, Game game)
{
logger?.LogTrace($"Retrieving game manifest for game {game.Title} with id {game.Id}");
@@ -2373,6 +2429,8 @@ namespace LANCommander.SDK.Services
}
#endregion
+ await pluginEventBus.PublishAsync(new GameBeforeLaunchEvent(gameId, installDirectory, action?.Name));
+
Task heartbeatTask = null;
try
@@ -2462,6 +2520,8 @@ namespace LANCommander.SDK.Services
}
#endregion
}
+
+ await pluginEventBus.PublishAsync(new GameAfterExitEvent(gameId, installDirectory));
}
}
diff --git a/LANCommander.SDK/Extensions/IServiceCollectionExtensions.cs b/LANCommander.SDK/Extensions/IServiceCollectionExtensions.cs
index 3ff0b5bf..683ea17e 100644
--- a/LANCommander.SDK/Extensions/IServiceCollectionExtensions.cs
+++ b/LANCommander.SDK/Extensions/IServiceCollectionExtensions.cs
@@ -2,6 +2,7 @@ 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;
@@ -60,8 +61,11 @@ public static class IServiceCollectionExtensions
services.AddSingleton();
services.AddSingleton();
-
+
services.TryAddSingleton();
+
+ // Plugin framework: in-process event bus shared by both hosts so plugins can react to lifecycle events.
+ services.TryAddSingleton();
return services;
}
diff --git a/LANCommander.SDK/Helpers/DisplayHelper.cs b/LANCommander.SDK/Helpers/DisplayHelper.cs
index 6fa404aa..277d50f8 100644
--- a/LANCommander.SDK/Helpers/DisplayHelper.cs
+++ b/LANCommander.SDK/Helpers/DisplayHelper.cs
@@ -115,20 +115,15 @@ namespace LANCommander.SDK.Helpers
// ── Linux helpers ─────────────────────────────────────────────────────────
///
- /// Parses xrandr 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.
+ /// Parses xrandr output to find the active resolution and refresh rate.
+ /// Works on X11 and XWayland.
///
/// Example xrandr output:
///
- /// 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*+
+ /// 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
///
///
private static bool TryGetScreenFromXrandr(out Bounds bounds, out int refreshRate, out int bitsPerPixel)
@@ -143,50 +138,24 @@ namespace LANCommander.SDK.Helpers
if (string.IsNullOrWhiteSpace(output))
return false;
- 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)
+ // "Screen 0: ... current 1920 x 1080 ..."
+ var screenMatch = Regex.Match(output, @"current\s+(\d+)\s*x\s*(\d+)");
+ if (!screenMatch.Success)
return false;
- var displayMatch = Regex.Match(lines[displayIndex], connectedLine);
- bounds.Width = int.Parse(displayMatch.Groups[2].Value);
- bounds.Height = int.Parse(displayMatch.Groups[3].Value);
+ bounds.Width = int.Parse(screenMatch.Groups[1].Value);
+ bounds.Height = int.Parse(screenMatch.Groups[2].Value);
- for (var i = displayIndex + 1; i < lines.Length; i++)
+ // 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))
{
- // 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;
- }
+ refreshRate = (int)Math.Round(rate);
}
return bounds.Width > 0 && bounds.Height > 0;
diff --git a/LANCommander.SDK/Helpers/TextFileHelper.cs b/LANCommander.SDK/Helpers/TextFileHelper.cs
index 5cfce113..2156af2e 100644
--- a/LANCommander.SDK/Helpers/TextFileHelper.cs
+++ b/LANCommander.SDK/Helpers/TextFileHelper.cs
@@ -1,6 +1,5 @@
using System.IO;
using System.Text.RegularExpressions;
-using AutoMapper;
namespace LANCommander.SDK.Helpers;
diff --git a/LANCommander.SDK/LANCommander.SDK.csproj b/LANCommander.SDK/LANCommander.SDK.csproj
index 820895d7..f7388d4e 100644
--- a/LANCommander.SDK/LANCommander.SDK.csproj
+++ b/LANCommander.SDK/LANCommander.SDK.csproj
@@ -13,6 +13,10 @@
gitlancommanderMIT
+
+ true
+
+ $(NoWarn);CS1591
@@ -27,6 +31,7 @@
+
diff --git a/LANCommander.SDK/Models/GameVersion.cs b/LANCommander.SDK/Models/GameVersion.cs
new file mode 100644
index 00000000..204034d9
--- /dev/null
+++ b/LANCommander.SDK/Models/GameVersion.cs
@@ -0,0 +1,24 @@
+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; }
+
+ ///
+ /// 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.
+ ///
+ public Guid? ArchiveId { get; set; }
+
+ public long CompressedSize { get; set; }
+ public long UncompressedSize { get; set; }
+ }
+}
diff --git a/LANCommander.SDK/Plugins/Events/AuthenticationEvents.cs b/LANCommander.SDK/Plugins/Events/AuthenticationEvents.cs
new file mode 100644
index 00000000..96cd682b
--- /dev/null
+++ b/LANCommander.SDK/Plugins/Events/AuthenticationEvents.cs
@@ -0,0 +1,9 @@
+using System;
+
+namespace LANCommander.SDK.Plugins.Events;
+
+/// Raised after a user successfully logs in.
+public sealed record UserLoggedInEvent(Guid UserId, string UserName);
+
+/// Raised after a user logs out.
+public sealed record UserLoggedOutEvent(Guid UserId, string UserName);
diff --git a/LANCommander.SDK/Plugins/Events/GameEvents.cs b/LANCommander.SDK/Plugins/Events/GameEvents.cs
new file mode 100644
index 00000000..1b7a5008
--- /dev/null
+++ b/LANCommander.SDK/Plugins/Events/GameEvents.cs
@@ -0,0 +1,27 @@
+using System;
+
+namespace LANCommander.SDK.Plugins.Events;
+
+/// Raised just before a game install begins.
+public sealed record GameInstallingEvent(Guid GameId, string? InstallDirectory);
+
+/// Raised after a game has finished installing.
+public sealed record GameInstalledEvent(Guid GameId, string InstallDirectory);
+
+/// Raised when a game install fails.
+public sealed record GameInstallFailedEvent(Guid GameId, string? InstallDirectory);
+
+/// Raised just before a game is uninstalled.
+public sealed record GameUninstallingEvent(Guid GameId, string? InstallDirectory);
+
+/// Raised after a game has finished uninstalling.
+public sealed record GameUninstalledEvent(Guid GameId);
+
+/// Raised immediately before a game's executable is launched.
+public sealed record GameBeforeLaunchEvent(Guid GameId, string InstallDirectory, string? Action);
+
+/// Raised immediately after a launched game process exits.
+public sealed record GameAfterExitEvent(Guid GameId, string InstallDirectory);
+
+/// Raised whenever the install/download queue changes.
+public sealed record InstallQueueChangedEvent;
diff --git a/LANCommander.SDK/Plugins/IPlugin.cs b/LANCommander.SDK/Plugins/IPlugin.cs
new file mode 100644
index 00000000..7d541dcc
--- /dev/null
+++ b/LANCommander.SDK/Plugins/IPlugin.cs
@@ -0,0 +1,37 @@
+using System.Threading;
+using System.Threading.Tasks;
+using Microsoft.Extensions.DependencyInjection;
+
+namespace LANCommander.SDK.Plugins;
+
+///
+/// The entry point contract every LANCommander plugin implements. Plugins are discovered
+/// from the host's Plugins drop-in folder and loaded once at startup.
+///
+public interface IPlugin
+{
+ /// Stable, globally unique identifier (e.g. "com.acme.myplugin").
+ string Id { get; }
+
+ /// Human readable display name.
+ string Name { get; }
+
+ /// Plugin version (SemVer recommended).
+ string Version { get; }
+
+ /// Plugin author.
+ string Author { get; }
+
+ ///
+ /// 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.
+ ///
+ void ConfigureServices(IServiceCollection services);
+
+ ///
+ /// 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.
+ ///
+ Task InitializeAsync(PluginContext context, CancellationToken cancellationToken);
+}
diff --git a/LANCommander.SDK/Plugins/IPluginEventBus.cs b/LANCommander.SDK/Plugins/IPluginEventBus.cs
new file mode 100644
index 00000000..7c5a661f
--- /dev/null
+++ b/LANCommander.SDK/Plugins/IPluginEventBus.cs
@@ -0,0 +1,24 @@
+using System;
+using System.Threading;
+using System.Threading.Tasks;
+
+namespace LANCommander.SDK.Plugins;
+
+///
+/// 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.
+///
+public interface IPluginEventBus
+{
+ ///
+ /// Subscribes a handler to events of type .
+ ///
+ /// A token that unsubscribes the handler when disposed.
+ IDisposable Subscribe(Func handler);
+
+ ///
+ /// Publishes an event to all subscribed handlers. Each handler is awaited and isolated so a
+ /// throwing handler cannot break the publisher or other handlers.
+ ///
+ Task PublishAsync(TEvent @event, CancellationToken cancellationToken = default);
+}
diff --git a/LANCommander.SDK/Plugins/IPluginPowerShellExtension.cs b/LANCommander.SDK/Plugins/IPluginPowerShellExtension.cs
new file mode 100644
index 00000000..eeee6569
--- /dev/null
+++ b/LANCommander.SDK/Plugins/IPluginPowerShellExtension.cs
@@ -0,0 +1,23 @@
+using System;
+using System.Collections.Generic;
+
+namespace LANCommander.SDK.Plugins;
+
+///
+/// Implemented by plugins that want to add PowerShell cmdlets or script modules into the
+/// LANCommander runspace. Register the implementation in ;
+/// the SDK's PowerShell runspace picks up all registered extensions when a script is executed.
+///
+public interface IPluginPowerShellExtension
+{
+ ///
+ /// Returns cmdlet types (classes decorated with [Cmdlet]) to register into each runspace.
+ ///
+ IEnumerable GetCmdletTypes();
+
+ ///
+ /// Returns absolute paths to PowerShell script modules (.psm1/.psd1) shipped with the plugin that
+ /// should be imported into each runspace.
+ ///
+ IEnumerable GetModulePaths();
+}
diff --git a/LANCommander.SDK/Plugins/LANCommanderPluginAttribute.cs b/LANCommander.SDK/Plugins/LANCommanderPluginAttribute.cs
new file mode 100644
index 00000000..7f6672cf
--- /dev/null
+++ b/LANCommander.SDK/Plugins/LANCommanderPluginAttribute.cs
@@ -0,0 +1,35 @@
+using System;
+
+namespace LANCommander.SDK.Plugins;
+
+///
+/// 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.
+///
+///
+/// [assembly: LANCommanderPlugin(typeof(MyPlugin), Id = "com.acme.myplugin",
+/// MinHostVersion = "1.1.0", Hosts = PluginHost.Server | PluginHost.Launcher)]
+///
+[AttributeUsage(AttributeTargets.Assembly, AllowMultiple = false)]
+public sealed class LANCommanderPluginAttribute : Attribute
+{
+ /// The concrete type implementing that serves as the entry point.
+ public Type EntryPoint { get; }
+
+ /// Optional override for the plugin id; when null the loader falls back to the instance's .
+ public string? Id { get; set; }
+
+ /// Minimum compatible host (SDK) version, inclusive. Null means no lower bound.
+ public string? MinHostVersion { get; set; }
+
+ /// Maximum compatible host (SDK) version, inclusive. Null means no upper bound.
+ public string? MaxHostVersion { get; set; }
+
+ /// The hosts this plugin supports. Defaults to both server and launcher.
+ public PluginHost Hosts { get; set; } = PluginHost.Server | PluginHost.Launcher;
+
+ public LANCommanderPluginAttribute(Type entryPoint)
+ {
+ EntryPoint = entryPoint;
+ }
+}
diff --git a/LANCommander.SDK/Plugins/PluginBootstrap.cs b/LANCommander.SDK/Plugins/PluginBootstrap.cs
new file mode 100644
index 00000000..e88c9605
--- /dev/null
+++ b/LANCommander.SDK/Plugins/PluginBootstrap.cs
@@ -0,0 +1,42 @@
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Logging;
+using LANCommander.SDK.Helpers;
+
+namespace LANCommander.SDK.Plugins;
+
+///
+/// Convenience helper that centralizes plugin discovery so every host wires it identically.
+/// Call as the last step while populating the service collection
+/// (before building the provider), then call
+/// on the returned loader after the provider is built.
+///
+public static class PluginBootstrap
+{
+ /// Name of the drop-in folder under the host's config directory.
+ public const string PluginsFolderName = "Plugins";
+
+ ///
+ /// Discovers plugins for from <config>/Plugins, lets each register
+ /// its services into , and registers the loader as a singleton so the
+ /// same instance can drive Phase 2 initialization.
+ ///
+ 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;
+ }
+}
diff --git a/LANCommander.SDK/Plugins/PluginContext.cs b/LANCommander.SDK/Plugins/PluginContext.cs
new file mode 100644
index 00000000..a1195d47
--- /dev/null
+++ b/LANCommander.SDK/Plugins/PluginContext.cs
@@ -0,0 +1,22 @@
+using System;
+using Microsoft.Extensions.Logging;
+
+namespace LANCommander.SDK.Plugins;
+
+///
+/// Runtime context handed to a plugin during .
+///
+public sealed class PluginContext
+{
+ /// The host the plugin is running inside (a single value, never a flags combination).
+ public PluginHost Host { get; init; }
+
+ /// The fully built host service provider (scoped per plugin during initialization).
+ public IServiceProvider Services { get; init; } = default!;
+
+ /// Absolute path to the folder the plugin was loaded from.
+ public string PluginDirectory { get; init; } = string.Empty;
+
+ /// Logger scoped to the plugin.
+ public ILogger Logger { get; init; } = default!;
+}
diff --git a/LANCommander.SDK/Plugins/PluginEventBus.cs b/LANCommander.SDK/Plugins/PluginEventBus.cs
new file mode 100644
index 00000000..7e21f808
--- /dev/null
+++ b/LANCommander.SDK/Plugins/PluginEventBus.cs
@@ -0,0 +1,75 @@
+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;
+
+///
+public sealed class PluginEventBus : IPluginEventBus
+{
+ private readonly ILogger _logger;
+ private readonly ConcurrentDictionary> _handlers = new();
+ private readonly object _lock = new();
+
+ public PluginEventBus(ILogger logger)
+ {
+ _logger = logger;
+ }
+
+ public IDisposable Subscribe(Func handler)
+ {
+ if (handler is null)
+ throw new ArgumentNullException(nameof(handler));
+
+ var list = _handlers.GetOrAdd(typeof(TEvent), _ => new List