Compare commits
13 commits
main
...
feature/pl
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
00d168e620 | ||
|
|
e77ba1b31e | ||
|
|
20d5190bc5 | ||
|
|
1558af8a98 | ||
|
|
8d0353a3c0 | ||
|
|
217fcfa0b6 | ||
|
|
0dc76c51b8 | ||
|
|
6e2aadd0e8 | ||
|
|
1e630a9ea2 | ||
|
|
1e4555edf1 | ||
|
|
d30a05c24d | ||
|
|
8737060679 | ||
|
|
1202e11491 |
207 changed files with 27705 additions and 818 deletions
27
.github/workflows/LANCommander.Documentation.yml
vendored
27
.github/workflows/LANCommander.Documentation.yml
vendored
|
|
@ -7,9 +7,32 @@ on:
|
||||||
- "LANCommander.Documentation/**"
|
- "LANCommander.Documentation/**"
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
dispatch:
|
publish:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
permissions:
|
||||||
|
contents: write
|
||||||
steps:
|
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
|
- name: Dispatch to Docusaurus repo
|
||||||
env:
|
env:
|
||||||
DISPATCH_TOKEN: ${{ secrets.DOCUMENTATION_DISPATCH_TOKEN }}
|
DISPATCH_TOKEN: ${{ secrets.DOCUMENTATION_DISPATCH_TOKEN }}
|
||||||
|
|
@ -22,7 +45,7 @@ jobs:
|
||||||
"event_type": "docs-sources-updated",
|
"event_type": "docs-sources-updated",
|
||||||
"client_payload": {
|
"client_payload": {
|
||||||
"repo": "'"${GITHUB_REPOSITORY}"'",
|
"repo": "'"${GITHUB_REPOSITORY}"'",
|
||||||
"sha": "'"${GITHUB_SHA}"'",
|
"sha": "'"$(git rev-parse HEAD)"'",
|
||||||
"ref": "'"${GITHUB_REF}"'"
|
"ref": "'"${GITHUB_REF}"'"
|
||||||
}
|
}
|
||||||
}'
|
}'
|
||||||
|
|
|
||||||
|
|
@ -16,8 +16,8 @@
|
||||||
<PackageVersion Include="Notify.NET" Version="1.1.0" />
|
<PackageVersion Include="Notify.NET" Version="1.1.0" />
|
||||||
<PackageVersion Include="Svrooij.PowerShell.DI" Version="1.3.4" />
|
<PackageVersion Include="Svrooij.PowerShell.DI" Version="1.3.4" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
<ItemGroup Label="AutoMapper">
|
<ItemGroup Label="Mapperly">
|
||||||
<PackageVersion Include="AutoMapper" Version="14.0.0" />
|
<PackageVersion Include="Riok.Mapperly" Version="4.3.1" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
<ItemGroup Label="AntDesign">
|
<ItemGroup Label="AntDesign">
|
||||||
<PackageVersion Include="AntDesign" Version="1.5.0" />
|
<PackageVersion Include="AntDesign" Version="1.5.0" />
|
||||||
|
|
|
||||||
|
|
@ -28,3 +28,4 @@ This site serves as the main documentation platform for the project. As such, it
|
||||||
- [Packager](/Packager/Overview)
|
- [Packager](/Packager/Overview)
|
||||||
- [Scripting](/Scripting/Overview)
|
- [Scripting](/Scripting/Overview)
|
||||||
- [SDK Documentation](/SDK/Overview)
|
- [SDK Documentation](/SDK/Overview)
|
||||||
|
- [Plugin Development](/Plugins/Overview)
|
||||||
415
LANCommander.Documentation/Plugins/API Reference.md
Normal file
415
LANCommander.Documentation/Plugins/API Reference.md
Normal file
|
|
@ -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<TEvent>(Func<TEvent, CancellationToken, Task> handler)`
|
||||||
|
- Subscribes a handler to events of type `TEvent`.
|
||||||
|
- `Task PublishAsync<TEvent>(TEvent event, CancellationToken cancellationToken)`
|
||||||
|
- Publishes an event to all subscribed handlers. Each handler is awaited and isolated so a throwing handler cannot break the publisher or other handlers.
|
||||||
|
|
||||||
|
### IPluginPowerShellExtension
|
||||||
|
|
||||||
|
`interface` — `LANCommander.SDK.Plugins.IPluginPowerShellExtension`
|
||||||
|
|
||||||
|
Implemented by plugins that want to add PowerShell cmdlets or script modules into the LANCommander runspace. Register the implementation in `ConfigureServices`; the SDK's PowerShell runspace picks up all registered extensions when a script is executed.
|
||||||
|
|
||||||
|
**Methods**
|
||||||
|
|
||||||
|
- `IEnumerable<Type> GetCmdletTypes()`
|
||||||
|
- Returns cmdlet types (classes decorated with `[Cmdlet]`) to register into each runspace.
|
||||||
|
- `IEnumerable<string> GetModulePaths()`
|
||||||
|
- Returns absolute paths to PowerShell script modules (.psm1/.psd1) shipped with the plugin that should be imported into each runspace.
|
||||||
|
|
||||||
|
### LANCommanderPluginAttribute
|
||||||
|
|
||||||
|
`attribute` — `LANCommander.SDK.Plugins.LANCommanderPluginAttribute`
|
||||||
|
|
||||||
|
Assembly-level attribute that marks an assembly as a LANCommander plugin and declares its entry point and compatibility metadata. This is the primary discovery mechanism used by the loader.
|
||||||
|
|
||||||
|
**Properties**
|
||||||
|
|
||||||
|
- `Type EntryPoint { get; }`
|
||||||
|
- The concrete type implementing `IPlugin` that serves as the entry point.
|
||||||
|
- `string Id { get; set; }`
|
||||||
|
- Optional override for the plugin id; when null the loader falls back to the instance's `Id`.
|
||||||
|
- `string MinHostVersion { get; set; }`
|
||||||
|
- Minimum compatible host (SDK) version, inclusive. Null means no lower bound.
|
||||||
|
- `string MaxHostVersion { get; set; }`
|
||||||
|
- Maximum compatible host (SDK) version, inclusive. Null means no upper bound.
|
||||||
|
- `PluginHost Hosts { get; set; }`
|
||||||
|
- The hosts this plugin supports. Defaults to both server and launcher.
|
||||||
|
|
||||||
|
### PluginBootstrap
|
||||||
|
|
||||||
|
`class` — `LANCommander.SDK.Plugins.PluginBootstrap`
|
||||||
|
|
||||||
|
Convenience helper that centralizes plugin discovery so every host wires it identically. Call `ConfigurePlugins` as the last step while populating the service collection (before building the provider), then call `InitializeAllAsync` on the returned loader after the provider is built.
|
||||||
|
|
||||||
|
**Methods**
|
||||||
|
|
||||||
|
- `PluginLoaderService ConfigurePlugins(IServiceCollection services, PluginHost host)`
|
||||||
|
- Discovers plugins for `host` from `<config>/Plugins`, lets each register its services into `services`, and registers the loader as a singleton so the same instance can drive Phase 2 initialization.
|
||||||
|
|
||||||
|
### PluginContext
|
||||||
|
|
||||||
|
`class` — `LANCommander.SDK.Plugins.PluginContext`
|
||||||
|
|
||||||
|
Runtime context handed to a plugin during `InitializeAsync`.
|
||||||
|
|
||||||
|
**Properties**
|
||||||
|
|
||||||
|
- `PluginHost Host { get; init; }`
|
||||||
|
- The host the plugin is running inside (a single value, never a flags combination).
|
||||||
|
- `IServiceProvider Services { get; init; }`
|
||||||
|
- The fully built host service provider (scoped per plugin during initialization).
|
||||||
|
- `string PluginDirectory { get; init; }`
|
||||||
|
- Absolute path to the folder the plugin was loaded from.
|
||||||
|
- `ILogger Logger { get; init; }`
|
||||||
|
- Logger scoped to the plugin.
|
||||||
|
|
||||||
|
### PluginEventBus
|
||||||
|
|
||||||
|
`class` — `LANCommander.SDK.Plugins.PluginEventBus`
|
||||||
|
|
||||||
|
**Methods**
|
||||||
|
|
||||||
|
- `IDisposable Subscribe<TEvent>(Func<TEvent, CancellationToken, Task> handler)`
|
||||||
|
- `Task PublishAsync<TEvent>(TEvent event, CancellationToken cancellationToken)`
|
||||||
|
|
||||||
|
### PluginLoadContext
|
||||||
|
|
||||||
|
`class` — `LANCommander.SDK.Plugins.PluginLoadContext`
|
||||||
|
|
||||||
|
An isolated `AssemblyLoadContext` for a single plugin. Uses an `AssemblyDependencyResolver` to resolve the plugin's private dependencies while deferring host-provided assemblies (the SDK, DI abstractions, Avalonia, etc.) to the default context so that shared types keep a single identity across the ALC boundary.
|
||||||
|
|
||||||
|
### PluginLoaderService
|
||||||
|
|
||||||
|
`class` — `LANCommander.SDK.Plugins.PluginLoaderService`
|
||||||
|
|
||||||
|
Discovers, loads, and initializes plugins from a drop-in folder. Split into two phases to match the "build the DI container once" constraint: `DiscoverAndConfigure` runs while the host is still populating its `IServiceCollection` (before the provider is built).`InitializeAllAsync` runs after the provider has been built.
|
||||||
|
|
||||||
|
**Properties**
|
||||||
|
|
||||||
|
- `IReadOnlyList<PluginManifest> LoadedPlugins { get; }`
|
||||||
|
- Plugins successfully loaded and configured during discovery.
|
||||||
|
|
||||||
|
**Methods**
|
||||||
|
|
||||||
|
- `void DiscoverAndConfigure(IServiceCollection services, PluginHost host, string pluginsRoot, string hostVersion, ILogger logger)`
|
||||||
|
- Phase 1: scans `pluginsRoot` for plugins, loads each into its own `PluginLoadContext`, applies host + version gates, instantiates the entry point, and lets it register services. A failure in one plugin never aborts the batch.
|
||||||
|
- `Task InitializeAllAsync(IServiceProvider serviceProvider, CancellationToken cancellationToken)`
|
||||||
|
- Phase 2: invokes `InitializeAsync` for every loaded plugin, each within its own DI scope. A failure in one plugin never aborts the others.
|
||||||
|
|
||||||
|
### PluginManifest
|
||||||
|
|
||||||
|
`class` — `LANCommander.SDK.Plugins.PluginManifest`
|
||||||
|
|
||||||
|
Parsed metadata describing a discovered plugin, derived from its `LANCommanderPluginAttribute`.
|
||||||
|
|
||||||
|
**Properties**
|
||||||
|
|
||||||
|
- `string Id { get; init; }`
|
||||||
|
- `Type EntryPoint { get; init; }`
|
||||||
|
- `string MinHostVersion { get; init; }`
|
||||||
|
- `string MaxHostVersion { get; init; }`
|
||||||
|
- `PluginHost Hosts { get; init; }`
|
||||||
|
- `Assembly Assembly { get; init; }`
|
||||||
|
- The assembly the plugin was loaded from.
|
||||||
|
- `string Directory { get; init; }`
|
||||||
|
- Absolute path to the folder the plugin was loaded from.
|
||||||
|
|
||||||
|
**Methods**
|
||||||
|
|
||||||
|
- `PluginManifest FromAttribute(LANCommanderPluginAttribute attribute, Assembly assembly, string directory)`
|
||||||
|
- Builds a manifest from an assembly-level plugin attribute.
|
||||||
|
|
||||||
|
### PluginHost
|
||||||
|
|
||||||
|
`enum` — `LANCommander.SDK.Plugins.PluginHost`
|
||||||
|
|
||||||
|
Identifies which LANCommander host a plugin targets. Used both as a single value (the host a plugin is being loaded into) and as a flags set (the hosts a plugin declares support for).
|
||||||
|
|
||||||
|
| Value | Description |
|
||||||
|
| --- | --- |
|
||||||
|
| `None` = `0` | |
|
||||||
|
| `Server` = `1` | |
|
||||||
|
| `Launcher` = `2` | |
|
||||||
|
|
||||||
|
## `LANCommander.SDK.Plugins.Events`
|
||||||
|
|
||||||
|
### GameAfterExitEvent
|
||||||
|
|
||||||
|
`record` — `LANCommander.SDK.Plugins.Events.GameAfterExitEvent`
|
||||||
|
|
||||||
|
Raised immediately after a launched game process exits.
|
||||||
|
|
||||||
|
**Properties**
|
||||||
|
|
||||||
|
- `Guid GameId { get; init; }`
|
||||||
|
- `string InstallDirectory { get; init; }`
|
||||||
|
|
||||||
|
### GameBeforeLaunchEvent
|
||||||
|
|
||||||
|
`record` — `LANCommander.SDK.Plugins.Events.GameBeforeLaunchEvent`
|
||||||
|
|
||||||
|
Raised immediately before a game's executable is launched.
|
||||||
|
|
||||||
|
**Properties**
|
||||||
|
|
||||||
|
- `Guid GameId { get; init; }`
|
||||||
|
- `string InstallDirectory { get; init; }`
|
||||||
|
- `string Action { get; init; }`
|
||||||
|
|
||||||
|
### GameInstallFailedEvent
|
||||||
|
|
||||||
|
`record` — `LANCommander.SDK.Plugins.Events.GameInstallFailedEvent`
|
||||||
|
|
||||||
|
Raised when a game install fails.
|
||||||
|
|
||||||
|
**Properties**
|
||||||
|
|
||||||
|
- `Guid GameId { get; init; }`
|
||||||
|
- `string InstallDirectory { get; init; }`
|
||||||
|
|
||||||
|
### GameInstalledEvent
|
||||||
|
|
||||||
|
`record` — `LANCommander.SDK.Plugins.Events.GameInstalledEvent`
|
||||||
|
|
||||||
|
Raised after a game has finished installing.
|
||||||
|
|
||||||
|
**Properties**
|
||||||
|
|
||||||
|
- `Guid GameId { get; init; }`
|
||||||
|
- `string InstallDirectory { get; init; }`
|
||||||
|
|
||||||
|
### GameInstallingEvent
|
||||||
|
|
||||||
|
`record` — `LANCommander.SDK.Plugins.Events.GameInstallingEvent`
|
||||||
|
|
||||||
|
Raised just before a game install begins.
|
||||||
|
|
||||||
|
**Properties**
|
||||||
|
|
||||||
|
- `Guid GameId { get; init; }`
|
||||||
|
- `string InstallDirectory { get; init; }`
|
||||||
|
|
||||||
|
### GameUninstalledEvent
|
||||||
|
|
||||||
|
`record` — `LANCommander.SDK.Plugins.Events.GameUninstalledEvent`
|
||||||
|
|
||||||
|
Raised after a game has finished uninstalling.
|
||||||
|
|
||||||
|
**Properties**
|
||||||
|
|
||||||
|
- `Guid GameId { get; init; }`
|
||||||
|
|
||||||
|
### GameUninstallingEvent
|
||||||
|
|
||||||
|
`record` — `LANCommander.SDK.Plugins.Events.GameUninstallingEvent`
|
||||||
|
|
||||||
|
Raised just before a game is uninstalled.
|
||||||
|
|
||||||
|
**Properties**
|
||||||
|
|
||||||
|
- `Guid GameId { get; init; }`
|
||||||
|
- `string InstallDirectory { get; init; }`
|
||||||
|
|
||||||
|
### InstallQueueChangedEvent
|
||||||
|
|
||||||
|
`record` — `LANCommander.SDK.Plugins.Events.InstallQueueChangedEvent`
|
||||||
|
|
||||||
|
Raised whenever the install/download queue changes.
|
||||||
|
|
||||||
|
### UserLoggedInEvent
|
||||||
|
|
||||||
|
`record` — `LANCommander.SDK.Plugins.Events.UserLoggedInEvent`
|
||||||
|
|
||||||
|
Raised after a user successfully logs in.
|
||||||
|
|
||||||
|
**Properties**
|
||||||
|
|
||||||
|
- `Guid UserId { get; init; }`
|
||||||
|
- `string UserName { get; init; }`
|
||||||
|
|
||||||
|
### UserLoggedOutEvent
|
||||||
|
|
||||||
|
`record` — `LANCommander.SDK.Plugins.Events.UserLoggedOutEvent`
|
||||||
|
|
||||||
|
Raised after a user logs out.
|
||||||
|
|
||||||
|
**Properties**
|
||||||
|
|
||||||
|
- `Guid UserId { get; init; }`
|
||||||
|
- `string UserName { get; init; }`
|
||||||
|
|
||||||
|
## `LANCommander.Launcher.Plugins.Extensions`
|
||||||
|
|
||||||
|
### IContextMenuExtension
|
||||||
|
|
||||||
|
`interface` — `LANCommander.Launcher.Plugins.Extensions.IContextMenuExtension`
|
||||||
|
|
||||||
|
Adds items to a game's context menu. Implementations are resolved from DI and their items appended to the consolidated game menu shown on covers and list rows.
|
||||||
|
|
||||||
|
**Properties**
|
||||||
|
|
||||||
|
- `int Order { get; }`
|
||||||
|
- Relative position among extension items; lower values appear first.
|
||||||
|
|
||||||
|
**Methods**
|
||||||
|
|
||||||
|
- `IEnumerable<Control> BuildMenuItems(Guid gameId)`
|
||||||
|
- Builds the menu items shown for the given game (typically `MenuItem`s).
|
||||||
|
|
||||||
|
### IFooterExtension
|
||||||
|
|
||||||
|
`interface` — `LANCommander.Launcher.Plugins.Extensions.IFooterExtension`
|
||||||
|
|
||||||
|
Adds a control to the launcher shell's footer. Implementations are resolved from DI and rendered, ordered by `Order`, alongside the built-in footer items.
|
||||||
|
|
||||||
|
**Properties**
|
||||||
|
|
||||||
|
- `int Order { get; }`
|
||||||
|
- Relative position among extension items; lower values appear first.
|
||||||
|
|
||||||
|
**Methods**
|
||||||
|
|
||||||
|
- `Control BuildContent()`
|
||||||
|
- Builds the control rendered in the footer.
|
||||||
|
|
||||||
|
### IGameDetailTabExtension
|
||||||
|
|
||||||
|
`interface` — `LANCommander.Launcher.Plugins.Extensions.IGameDetailTabExtension`
|
||||||
|
|
||||||
|
Adds an additional tab to a game's detail view. Implementations are resolved from DI and appended, ordered by `Order`, after the built-in tabs.
|
||||||
|
|
||||||
|
**Properties**
|
||||||
|
|
||||||
|
- `string Header { get; }`
|
||||||
|
- Header shown on the tab.
|
||||||
|
- `int Order { get; }`
|
||||||
|
- Relative position among extension tabs; lower values appear first.
|
||||||
|
|
||||||
|
**Methods**
|
||||||
|
|
||||||
|
- `Control BuildContent(Guid gameId)`
|
||||||
|
- Builds the control rendered inside the tab for the given game.
|
||||||
|
|
||||||
|
### INavigationPageExtension
|
||||||
|
|
||||||
|
`interface` — `LANCommander.Launcher.Plugins.Extensions.INavigationPageExtension`
|
||||||
|
|
||||||
|
Adds a top-level navigable destination reachable from the launcher shell. The view model is registered with the `IViewRegistry` so the shell's content control can render the associated view when navigated to.
|
||||||
|
|
||||||
|
**Properties**
|
||||||
|
|
||||||
|
- `string Label { get; }`
|
||||||
|
- Label shown for the navigation entry.
|
||||||
|
- `int Order { get; }`
|
||||||
|
- Relative position among extension destinations; lower values appear first.
|
||||||
|
- `Type ViewModelType { get; }`
|
||||||
|
- The view model type used both as the navigation target and the registry key.
|
||||||
|
|
||||||
|
**Methods**
|
||||||
|
|
||||||
|
- `PluginViewModelBase CreateViewModel()`
|
||||||
|
- Creates the view model instance shown when the destination is activated.
|
||||||
|
- `Control BuildView()`
|
||||||
|
- Builds the control that renders `ViewModelType`.
|
||||||
|
|
||||||
|
### ISettingsPageExtension
|
||||||
|
|
||||||
|
`interface` — `LANCommander.Launcher.Plugins.Extensions.ISettingsPageExtension`
|
||||||
|
|
||||||
|
Adds an additional section to the launcher's settings page. Implementations are resolved from DI and appended, ordered by `Order`, beneath the built-in settings sections.
|
||||||
|
|
||||||
|
**Properties**
|
||||||
|
|
||||||
|
- `string Title { get; }`
|
||||||
|
- Heading shown for the section.
|
||||||
|
- `int Order { get; }`
|
||||||
|
- Relative position among extension sections; lower values appear first.
|
||||||
|
|
||||||
|
**Methods**
|
||||||
|
|
||||||
|
- `Control BuildContent()`
|
||||||
|
- Builds the control rendered inside the section.
|
||||||
|
|
||||||
|
## `LANCommander.Launcher.Plugins`
|
||||||
|
|
||||||
|
### IViewRegistry
|
||||||
|
|
||||||
|
`interface` — `LANCommander.Launcher.Plugins.IViewRegistry`
|
||||||
|
|
||||||
|
Maps view model types to the Avalonia controls that render them. Seeded at startup with the launcher's built-in mappings and extended at runtime by plugins that add navigable views. Consumers apply `AsDataTemplate` to a `ContentControl` so content is resolved by view model type, replacing the previously hard-coded inline XAML data templates.
|
||||||
|
|
||||||
|
**Methods**
|
||||||
|
|
||||||
|
- `void Register(Type viewModelType, Func<Control> factory)`
|
||||||
|
- Register a control factory for the given view model type.
|
||||||
|
- `void Register<TViewModel>(Func<Control> factory)`
|
||||||
|
- Register a control factory for `TViewModel`.
|
||||||
|
- `IDataTemplate AsDataTemplate()`
|
||||||
|
- Build an `IDataTemplate` backed by the current registrations. Matching prefers the most-derived registered type, preserving the launcher's existing rule that DepotGameDetailViewModel resolves before its GameDetailViewModel base.
|
||||||
|
|
||||||
|
### PluginViewModelBase
|
||||||
|
|
||||||
|
`class` — `LANCommander.Launcher.Plugins.PluginViewModelBase`
|
||||||
|
|
||||||
|
Base type for view models supplied by plugins. Lives in this project (rather than the launcher's ViewModels assembly) so plugins can derive from it without taking a dependency on the launcher application itself, avoiding a circular reference.
|
||||||
|
|
||||||
|
### ViewRegistry
|
||||||
|
|
||||||
|
`class` — `LANCommander.Launcher.Plugins.ViewRegistry`
|
||||||
|
|
||||||
|
**Methods**
|
||||||
|
|
||||||
|
- `void Register(Type viewModelType, Func<Control> factory)`
|
||||||
|
- `void Register<TViewModel>(Func<Control> factory)`
|
||||||
|
- `IDataTemplate AsDataTemplate()`
|
||||||
|
|
||||||
208
LANCommander.Documentation/Plugins/Extension Points.md
Normal file
208
LANCommander.Documentation/Plugins/Extension Points.md
Normal file
|
|
@ -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<ISettingsPageExtension, MySettingsExtension>();
|
||||||
|
services.AddSingleton<IContextMenuExtension, MyContextMenuExtension>();
|
||||||
|
services.AddSingleton<IPluginPowerShellExtension, MyPowerShellExtension>();
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Where a UI extension point exposes an `Order` property, implementations are sorted ascending, lower
|
||||||
|
values appear first, and rendered alongside the built-in items.
|
||||||
|
|
||||||
|
## Launcher UI extensions
|
||||||
|
|
||||||
|
The launcher exposes five UI extension points, all in the
|
||||||
|
`LANCommander.Launcher.Plugins.Extensions` namespace. Each one builds an Avalonia `Control`.
|
||||||
|
|
||||||
|
| Interface | What it adds |
|
||||||
|
| --- | --- |
|
||||||
|
| [`INavigationPageExtension`](/Plugins/API%20Reference#inavigationpageextension) | A top-level navigable page reachable from the shell. |
|
||||||
|
| [`ISettingsPageExtension`](/Plugins/API%20Reference#isettingspageextension) | A section on the settings page. |
|
||||||
|
| [`IGameDetailTabExtension`](/Plugins/API%20Reference#igamedetailtabextension) | A tab on a game's detail view. |
|
||||||
|
| [`IContextMenuExtension`](/Plugins/API%20Reference#icontextmenuextension) | Items on a game's context menu. |
|
||||||
|
| [`IFooterExtension`](/Plugins/API%20Reference#ifooterextension) | A widget in the shell footer. |
|
||||||
|
|
||||||
|
:::tip Build controls in code, not XAML
|
||||||
|
Because plugins load in their own `AssemblyLoadContext`, Avalonia's compiled-XAML asset resolution
|
||||||
|
(`avares://`) does not reliably resolve across the boundary. The recommended authoring path for plugin
|
||||||
|
views is to build controls in code, as the examples below do. This is exactly what the reference plugin
|
||||||
|
does.
|
||||||
|
:::
|
||||||
|
|
||||||
|
### Example: a settings section
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
using Avalonia.Controls;
|
||||||
|
using Avalonia.Layout;
|
||||||
|
using LANCommander.Launcher.Plugins.Extensions;
|
||||||
|
|
||||||
|
public sealed class MySettingsExtension : ISettingsPageExtension
|
||||||
|
{
|
||||||
|
public string Title => "My Plugin";
|
||||||
|
public int Order => 0;
|
||||||
|
|
||||||
|
public Control BuildContent()
|
||||||
|
{
|
||||||
|
var panel = new StackPanel { Spacing = 8 };
|
||||||
|
|
||||||
|
panel.Children.Add(new TextBlock
|
||||||
|
{
|
||||||
|
Text = "This settings section was added by my plugin.",
|
||||||
|
TextWrapping = Avalonia.Media.TextWrapping.Wrap,
|
||||||
|
});
|
||||||
|
|
||||||
|
panel.Children.Add(new CheckBox
|
||||||
|
{
|
||||||
|
Content = "Enable my feature",
|
||||||
|
HorizontalAlignment = HorizontalAlignment.Left,
|
||||||
|
});
|
||||||
|
|
||||||
|
return panel;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Example: game context menu items
|
||||||
|
|
||||||
|
`IContextMenuExtension` receives the id of the game the menu was opened for and returns any number of
|
||||||
|
controls (typically `MenuItem`s) to append:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
public sealed class MyContextMenuExtension : IContextMenuExtension
|
||||||
|
{
|
||||||
|
public int Order => 0;
|
||||||
|
|
||||||
|
public IEnumerable<Control> BuildMenuItems(Guid gameId)
|
||||||
|
{
|
||||||
|
var item = new MenuItem { Header = "Do something with this game" };
|
||||||
|
item.Click += (_, _) => { /* ... */ };
|
||||||
|
yield return item;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Example: a navigation page
|
||||||
|
|
||||||
|
A navigation page pairs a view model (deriving from
|
||||||
|
[`PluginViewModelBase`](/Plugins/API%20Reference#pluginviewmodelbase)) with a control that renders it.
|
||||||
|
The view model type doubles as the registry key used by the shell's content control.
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
public sealed class MyPageExtension : INavigationPageExtension
|
||||||
|
{
|
||||||
|
public string Label => "My Page";
|
||||||
|
public int Order => 100;
|
||||||
|
public Type ViewModelType => typeof(MyPageViewModel);
|
||||||
|
|
||||||
|
public PluginViewModelBase CreateViewModel() => new MyPageViewModel();
|
||||||
|
|
||||||
|
public Control BuildView() => new TextBlock { Text = "Hello from my page" };
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Server metadata providers
|
||||||
|
|
||||||
|
On the server, a plugin can contribute additional metadata providers by registering an
|
||||||
|
`IMetadataProvider` (from `LANCommander.Server.Services`). Registered providers are picked up by the
|
||||||
|
server's provider enumeration and used alongside the built-in ones.
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
public void ConfigureServices(IServiceCollection services)
|
||||||
|
{
|
||||||
|
services.AddSingleton<IMetadataProvider, MyMetadataProvider>();
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## PowerShell extensions
|
||||||
|
|
||||||
|
LANCommander runs installs and other tasks through an embedded PowerShell runtime. A plugin can add its
|
||||||
|
own cmdlets and script modules by implementing
|
||||||
|
[`IPluginPowerShellExtension`](/Plugins/API%20Reference#ipluginpowershellextension). Registered
|
||||||
|
extensions are picked up whenever a script runspace is created, in both hosts.
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
using System.Management.Automation;
|
||||||
|
using LANCommander.SDK.Plugins;
|
||||||
|
|
||||||
|
// The cmdlet itself.
|
||||||
|
[Cmdlet(VerbsCommon.Get, "MyGreeting")]
|
||||||
|
public sealed class GetMyGreetingCmdlet : PSCmdlet
|
||||||
|
{
|
||||||
|
[Parameter(Position = 0)]
|
||||||
|
public string Name { get; set; } = "World";
|
||||||
|
|
||||||
|
protected override void ProcessRecord() => WriteObject($"Hello, {Name}!");
|
||||||
|
}
|
||||||
|
|
||||||
|
// The extension that exposes it to the runspace.
|
||||||
|
public sealed class MyPowerShellExtension : IPluginPowerShellExtension
|
||||||
|
{
|
||||||
|
public IEnumerable<Type> GetCmdletTypes() => new[] { typeof(GetMyGreetingCmdlet) };
|
||||||
|
|
||||||
|
public IEnumerable<string> GetModulePaths() => Array.Empty<string>();
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Once registered, `Get-MyGreeting` is callable from any LANCommander script. To ship script modules
|
||||||
|
(`.psm1`/`.psd1`) instead of (or in addition to) cmdlets, return their absolute paths from
|
||||||
|
`GetModulePaths()`. The plugin directory is available to you via `PluginContext.PluginDirectory`.
|
||||||
|
|
||||||
|
## Lifecycle events
|
||||||
|
|
||||||
|
The [`IPluginEventBus`](/Plugins/API%20Reference#iplugineventbus) is an in-process, strongly typed
|
||||||
|
event aggregator registered as a singleton in both hosts. Resolve it in `InitializeAsync` and subscribe
|
||||||
|
to the events you care about. `Subscribe` returns an `IDisposable`; keep it and dispose it to
|
||||||
|
unsubscribe.
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
public Task InitializeAsync(PluginContext context, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var events = context.Services.GetRequiredService<IPluginEventBus>();
|
||||||
|
|
||||||
|
events.Subscribe<GameInstalledEvent>((evt, ct) =>
|
||||||
|
{
|
||||||
|
context.Logger.LogInformation("Installed {GameId} to {Dir}", evt.GameId, evt.InstallDirectory);
|
||||||
|
return Task.CompletedTask;
|
||||||
|
});
|
||||||
|
|
||||||
|
return Task.CompletedTask;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Handlers are awaited and isolated: a throwing handler cannot break the publisher or other subscribers.
|
||||||
|
|
||||||
|
The events published today live in the `LANCommander.SDK.Plugins.Events` namespace:
|
||||||
|
|
||||||
|
| Event | Raised when |
|
||||||
|
| --- | --- |
|
||||||
|
| `GameInstallingEvent` | A game install is about to begin. |
|
||||||
|
| `GameInstalledEvent` | A game has finished installing. |
|
||||||
|
| `GameInstallFailedEvent` | A game install fails. |
|
||||||
|
| `GameUninstallingEvent` | A game is about to be uninstalled. |
|
||||||
|
| `GameUninstalledEvent` | A game has finished uninstalling. |
|
||||||
|
| `GameBeforeLaunchEvent` | A game's executable is about to launch. |
|
||||||
|
| `GameAfterExitEvent` | A launched game process has exited. |
|
||||||
|
| `InstallQueueChangedEvent` | The install/download queue changes. |
|
||||||
|
| `UserLoggedInEvent` | A user successfully logs in. |
|
||||||
|
| `UserLoggedOutEvent` | A user logs out. |
|
||||||
|
|
||||||
|
See the [API Reference](/Plugins/API%20Reference#lancommandersdkpluginsevents) for the exact payload of
|
||||||
|
each event.
|
||||||
161
LANCommander.Documentation/Plugins/Getting Started.md
Normal file
161
LANCommander.Documentation/Plugins/Getting Started.md
Normal file
|
|
@ -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
|
||||||
|
<ItemGroup>
|
||||||
|
<!-- Core plugin contracts, events, and the PowerShell extension point. -->
|
||||||
|
<ProjectReference Include="..\LANCommander.SDK\LANCommander.SDK.csproj" Private="false" />
|
||||||
|
|
||||||
|
<!-- Launcher UI extension points (only needed if you extend the launcher UI). -->
|
||||||
|
<ProjectReference Include="..\LANCommander.Launcher.Plugins\LANCommander.Launcher.Plugins.csproj" Private="false" />
|
||||||
|
|
||||||
|
<!-- Server contracts such as IMetadataProvider (only needed for server extensions). -->
|
||||||
|
<ProjectReference Include="..\LANCommander.Server.Services\LANCommander.Server.Services.csproj" Private="false" />
|
||||||
|
</ItemGroup>
|
||||||
|
```
|
||||||
|
|
||||||
|
:::note
|
||||||
|
`Private=false` keeps the contract assemblies out of your plugin's output folder. This is important:
|
||||||
|
the framework preserves type identity across the plugin's load context by deferring these shared
|
||||||
|
assemblies to the host. Your plugin's *own* private dependencies (NuGet packages, helper libraries)
|
||||||
|
should ship normally so they land next to your plugin's DLL.
|
||||||
|
:::
|
||||||
|
|
||||||
|
## 3. Implement the entry point
|
||||||
|
|
||||||
|
Every plugin has a single entry point that implements
|
||||||
|
[`IPlugin`](/Plugins/API%20Reference#iplugin). The two lifecycle methods map directly onto the host's
|
||||||
|
"build the DI container once" model:
|
||||||
|
|
||||||
|
- **`ConfigureServices`** runs while the host is still populating its service collection, *before* the
|
||||||
|
provider is built. Only register services here — do not resolve them.
|
||||||
|
- **`InitializeAsync`** runs *after* the provider is built. Resolve services, subscribe to events, and do
|
||||||
|
any asynchronous startup work here. You receive a
|
||||||
|
[`PluginContext`](/Plugins/API%20Reference#plugincontext) with the host identity, a scoped service
|
||||||
|
provider, the plugin's directory, and a scoped logger.
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
using LANCommander.SDK.Plugins;
|
||||||
|
using LANCommander.SDK.Plugins.Events;
|
||||||
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
|
||||||
|
namespace MyCompany.MyPlugin;
|
||||||
|
|
||||||
|
public sealed class MyPlugin : IPlugin
|
||||||
|
{
|
||||||
|
public string Id => "com.mycompany.myplugin";
|
||||||
|
public string Name => "My Plugin";
|
||||||
|
public string Version => "1.0.0";
|
||||||
|
public string Author => "My Company";
|
||||||
|
|
||||||
|
private IDisposable? _launchSubscription;
|
||||||
|
|
||||||
|
public void ConfigureServices(IServiceCollection services)
|
||||||
|
{
|
||||||
|
// Register anything you'll resolve later, or any extension point implementations.
|
||||||
|
// e.g. services.AddSingleton<ISettingsPageExtension, MySettingsExtension>();
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task InitializeAsync(PluginContext context, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var events = context.Services.GetRequiredService<IPluginEventBus>();
|
||||||
|
|
||||||
|
_launchSubscription = events.Subscribe<GameBeforeLaunchEvent>((evt, ct) =>
|
||||||
|
{
|
||||||
|
context.Logger.LogInformation("Game {GameId} is about to launch", evt.GameId);
|
||||||
|
return Task.CompletedTask;
|
||||||
|
});
|
||||||
|
|
||||||
|
context.Logger.LogInformation("{Name} initialized on host {Host}", Name, context.Host);
|
||||||
|
|
||||||
|
return Task.CompletedTask;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 4. Mark the assembly as a plugin
|
||||||
|
|
||||||
|
Discovery is driven by an assembly-level
|
||||||
|
[`[LANCommanderPlugin]`](/Plugins/API%20Reference#lancommanderpluginattribute) attribute. It names the
|
||||||
|
entry point and declares compatibility metadata. Place it anywhere in your project (a common choice is
|
||||||
|
above the `namespace` declaration in your entry point file):
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
using LANCommander.SDK.Plugins;
|
||||||
|
|
||||||
|
[assembly: LANCommanderPlugin(
|
||||||
|
typeof(MyCompany.MyPlugin.MyPlugin),
|
||||||
|
Id = "com.mycompany.myplugin",
|
||||||
|
MinHostVersion = "1.1.0",
|
||||||
|
Hosts = PluginHost.Server | PluginHost.Launcher)]
|
||||||
|
```
|
||||||
|
|
||||||
|
- **`Id`** is optional; when omitted the loader falls back to the entry point's `IPlugin.Id`.
|
||||||
|
- **`MinHostVersion` / `MaxHostVersion`** are optional SemVer bounds (inclusive). A plugin outside the
|
||||||
|
host's version range is skipped.
|
||||||
|
- **`Hosts`** declares which hosts the plugin supports. Defaults to both server and launcher.
|
||||||
|
|
||||||
|
## 5. Build and deploy
|
||||||
|
|
||||||
|
Build your plugin and copy its output into a subfolder of LANCommander's `Plugins` directory. That
|
||||||
|
directory lives inside LANCommander's data folder — `Data/Plugins` next to the executable, or under your
|
||||||
|
user profile's application data if the install directory is not writable:
|
||||||
|
|
||||||
|
```
|
||||||
|
Data/
|
||||||
|
└── Plugins/
|
||||||
|
└── MyCompany.MyPlugin/
|
||||||
|
├── MyCompany.MyPlugin.dll
|
||||||
|
├── MyCompany.MyPlugin.deps.json
|
||||||
|
└── (your private dependencies)
|
||||||
|
```
|
||||||
|
|
||||||
|
The loader prefers an assembly named after the folder (`MyCompany.MyPlugin.dll` in the example above),
|
||||||
|
so naming the folder after your main assembly is the most reliable convention.
|
||||||
|
|
||||||
|
## 6. Verify it loaded
|
||||||
|
|
||||||
|
Start the host and check the logs. A successful load emits an entry like:
|
||||||
|
|
||||||
|
```
|
||||||
|
Loaded plugin 'My Plugin' (com.mycompany.myplugin) v1.0.0 by My Company
|
||||||
|
```
|
||||||
|
|
||||||
|
If your plugin does not appear, the logs will explain why — a missing `[LANCommanderPlugin]` attribute,
|
||||||
|
an incompatible host version, a host it does not target, or an exception thrown during
|
||||||
|
`ConfigureServices`. Every failure is isolated and logged rather than crashing the host.
|
||||||
|
|
||||||
|
## Next steps
|
||||||
|
|
||||||
|
Now that your plugin loads, head to [Extension Points](/Plugins/Extension%20Points) to add real
|
||||||
|
functionality, or browse the [API Reference](/Plugins/API%20Reference) for the complete surface.
|
||||||
50
LANCommander.Documentation/Plugins/Overview.md
Normal file
50
LANCommander.Documentation/Plugins/Overview.md
Normal file
|
|
@ -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.
|
||||||
|
|
@ -17,6 +17,7 @@ namespace LANCommander.Launcher.Models
|
||||||
public DateTime QueuedOn { get; set; }
|
public DateTime QueuedOn { get; set; }
|
||||||
public DateTime? CompletedOn { get; set; }
|
public DateTime? CompletedOn { get; set; }
|
||||||
public bool IsUpdate { get; set; }
|
public bool IsUpdate { get; set; }
|
||||||
|
public SDK.Models.GameVersion? TargetVersion { get; set; }
|
||||||
public bool State {
|
public bool State {
|
||||||
get
|
get
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,16 @@
|
||||||
|
using Avalonia.Controls;
|
||||||
|
|
||||||
|
namespace LANCommander.Launcher.Plugins.Extensions;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Adds items to a game's context menu. Implementations are resolved from DI and their items
|
||||||
|
/// appended to the consolidated game menu shown on covers and list rows.
|
||||||
|
/// </summary>
|
||||||
|
public interface IContextMenuExtension
|
||||||
|
{
|
||||||
|
/// <summary>Relative position among extension items; lower values appear first.</summary>
|
||||||
|
int Order { get; }
|
||||||
|
|
||||||
|
/// <summary>Builds the menu items shown for the given game (typically <see cref="MenuItem"/>s).</summary>
|
||||||
|
IEnumerable<Control> BuildMenuItems(Guid gameId);
|
||||||
|
}
|
||||||
16
LANCommander.Launcher.Plugins/Extensions/IFooterExtension.cs
Normal file
16
LANCommander.Launcher.Plugins/Extensions/IFooterExtension.cs
Normal file
|
|
@ -0,0 +1,16 @@
|
||||||
|
using Avalonia.Controls;
|
||||||
|
|
||||||
|
namespace LANCommander.Launcher.Plugins.Extensions;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Adds a control to the launcher shell's footer. Implementations are resolved from DI and
|
||||||
|
/// rendered, ordered by <see cref="Order"/>, alongside the built-in footer items.
|
||||||
|
/// </summary>
|
||||||
|
public interface IFooterExtension
|
||||||
|
{
|
||||||
|
/// <summary>Relative position among extension items; lower values appear first.</summary>
|
||||||
|
int Order { get; }
|
||||||
|
|
||||||
|
/// <summary>Builds the control rendered in the footer.</summary>
|
||||||
|
Control BuildContent();
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,19 @@
|
||||||
|
using Avalonia.Controls;
|
||||||
|
|
||||||
|
namespace LANCommander.Launcher.Plugins.Extensions;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Adds an additional tab to a game's detail view. Implementations are resolved from DI and
|
||||||
|
/// appended, ordered by <see cref="Order"/>, after the built-in tabs.
|
||||||
|
/// </summary>
|
||||||
|
public interface IGameDetailTabExtension
|
||||||
|
{
|
||||||
|
/// <summary>Header shown on the tab.</summary>
|
||||||
|
string Header { get; }
|
||||||
|
|
||||||
|
/// <summary>Relative position among extension tabs; lower values appear first.</summary>
|
||||||
|
int Order { get; }
|
||||||
|
|
||||||
|
/// <summary>Builds the control rendered inside the tab for the given game.</summary>
|
||||||
|
Control BuildContent(Guid gameId);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,26 @@
|
||||||
|
using Avalonia.Controls;
|
||||||
|
|
||||||
|
namespace LANCommander.Launcher.Plugins.Extensions;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Adds a top-level navigable destination reachable from the launcher shell. The view model
|
||||||
|
/// is registered with the <see cref="IViewRegistry"/> so the shell's content control can render the
|
||||||
|
/// associated view when navigated to.
|
||||||
|
/// </summary>
|
||||||
|
public interface INavigationPageExtension
|
||||||
|
{
|
||||||
|
/// <summary>Label shown for the navigation entry.</summary>
|
||||||
|
string Label { get; }
|
||||||
|
|
||||||
|
/// <summary>Relative position among extension destinations; lower values appear first.</summary>
|
||||||
|
int Order { get; }
|
||||||
|
|
||||||
|
/// <summary>The view model type used both as the navigation target and the registry key.</summary>
|
||||||
|
Type ViewModelType { get; }
|
||||||
|
|
||||||
|
/// <summary>Creates the view model instance shown when the destination is activated.</summary>
|
||||||
|
PluginViewModelBase CreateViewModel();
|
||||||
|
|
||||||
|
/// <summary>Builds the control that renders <see cref="ViewModelType"/>.</summary>
|
||||||
|
Control BuildView();
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,19 @@
|
||||||
|
using Avalonia.Controls;
|
||||||
|
|
||||||
|
namespace LANCommander.Launcher.Plugins.Extensions;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Adds an additional section to the launcher's settings page. Implementations are resolved
|
||||||
|
/// from DI and appended, ordered by <see cref="Order"/>, beneath the built-in settings sections.
|
||||||
|
/// </summary>
|
||||||
|
public interface ISettingsPageExtension
|
||||||
|
{
|
||||||
|
/// <summary>Heading shown for the section.</summary>
|
||||||
|
string Title { get; }
|
||||||
|
|
||||||
|
/// <summary>Relative position among extension sections; lower values appear first.</summary>
|
||||||
|
int Order { get; }
|
||||||
|
|
||||||
|
/// <summary>Builds the control rendered inside the section.</summary>
|
||||||
|
Control BuildContent();
|
||||||
|
}
|
||||||
26
LANCommander.Launcher.Plugins/IViewRegistry.cs
Normal file
26
LANCommander.Launcher.Plugins/IViewRegistry.cs
Normal file
|
|
@ -0,0 +1,26 @@
|
||||||
|
using Avalonia.Controls;
|
||||||
|
using Avalonia.Controls.Templates;
|
||||||
|
|
||||||
|
namespace LANCommander.Launcher.Plugins;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Maps view model types to the Avalonia controls that render them. Seeded at startup with the
|
||||||
|
/// launcher's built-in mappings and extended at runtime by plugins that add navigable views.
|
||||||
|
/// Consumers apply <see cref="AsDataTemplate"/> to a <see cref="ContentControl"/> so content is
|
||||||
|
/// resolved by view model type, replacing the previously hard-coded inline XAML data templates.
|
||||||
|
/// </summary>
|
||||||
|
public interface IViewRegistry
|
||||||
|
{
|
||||||
|
/// <summary>Register a control factory for the given view model type.</summary>
|
||||||
|
void Register(Type viewModelType, Func<Control> factory);
|
||||||
|
|
||||||
|
/// <summary>Register a control factory for <typeparamref name="TViewModel"/>.</summary>
|
||||||
|
void Register<TViewModel>(Func<Control> factory);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Build an <see cref="IDataTemplate"/> backed by the current registrations. Matching prefers
|
||||||
|
/// the most-derived registered type, preserving the launcher's existing rule that
|
||||||
|
/// DepotGameDetailViewModel resolves before its GameDetailViewModel base.
|
||||||
|
/// </summary>
|
||||||
|
IDataTemplate AsDataTemplate();
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,22 @@
|
||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
|
||||||
|
<PropertyGroup>
|
||||||
|
<TargetFramework>net10.0</TargetFramework>
|
||||||
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
|
<Nullable>enable</Nullable>
|
||||||
|
<!-- Emit XML docs so the plugin API reference can be generated from source comments. -->
|
||||||
|
<GenerateDocumentationFile>true</GenerateDocumentationFile>
|
||||||
|
<!-- Don't fail/spam the build for members without XML comments. -->
|
||||||
|
<NoWarn>$(NoWarn);CS1591</NoWarn>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<PackageReference Include="Avalonia" />
|
||||||
|
<PackageReference Include="CommunityToolkit.Mvvm" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<ProjectReference Include="..\LANCommander.SDK\LANCommander.SDK.csproj" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
</Project>
|
||||||
12
LANCommander.Launcher.Plugins/PluginViewModelBase.cs
Normal file
12
LANCommander.Launcher.Plugins/PluginViewModelBase.cs
Normal file
|
|
@ -0,0 +1,12 @@
|
||||||
|
using CommunityToolkit.Mvvm.ComponentModel;
|
||||||
|
|
||||||
|
namespace LANCommander.Launcher.Plugins;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Base type for view models supplied by plugins. Lives in this project (rather than the
|
||||||
|
/// launcher's ViewModels assembly) so plugins can derive from it without taking a dependency
|
||||||
|
/// on the launcher application itself, avoiding a circular reference.
|
||||||
|
/// </summary>
|
||||||
|
public abstract class PluginViewModelBase : ObservableObject
|
||||||
|
{
|
||||||
|
}
|
||||||
58
LANCommander.Launcher.Plugins/ViewRegistry.cs
Normal file
58
LANCommander.Launcher.Plugins/ViewRegistry.cs
Normal file
|
|
@ -0,0 +1,58 @@
|
||||||
|
using Avalonia.Controls;
|
||||||
|
using Avalonia.Controls.Templates;
|
||||||
|
|
||||||
|
namespace LANCommander.Launcher.Plugins;
|
||||||
|
|
||||||
|
/// <inheritdoc cref="IViewRegistry" />
|
||||||
|
public sealed class ViewRegistry : IViewRegistry
|
||||||
|
{
|
||||||
|
private readonly List<Registration> _registrations = new();
|
||||||
|
|
||||||
|
public void Register(Type viewModelType, Func<Control> factory)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(viewModelType);
|
||||||
|
ArgumentNullException.ThrowIfNull(factory);
|
||||||
|
|
||||||
|
_registrations.Add(new Registration(viewModelType, factory));
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Register<TViewModel>(Func<Control> factory) => Register(typeof(TViewModel), factory);
|
||||||
|
|
||||||
|
public IDataTemplate AsDataTemplate() => new RegistryDataTemplate(_registrations);
|
||||||
|
|
||||||
|
private readonly record struct Registration(Type ViewModelType, Func<Control> Factory);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A live view over the registry's registrations. Because it holds the same list instance the
|
||||||
|
/// registry appends to, plugin registrations made after this template is attached are still
|
||||||
|
/// picked up.
|
||||||
|
/// </summary>
|
||||||
|
private sealed class RegistryDataTemplate(IReadOnlyList<Registration> registrations) : IDataTemplate
|
||||||
|
{
|
||||||
|
public bool Match(object? data) => data != null && FindFactory(data.GetType()) != null;
|
||||||
|
|
||||||
|
public Control? Build(object? data) => data == null ? null : FindFactory(data.GetType())?.Invoke();
|
||||||
|
|
||||||
|
private Func<Control>? FindFactory(Type dataType)
|
||||||
|
{
|
||||||
|
Type? bestType = null;
|
||||||
|
Func<Control>? bestFactory = null;
|
||||||
|
|
||||||
|
foreach (var registration in registrations)
|
||||||
|
{
|
||||||
|
if (!registration.ViewModelType.IsAssignableFrom(dataType))
|
||||||
|
continue;
|
||||||
|
|
||||||
|
// Prefer the most-derived match: a candidate wins if the current best is one of its
|
||||||
|
// base types (i.e. the candidate is more specific), or if there is no best yet.
|
||||||
|
if (bestType == null || bestType.IsAssignableFrom(registration.ViewModelType))
|
||||||
|
{
|
||||||
|
bestType = registration.ViewModelType;
|
||||||
|
bestFactory = registration.Factory;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return bestFactory;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -4,6 +4,8 @@ using LANCommander.Launcher.Models;
|
||||||
using LANCommander.SDK;
|
using LANCommander.SDK;
|
||||||
using LANCommander.SDK.Extensions;
|
using LANCommander.SDK.Extensions;
|
||||||
using LANCommander.SDK.Helpers;
|
using LANCommander.SDK.Helpers;
|
||||||
|
using LANCommander.SDK.Plugins;
|
||||||
|
using LANCommander.SDK.Plugins.Events;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using Microsoft.Extensions.Logging;
|
using Microsoft.Extensions.Logging;
|
||||||
using Microsoft.Extensions.DependencyInjection;
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
|
@ -22,6 +24,7 @@ namespace LANCommander.Launcher.Services
|
||||||
ToolService toolService,
|
ToolService toolService,
|
||||||
ToolClient toolClient,
|
ToolClient toolClient,
|
||||||
IConnectionClient connectionClient,
|
IConnectionClient connectionClient,
|
||||||
|
IPluginEventBus pluginEventBus,
|
||||||
IServiceProvider serviceProvider) : BaseDatabaseService<Game>(dbContext, logger)
|
IServiceProvider serviceProvider) : BaseDatabaseService<Game>(dbContext, logger)
|
||||||
{
|
{
|
||||||
public Dictionary<Guid, Process> RunningProcesses = new Dictionary<Guid, Process>();
|
public Dictionary<Guid, Process> RunningProcesses = new Dictionary<Guid, Process>();
|
||||||
|
|
@ -49,6 +52,7 @@ namespace LANCommander.Launcher.Services
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
OnUninstall?.Invoke(game);
|
OnUninstall?.Invoke(game);
|
||||||
|
await pluginEventBus.PublishAsync(new GameUninstallingEvent(game.Id, game.InstallDirectory));
|
||||||
|
|
||||||
var installService = serviceProvider.GetService<InstallService>();
|
var installService = serviceProvider.GetService<InstallService>();
|
||||||
installService?.ClearCompleted(game.Id);
|
installService?.ClearCompleted(game.Id);
|
||||||
|
|
@ -94,6 +98,7 @@ namespace LANCommander.Launcher.Services
|
||||||
await UpdateAsync(game);
|
await UpdateAsync(game);
|
||||||
|
|
||||||
OnUninstallComplete?.Invoke(game);
|
OnUninstallComplete?.Invoke(game);
|
||||||
|
await pluginEventBus.PublishAsync(new GameUninstalledEvent(game.Id));
|
||||||
|
|
||||||
operation.Complete();
|
operation.Complete();
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,8 @@ using Microsoft.Extensions.Logging;
|
||||||
using System.Collections.ObjectModel;
|
using System.Collections.ObjectModel;
|
||||||
using System.Diagnostics;
|
using System.Diagnostics;
|
||||||
using LANCommander.SDK.Models;
|
using LANCommander.SDK.Models;
|
||||||
|
using LANCommander.SDK.Plugins;
|
||||||
|
using LANCommander.SDK.Plugins.Events;
|
||||||
using LANCommander.SDK.Services;
|
using LANCommander.SDK.Services;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using Game = LANCommander.Launcher.Data.Models.Game;
|
using Game = LANCommander.Launcher.Data.Models.Game;
|
||||||
|
|
@ -24,6 +26,7 @@ namespace LANCommander.Launcher.Services
|
||||||
private readonly RedistributableClient _redistributableClient;
|
private readonly RedistributableClient _redistributableClient;
|
||||||
private readonly ToolClient _toolClient;
|
private readonly ToolClient _toolClient;
|
||||||
private readonly MediaClient _mediaClient;
|
private readonly MediaClient _mediaClient;
|
||||||
|
private readonly IPluginEventBus _pluginEventBus;
|
||||||
|
|
||||||
private Stopwatch Stopwatch { get; set; }
|
private Stopwatch Stopwatch { get; set; }
|
||||||
|
|
||||||
|
|
@ -64,7 +67,8 @@ namespace LANCommander.Launcher.Services
|
||||||
GameClient gameClient,
|
GameClient gameClient,
|
||||||
RedistributableClient redistributableClient,
|
RedistributableClient redistributableClient,
|
||||||
ToolClient toolClient,
|
ToolClient toolClient,
|
||||||
MediaClient mediaClient) : base(logger)
|
MediaClient mediaClient,
|
||||||
|
IPluginEventBus pluginEventBus) : base(logger)
|
||||||
{
|
{
|
||||||
_gameService = gameService;
|
_gameService = gameService;
|
||||||
_toolService = toolService;
|
_toolService = toolService;
|
||||||
|
|
@ -73,6 +77,16 @@ namespace LANCommander.Launcher.Services
|
||||||
_redistributableClient = redistributableClient;
|
_redistributableClient = redistributableClient;
|
||||||
_toolClient = toolClient;
|
_toolClient = toolClient;
|
||||||
_mediaClient = mediaClient;
|
_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();
|
Stopwatch = new Stopwatch();
|
||||||
|
|
||||||
|
|
@ -568,6 +582,13 @@ namespace LANCommander.Launcher.Services
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (queueItem.TargetVersion != null)
|
||||||
|
{
|
||||||
|
await SwitchToVersion(queueItem, localGame, remoteGame);
|
||||||
|
await Next();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (localGame.Installed && !queueItem.DependsOnId.HasValue
|
if (localGame.Installed && !queueItem.DependsOnId.HasValue
|
||||||
&& !string.IsNullOrEmpty(localGame.InstallDirectory)
|
&& !string.IsNullOrEmpty(localGame.InstallDirectory)
|
||||||
&& ManifestHelper.Exists(localGame.InstallDirectory, localGame.Id))
|
&& ManifestHelper.Exists(localGame.InstallDirectory, localGame.Id))
|
||||||
|
|
@ -728,6 +749,8 @@ namespace LANCommander.Launcher.Services
|
||||||
currentItem.Status = InstallStatus.Downloading;
|
currentItem.Status = InstallStatus.Downloading;
|
||||||
OnQueueChanged?.Invoke();
|
OnQueueChanged?.Invoke();
|
||||||
|
|
||||||
|
await _pluginEventBus.PublishAsync(new GameInstallingEvent(localGame.Id, currentItem.InstallDirectory));
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
// Build a plan item from the queue item's tasks
|
// Build a plan item from the queue item's tasks
|
||||||
|
|
@ -943,6 +966,136 @@ namespace LANCommander.Launcher.Services
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Queues an explicit install/rollback of an already-installed game to a specific version.
|
||||||
|
/// The switch runs through the download queue (so it shows progress and supports cancel)
|
||||||
|
/// and is processed by <see cref="SwitchToVersion"/>.
|
||||||
|
/// </summary>
|
||||||
|
public async Task AddVersionSwitchAsync(Game localGame, SDK.Models.GameVersion version)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(localGame);
|
||||||
|
ArgumentNullException.ThrowIfNull(version);
|
||||||
|
|
||||||
|
if (version.ArchiveId == null || version.ArchiveId == Guid.Empty)
|
||||||
|
throw new InstallException("The selected version has no archive to install.");
|
||||||
|
|
||||||
|
if (string.IsNullOrWhiteSpace(localGame.InstallDirectory))
|
||||||
|
throw new InstallException("The game is not installed.");
|
||||||
|
|
||||||
|
var remoteGame = await _gameClient.GetAsync(localGame.Id);
|
||||||
|
|
||||||
|
if (remoteGame == null)
|
||||||
|
throw new InstallException($"Could not fetch game info for game {localGame.Id}");
|
||||||
|
|
||||||
|
// Drop any settled (non-active) history for this game so the switch shows as a fresh item.
|
||||||
|
var staleItems = Queue.Where(i => !i.State && i.Id == localGame.Id).ToList();
|
||||||
|
|
||||||
|
foreach (var staleItem in staleItems)
|
||||||
|
Queue.Remove(staleItem);
|
||||||
|
|
||||||
|
// If a switch/install for this game is already in flight, don't queue a duplicate.
|
||||||
|
if (Queue.Any(i => i.Id == localGame.Id
|
||||||
|
&& i.Status.ValueIsIn(InstallStatus.Queued, InstallStatus.Starting, InstallStatus.Downloading)))
|
||||||
|
{
|
||||||
|
Logger?.LogInformation("[InstallQueue] AddVersionSwitch: Game {GameId} already has an active queue item, skipping", localGame.Id);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var queueItem = new InstallQueueGame(remoteGame)
|
||||||
|
{
|
||||||
|
InstallDirectory = localGame.InstallDirectory,
|
||||||
|
Version = version.Version,
|
||||||
|
TargetVersion = version,
|
||||||
|
IsUpdate = !string.IsNullOrWhiteSpace(localGame.InstalledVersion)
|
||||||
|
&& version.Version != localGame.InstalledVersion,
|
||||||
|
};
|
||||||
|
|
||||||
|
Queue.Add(queueItem);
|
||||||
|
|
||||||
|
_pendingNotificationRoots.Add(localGame.Id);
|
||||||
|
|
||||||
|
Logger?.LogInformation("[InstallQueue] AddVersionSwitch: Queued switch of {Title} ({Id}) to version {Version}",
|
||||||
|
localGame.Title, localGame.Id, version.Version);
|
||||||
|
|
||||||
|
if (!Queue.Any(i => i.State))
|
||||||
|
{
|
||||||
|
queueItem.Status = InstallStatus.Starting;
|
||||||
|
await Next();
|
||||||
|
}
|
||||||
|
|
||||||
|
OnQueueChanged?.Invoke();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Applies an explicit version switch queue item. Downloads the target version's full
|
||||||
|
/// archive, extracts it over the install directory, then writes the version-scoped manifest
|
||||||
|
/// and scripts so on-disk config matches the chosen version. Updates the local
|
||||||
|
/// InstalledVersion. Cancellable via the queue item's cancellation token.
|
||||||
|
/// </summary>
|
||||||
|
private async Task SwitchToVersion(InstallQueueGame currentItem, Game localGame, SDK.Models.Game remoteGame)
|
||||||
|
{
|
||||||
|
var version = currentItem.TargetVersion;
|
||||||
|
|
||||||
|
using (var operation = Logger.BeginOperation("Switching game {GameTitle} ({GameId}) to version {Version}", localGame.Title, localGame.Id, version.Version))
|
||||||
|
{
|
||||||
|
Logger?.LogInformation("[InstallQueue] SwitchToVersion: Switching {Title} ({Id}) from {InstalledVersion} to {TargetVersion} (archive {ArchiveId})",
|
||||||
|
localGame.Title, localGame.Id, localGame.InstalledVersion, version.Version, version.ArchiveId);
|
||||||
|
|
||||||
|
currentItem.Status = InstallStatus.Downloading;
|
||||||
|
OnQueueChanged?.Invoke();
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var success = await _gameClient.ApplyUpdateArchiveAsync(version.ArchiveId.Value, localGame.Id, localGame.InstallDirectory, currentItem.CancellationToken.Token);
|
||||||
|
|
||||||
|
if (!success)
|
||||||
|
throw new InstallCanceledException("Version switch was canceled");
|
||||||
|
|
||||||
|
// Write the version-scoped manifest and scripts so on-disk config matches the chosen version.
|
||||||
|
await _gameClient.RefreshManifestAndScriptsAsync(localGame.InstallDirectory, localGame.Id, version.Id);
|
||||||
|
|
||||||
|
localGame.InstalledVersion = version.Version;
|
||||||
|
await _gameService.UpdateAsync(localGame);
|
||||||
|
}
|
||||||
|
catch (InstallCanceledException)
|
||||||
|
{
|
||||||
|
Logger?.LogError("Version switch canceled, removing from queue");
|
||||||
|
Queue.Remove(currentItem);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
catch (InstallException ex)
|
||||||
|
{
|
||||||
|
Logger?.LogError(ex, "An error occurred during version switch");
|
||||||
|
currentItem.Status = InstallStatus.Failed;
|
||||||
|
OnQueueChanged?.Invoke();
|
||||||
|
OnInstallFail?.Invoke(localGame);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Logger?.LogError(ex, "An unknown error occurred during version switch");
|
||||||
|
currentItem.Status = InstallStatus.Failed;
|
||||||
|
OnQueueChanged?.Invoke();
|
||||||
|
OnInstallFail?.Invoke(localGame);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
currentItem.CompletedOn = DateTime.Now;
|
||||||
|
currentItem.Status = InstallStatus.Complete;
|
||||||
|
currentItem.Progress = 1;
|
||||||
|
currentItem.BytesDownloaded = currentItem.TotalBytes;
|
||||||
|
|
||||||
|
OnQueueChanged?.Invoke();
|
||||||
|
|
||||||
|
Logger?.LogInformation("[InstallQueue] SwitchToVersion: Completed switch of {Title} ({Id}) to version {Version}",
|
||||||
|
localGame.Title, localGame.Id, version.Version);
|
||||||
|
|
||||||
|
OnInstallComplete?.Invoke(localGame);
|
||||||
|
|
||||||
|
operation.Complete();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
public async Task Install(InstallQueueTool currentItem, Tool localTool, SDK.Models.Tool remoteTool)
|
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))
|
using (var operation = Logger.BeginOperation("Installing tool {ToolName} ({ToolId})", localTool.Name, localTool.Id))
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,7 @@ using Avalonia.Data.Core.Plugins;
|
||||||
using Avalonia.Markup.Xaml;
|
using Avalonia.Markup.Xaml;
|
||||||
using LANCommander.Launcher.Input;
|
using LANCommander.Launcher.Input;
|
||||||
using LANCommander.Launcher.Helpers;
|
using LANCommander.Launcher.Helpers;
|
||||||
|
using LANCommander.Launcher.Plugins;
|
||||||
using LANCommander.Launcher.Services;
|
using LANCommander.Launcher.Services;
|
||||||
using LANCommander.Launcher.ViewModels;
|
using LANCommander.Launcher.ViewModels;
|
||||||
using LANCommander.Launcher.Views;
|
using LANCommander.Launcher.Views;
|
||||||
|
|
@ -194,6 +195,16 @@ public partial class App : Application
|
||||||
_logger?.LogInformation("Initializing view model...");
|
_logger?.LogInformation("Initializing view model...");
|
||||||
await mainViewModel.InitializeAsync().ConfigureAwait(false);
|
await mainViewModel.InitializeAsync().ConfigureAwait(false);
|
||||||
_logger?.LogInformation("View model initialized, application ready");
|
_logger?.LogInformation("View model initialized, application ready");
|
||||||
|
|
||||||
|
// Initialize plugins now that the service provider and core services are ready.
|
||||||
|
await Services!.GetRequiredService<LANCommander.SDK.Plugins.PluginLoaderService>()
|
||||||
|
.InitializeAllAsync(Services!).ConfigureAwait(false);
|
||||||
|
|
||||||
|
// Register plugin navigable views with the shared registry so the shell's
|
||||||
|
// content control can render them. The registry's data template reads its registration
|
||||||
|
// list live, so mappings added here are picked up even though the template was attached
|
||||||
|
// when the shell view was constructed.
|
||||||
|
RegisterPluginNavigationViews();
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
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<IViewRegistry>();
|
||||||
|
|
||||||
|
if (registry is null)
|
||||||
|
return;
|
||||||
|
|
||||||
|
foreach (var extension in Services.GetServices<Plugins.Extensions.INavigationPageExtension>())
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
registry.Register(extension.ViewModelType, extension.BuildView);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger?.LogWarning(ex, "Could not register navigation view for plugin extension {Extension}", extension.GetType().FullName);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private static void ConfigureServices(IServiceCollection services)
|
private static void ConfigureServices(IServiceCollection services)
|
||||||
{
|
{
|
||||||
// Configure logging to console and file
|
// Configure logging to console and file
|
||||||
|
|
@ -265,6 +299,37 @@ public partial class App : Application
|
||||||
services.AddSingleton<TaskbarProgressService>();
|
services.AddSingleton<TaskbarProgressService>();
|
||||||
services.AddSingleton<SingleInstanceService>();
|
services.AddSingleton<SingleInstanceService>();
|
||||||
services.AddSingleton<INavigationService, NavigationService>();
|
services.AddSingleton<INavigationService, NavigationService>();
|
||||||
|
|
||||||
|
// View registry: seed the built-in view model -> view mappings that were previously declared
|
||||||
|
// as inline DataTemplates in MainWindow.axaml / ShellView.axaml. Plugins may append further
|
||||||
|
// mappings during initialization. Ordering preserves the DepotGameDetailViewModel-before-
|
||||||
|
// GameDetailViewModel rule via most-derived-first matching in ViewRegistry.
|
||||||
|
services.AddSingleton<IViewRegistry>(_ =>
|
||||||
|
{
|
||||||
|
var registry = new ViewRegistry();
|
||||||
|
|
||||||
|
// App-level shell hosted in MainWindow's ContentControl
|
||||||
|
registry.Register<SplashViewModel>(() => new SplashView());
|
||||||
|
registry.Register<ServerSelectionViewModel>(() => new ServerSelectionView());
|
||||||
|
registry.Register<LoginViewModel>(() => new LoginView());
|
||||||
|
registry.Register<ShellViewModel>(() => new ShellView());
|
||||||
|
|
||||||
|
// Shell content hosted in ShellView's TransitioningContentControl
|
||||||
|
registry.Register<DepotViewModel>(() => new DepotView());
|
||||||
|
registry.Register<DepotBrowseViewModel>(() => new DepotBrowseView());
|
||||||
|
registry.Register<DepotGameDetailViewModel>(() => new GameDetailView());
|
||||||
|
registry.Register<GamesListViewModel>(() => new GamesListView());
|
||||||
|
registry.Register<LibraryViewModel>(() => new GamesListView());
|
||||||
|
registry.Register<GameDetailViewModel>(() => new GameDetailView());
|
||||||
|
registry.Register<SettingsViewModel>(() => new SettingsView());
|
||||||
|
registry.Register<DownloadQueueViewModel>(() => new DownloadQueuePageView());
|
||||||
|
|
||||||
|
return registry;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Plugin framework: discover drop-in plugins and let them register services. Must be the last
|
||||||
|
// registration step because the service provider is built immediately after this method returns.
|
||||||
|
LANCommander.SDK.Plugins.PluginBootstrap.ConfigurePlugins(services, LANCommander.SDK.Plugins.PluginHost.Launcher);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static OSPlatform GetOSPlatform()
|
private static OSPlatform GetOSPlatform()
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,7 @@
|
||||||
|
using System;
|
||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
using System.Collections.Specialized;
|
using System.Collections.Specialized;
|
||||||
|
using System.Linq;
|
||||||
using System.Runtime.CompilerServices;
|
using System.Runtime.CompilerServices;
|
||||||
using Avalonia;
|
using Avalonia;
|
||||||
using Avalonia.Controls;
|
using Avalonia.Controls;
|
||||||
|
|
@ -8,8 +10,10 @@ using Avalonia.Data.Converters;
|
||||||
using Avalonia.Input;
|
using Avalonia.Input;
|
||||||
using Avalonia.Threading;
|
using Avalonia.Threading;
|
||||||
using Avalonia.VisualTree;
|
using Avalonia.VisualTree;
|
||||||
|
using LANCommander.Launcher.Plugins.Extensions;
|
||||||
using LANCommander.Launcher.ViewModels;
|
using LANCommander.Launcher.ViewModels;
|
||||||
using LANCommander.Launcher.ViewModels.Components;
|
using LANCommander.Launcher.ViewModels.Components;
|
||||||
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
|
||||||
namespace LANCommander.Launcher.Controls;
|
namespace LANCommander.Launcher.Controls;
|
||||||
|
|
||||||
|
|
@ -196,18 +200,70 @@ public static class GameContextMenu
|
||||||
items.Add(Item("Browse Files", vm, "BrowseFilesCommand", visiblePath: "IsInstalled"));
|
items.Add(Item("Browse Files", vm, "BrowseFilesCommand", visiblePath: "IsInstalled"));
|
||||||
items.Add(Item("View Manual", vm, "OpenFirstManualCommand", visiblePath: "HasManuals"));
|
items.Add(Item("View Manual", vm, "OpenFirstManualCommand", visiblePath: "HasManuals"));
|
||||||
items.Add(Item("Modify", vm, "ModifyCommand", visiblePath: "IsInstalled"));
|
items.Add(Item("Modify", vm, "ModifyCommand", visiblePath: "IsInstalled"));
|
||||||
|
items.Add(Item("Select Version...", vm, "SelectVersionCommand", visiblePath: "IsInstalled"));
|
||||||
items.Add(Separator(vm, "IsInstalled"));
|
items.Add(Separator(vm, "IsInstalled"));
|
||||||
items.Add(Item("Verify Files", vm, "VerifyFilesCommand", visiblePath: "IsInstalled", enabledPath: "IsVerifyingFiles", enabledInvert: true));
|
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("Uninstall", vm, "UninstallCommand", visiblePath: "IsInstalled", enabledPath: "IsUninstalling", enabledInvert: true));
|
||||||
items.Add(Item("Add to Library", vm, "AddToLibraryCommand", visiblePath: "IsInLibrary", visibleInvert: true));
|
items.Add(Item("Add to Library", vm, "AddToLibraryCommand", visiblePath: "IsInLibrary", visibleInvert: true));
|
||||||
items.Add(Item("Remove from Library", vm, "RemoveFromLibraryCommand", enabledPath: "IsInLibrary"));
|
items.Add(Item("Remove from Library", vm, "RemoveFromLibraryCommand", enabledPath: "IsInLibrary"));
|
||||||
|
|
||||||
|
AppendPluginItems(items, vm.GameId);
|
||||||
|
|
||||||
flyout.Items.Clear();
|
flyout.Items.Clear();
|
||||||
|
|
||||||
foreach (var item in items)
|
foreach (var item in items)
|
||||||
flyout.Items.Add(item);
|
flyout.Items.Add(item);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Appends items added by plugins (via <see cref="IContextMenuExtension"/>) after the
|
||||||
|
/// built-in items, separated by a divider. A failing extension is skipped so the core menu
|
||||||
|
/// still renders.
|
||||||
|
/// </summary>
|
||||||
|
private static void AppendPluginItems(List<Control> items, Guid gameId)
|
||||||
|
{
|
||||||
|
var extensions = App.Services?
|
||||||
|
.GetServices<IContextMenuExtension>()
|
||||||
|
.OrderBy(c => c.Order)
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
if (extensions == null || extensions.Count == 0)
|
||||||
|
return;
|
||||||
|
|
||||||
|
var added = false;
|
||||||
|
|
||||||
|
foreach (var extension in extensions)
|
||||||
|
{
|
||||||
|
IEnumerable<Control>? extensionItems;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
extensionItems = extension.BuildMenuItems(gameId);
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (extensionItems == null)
|
||||||
|
continue;
|
||||||
|
|
||||||
|
foreach (var control in extensionItems)
|
||||||
|
{
|
||||||
|
if (control == null)
|
||||||
|
continue;
|
||||||
|
|
||||||
|
if (!added)
|
||||||
|
{
|
||||||
|
items.Add(new Separator());
|
||||||
|
added = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
items.Add(control);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private static MenuItem Item(
|
private static MenuItem Item(
|
||||||
string header,
|
string header,
|
||||||
GameActionBarViewModel vm,
|
GameActionBarViewModel vm,
|
||||||
|
|
|
||||||
|
|
@ -41,6 +41,7 @@
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
|
<ProjectReference Include="..\LANCommander.Launcher.Plugins\LANCommander.Launcher.Plugins.csproj" />
|
||||||
<ProjectReference Include="..\LANCommander.Launcher.Services\LANCommander.Launcher.Services.csproj" />
|
<ProjectReference Include="..\LANCommander.Launcher.Services\LANCommander.Launcher.Services.csproj" />
|
||||||
<ProjectReference Include="..\LANCommander.Launcher.Settings\LANCommander.Launcher.Settings.csproj" />
|
<ProjectReference Include="..\LANCommander.Launcher.Settings\LANCommander.Launcher.Settings.csproj" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,7 @@ using LANCommander.Launcher.Services;
|
||||||
using LANCommander.Launcher.Services.Extensions;
|
using LANCommander.Launcher.Services.Extensions;
|
||||||
using LANCommander.SDK;
|
using LANCommander.SDK;
|
||||||
using LANCommander.SDK.Extensions;
|
using LANCommander.SDK.Extensions;
|
||||||
|
using LANCommander.SDK.Plugins;
|
||||||
using LANCommander.SDK.Services;
|
using LANCommander.SDK.Services;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using Microsoft.Extensions.Configuration;
|
using Microsoft.Extensions.Configuration;
|
||||||
|
|
@ -106,8 +107,14 @@ class Program
|
||||||
services.AddSingleton<InstallService>();
|
services.AddSingleton<InstallService>();
|
||||||
services.AddSingleton<SingleInstanceService>();
|
services.AddSingleton<SingleInstanceService>();
|
||||||
|
|
||||||
|
// Plugin framework: discover drop-in plugins and let them register services before the
|
||||||
|
// provider is built. UI extensions register harmlessly but are never resolved in headless mode.
|
||||||
|
var pluginLoader = PluginBootstrap.ConfigurePlugins(services, PluginHost.Launcher);
|
||||||
|
|
||||||
var serviceProvider = services.BuildServiceProvider();
|
var serviceProvider = services.BuildServiceProvider();
|
||||||
|
|
||||||
|
await pluginLoader.InitializeAllAsync(serviceProvider).ConfigureAwait(false);
|
||||||
|
|
||||||
if (settings.Debug.EnableScriptDebugging)
|
if (settings.Debug.EnableScriptDebugging)
|
||||||
{
|
{
|
||||||
HeadlessServiceProvider = serviceProvider;
|
HeadlessServiceProvider = serviceProvider;
|
||||||
|
|
|
||||||
|
|
@ -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<GameService>();
|
||||||
|
var gameClient = scope.ServiceProvider.GetRequiredService<GameClient>();
|
||||||
|
var installService = scope.ServiceProvider.GetRequiredService<InstallService>();
|
||||||
|
|
||||||
|
var localGame = await gameService.GetAsync(GameId);
|
||||||
|
if (localGame == null)
|
||||||
|
throw new InvalidOperationException("Game not found in local database");
|
||||||
|
|
||||||
|
var versions = (await gameClient.GetVersionsAsync(GameId))?.ToList() ?? [];
|
||||||
|
|
||||||
|
// Only versions that carry an archive can be installed or rolled back to.
|
||||||
|
var installable = versions
|
||||||
|
.Where(v => v.ArchiveId.HasValue && v.ArchiveId.Value != Guid.Empty)
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
if (installable.Count == 0)
|
||||||
|
{
|
||||||
|
await Views.AlertOverlay.ShowAsync("No Versions Available", "This game has no downloadable versions.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var installedVersion = installable.FirstOrDefault(v => v.Version == localGame.InstalledVersion);
|
||||||
|
var installedSortOrder = installedVersion?.SortOrder;
|
||||||
|
|
||||||
|
var versionsVm = new GameVersionsViewModel
|
||||||
|
{
|
||||||
|
DialogTitle = $"{Title} — Versions",
|
||||||
|
};
|
||||||
|
|
||||||
|
foreach (var version in installable)
|
||||||
|
{
|
||||||
|
var isInstalled = version.Version == localGame.InstalledVersion;
|
||||||
|
var isNewer = installedSortOrder.HasValue && version.SortOrder > installedSortOrder.Value;
|
||||||
|
versionsVm.Versions.Add(new GameVersionItemViewModel(version, isInstalled, isNewer));
|
||||||
|
}
|
||||||
|
|
||||||
|
var tcs = new System.Threading.Tasks.TaskCompletionSource<SDK.Models.GameVersion?>();
|
||||||
|
|
||||||
|
await Dispatcher.UIThread.InvokeAsync(() =>
|
||||||
|
{
|
||||||
|
var overlay = new Views.GameVersionsOverlay
|
||||||
|
{
|
||||||
|
DataContext = versionsVm,
|
||||||
|
HorizontalAlignment = global::Avalonia.Layout.HorizontalAlignment.Stretch,
|
||||||
|
VerticalAlignment = global::Avalonia.Layout.VerticalAlignment.Stretch,
|
||||||
|
};
|
||||||
|
|
||||||
|
overlay.VersionSelected += (_, v) => tcs.TrySetResult(v);
|
||||||
|
|
||||||
|
var mainWindow = (Application.Current?.ApplicationLifetime
|
||||||
|
as IClassicDesktopStyleApplicationLifetime)?.MainWindow;
|
||||||
|
|
||||||
|
var layer = OverlayLayer.GetOverlayLayer(mainWindow);
|
||||||
|
|
||||||
|
if (layer is not null)
|
||||||
|
{
|
||||||
|
overlay.Bind(global::Avalonia.Layout.Layoutable.WidthProperty, new Binding("Bounds.Width") { Source = layer });
|
||||||
|
overlay.Bind(global::Avalonia.Layout.Layoutable.HeightProperty, new Binding("Bounds.Height") { Source = layer });
|
||||||
|
|
||||||
|
layer.Children.Add(overlay);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
tcs.TrySetResult(null);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
var selected = await tcs.Task;
|
||||||
|
|
||||||
|
if (selected == null)
|
||||||
|
return;
|
||||||
|
|
||||||
|
IsInstalling = true;
|
||||||
|
StatusMessage = $"Preparing to switch to version {selected.Version}...";
|
||||||
|
|
||||||
|
_logger.LogInformation("Queuing switch of game {GameId} ({Title}) to version {Version}", GameId, Title, selected.Version);
|
||||||
|
|
||||||
|
await installService.AddVersionSwitchAsync(localGame, selected);
|
||||||
|
|
||||||
|
StatusMessage = "Added to download queue";
|
||||||
|
InstallRequested?.Invoke(this, EventArgs.Empty);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogError(ex, "Failed to switch version for game {GameId} ({Title})", GameId, Title);
|
||||||
|
StatusMessage = $"Failed to switch version: {ex.Message}";
|
||||||
|
await Views.AlertOverlay.ShowAsync("Failed to Switch Version", ex.Message);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
IsInstalling = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
[RelayCommand]
|
[RelayCommand]
|
||||||
private async Task UninstallAsync()
|
private async Task UninstallAsync()
|
||||||
{
|
{
|
||||||
|
|
|
||||||
55
LANCommander.Launcher/ViewModels/GameVersionsViewModel.cs
Normal file
55
LANCommander.Launcher/ViewModels/GameVersionsViewModel.cs
Normal file
|
|
@ -0,0 +1,55 @@
|
||||||
|
using System;
|
||||||
|
using System.Collections.ObjectModel;
|
||||||
|
using ByteSizeLib;
|
||||||
|
using CommunityToolkit.Mvvm.ComponentModel;
|
||||||
|
|
||||||
|
namespace LANCommander.Launcher.ViewModels;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// ViewModel for the version picker overlay. Lists every downloadable version for a game so the
|
||||||
|
/// user can install or roll back to a specific one, along with its changelog and download size.
|
||||||
|
/// </summary>
|
||||||
|
public partial class GameVersionsViewModel : ViewModelBase
|
||||||
|
{
|
||||||
|
[ObservableProperty]
|
||||||
|
private string _dialogTitle = string.Empty;
|
||||||
|
|
||||||
|
[ObservableProperty]
|
||||||
|
private ObservableCollection<GameVersionItemViewModel> _versions = new();
|
||||||
|
}
|
||||||
|
|
||||||
|
public partial class GameVersionItemViewModel : ViewModelBase
|
||||||
|
{
|
||||||
|
public SDK.Models.GameVersion Version { get; }
|
||||||
|
|
||||||
|
public string VersionLabel => string.IsNullOrWhiteSpace(Version.Version) ? "(unversioned)" : Version.Version;
|
||||||
|
|
||||||
|
public string ChangelogText => Version.Changelog ?? string.Empty;
|
||||||
|
public bool HasChangelog => !string.IsNullOrWhiteSpace(Version.Changelog);
|
||||||
|
|
||||||
|
public string SizeText => Version.CompressedSize > 0
|
||||||
|
? ByteSize.FromBytes(Version.CompressedSize).ToString("0.##")
|
||||||
|
: string.Empty;
|
||||||
|
public bool HasSize => Version.CompressedSize > 0;
|
||||||
|
|
||||||
|
public bool CreatedOnKnown => Version.CreatedOn != default;
|
||||||
|
public string CreatedOnText => Version.CreatedOn.ToLocalTime().ToString("MMM d, yyyy");
|
||||||
|
|
||||||
|
/// <summary>True when this version matches the game's currently installed version.</summary>
|
||||||
|
public bool IsInstalled { get; }
|
||||||
|
|
||||||
|
/// <summary>Only versions that carry an archive and aren't already installed can be switched to.</summary>
|
||||||
|
public bool IsInstallable => !IsInstalled
|
||||||
|
&& Version.ArchiveId.HasValue
|
||||||
|
&& Version.ArchiveId.Value != Guid.Empty;
|
||||||
|
|
||||||
|
/// <summary>Label for the action button: "Update" for a newer version, "Roll Back" for an older one.</summary>
|
||||||
|
public string ButtonText { get; }
|
||||||
|
|
||||||
|
public GameVersionItemViewModel(SDK.Models.GameVersion version, bool isInstalled, bool isNewerThanInstalled)
|
||||||
|
{
|
||||||
|
Version = version;
|
||||||
|
IsInstalled = isInstalled;
|
||||||
|
ButtonText = isNewerThanInstalled ? "Update" : "Roll Back";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -7,14 +7,67 @@ using global::Avalonia.VisualTree;
|
||||||
using LANCommander.Launcher.Controls;
|
using LANCommander.Launcher.Controls;
|
||||||
using LANCommander.Launcher.ViewModels;
|
using LANCommander.Launcher.ViewModels;
|
||||||
using LANCommander.Launcher.ViewModels.Components;
|
using LANCommander.Launcher.ViewModels.Components;
|
||||||
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
|
||||||
namespace LANCommander.Launcher.Views;
|
namespace LANCommander.Launcher.Views;
|
||||||
|
|
||||||
public partial class GameDetailView : UserControl
|
public partial class GameDetailView : UserControl
|
||||||
{
|
{
|
||||||
|
private bool _pluginTabsAdded;
|
||||||
|
|
||||||
public GameDetailView()
|
public GameDetailView()
|
||||||
{
|
{
|
||||||
InitializeComponent();
|
InitializeComponent();
|
||||||
|
|
||||||
|
DataContextChanged += (_, _) => AppendPluginTabs();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Appends plugin detail sections (via <see cref="Plugins.Extensions.IGameDetailTabExtension"/>) to
|
||||||
|
/// the bottom of the left column once a game is bound. A failing extension is skipped so the
|
||||||
|
/// built-in detail content still renders.
|
||||||
|
/// </summary>
|
||||||
|
private void AppendPluginTabs()
|
||||||
|
{
|
||||||
|
if (_pluginTabsAdded || DataContext is not GameDetailViewModel detailVm)
|
||||||
|
return;
|
||||||
|
|
||||||
|
var extensions = App.Services?
|
||||||
|
.GetServices<Plugins.Extensions.IGameDetailTabExtension>()
|
||||||
|
.OrderBy(c => c.Order)
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
if (extensions == null || extensions.Count == 0)
|
||||||
|
return;
|
||||||
|
|
||||||
|
_pluginTabsAdded = true;
|
||||||
|
|
||||||
|
foreach (var extension in extensions)
|
||||||
|
{
|
||||||
|
Control content;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
content = extension.BuildContent(detailVm.Id);
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
var header = new TextBlock
|
||||||
|
{
|
||||||
|
Text = extension.Header,
|
||||||
|
FontWeight = global::Avalonia.Media.FontWeight.SemiBold,
|
||||||
|
FontSize = 16,
|
||||||
|
};
|
||||||
|
|
||||||
|
var stack = new StackPanel { Spacing = 8 };
|
||||||
|
stack.Children.Add(header);
|
||||||
|
stack.Children.Add(content);
|
||||||
|
|
||||||
|
LeftContent.Children.Add(stack);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
|
|
|
||||||
96
LANCommander.Launcher/Views/GameVersionsOverlay.axaml
Normal file
96
LANCommander.Launcher/Views/GameVersionsOverlay.axaml
Normal file
|
|
@ -0,0 +1,96 @@
|
||||||
|
<UserControl xmlns="https://github.com/avaloniaui"
|
||||||
|
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||||
|
xmlns:vm="using:LANCommander.Launcher.ViewModels"
|
||||||
|
xmlns:controls="using:LANCommander.Launcher.Controls"
|
||||||
|
x:Class="LANCommander.Launcher.Views.GameVersionsOverlay"
|
||||||
|
x:DataType="vm:GameVersionsViewModel"
|
||||||
|
controls:AutoFocus.IsEnabled="True">
|
||||||
|
|
||||||
|
<!-- Full-window dim backdrop -->
|
||||||
|
<Grid Background="#AA000000">
|
||||||
|
|
||||||
|
<!-- Centered card -->
|
||||||
|
<Border Background="{DynamicResource SystemControlBackgroundChromeMediumBrush}"
|
||||||
|
Width="480"
|
||||||
|
MaxHeight="620"
|
||||||
|
HorizontalAlignment="Center"
|
||||||
|
VerticalAlignment="Center"
|
||||||
|
BoxShadow="0 8 40 8 #88000000">
|
||||||
|
|
||||||
|
<StackPanel Spacing="0">
|
||||||
|
|
||||||
|
<!-- Header -->
|
||||||
|
<Border Padding="20,16,20,8">
|
||||||
|
<TextBlock FontSize="15" FontWeight="SemiBold"
|
||||||
|
HorizontalAlignment="Center"
|
||||||
|
TextTrimming="CharacterEllipsis"
|
||||||
|
Text="{Binding DialogTitle}" />
|
||||||
|
</Border>
|
||||||
|
|
||||||
|
<!-- Version list -->
|
||||||
|
<Border Padding="12,4,12,0">
|
||||||
|
<ScrollViewer MaxHeight="460"
|
||||||
|
HorizontalScrollBarVisibility="Disabled"
|
||||||
|
VerticalScrollBarVisibility="Auto">
|
||||||
|
<ItemsControl ItemsSource="{Binding Versions}">
|
||||||
|
<ItemsControl.ItemTemplate>
|
||||||
|
<DataTemplate x:DataType="vm:GameVersionItemViewModel">
|
||||||
|
<Border Background="{DynamicResource SystemControlBackgroundChromeMediumLowBrush}"
|
||||||
|
Padding="12,10"
|
||||||
|
Margin="0,0,0,6"
|
||||||
|
CornerRadius="4">
|
||||||
|
<StackPanel Spacing="6">
|
||||||
|
<DockPanel>
|
||||||
|
<StackPanel DockPanel.Dock="Right"
|
||||||
|
Orientation="Horizontal"
|
||||||
|
Spacing="8"
|
||||||
|
VerticalAlignment="Center">
|
||||||
|
<TextBlock Text="Installed"
|
||||||
|
FontSize="11"
|
||||||
|
Opacity="0.55"
|
||||||
|
VerticalAlignment="Center"
|
||||||
|
IsVisible="{Binding IsInstalled}" />
|
||||||
|
<Button Content="{Binding ButtonText}"
|
||||||
|
Classes="Primary"
|
||||||
|
Click="Install_Click"
|
||||||
|
IsVisible="{Binding IsInstallable}" />
|
||||||
|
</StackPanel>
|
||||||
|
<StackPanel Spacing="2">
|
||||||
|
<TextBlock Text="{Binding VersionLabel}"
|
||||||
|
FontWeight="SemiBold"
|
||||||
|
FontSize="14" />
|
||||||
|
<TextBlock Text="{Binding CreatedOnText}"
|
||||||
|
FontSize="11"
|
||||||
|
Opacity="0.55"
|
||||||
|
IsVisible="{Binding CreatedOnKnown}" />
|
||||||
|
</StackPanel>
|
||||||
|
</DockPanel>
|
||||||
|
<TextBlock Text="{Binding ChangelogText}"
|
||||||
|
FontSize="12"
|
||||||
|
TextWrapping="Wrap"
|
||||||
|
IsVisible="{Binding HasChangelog}" />
|
||||||
|
<TextBlock FontSize="11"
|
||||||
|
Opacity="0.55"
|
||||||
|
IsVisible="{Binding HasSize}">
|
||||||
|
<Run Text="Download:" />
|
||||||
|
<Run Text="{Binding SizeText}" />
|
||||||
|
</TextBlock>
|
||||||
|
</StackPanel>
|
||||||
|
</Border>
|
||||||
|
</DataTemplate>
|
||||||
|
</ItemsControl.ItemTemplate>
|
||||||
|
</ItemsControl>
|
||||||
|
</ScrollViewer>
|
||||||
|
</Border>
|
||||||
|
|
||||||
|
<!-- Footer -->
|
||||||
|
<Border Padding="20,12,20,16" Classes="DialogFooter">
|
||||||
|
<Button Content="Close"
|
||||||
|
HorizontalAlignment="Right"
|
||||||
|
Click="Close_Click" />
|
||||||
|
</Border>
|
||||||
|
|
||||||
|
</StackPanel>
|
||||||
|
</Border>
|
||||||
|
</Grid>
|
||||||
|
</UserControl>
|
||||||
33
LANCommander.Launcher/Views/GameVersionsOverlay.axaml.cs
Normal file
33
LANCommander.Launcher/Views/GameVersionsOverlay.axaml.cs
Normal file
|
|
@ -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
|
||||||
|
{
|
||||||
|
/// <summary>Raised when the overlay closes. Carries the chosen version, or null when dismissed.</summary>
|
||||||
|
public event EventHandler<SDK.Models.GameVersion?>? VersionSelected;
|
||||||
|
|
||||||
|
public GameVersionsOverlay()
|
||||||
|
{
|
||||||
|
InitializeComponent();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void Install_Click(object? sender, RoutedEventArgs e)
|
||||||
|
{
|
||||||
|
if (sender is Button { DataContext: GameVersionItemViewModel item })
|
||||||
|
Close(item.Version);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void Close_Click(object? sender, RoutedEventArgs e) => Close(null);
|
||||||
|
|
||||||
|
private void Close(SDK.Models.GameVersion? version)
|
||||||
|
{
|
||||||
|
var layer = OverlayLayer.GetOverlayLayer(this);
|
||||||
|
VersionSelected?.Invoke(this, version);
|
||||||
|
layer?.Children.Remove(this);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -40,23 +40,9 @@
|
||||||
<Border x:Name="ResizeSE" Grid.Row="2" Grid.Column="2" Cursor="BottomRightCorner" Background="Transparent" PointerPressed="ResizeGrip_PointerPressed" />
|
<Border x:Name="ResizeSE" Grid.Row="2" Grid.Column="2" Cursor="BottomRightCorner" Background="Transparent" PointerPressed="ResizeGrip_PointerPressed" />
|
||||||
</Grid>
|
</Grid>
|
||||||
|
|
||||||
<!-- Main Content (fills full window height) -->
|
<!-- Main Content (fills full window height). Data templates are supplied in code-behind from
|
||||||
<ContentControl Content="{Binding CurrentView}" ZIndex="0">
|
the IViewRegistry so plugins can add view mappings. -->
|
||||||
<ContentControl.DataTemplates>
|
<ContentControl x:Name="MainContent" Content="{Binding CurrentView}" ZIndex="0" />
|
||||||
<DataTemplate DataType="vm:SplashViewModel">
|
|
||||||
<views:SplashView />
|
|
||||||
</DataTemplate>
|
|
||||||
<DataTemplate DataType="vm:ServerSelectionViewModel">
|
|
||||||
<views:ServerSelectionView />
|
|
||||||
</DataTemplate>
|
|
||||||
<DataTemplate DataType="vm:LoginViewModel">
|
|
||||||
<views:LoginView />
|
|
||||||
</DataTemplate>
|
|
||||||
<DataTemplate DataType="vm:ShellViewModel">
|
|
||||||
<views:ShellView />
|
|
||||||
</DataTemplate>
|
|
||||||
</ContentControl.DataTemplates>
|
|
||||||
</ContentControl>
|
|
||||||
|
|
||||||
<!-- Floating Title Bar (overlays content) -->
|
<!-- Floating Title Bar (overlays content) -->
|
||||||
<Grid VerticalAlignment="Top"
|
<Grid VerticalAlignment="Top"
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,9 @@ using System;
|
||||||
using Avalonia.Controls;
|
using Avalonia.Controls;
|
||||||
using Avalonia.Input;
|
using Avalonia.Input;
|
||||||
using Avalonia.Interactivity;
|
using Avalonia.Interactivity;
|
||||||
|
using LANCommander.Launcher.Plugins;
|
||||||
using LANCommander.Launcher.ViewModels;
|
using LANCommander.Launcher.ViewModels;
|
||||||
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
|
||||||
namespace LANCommander.Launcher.Views;
|
namespace LANCommander.Launcher.Views;
|
||||||
|
|
||||||
|
|
@ -18,6 +20,12 @@ public partial class MainWindow : Window
|
||||||
{
|
{
|
||||||
InitializeComponent();
|
InitializeComponent();
|
||||||
|
|
||||||
|
// Content templates come from the shared view registry (seeded with the built-in mappings and
|
||||||
|
// extendable by plugins) rather than inline XAML DataTemplates.
|
||||||
|
var registry = App.Services?.GetService<IViewRegistry>();
|
||||||
|
if (registry != null)
|
||||||
|
MainContent.DataTemplates.Add(registry.AsDataTemplate());
|
||||||
|
|
||||||
Closing += (_, e) =>
|
Closing += (_, e) =>
|
||||||
{
|
{
|
||||||
// Hide to the system tray instead of closing; the app keeps running.
|
// Hide to the system tray instead of closing; the app keeps running.
|
||||||
|
|
|
||||||
|
|
@ -35,7 +35,7 @@
|
||||||
|
|
||||||
<!-- Content -->
|
<!-- Content -->
|
||||||
<ScrollViewer Grid.Row="1" HorizontalScrollBarVisibility="Disabled" VerticalScrollBarVisibility="Auto">
|
<ScrollViewer Grid.Row="1" HorizontalScrollBarVisibility="Disabled" VerticalScrollBarVisibility="Auto">
|
||||||
<StackPanel Spacing="24" Margin="24">
|
<StackPanel x:Name="SectionsPanel" Spacing="24" Margin="24">
|
||||||
|
|
||||||
<!-- Status Message -->
|
<!-- Status Message -->
|
||||||
<TextBlock Text="{Binding StatusMessage}"
|
<TextBlock Text="{Binding StatusMessage}"
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,9 @@
|
||||||
|
using System.Linq;
|
||||||
using Avalonia.Controls;
|
using Avalonia.Controls;
|
||||||
|
using Avalonia.Layout;
|
||||||
|
using Avalonia.Media;
|
||||||
|
using LANCommander.Launcher.Plugins.Extensions;
|
||||||
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
|
||||||
namespace LANCommander.Launcher.Views;
|
namespace LANCommander.Launcher.Views;
|
||||||
|
|
||||||
|
|
@ -7,5 +12,61 @@ public partial class SettingsView : UserControl
|
||||||
public SettingsView()
|
public SettingsView()
|
||||||
{
|
{
|
||||||
InitializeComponent();
|
InitializeComponent();
|
||||||
|
|
||||||
|
AppendPluginSections();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Appends any plugin settings sections beneath the built-in sections, styled to
|
||||||
|
/// match the surrounding cards so extensions look native.
|
||||||
|
/// </summary>
|
||||||
|
private void AppendPluginSections()
|
||||||
|
{
|
||||||
|
var extensions = App.Services?
|
||||||
|
.GetServices<ISettingsPageExtension>()
|
||||||
|
.OrderBy(c => c.Order)
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
if (extensions == null || extensions.Count == 0)
|
||||||
|
return;
|
||||||
|
|
||||||
|
foreach (var extension in extensions)
|
||||||
|
{
|
||||||
|
Control content;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
content = extension.BuildContent();
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
// A misbehaving plugin must not break the settings page.
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
var header = new TextBlock
|
||||||
|
{
|
||||||
|
Text = extension.Title,
|
||||||
|
FontWeight = FontWeight.SemiBold,
|
||||||
|
FontSize = 16,
|
||||||
|
};
|
||||||
|
|
||||||
|
var stack = new StackPanel { Spacing = 12 };
|
||||||
|
stack.Children.Add(header);
|
||||||
|
stack.Children.Add(content);
|
||||||
|
|
||||||
|
var card = new Border
|
||||||
|
{
|
||||||
|
Padding = new Avalonia.Thickness(16),
|
||||||
|
CornerRadius = new Avalonia.CornerRadius(8),
|
||||||
|
Child = stack,
|
||||||
|
HorizontalAlignment = HorizontalAlignment.Stretch,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (this.TryFindResource("SystemControlBackgroundChromeMediumLowBrush", out var brush) && brush is IBrush background)
|
||||||
|
card.Background = background;
|
||||||
|
|
||||||
|
SectionsPanel.Children.Add(card);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -19,39 +19,14 @@
|
||||||
|
|
||||||
<Grid RowDefinitions="*,Auto">
|
<Grid RowDefinitions="*,Auto">
|
||||||
|
|
||||||
<!-- ── Main Content ──────────────────────────────────────────── -->
|
<!-- ── Main Content ──────────────────────────────────────────────
|
||||||
<TransitioningContentControl Grid.Row="0" Content="{Binding ContentView}">
|
Data templates are supplied in code-behind from the IViewRegistry so plugins can
|
||||||
|
add navigable views. Most-derived-first matching preserves the
|
||||||
|
DepotGameDetailViewModel-before-GameDetailViewModel resolution rule. -->
|
||||||
|
<TransitioningContentControl x:Name="ContentHost" Grid.Row="0" Content="{Binding ContentView}">
|
||||||
<TransitioningContentControl.PageTransition>
|
<TransitioningContentControl.PageTransition>
|
||||||
<controls:FadeInPageTransition Duration="0:0:0.2" />
|
<controls:FadeInPageTransition Duration="0:0:0.2" />
|
||||||
</TransitioningContentControl.PageTransition>
|
</TransitioningContentControl.PageTransition>
|
||||||
<TransitioningContentControl.DataTemplates>
|
|
||||||
<DataTemplate DataType="vm:DepotViewModel">
|
|
||||||
<views:DepotView />
|
|
||||||
</DataTemplate>
|
|
||||||
<DataTemplate DataType="vm:DepotBrowseViewModel">
|
|
||||||
<views:DepotBrowseView />
|
|
||||||
</DataTemplate>
|
|
||||||
<!-- DepotGameDetailViewModel must appear before GameDetailViewModel
|
|
||||||
so the more-specific type is matched first. -->
|
|
||||||
<DataTemplate DataType="vm:DepotGameDetailViewModel">
|
|
||||||
<views:GameDetailView />
|
|
||||||
</DataTemplate>
|
|
||||||
<DataTemplate DataType="vm:GamesListViewModel">
|
|
||||||
<views:GamesListView />
|
|
||||||
</DataTemplate>
|
|
||||||
<DataTemplate DataType="vm:LibraryViewModel">
|
|
||||||
<views:GamesListView />
|
|
||||||
</DataTemplate>
|
|
||||||
<DataTemplate DataType="vm:GameDetailViewModel">
|
|
||||||
<views:GameDetailView />
|
|
||||||
</DataTemplate>
|
|
||||||
<DataTemplate DataType="vm:SettingsViewModel">
|
|
||||||
<views:SettingsView />
|
|
||||||
</DataTemplate>
|
|
||||||
<DataTemplate DataType="vm:DownloadQueueViewModel">
|
|
||||||
<views:DownloadQueuePageView />
|
|
||||||
</DataTemplate>
|
|
||||||
</TransitioningContentControl.DataTemplates>
|
|
||||||
</TransitioningContentControl>
|
</TransitioningContentControl>
|
||||||
|
|
||||||
<!-- ── Loading overlay (shown during async navigation) ───── -->
|
<!-- ── Loading overlay (shown during async navigation) ───── -->
|
||||||
|
|
@ -186,7 +161,10 @@
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
</Panel>
|
</Panel>
|
||||||
|
|
||||||
<Panel Grid.Column="2" HorizontalAlignment="Right">
|
<StackPanel Grid.Column="2" Orientation="Horizontal" Spacing="4" HorizontalAlignment="Right">
|
||||||
|
<!-- Plugin footer items, ordered in code-behind -->
|
||||||
|
<StackPanel x:Name="FooterPluginItems" Orientation="Horizontal" Spacing="4" VerticalAlignment="Center" />
|
||||||
|
|
||||||
<!-- Chat button -->
|
<!-- Chat button -->
|
||||||
<Button Command="{Binding OpenChatCommand}"
|
<Button Command="{Binding OpenChatCommand}"
|
||||||
Classes="Text">
|
Classes="Text">
|
||||||
|
|
@ -202,7 +180,7 @@
|
||||||
</Border>
|
</Border>
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
</Button>
|
</Button>
|
||||||
</Panel>
|
</StackPanel>
|
||||||
|
|
||||||
</Grid>
|
</Grid>
|
||||||
</Border>
|
</Border>
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,9 @@
|
||||||
using System;
|
using System;
|
||||||
|
using System.Linq;
|
||||||
using Avalonia.Controls;
|
using Avalonia.Controls;
|
||||||
using Avalonia.Input;
|
using Avalonia.Input;
|
||||||
using Avalonia.Threading;
|
using Avalonia.Threading;
|
||||||
|
using LANCommander.Launcher.Plugins.Extensions;
|
||||||
using LANCommander.Launcher.Services;
|
using LANCommander.Launcher.Services;
|
||||||
using LANCommander.Launcher.ViewModels;
|
using LANCommander.Launcher.ViewModels;
|
||||||
using Microsoft.Extensions.DependencyInjection;
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
|
@ -19,6 +21,14 @@ public partial class ShellView : UserControl
|
||||||
{
|
{
|
||||||
InitializeComponent();
|
InitializeComponent();
|
||||||
|
|
||||||
|
// Content templates come from the shared view registry (seeded with the built-in mappings and
|
||||||
|
// extendable by plugins) rather than inline XAML DataTemplates.
|
||||||
|
var registry = App.Services?.GetService<LANCommander.Launcher.Plugins.IViewRegistry>();
|
||||||
|
if (registry != null)
|
||||||
|
ContentHost.DataTemplates.Add(registry.AsDataTemplate());
|
||||||
|
|
||||||
|
AppendFooterExtensions();
|
||||||
|
|
||||||
KeyDown += OnKeyDown;
|
KeyDown += OnKeyDown;
|
||||||
|
|
||||||
DataContextChanged += (_, _) =>
|
DataContextChanged += (_, _) =>
|
||||||
|
|
@ -34,6 +44,34 @@ public partial class ShellView : UserControl
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Renders any plugin footer controls (via <see cref="IFooterExtension"/>) to the
|
||||||
|
/// left of the chat button, ordered by their declared <c>Order</c>. A failing extension is
|
||||||
|
/// skipped so the built-in footer still renders.
|
||||||
|
/// </summary>
|
||||||
|
private void AppendFooterExtensions()
|
||||||
|
{
|
||||||
|
var extensions = App.Services?
|
||||||
|
.GetServices<IFooterExtension>()
|
||||||
|
.OrderBy(c => c.Order)
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
if (extensions == null || extensions.Count == 0)
|
||||||
|
return;
|
||||||
|
|
||||||
|
foreach (var extension in extensions)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
FooterPluginItems.Children.Add(extension.BuildContent());
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
// A misbehaving plugin must not break the shell footer.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private void OnKeyDown(object? sender, KeyEventArgs e)
|
private void OnKeyDown(object? sender, KeyEventArgs e)
|
||||||
{
|
{
|
||||||
if (e.Key == Key.Escape && !e.Handled)
|
if (e.Key == Key.Escape && !e.Handled)
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,21 @@
|
||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
|
||||||
|
<PropertyGroup>
|
||||||
|
<OutputType>Exe</OutputType>
|
||||||
|
<TargetFramework>net10.0</TargetFramework>
|
||||||
|
<Nullable>enable</Nullable>
|
||||||
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
|
<!-- Utility only; not part of the shipped app build graph. -->
|
||||||
|
<IsPackable>false</IsPackable>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<!--
|
||||||
|
References the plugin contract assemblies so their compiled types + generated XML docs land in
|
||||||
|
this tool's output folder, where the generator reflects over them to build the API reference.
|
||||||
|
-->
|
||||||
|
<ItemGroup>
|
||||||
|
<ProjectReference Include="..\LANCommander.SDK\LANCommander.SDK.csproj" />
|
||||||
|
<ProjectReference Include="..\LANCommander.Launcher.Plugins\LANCommander.Launcher.Plugins.csproj" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
</Project>
|
||||||
206
LANCommander.PluginDocsGenerator/Program.cs
Normal file
206
LANCommander.PluginDocsGenerator/Program.cs
Normal file
|
|
@ -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 == "<Clone>$"))
|
||||||
|
return "record";
|
||||||
|
return "class";
|
||||||
|
}
|
||||||
|
|
||||||
|
static string ResolveDefaultOutputPath()
|
||||||
|
{
|
||||||
|
var dir = new DirectoryInfo(AppContext.BaseDirectory);
|
||||||
|
while (dir is not null && !Directory.Exists(Path.Combine(dir.FullName, "LANCommander.Documentation")))
|
||||||
|
dir = dir.Parent;
|
||||||
|
|
||||||
|
if (dir is null)
|
||||||
|
throw new InvalidOperationException("Could not locate the LANCommander.Documentation directory. Pass an output path explicitly.");
|
||||||
|
|
||||||
|
return Path.Combine(dir.FullName, "LANCommander.Documentation", "Plugins", "API Reference.md");
|
||||||
|
}
|
||||||
85
LANCommander.PluginDocsGenerator/Signatures.cs
Normal file
85
LANCommander.PluginDocsGenerator/Signatures.cs
Normal file
|
|
@ -0,0 +1,85 @@
|
||||||
|
using System.Reflection;
|
||||||
|
|
||||||
|
namespace LANCommander.PluginDocsGenerator;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Renders human-readable C#-style signatures for members, used in the API reference output.
|
||||||
|
/// </summary>
|
||||||
|
internal static class Signatures
|
||||||
|
{
|
||||||
|
public static string Property(PropertyInfo property)
|
||||||
|
{
|
||||||
|
var accessors = property switch
|
||||||
|
{
|
||||||
|
{ CanRead: true, SetMethod.IsPublic: true } when IsInit(property) => "{ get; init; }",
|
||||||
|
{ CanRead: true, SetMethod.IsPublic: true } => "{ get; set; }",
|
||||||
|
{ CanRead: true } => "{ get; }",
|
||||||
|
_ => "{ set; }",
|
||||||
|
};
|
||||||
|
|
||||||
|
return $"{Friendly(property.PropertyType)} {property.Name} {accessors}";
|
||||||
|
}
|
||||||
|
|
||||||
|
public static string Method(MethodInfo method)
|
||||||
|
{
|
||||||
|
var generics = method.IsGenericMethodDefinition
|
||||||
|
? "<" + string.Join(", ", method.GetGenericArguments().Select(a => a.Name)) + ">"
|
||||||
|
: "";
|
||||||
|
|
||||||
|
var parameters = string.Join(", ", method.GetParameters().Select(p => $"{Friendly(p.ParameterType)} {p.Name}"));
|
||||||
|
|
||||||
|
return $"{Friendly(method.ReturnType)} {method.Name}{generics}({parameters})";
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool IsInit(PropertyInfo property)
|
||||||
|
{
|
||||||
|
var setMethod = property.SetMethod;
|
||||||
|
return setMethod is not null
|
||||||
|
&& setMethod.ReturnParameter.GetRequiredCustomModifiers()
|
||||||
|
.Any(m => m.FullName == "System.Runtime.CompilerServices.IsExternalInit");
|
||||||
|
}
|
||||||
|
|
||||||
|
public static string Friendly(Type type)
|
||||||
|
{
|
||||||
|
if (Nullable.GetUnderlyingType(type) is { } underlying)
|
||||||
|
return Friendly(underlying) + "?";
|
||||||
|
|
||||||
|
if (type.IsByRef)
|
||||||
|
return Friendly(type.GetElementType()!);
|
||||||
|
|
||||||
|
if (type.IsArray)
|
||||||
|
return Friendly(type.GetElementType()!) + "[]";
|
||||||
|
|
||||||
|
if (type.IsGenericParameter)
|
||||||
|
return type.Name;
|
||||||
|
|
||||||
|
if (type.IsGenericType)
|
||||||
|
{
|
||||||
|
var name = type.Name.Split('`')[0];
|
||||||
|
var args = string.Join(", ", type.GetGenericArguments().Select(Friendly));
|
||||||
|
return $"{name}<{args}>";
|
||||||
|
}
|
||||||
|
|
||||||
|
return Aliases.TryGetValue(type.FullName ?? "", out var alias) ? alias : type.Name;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static readonly Dictionary<string, string> Aliases = new()
|
||||||
|
{
|
||||||
|
["System.Void"] = "void",
|
||||||
|
["System.Object"] = "object",
|
||||||
|
["System.String"] = "string",
|
||||||
|
["System.Boolean"] = "bool",
|
||||||
|
["System.Byte"] = "byte",
|
||||||
|
["System.SByte"] = "sbyte",
|
||||||
|
["System.Char"] = "char",
|
||||||
|
["System.Int16"] = "short",
|
||||||
|
["System.UInt16"] = "ushort",
|
||||||
|
["System.Int32"] = "int",
|
||||||
|
["System.UInt32"] = "uint",
|
||||||
|
["System.Int64"] = "long",
|
||||||
|
["System.UInt64"] = "ulong",
|
||||||
|
["System.Single"] = "float",
|
||||||
|
["System.Double"] = "double",
|
||||||
|
["System.Decimal"] = "decimal",
|
||||||
|
};
|
||||||
|
}
|
||||||
97
LANCommander.PluginDocsGenerator/XmlDocLookup.cs
Normal file
97
LANCommander.PluginDocsGenerator/XmlDocLookup.cs
Normal file
|
|
@ -0,0 +1,97 @@
|
||||||
|
using System.Reflection;
|
||||||
|
using System.Text;
|
||||||
|
using System.Xml.Linq;
|
||||||
|
|
||||||
|
namespace LANCommander.PluginDocsGenerator;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Loads the XML documentation files that sit alongside the given assemblies and exposes their
|
||||||
|
/// <summary> text keyed by XML documentation member id.
|
||||||
|
/// </summary>
|
||||||
|
internal sealed class XmlDocLookup
|
||||||
|
{
|
||||||
|
private readonly Dictionary<string, string> _summaries = new(StringComparer.Ordinal);
|
||||||
|
|
||||||
|
public XmlDocLookup(IEnumerable<Assembly> assemblies)
|
||||||
|
{
|
||||||
|
foreach (var assembly in assemblies)
|
||||||
|
{
|
||||||
|
var xmlPath = Path.ChangeExtension(assembly.Location, ".xml");
|
||||||
|
if (!File.Exists(xmlPath))
|
||||||
|
continue;
|
||||||
|
|
||||||
|
foreach (var member in XDocument.Load(xmlPath).Descendants("member"))
|
||||||
|
{
|
||||||
|
var name = member.Attribute("name")?.Value;
|
||||||
|
var summary = member.Element("summary");
|
||||||
|
if (name is null || summary is null)
|
||||||
|
continue;
|
||||||
|
|
||||||
|
_summaries[name] = Render(summary);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public string? GetSummary(string memberId) => _summaries.TryGetValue(memberId, out var value) ? value : null;
|
||||||
|
|
||||||
|
// Flattens a <summary> element into plain text, resolving the common inline doc tags.
|
||||||
|
private static string Render(XElement summary)
|
||||||
|
{
|
||||||
|
var sb = new StringBuilder();
|
||||||
|
RenderNodes(summary, sb);
|
||||||
|
|
||||||
|
// Collapse the incidental whitespace/indentation that XML doc comments carry.
|
||||||
|
var text = string.Join(" ", sb.ToString().Split((char[]?)null, StringSplitOptions.RemoveEmptyEntries));
|
||||||
|
return text.Trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void RenderNodes(XElement element, StringBuilder sb)
|
||||||
|
{
|
||||||
|
foreach (var node in element.Nodes())
|
||||||
|
{
|
||||||
|
switch (node)
|
||||||
|
{
|
||||||
|
case XText text:
|
||||||
|
sb.Append(text.Value);
|
||||||
|
break;
|
||||||
|
case XElement child:
|
||||||
|
RenderElement(child, sb);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void RenderElement(XElement element, StringBuilder sb)
|
||||||
|
{
|
||||||
|
switch (element.Name.LocalName)
|
||||||
|
{
|
||||||
|
case "see":
|
||||||
|
case "seealso":
|
||||||
|
var reference = element.Attribute("cref")?.Value ?? element.Attribute("href")?.Value;
|
||||||
|
sb.Append('`').Append(ShortName(reference)).Append('`');
|
||||||
|
break;
|
||||||
|
case "paramref":
|
||||||
|
case "typeparamref":
|
||||||
|
sb.Append('`').Append(element.Attribute("name")?.Value).Append('`');
|
||||||
|
break;
|
||||||
|
case "c":
|
||||||
|
sb.Append('`').Append(element.Value).Append('`');
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
RenderNodes(element, sb);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// "T:LANCommander.SDK.Plugins.IPlugin" -> "IPlugin", "IPlugin.InitializeAsync" -> "InitializeAsync".
|
||||||
|
private static string ShortName(string? cref)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrEmpty(cref))
|
||||||
|
return "";
|
||||||
|
|
||||||
|
var withoutPrefix = cref.Length > 1 && cref[1] == ':' ? cref[2..] : cref;
|
||||||
|
var withoutParameters = withoutPrefix.Split('(')[0];
|
||||||
|
var segments = withoutParameters.Split('.');
|
||||||
|
return segments[^1];
|
||||||
|
}
|
||||||
|
}
|
||||||
60
LANCommander.PluginDocsGenerator/XmlId.cs
Normal file
60
LANCommander.PluginDocsGenerator/XmlId.cs
Normal file
|
|
@ -0,0 +1,60 @@
|
||||||
|
using System.Reflection;
|
||||||
|
using System.Text;
|
||||||
|
|
||||||
|
namespace LANCommander.PluginDocsGenerator;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Builds the member identifiers used in .NET XML documentation files (e.g. "M:Namespace.Type.Method(System.Int32)")
|
||||||
|
/// from reflection, so summaries can be looked up for a given <see cref="MemberInfo"/>.
|
||||||
|
/// See ECMA-334 / the C# spec "Processing the documentation file" for the ID string format.
|
||||||
|
/// </summary>
|
||||||
|
internal static class XmlId
|
||||||
|
{
|
||||||
|
public static string ForType(Type type) => "T:" + TypeName(type);
|
||||||
|
|
||||||
|
public static string ForField(FieldInfo field) => "F:" + TypeName(field.DeclaringType!) + "." + field.Name;
|
||||||
|
|
||||||
|
public static string ForProperty(PropertyInfo property) => "P:" + TypeName(property.DeclaringType!) + "." + property.Name;
|
||||||
|
|
||||||
|
public static string ForMethod(MethodBase method)
|
||||||
|
{
|
||||||
|
var sb = new StringBuilder("M:");
|
||||||
|
sb.Append(TypeName(method.DeclaringType!));
|
||||||
|
sb.Append('.');
|
||||||
|
sb.Append(method.Name.Replace('.', '#')); // constructors: ".ctor" -> "#ctor"
|
||||||
|
|
||||||
|
if (method is MethodInfo { IsGenericMethodDefinition: true } gm)
|
||||||
|
sb.Append("``").Append(gm.GetGenericArguments().Length);
|
||||||
|
|
||||||
|
var parameters = method.GetParameters();
|
||||||
|
if (parameters.Length > 0)
|
||||||
|
sb.Append('(').Append(string.Join(",", parameters.Select(p => ParameterName(p.ParameterType)))).Append(')');
|
||||||
|
|
||||||
|
return sb.ToString();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Full name of a type as it appears in a T: reference (nested '+' -> '.').
|
||||||
|
private static string TypeName(Type type) => (type.FullName ?? type.Namespace + "." + type.Name).Replace('+', '.');
|
||||||
|
|
||||||
|
// Encoding of a type when used as a method parameter.
|
||||||
|
private static string ParameterName(Type type)
|
||||||
|
{
|
||||||
|
if (type.IsByRef)
|
||||||
|
return ParameterName(type.GetElementType()!) + "@";
|
||||||
|
|
||||||
|
if (type.IsArray)
|
||||||
|
return ParameterName(type.GetElementType()!) + "[]";
|
||||||
|
|
||||||
|
if (type.IsGenericParameter)
|
||||||
|
return (type.DeclaringMethod is not null ? "``" : "`") + type.GenericParameterPosition;
|
||||||
|
|
||||||
|
if (type.IsGenericType)
|
||||||
|
{
|
||||||
|
var definition = type.GetGenericTypeDefinition().FullName!.Split('`')[0].Replace('+', '.');
|
||||||
|
var args = type.GetGenericArguments().Select(ParameterName);
|
||||||
|
return definition + "{" + string.Join(",", args) + "}";
|
||||||
|
}
|
||||||
|
|
||||||
|
return TypeName(type);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -148,7 +148,7 @@ public class GameInstallationSharedDirectoryTests : IDisposable
|
||||||
/// resolution and local file verification, so they are safe to leave null.
|
/// resolution and local file verification, so they are safe to leave null.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
private static GameClient CreateClient() =>
|
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)
|
private static void InstallGameFiles(string installDirectory)
|
||||||
{
|
{
|
||||||
|
|
|
||||||
78
LANCommander.SDK.Tests/Plugins/PluginEventBusTests.cs
Normal file
78
LANCommander.SDK.Tests/Plugins/PluginEventBusTests.cs
Normal file
|
|
@ -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<PluginEventBus>.Instance);
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task PublishAsync_InvokesSubscribedHandler()
|
||||||
|
{
|
||||||
|
var bus = CreateBus();
|
||||||
|
var received = 0;
|
||||||
|
|
||||||
|
bus.Subscribe<SampleEvent>((e, _) => { received = e.Value; return Task.CompletedTask; });
|
||||||
|
|
||||||
|
await bus.PublishAsync(new SampleEvent(42));
|
||||||
|
|
||||||
|
Assert.Equal(42, received);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task PublishAsync_OnlyInvokesHandlersForMatchingType()
|
||||||
|
{
|
||||||
|
var bus = CreateBus();
|
||||||
|
var sampleCalled = false;
|
||||||
|
|
||||||
|
bus.Subscribe<SampleEvent>((_, _) => { sampleCalled = true; return Task.CompletedTask; });
|
||||||
|
|
||||||
|
await bus.PublishAsync(new OtherEvent("nope"));
|
||||||
|
|
||||||
|
Assert.False(sampleCalled);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task PublishAsync_IsolatesThrowingHandlers()
|
||||||
|
{
|
||||||
|
var bus = CreateBus();
|
||||||
|
var secondCalled = false;
|
||||||
|
|
||||||
|
bus.Subscribe<SampleEvent>((_, _) => throw new InvalidOperationException("boom"));
|
||||||
|
bus.Subscribe<SampleEvent>((_, _) => { secondCalled = true; return Task.CompletedTask; });
|
||||||
|
|
||||||
|
// Must not throw, and the second handler must still run.
|
||||||
|
await bus.PublishAsync(new SampleEvent(1));
|
||||||
|
|
||||||
|
Assert.True(secondCalled);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Dispose_Unsubscribes()
|
||||||
|
{
|
||||||
|
var bus = CreateBus();
|
||||||
|
var count = 0;
|
||||||
|
|
||||||
|
var subscription = bus.Subscribe<SampleEvent>((_, _) => { count++; return Task.CompletedTask; });
|
||||||
|
|
||||||
|
await bus.PublishAsync(new SampleEvent(1));
|
||||||
|
subscription.Dispose();
|
||||||
|
await bus.PublishAsync(new SampleEvent(1));
|
||||||
|
|
||||||
|
Assert.Equal(1, count);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task PublishAsync_NoSubscribers_DoesNothing()
|
||||||
|
{
|
||||||
|
var bus = CreateBus();
|
||||||
|
await bus.PublishAsync(new SampleEvent(1)); // should simply return
|
||||||
|
}
|
||||||
|
}
|
||||||
28
LANCommander.SDK.Tests/Plugins/PluginVersionGateTests.cs
Normal file
28
LANCommander.SDK.Tests/Plugins/PluginVersionGateTests.cs
Normal file
|
|
@ -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"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -9,6 +9,8 @@ using LANCommander.SDK.Exceptions;
|
||||||
using LANCommander.SDK.Extensions;
|
using LANCommander.SDK.Extensions;
|
||||||
using LANCommander.SDK.Factories;
|
using LANCommander.SDK.Factories;
|
||||||
using LANCommander.SDK.Models;
|
using LANCommander.SDK.Models;
|
||||||
|
using LANCommander.SDK.Plugins;
|
||||||
|
using LANCommander.SDK.Plugins.Events;
|
||||||
using LANCommander.SDK.Providers;
|
using LANCommander.SDK.Providers;
|
||||||
using Microsoft.Extensions.Logging;
|
using Microsoft.Extensions.Logging;
|
||||||
using AuthenticationProvider = LANCommander.SDK.Models.AuthenticationProvider;
|
using AuthenticationProvider = LANCommander.SDK.Models.AuthenticationProvider;
|
||||||
|
|
@ -22,7 +24,8 @@ public class AuthenticationClient(
|
||||||
ISettingsProvider settingsProvider,
|
ISettingsProvider settingsProvider,
|
||||||
ApiRequestFactory apiRequestFactory,
|
ApiRequestFactory apiRequestFactory,
|
||||||
IConnectionClient connectionClient,
|
IConnectionClient connectionClient,
|
||||||
ProfileClient profileClient)
|
ProfileClient profileClient,
|
||||||
|
IPluginEventBus pluginEventBus)
|
||||||
{
|
{
|
||||||
public async Task<AuthToken> AuthenticateAsync(string username, string password, Uri serverAddress)
|
public async Task<AuthToken> AuthenticateAsync(string username, string password, Uri serverAddress)
|
||||||
{
|
{
|
||||||
|
|
@ -65,6 +68,18 @@ public class AuthenticationClient(
|
||||||
|
|
||||||
await configRefresher.RefreshAsync();
|
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;
|
return token;
|
||||||
|
|
||||||
case HttpStatusCode.Forbidden:
|
case HttpStatusCode.Forbidden:
|
||||||
|
|
@ -88,6 +103,17 @@ public class AuthenticationClient(
|
||||||
|
|
||||||
public async Task LogoutAsync()
|
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
|
try
|
||||||
{
|
{
|
||||||
await apiRequestFactory
|
await apiRequestFactory
|
||||||
|
|
@ -106,6 +132,18 @@ public class AuthenticationClient(
|
||||||
tokenProvider.SetToken(null);
|
tokenProvider.SetToken(null);
|
||||||
|
|
||||||
profileClient.ClearCache();
|
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)
|
public async Task RegisterAsync(string username, string password, string passwordConfirmation)
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,8 @@ using System.Threading;
|
||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
using LANCommander.SDK.Abstractions;
|
using LANCommander.SDK.Abstractions;
|
||||||
using LANCommander.SDK.Factories;
|
using LANCommander.SDK.Factories;
|
||||||
|
using LANCommander.SDK.Plugins;
|
||||||
|
using LANCommander.SDK.Plugins.Events;
|
||||||
using Action = System.Action;
|
using Action = System.Action;
|
||||||
|
|
||||||
namespace LANCommander.SDK.Services
|
namespace LANCommander.SDK.Services
|
||||||
|
|
@ -74,7 +76,8 @@ namespace LANCommander.SDK.Services
|
||||||
ScriptClient scriptClient,
|
ScriptClient scriptClient,
|
||||||
ProfileClient profileClient,
|
ProfileClient profileClient,
|
||||||
LobbyClient lobbyClient,
|
LobbyClient lobbyClient,
|
||||||
ToolClient toolClient)
|
ToolClient toolClient,
|
||||||
|
IPluginEventBus pluginEventBus)
|
||||||
{
|
{
|
||||||
public delegate void OnArchiveEntryExtractionProgressHandler(object sender, ArchiveEntryExtractionProgressArgs e);
|
public delegate void OnArchiveEntryExtractionProgressHandler(object sender, ArchiveEntryExtractionProgressArgs e);
|
||||||
public event OnArchiveEntryExtractionProgressHandler OnArchiveEntryExtractionProgress;
|
public event OnArchiveEntryExtractionProgressHandler OnArchiveEntryExtractionProgress;
|
||||||
|
|
@ -130,6 +133,26 @@ namespace LANCommander.SDK.Services
|
||||||
.GetAsync<Models.Manifest.Game>();
|
.GetAsync<Models.Manifest.Game>();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async Task<Models.Manifest.Game> GetManifestAsync(Guid id, Guid versionId)
|
||||||
|
{
|
||||||
|
return await apiRequestFactory
|
||||||
|
.Create()
|
||||||
|
.UseAuthenticationToken()
|
||||||
|
.UseVersioning()
|
||||||
|
.UseRoute($"/api/Games/{id}/Versions/{versionId}/Manifest")
|
||||||
|
.GetAsync<Models.Manifest.Game>();
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<IEnumerable<Models.GameVersion>> GetVersionsAsync(Guid id)
|
||||||
|
{
|
||||||
|
return await apiRequestFactory
|
||||||
|
.Create()
|
||||||
|
.UseAuthenticationToken()
|
||||||
|
.UseVersioning()
|
||||||
|
.UseRoute($"/api/Games/{id}/Versions")
|
||||||
|
.GetAsync<IEnumerable<Models.GameVersion>>();
|
||||||
|
}
|
||||||
|
|
||||||
public async Task<ICollection<Models.Manifest.Game>> GetManifestsAsync(string installDirectory, Guid id)
|
public async Task<ICollection<Models.Manifest.Game>> GetManifestsAsync(string installDirectory, Guid id)
|
||||||
{
|
{
|
||||||
var manifests = new List<Models.Manifest.Game>();
|
var manifests = new List<Models.Manifest.Game>();
|
||||||
|
|
@ -308,6 +331,16 @@ namespace LANCommander.SDK.Services
|
||||||
.GetAsync<IEnumerable<Script>>();
|
.GetAsync<IEnumerable<Script>>();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async Task<IEnumerable<Script>> GetScriptsAsync(Guid id, Guid versionId)
|
||||||
|
{
|
||||||
|
return await apiRequestFactory
|
||||||
|
.Create()
|
||||||
|
.UseAuthenticationToken()
|
||||||
|
.UseVersioning()
|
||||||
|
.UseRoute($"/api/Games/{id}/Versions/{versionId}/Scripts")
|
||||||
|
.GetAsync<IEnumerable<Script>>();
|
||||||
|
}
|
||||||
|
|
||||||
public async Task<bool> CheckForUpdateAsync(Guid id, string currentVersion)
|
public async Task<bool> CheckForUpdateAsync(Guid id, string currentVersion)
|
||||||
{
|
{
|
||||||
return await apiRequestFactory
|
return await apiRequestFactory
|
||||||
|
|
@ -1809,6 +1842,29 @@ namespace LANCommander.SDK.Services
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Refreshes the on-disk manifest and scripts for an installed game using a specific version,
|
||||||
|
/// writing the version-scoped manifest and its scripts to the game's install directory. Used
|
||||||
|
/// when installing or rolling back to a particular version so the local config matches exactly.
|
||||||
|
/// </summary>
|
||||||
|
public async Task RefreshManifestAndScriptsAsync(string installDirectory, Guid gameId, Guid versionId)
|
||||||
|
{
|
||||||
|
logger?.LogTrace("Refreshing version {VersionId} manifest and scripts for game {GameId} in {InstallDirectory}", versionId, gameId, installDirectory);
|
||||||
|
|
||||||
|
var manifest = await GetManifestAsync(gameId, versionId);
|
||||||
|
await ManifestHelper.WriteAsync(manifest, installDirectory);
|
||||||
|
|
||||||
|
var scripts = await GetScriptsAsync(gameId, versionId);
|
||||||
|
|
||||||
|
if (scripts != null && scripts.Any())
|
||||||
|
{
|
||||||
|
var game = new Game { Id = gameId };
|
||||||
|
|
||||||
|
foreach (var script in scripts)
|
||||||
|
await ScriptHelper.SaveScriptAsync(game, script, installDirectory);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private async Task<Models.Manifest.Game> WriteManifestAsync(string installDirectory, Game game)
|
private async Task<Models.Manifest.Game> WriteManifestAsync(string installDirectory, Game game)
|
||||||
{
|
{
|
||||||
logger?.LogTrace($"Retrieving game manifest for game {game.Title} with id {game.Id}");
|
logger?.LogTrace($"Retrieving game manifest for game {game.Title} with id {game.Id}");
|
||||||
|
|
@ -2373,6 +2429,8 @@ namespace LANCommander.SDK.Services
|
||||||
}
|
}
|
||||||
#endregion
|
#endregion
|
||||||
|
|
||||||
|
await pluginEventBus.PublishAsync(new GameBeforeLaunchEvent(gameId, installDirectory, action?.Name));
|
||||||
|
|
||||||
Task heartbeatTask = null;
|
Task heartbeatTask = null;
|
||||||
|
|
||||||
try
|
try
|
||||||
|
|
@ -2462,6 +2520,8 @@ namespace LANCommander.SDK.Services
|
||||||
}
|
}
|
||||||
#endregion
|
#endregion
|
||||||
}
|
}
|
||||||
|
|
||||||
|
await pluginEventBus.PublishAsync(new GameAfterExitEvent(gameId, installDirectory));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ using LANCommander.SDK.Abstractions;
|
||||||
using LANCommander.SDK.Clients;
|
using LANCommander.SDK.Clients;
|
||||||
using LANCommander.SDK.Factories;
|
using LANCommander.SDK.Factories;
|
||||||
using LANCommander.SDK.Models;
|
using LANCommander.SDK.Models;
|
||||||
|
using LANCommander.SDK.Plugins;
|
||||||
using LANCommander.SDK.PowerShell;
|
using LANCommander.SDK.PowerShell;
|
||||||
using LANCommander.SDK.Providers;
|
using LANCommander.SDK.Providers;
|
||||||
using LANCommander.SDK.Rpc.Client;
|
using LANCommander.SDK.Rpc.Client;
|
||||||
|
|
@ -63,6 +64,9 @@ public static class IServiceCollectionExtensions
|
||||||
|
|
||||||
services.TryAddSingleton<IChatClient, ChatClient>();
|
services.TryAddSingleton<IChatClient, ChatClient>();
|
||||||
|
|
||||||
|
// Plugin framework: in-process event bus shared by both hosts so plugins can react to lifecycle events.
|
||||||
|
services.TryAddSingleton<IPluginEventBus, PluginEventBus>();
|
||||||
|
|
||||||
return services;
|
return services;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -1,6 +1,5 @@
|
||||||
using System.IO;
|
using System.IO;
|
||||||
using System.Text.RegularExpressions;
|
using System.Text.RegularExpressions;
|
||||||
using AutoMapper;
|
|
||||||
|
|
||||||
namespace LANCommander.SDK.Helpers;
|
namespace LANCommander.SDK.Helpers;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,10 @@
|
||||||
<RepositoryType>git</RepositoryType>
|
<RepositoryType>git</RepositoryType>
|
||||||
<PackageTags>lancommander</PackageTags>
|
<PackageTags>lancommander</PackageTags>
|
||||||
<PackageLicenseExpression>MIT</PackageLicenseExpression>
|
<PackageLicenseExpression>MIT</PackageLicenseExpression>
|
||||||
|
<!-- Emit XML docs so the plugin API reference can be generated from source comments. -->
|
||||||
|
<GenerateDocumentationFile>true</GenerateDocumentationFile>
|
||||||
|
<!-- Don't fail/spam the build for members without XML comments. -->
|
||||||
|
<NoWarn>$(NoWarn);CS1591</NoWarn>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
|
|
@ -27,6 +31,7 @@
|
||||||
<PackageReference Include="Microsoft.Extensions.Hosting.Abstractions" />
|
<PackageReference Include="Microsoft.Extensions.Hosting.Abstractions" />
|
||||||
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" />
|
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" />
|
||||||
<PackageReference Include="Microsoft.Extensions.Logging.Configuration" />
|
<PackageReference Include="Microsoft.Extensions.Logging.Configuration" />
|
||||||
|
<PackageReference Include="Microsoft.Extensions.Logging.Console" />
|
||||||
<PackageReference Include="Microsoft.Extensions.Options.ConfigurationExtensions" />
|
<PackageReference Include="Microsoft.Extensions.Options.ConfigurationExtensions" />
|
||||||
<PackageReference Include="Microsoft.PowerShell.Commands.Diagnostics" />
|
<PackageReference Include="Microsoft.PowerShell.Commands.Diagnostics" />
|
||||||
<PackageReference Include="Microsoft.PowerShell.SDK" />
|
<PackageReference Include="Microsoft.PowerShell.SDK" />
|
||||||
|
|
|
||||||
24
LANCommander.SDK/Models/GameVersion.cs
Normal file
24
LANCommander.SDK/Models/GameVersion.cs
Normal file
|
|
@ -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; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The id of the archive attached to this version, if one has been uploaded. Null when the
|
||||||
|
/// version exists only to hold config (Scripts, Actions, SavePaths) without a build.
|
||||||
|
/// </summary>
|
||||||
|
public Guid? ArchiveId { get; set; }
|
||||||
|
|
||||||
|
public long CompressedSize { get; set; }
|
||||||
|
public long UncompressedSize { get; set; }
|
||||||
|
}
|
||||||
|
}
|
||||||
9
LANCommander.SDK/Plugins/Events/AuthenticationEvents.cs
Normal file
9
LANCommander.SDK/Plugins/Events/AuthenticationEvents.cs
Normal file
|
|
@ -0,0 +1,9 @@
|
||||||
|
using System;
|
||||||
|
|
||||||
|
namespace LANCommander.SDK.Plugins.Events;
|
||||||
|
|
||||||
|
/// <summary>Raised after a user successfully logs in.</summary>
|
||||||
|
public sealed record UserLoggedInEvent(Guid UserId, string UserName);
|
||||||
|
|
||||||
|
/// <summary>Raised after a user logs out.</summary>
|
||||||
|
public sealed record UserLoggedOutEvent(Guid UserId, string UserName);
|
||||||
27
LANCommander.SDK/Plugins/Events/GameEvents.cs
Normal file
27
LANCommander.SDK/Plugins/Events/GameEvents.cs
Normal file
|
|
@ -0,0 +1,27 @@
|
||||||
|
using System;
|
||||||
|
|
||||||
|
namespace LANCommander.SDK.Plugins.Events;
|
||||||
|
|
||||||
|
/// <summary>Raised just before a game install begins.</summary>
|
||||||
|
public sealed record GameInstallingEvent(Guid GameId, string? InstallDirectory);
|
||||||
|
|
||||||
|
/// <summary>Raised after a game has finished installing.</summary>
|
||||||
|
public sealed record GameInstalledEvent(Guid GameId, string InstallDirectory);
|
||||||
|
|
||||||
|
/// <summary>Raised when a game install fails.</summary>
|
||||||
|
public sealed record GameInstallFailedEvent(Guid GameId, string? InstallDirectory);
|
||||||
|
|
||||||
|
/// <summary>Raised just before a game is uninstalled.</summary>
|
||||||
|
public sealed record GameUninstallingEvent(Guid GameId, string? InstallDirectory);
|
||||||
|
|
||||||
|
/// <summary>Raised after a game has finished uninstalling.</summary>
|
||||||
|
public sealed record GameUninstalledEvent(Guid GameId);
|
||||||
|
|
||||||
|
/// <summary>Raised immediately before a game's executable is launched.</summary>
|
||||||
|
public sealed record GameBeforeLaunchEvent(Guid GameId, string InstallDirectory, string? Action);
|
||||||
|
|
||||||
|
/// <summary>Raised immediately after a launched game process exits.</summary>
|
||||||
|
public sealed record GameAfterExitEvent(Guid GameId, string InstallDirectory);
|
||||||
|
|
||||||
|
/// <summary>Raised whenever the install/download queue changes.</summary>
|
||||||
|
public sealed record InstallQueueChangedEvent;
|
||||||
37
LANCommander.SDK/Plugins/IPlugin.cs
Normal file
37
LANCommander.SDK/Plugins/IPlugin.cs
Normal file
|
|
@ -0,0 +1,37 @@
|
||||||
|
using System.Threading;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
|
||||||
|
namespace LANCommander.SDK.Plugins;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The entry point contract every LANCommander plugin implements. Plugins are discovered
|
||||||
|
/// from the host's <c>Plugins</c> drop-in folder and loaded once at startup.
|
||||||
|
/// </summary>
|
||||||
|
public interface IPlugin
|
||||||
|
{
|
||||||
|
/// <summary>Stable, globally unique identifier (e.g. "com.acme.myplugin").</summary>
|
||||||
|
string Id { get; }
|
||||||
|
|
||||||
|
/// <summary>Human readable display name.</summary>
|
||||||
|
string Name { get; }
|
||||||
|
|
||||||
|
/// <summary>Plugin version (SemVer recommended).</summary>
|
||||||
|
string Version { get; }
|
||||||
|
|
||||||
|
/// <summary>Plugin author.</summary>
|
||||||
|
string Author { get; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Registers the plugin's own services into the host's DI container. Called during host
|
||||||
|
/// startup <b>before</b> the service provider is built, so implementations must only register
|
||||||
|
/// services and must not attempt to resolve them.
|
||||||
|
/// </summary>
|
||||||
|
void ConfigureServices(IServiceCollection services);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Asynchronous startup hook, invoked <b>after</b> the host's service provider is built. Use this
|
||||||
|
/// to resolve services, subscribe to lifecycle events, register UI extensions, etc.
|
||||||
|
/// </summary>
|
||||||
|
Task InitializeAsync(PluginContext context, CancellationToken cancellationToken);
|
||||||
|
}
|
||||||
24
LANCommander.SDK/Plugins/IPluginEventBus.cs
Normal file
24
LANCommander.SDK/Plugins/IPluginEventBus.cs
Normal file
|
|
@ -0,0 +1,24 @@
|
||||||
|
using System;
|
||||||
|
using System.Threading;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
|
||||||
|
namespace LANCommander.SDK.Plugins;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A minimal in-process, strongly-typed event aggregator that lets plugins react to host lifecycle
|
||||||
|
/// events (game install/launch/uninstall, login, etc.). Registered as a singleton in both hosts.
|
||||||
|
/// </summary>
|
||||||
|
public interface IPluginEventBus
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Subscribes a handler to events of type <typeparamref name="TEvent"/>.
|
||||||
|
/// </summary>
|
||||||
|
/// <returns>A token that unsubscribes the handler when disposed.</returns>
|
||||||
|
IDisposable Subscribe<TEvent>(Func<TEvent, CancellationToken, Task> handler);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Publishes an event to all subscribed handlers. Each handler is awaited and isolated so a
|
||||||
|
/// throwing handler cannot break the publisher or other handlers.
|
||||||
|
/// </summary>
|
||||||
|
Task PublishAsync<TEvent>(TEvent @event, CancellationToken cancellationToken = default);
|
||||||
|
}
|
||||||
23
LANCommander.SDK/Plugins/IPluginPowerShellExtension.cs
Normal file
23
LANCommander.SDK/Plugins/IPluginPowerShellExtension.cs
Normal file
|
|
@ -0,0 +1,23 @@
|
||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
|
||||||
|
namespace LANCommander.SDK.Plugins;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Implemented by plugins that want to add PowerShell cmdlets or script modules into the
|
||||||
|
/// LANCommander runspace. Register the implementation in <see cref="IPlugin.ConfigureServices"/>;
|
||||||
|
/// the SDK's PowerShell runspace picks up all registered extensions when a script is executed.
|
||||||
|
/// </summary>
|
||||||
|
public interface IPluginPowerShellExtension
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Returns cmdlet types (classes decorated with <c>[Cmdlet]</c>) to register into each runspace.
|
||||||
|
/// </summary>
|
||||||
|
IEnumerable<Type> GetCmdletTypes();
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Returns absolute paths to PowerShell script modules (.psm1/.psd1) shipped with the plugin that
|
||||||
|
/// should be imported into each runspace.
|
||||||
|
/// </summary>
|
||||||
|
IEnumerable<string> GetModulePaths();
|
||||||
|
}
|
||||||
35
LANCommander.SDK/Plugins/LANCommanderPluginAttribute.cs
Normal file
35
LANCommander.SDK/Plugins/LANCommanderPluginAttribute.cs
Normal file
|
|
@ -0,0 +1,35 @@
|
||||||
|
using System;
|
||||||
|
|
||||||
|
namespace LANCommander.SDK.Plugins;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Assembly-level attribute that marks an assembly as a LANCommander plugin and declares its
|
||||||
|
/// entry point and compatibility metadata. This is the primary discovery mechanism used by the loader.
|
||||||
|
/// </summary>
|
||||||
|
/// <example>
|
||||||
|
/// [assembly: LANCommanderPlugin(typeof(MyPlugin), Id = "com.acme.myplugin",
|
||||||
|
/// MinHostVersion = "1.1.0", Hosts = PluginHost.Server | PluginHost.Launcher)]
|
||||||
|
/// </example>
|
||||||
|
[AttributeUsage(AttributeTargets.Assembly, AllowMultiple = false)]
|
||||||
|
public sealed class LANCommanderPluginAttribute : Attribute
|
||||||
|
{
|
||||||
|
/// <summary>The concrete type implementing <see cref="IPlugin"/> that serves as the entry point.</summary>
|
||||||
|
public Type EntryPoint { get; }
|
||||||
|
|
||||||
|
/// <summary>Optional override for the plugin id; when null the loader falls back to the instance's <see cref="IPlugin.Id"/>.</summary>
|
||||||
|
public string? Id { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Minimum compatible host (SDK) version, inclusive. Null means no lower bound.</summary>
|
||||||
|
public string? MinHostVersion { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Maximum compatible host (SDK) version, inclusive. Null means no upper bound.</summary>
|
||||||
|
public string? MaxHostVersion { get; set; }
|
||||||
|
|
||||||
|
/// <summary>The hosts this plugin supports. Defaults to both server and launcher.</summary>
|
||||||
|
public PluginHost Hosts { get; set; } = PluginHost.Server | PluginHost.Launcher;
|
||||||
|
|
||||||
|
public LANCommanderPluginAttribute(Type entryPoint)
|
||||||
|
{
|
||||||
|
EntryPoint = entryPoint;
|
||||||
|
}
|
||||||
|
}
|
||||||
42
LANCommander.SDK/Plugins/PluginBootstrap.cs
Normal file
42
LANCommander.SDK/Plugins/PluginBootstrap.cs
Normal file
|
|
@ -0,0 +1,42 @@
|
||||||
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
using LANCommander.SDK.Helpers;
|
||||||
|
|
||||||
|
namespace LANCommander.SDK.Plugins;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Convenience helper that centralizes plugin discovery so every host wires it identically.
|
||||||
|
/// Call <see cref="ConfigurePlugins"/> as the last step while populating the service collection
|
||||||
|
/// (before building the provider), then call <see cref="PluginLoaderService.InitializeAllAsync"/>
|
||||||
|
/// on the returned loader after the provider is built.
|
||||||
|
/// </summary>
|
||||||
|
public static class PluginBootstrap
|
||||||
|
{
|
||||||
|
/// <summary>Name of the drop-in folder under the host's config directory.</summary>
|
||||||
|
public const string PluginsFolderName = "Plugins";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Discovers plugins for <paramref name="host"/> from <c><config>/Plugins</c>, lets each register
|
||||||
|
/// its services into <paramref name="services"/>, and registers the loader as a singleton so the
|
||||||
|
/// same instance can drive Phase 2 initialization.
|
||||||
|
/// </summary>
|
||||||
|
public static PluginLoaderService ConfigurePlugins(IServiceCollection services, PluginHost host)
|
||||||
|
{
|
||||||
|
var loader = new PluginLoaderService();
|
||||||
|
|
||||||
|
// Discovery runs before the host's provider exists, so use a throwaway logger factory just for
|
||||||
|
// discovery diagnostics. Phase 2 uses the host's real logger factory.
|
||||||
|
using (var loggerFactory = LoggerFactory.Create(builder => builder.AddSimpleConsole()))
|
||||||
|
{
|
||||||
|
var logger = loggerFactory.CreateLogger("LANCommander.Plugins");
|
||||||
|
var pluginsRoot = AppPaths.GetConfigPath(PluginsFolderName);
|
||||||
|
var hostVersion = VersionHelper.GetCurrentVersion().ToString();
|
||||||
|
|
||||||
|
loader.DiscoverAndConfigure(services, host, pluginsRoot, hostVersion, logger);
|
||||||
|
}
|
||||||
|
|
||||||
|
services.AddSingleton(loader);
|
||||||
|
|
||||||
|
return loader;
|
||||||
|
}
|
||||||
|
}
|
||||||
22
LANCommander.SDK/Plugins/PluginContext.cs
Normal file
22
LANCommander.SDK/Plugins/PluginContext.cs
Normal file
|
|
@ -0,0 +1,22 @@
|
||||||
|
using System;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
|
||||||
|
namespace LANCommander.SDK.Plugins;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Runtime context handed to a plugin during <see cref="IPlugin.InitializeAsync"/>.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class PluginContext
|
||||||
|
{
|
||||||
|
/// <summary>The host the plugin is running inside (a single value, never a flags combination).</summary>
|
||||||
|
public PluginHost Host { get; init; }
|
||||||
|
|
||||||
|
/// <summary>The fully built host service provider (scoped per plugin during initialization).</summary>
|
||||||
|
public IServiceProvider Services { get; init; } = default!;
|
||||||
|
|
||||||
|
/// <summary>Absolute path to the folder the plugin was loaded from.</summary>
|
||||||
|
public string PluginDirectory { get; init; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>Logger scoped to the plugin.</summary>
|
||||||
|
public ILogger Logger { get; init; } = default!;
|
||||||
|
}
|
||||||
75
LANCommander.SDK/Plugins/PluginEventBus.cs
Normal file
75
LANCommander.SDK/Plugins/PluginEventBus.cs
Normal file
|
|
@ -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;
|
||||||
|
|
||||||
|
/// <inheritdoc cref="IPluginEventBus"/>
|
||||||
|
public sealed class PluginEventBus : IPluginEventBus
|
||||||
|
{
|
||||||
|
private readonly ILogger<PluginEventBus> _logger;
|
||||||
|
private readonly ConcurrentDictionary<Type, List<object>> _handlers = new();
|
||||||
|
private readonly object _lock = new();
|
||||||
|
|
||||||
|
public PluginEventBus(ILogger<PluginEventBus> logger)
|
||||||
|
{
|
||||||
|
_logger = logger;
|
||||||
|
}
|
||||||
|
|
||||||
|
public IDisposable Subscribe<TEvent>(Func<TEvent, CancellationToken, Task> handler)
|
||||||
|
{
|
||||||
|
if (handler is null)
|
||||||
|
throw new ArgumentNullException(nameof(handler));
|
||||||
|
|
||||||
|
var list = _handlers.GetOrAdd(typeof(TEvent), _ => new List<object>());
|
||||||
|
|
||||||
|
lock (_lock)
|
||||||
|
list.Add(handler);
|
||||||
|
|
||||||
|
return new Subscription(() =>
|
||||||
|
{
|
||||||
|
lock (_lock)
|
||||||
|
list.Remove(handler);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task PublishAsync<TEvent>(TEvent @event, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
if (!_handlers.TryGetValue(typeof(TEvent), out var list))
|
||||||
|
return;
|
||||||
|
|
||||||
|
object[] snapshot;
|
||||||
|
lock (_lock)
|
||||||
|
snapshot = list.ToArray();
|
||||||
|
|
||||||
|
foreach (var entry in snapshot)
|
||||||
|
{
|
||||||
|
var handler = (Func<TEvent, CancellationToken, Task>)entry;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await handler(@event, cancellationToken).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogError(ex, "A plugin handler for {EventType} threw an exception", typeof(TEvent).Name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class Subscription : IDisposable
|
||||||
|
{
|
||||||
|
private Action? _unsubscribe;
|
||||||
|
|
||||||
|
public Subscription(Action unsubscribe) => _unsubscribe = unsubscribe;
|
||||||
|
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
_unsubscribe?.Invoke();
|
||||||
|
_unsubscribe = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
15
LANCommander.SDK/Plugins/PluginHost.cs
Normal file
15
LANCommander.SDK/Plugins/PluginHost.cs
Normal file
|
|
@ -0,0 +1,15 @@
|
||||||
|
using System;
|
||||||
|
|
||||||
|
namespace LANCommander.SDK.Plugins;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Identifies which LANCommander host a plugin targets. Used both as a single value
|
||||||
|
/// (the host a plugin is being loaded into) and as a flags set (the hosts a plugin declares support for).
|
||||||
|
/// </summary>
|
||||||
|
[Flags]
|
||||||
|
public enum PluginHost
|
||||||
|
{
|
||||||
|
None = 0,
|
||||||
|
Server = 1 << 0,
|
||||||
|
Launcher = 1 << 1,
|
||||||
|
}
|
||||||
72
LANCommander.SDK/Plugins/PluginLoadContext.cs
Normal file
72
LANCommander.SDK/Plugins/PluginLoadContext.cs
Normal file
|
|
@ -0,0 +1,72 @@
|
||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Reflection;
|
||||||
|
using System.Runtime.Loader;
|
||||||
|
|
||||||
|
namespace LANCommander.SDK.Plugins;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// An isolated <see cref="AssemblyLoadContext"/> for a single plugin. Uses an
|
||||||
|
/// <see cref="AssemblyDependencyResolver"/> to resolve the plugin's private dependencies while
|
||||||
|
/// deferring host-provided assemblies (the SDK, DI abstractions, Avalonia, etc.) to the default
|
||||||
|
/// context so that shared types keep a single identity across the ALC boundary.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class PluginLoadContext : AssemblyLoadContext
|
||||||
|
{
|
||||||
|
private readonly AssemblyDependencyResolver _resolver;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Simple assembly names that must always resolve to the host's already-loaded copy so that
|
||||||
|
/// contract types (<see cref="IPlugin"/>, DI, event bus, UI contracts) share identity. Matched
|
||||||
|
/// as a prefix so, e.g., all Avalonia.* assemblies are covered.
|
||||||
|
/// </summary>
|
||||||
|
private static readonly string[] SharedAssemblyPrefixes =
|
||||||
|
{
|
||||||
|
"LANCommander.SDK",
|
||||||
|
"LANCommander.Launcher.Plugins",
|
||||||
|
"LANCommander.Server.Services",
|
||||||
|
"Microsoft.Extensions.DependencyInjection",
|
||||||
|
"Microsoft.Extensions.Logging",
|
||||||
|
"Microsoft.Extensions.Hosting",
|
||||||
|
"Microsoft.Extensions.Options",
|
||||||
|
"Microsoft.Extensions.Configuration",
|
||||||
|
"Avalonia",
|
||||||
|
"CommunityToolkit.Mvvm",
|
||||||
|
"System.Management.Automation",
|
||||||
|
};
|
||||||
|
|
||||||
|
public PluginLoadContext(string mainAssemblyPath)
|
||||||
|
: base(name: $"Plugin:{System.IO.Path.GetFileNameWithoutExtension(mainAssemblyPath)}", isCollectible: false)
|
||||||
|
{
|
||||||
|
_resolver = new AssemblyDependencyResolver(mainAssemblyPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected override Assembly? Load(AssemblyName assemblyName)
|
||||||
|
{
|
||||||
|
if (IsShared(assemblyName.Name))
|
||||||
|
return null; // defer to the default context for shared/host-provided assemblies
|
||||||
|
|
||||||
|
var path = _resolver.ResolveAssemblyToPath(assemblyName);
|
||||||
|
return path is null ? null : LoadFromAssemblyPath(path);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected override IntPtr LoadUnmanagedDll(string unmanagedDllName)
|
||||||
|
{
|
||||||
|
var path = _resolver.ResolveUnmanagedDllToPath(unmanagedDllName);
|
||||||
|
return path is null ? IntPtr.Zero : LoadUnmanagedDllFromPath(path);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool IsShared(string? simpleName)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrEmpty(simpleName))
|
||||||
|
return false;
|
||||||
|
|
||||||
|
foreach (var prefix in SharedAssemblyPrefixes)
|
||||||
|
{
|
||||||
|
if (simpleName.StartsWith(prefix, StringComparison.OrdinalIgnoreCase))
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
191
LANCommander.SDK/Plugins/PluginLoaderService.cs
Normal file
191
LANCommander.SDK/Plugins/PluginLoaderService.cs
Normal file
|
|
@ -0,0 +1,191 @@
|
||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.IO;
|
||||||
|
using System.Linq;
|
||||||
|
using System.Reflection;
|
||||||
|
using System.Threading;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
using Semver;
|
||||||
|
|
||||||
|
namespace LANCommander.SDK.Plugins;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Discovers, loads, and initializes plugins from a drop-in folder. Split into two phases to match
|
||||||
|
/// the "build the DI container once" constraint:
|
||||||
|
/// <list type="number">
|
||||||
|
/// <item><see cref="DiscoverAndConfigure"/> runs while the host is still populating its
|
||||||
|
/// <see cref="IServiceCollection"/> (before the provider is built).</item>
|
||||||
|
/// <item><see cref="InitializeAllAsync"/> runs after the provider has been built.</item>
|
||||||
|
/// </list>
|
||||||
|
/// </summary>
|
||||||
|
public sealed class PluginLoaderService
|
||||||
|
{
|
||||||
|
private readonly List<LoadedPlugin> _loaded = new();
|
||||||
|
private PluginHost _host;
|
||||||
|
|
||||||
|
/// <summary>Plugins successfully loaded and configured during discovery.</summary>
|
||||||
|
public IReadOnlyList<PluginManifest> LoadedPlugins => _loaded.Select(p => p.Manifest).ToList();
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Phase 1: scans <paramref name="pluginsRoot"/> for plugins, loads each into its own
|
||||||
|
/// <see cref="PluginLoadContext"/>, applies host + version gates, instantiates the entry point,
|
||||||
|
/// and lets it register services. A failure in one plugin never aborts the batch.
|
||||||
|
/// </summary>
|
||||||
|
public void DiscoverAndConfigure(
|
||||||
|
IServiceCollection services,
|
||||||
|
PluginHost host,
|
||||||
|
string pluginsRoot,
|
||||||
|
string hostVersion,
|
||||||
|
ILogger logger)
|
||||||
|
{
|
||||||
|
_host = host;
|
||||||
|
|
||||||
|
if (!Directory.Exists(pluginsRoot))
|
||||||
|
{
|
||||||
|
logger.LogInformation("Plugins directory '{PluginsRoot}' does not exist; no plugins loaded", pluginsRoot);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var directory in Directory.GetDirectories(pluginsRoot))
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
TryLoadPlugin(services, host, directory, hostVersion, logger);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
logger.LogError(ex, "Failed to load plugin from '{Directory}'", directory);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void TryLoadPlugin(
|
||||||
|
IServiceCollection services,
|
||||||
|
PluginHost host,
|
||||||
|
string directory,
|
||||||
|
string hostVersion,
|
||||||
|
ILogger logger)
|
||||||
|
{
|
||||||
|
var mainAssemblyPath = ResolveMainAssemblyPath(directory);
|
||||||
|
|
||||||
|
if (mainAssemblyPath is null)
|
||||||
|
{
|
||||||
|
logger.LogWarning("No plugin assembly found in '{Directory}' (expected '{Name}.dll' or a single assembly with a .deps.json)", directory, new DirectoryInfo(directory).Name);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var context = new PluginLoadContext(mainAssemblyPath);
|
||||||
|
var assembly = context.LoadFromAssemblyPath(mainAssemblyPath);
|
||||||
|
|
||||||
|
var attribute = assembly.GetCustomAttribute<LANCommanderPluginAttribute>();
|
||||||
|
|
||||||
|
if (attribute is null)
|
||||||
|
{
|
||||||
|
logger.LogWarning("Assembly '{Assembly}' is missing a [LANCommanderPlugin] attribute; skipping", assembly.FullName);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var manifest = PluginManifest.FromAttribute(attribute, assembly, directory);
|
||||||
|
|
||||||
|
if ((manifest.Hosts & host) == 0)
|
||||||
|
{
|
||||||
|
logger.LogDebug("Plugin '{Id}' does not target host {Host}; skipping", manifest.Id, host);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!IsVersionCompatible(hostVersion, manifest.MinHostVersion, manifest.MaxHostVersion))
|
||||||
|
{
|
||||||
|
logger.LogWarning(
|
||||||
|
"Plugin '{Id}' is incompatible with host version {HostVersion} (requires {Min}..{Max}); skipping",
|
||||||
|
manifest.Id, hostVersion, manifest.MinHostVersion ?? "*", manifest.MaxHostVersion ?? "*");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Activator.CreateInstance(manifest.EntryPoint) is not IPlugin plugin)
|
||||||
|
{
|
||||||
|
logger.LogWarning("Entry point '{EntryPoint}' for plugin '{Id}' does not implement IPlugin; skipping", manifest.EntryPoint.FullName, manifest.Id);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
plugin.ConfigureServices(services);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
logger.LogError(ex, "Plugin '{Id}' threw during ConfigureServices; skipping", plugin.Id);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_loaded.Add(new LoadedPlugin(plugin, manifest));
|
||||||
|
logger.LogInformation("Loaded plugin '{Name}' ({Id}) v{Version} by {Author}", plugin.Name, plugin.Id, plugin.Version, plugin.Author);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Phase 2: invokes <see cref="IPlugin.InitializeAsync"/> for every loaded plugin, each within its
|
||||||
|
/// own DI scope. A failure in one plugin never aborts the others.
|
||||||
|
/// </summary>
|
||||||
|
public async Task InitializeAllAsync(IServiceProvider serviceProvider, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
var loggerFactory = serviceProvider.GetRequiredService<ILoggerFactory>();
|
||||||
|
|
||||||
|
foreach (var loaded in _loaded)
|
||||||
|
{
|
||||||
|
var logger = loggerFactory.CreateLogger($"Plugin:{loaded.Plugin.Id}");
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
using var scope = serviceProvider.CreateScope();
|
||||||
|
|
||||||
|
var context = new PluginContext
|
||||||
|
{
|
||||||
|
Host = _host,
|
||||||
|
Services = scope.ServiceProvider,
|
||||||
|
PluginDirectory = loaded.Manifest.Directory,
|
||||||
|
Logger = logger,
|
||||||
|
};
|
||||||
|
|
||||||
|
await loaded.Plugin.InitializeAsync(context, cancellationToken).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
logger.LogError(ex, "Plugin '{Id}' threw during InitializeAsync", loaded.Plugin.Id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string? ResolveMainAssemblyPath(string directory)
|
||||||
|
{
|
||||||
|
// Preferred convention: an assembly named after the plugin folder.
|
||||||
|
var conventional = Path.Combine(directory, $"{new DirectoryInfo(directory).Name}.dll");
|
||||||
|
if (File.Exists(conventional))
|
||||||
|
return conventional;
|
||||||
|
|
||||||
|
// Fallback: the single assembly in the folder that ships a .deps.json (i.e. the main project output).
|
||||||
|
var candidates = Directory.GetFiles(directory, "*.dll")
|
||||||
|
.Where(dll => File.Exists(Path.ChangeExtension(dll, ".deps.json")))
|
||||||
|
.ToArray();
|
||||||
|
|
||||||
|
return candidates.Length == 1 ? candidates[0] : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
internal static bool IsVersionCompatible(string hostVersion, string? min, string? max)
|
||||||
|
{
|
||||||
|
if (!SemVersion.TryParse(hostVersion, SemVersionStyles.Any, out var host))
|
||||||
|
return true; // can't evaluate the host version, so don't block
|
||||||
|
|
||||||
|
if (min is not null && SemVersion.TryParse(min, SemVersionStyles.Any, out var minVersion)
|
||||||
|
&& host.ComparePrecedenceTo(minVersion) < 0)
|
||||||
|
return false;
|
||||||
|
|
||||||
|
if (max is not null && SemVersion.TryParse(max, SemVersionStyles.Any, out var maxVersion)
|
||||||
|
&& host.ComparePrecedenceTo(maxVersion) > 0)
|
||||||
|
return false;
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private readonly record struct LoadedPlugin(IPlugin Plugin, PluginManifest Manifest);
|
||||||
|
}
|
||||||
35
LANCommander.SDK/Plugins/PluginManifest.cs
Normal file
35
LANCommander.SDK/Plugins/PluginManifest.cs
Normal file
|
|
@ -0,0 +1,35 @@
|
||||||
|
using System;
|
||||||
|
using System.Reflection;
|
||||||
|
|
||||||
|
namespace LANCommander.SDK.Plugins;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Parsed metadata describing a discovered plugin, derived from its <see cref="LANCommanderPluginAttribute"/>.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class PluginManifest
|
||||||
|
{
|
||||||
|
public string Id { get; init; } = string.Empty;
|
||||||
|
public Type EntryPoint { get; init; } = default!;
|
||||||
|
public string? MinHostVersion { get; init; }
|
||||||
|
public string? MaxHostVersion { get; init; }
|
||||||
|
public PluginHost Hosts { get; init; } = PluginHost.Server | PluginHost.Launcher;
|
||||||
|
|
||||||
|
/// <summary>The assembly the plugin was loaded from.</summary>
|
||||||
|
public Assembly Assembly { get; init; } = default!;
|
||||||
|
|
||||||
|
/// <summary>Absolute path to the folder the plugin was loaded from.</summary>
|
||||||
|
public string Directory { get; init; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>Builds a manifest from an assembly-level plugin attribute.</summary>
|
||||||
|
public static PluginManifest FromAttribute(LANCommanderPluginAttribute attribute, Assembly assembly, string directory)
|
||||||
|
=> new()
|
||||||
|
{
|
||||||
|
Id = attribute.Id ?? attribute.EntryPoint.FullName ?? attribute.EntryPoint.Name,
|
||||||
|
EntryPoint = attribute.EntryPoint,
|
||||||
|
MinHostVersion = attribute.MinHostVersion,
|
||||||
|
MaxHostVersion = attribute.MaxHostVersion,
|
||||||
|
Hosts = attribute.Hosts,
|
||||||
|
Assembly = assembly,
|
||||||
|
Directory = directory,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
@ -3,14 +3,17 @@ using Microsoft.Extensions.Logging;
|
||||||
using System;
|
using System;
|
||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
using System.IO;
|
using System.IO;
|
||||||
|
using System.Linq;
|
||||||
using System.Management.Automation;
|
using System.Management.Automation;
|
||||||
using System.Management.Automation.Runspaces;
|
using System.Management.Automation.Runspaces;
|
||||||
|
using System.Reflection;
|
||||||
using System.Runtime.InteropServices;
|
using System.Runtime.InteropServices;
|
||||||
using System.Text;
|
using System.Text;
|
||||||
using System.Text.RegularExpressions;
|
using System.Text.RegularExpressions;
|
||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
using LANCommander.SDK.Abstractions;
|
using LANCommander.SDK.Abstractions;
|
||||||
using LANCommander.SDK.Factories;
|
using LANCommander.SDK.Factories;
|
||||||
|
using LANCommander.SDK.Plugins;
|
||||||
using LANCommander.SDK.PowerShell.Extensions;
|
using LANCommander.SDK.PowerShell.Extensions;
|
||||||
using Microsoft.Extensions.DependencyInjection;
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
using Microsoft.Extensions.Options;
|
using Microsoft.Extensions.Options;
|
||||||
|
|
@ -181,6 +184,8 @@ namespace LANCommander.SDK.PowerShell
|
||||||
|
|
||||||
initialSessionState.AddCustomCmdlets();
|
initialSessionState.AddCustomCmdlets();
|
||||||
|
|
||||||
|
RegisterPluginCmdlets(initialSessionState);
|
||||||
|
|
||||||
DisableWow64Redirection();
|
DisableWow64Redirection();
|
||||||
|
|
||||||
using (Runspace runspace = RunspaceFactory.CreateRunspace(initialSessionState))
|
using (Runspace runspace = RunspaceFactory.CreateRunspace(initialSessionState))
|
||||||
|
|
@ -189,9 +194,14 @@ namespace LANCommander.SDK.PowerShell
|
||||||
|
|
||||||
var modulesPath = AppPaths.GetConfigPath("Modules");
|
var modulesPath = AppPaths.GetConfigPath("Modules");
|
||||||
|
|
||||||
|
var moduleSources = new List<string>();
|
||||||
|
|
||||||
if (Directory.Exists(modulesPath))
|
if (Directory.Exists(modulesPath))
|
||||||
{
|
moduleSources.AddRange(Directory.GetDirectories(modulesPath));
|
||||||
foreach (var moduleDirectory in Directory.GetDirectories(modulesPath))
|
|
||||||
|
moduleSources.AddRange(GetPluginModulePaths());
|
||||||
|
|
||||||
|
foreach (var moduleDirectory in moduleSources)
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
|
|
@ -214,7 +224,6 @@ namespace LANCommander.SDK.PowerShell
|
||||||
Logger.LogWarning(ex, "Failed to load module {ModuleDirectory}", moduleDirectory);
|
Logger.LogWarning(ex, "Failed to load module {ModuleDirectory}", moduleDirectory);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
// Ensure TLS 1.2 is available for web requests (GitHub, etc.)
|
// Ensure TLS 1.2 is available for web requests (GitHub, etc.)
|
||||||
using (var tls = System.Management.Automation.PowerShell.Create())
|
using (var tls = System.Management.Automation.PowerShell.Create())
|
||||||
|
|
@ -347,6 +356,96 @@ namespace LANCommander.SDK.PowerShell
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private void RegisterPluginCmdlets(InitialSessionState initialSessionState)
|
||||||
|
{
|
||||||
|
IEnumerable<IPluginPowerShellExtension> extensions;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
extensions = ServiceProvider.GetServices<IPluginPowerShellExtension>();
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Logger?.LogWarning(ex, "Could not resolve plugin PowerShell extensions");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var extension in extensions)
|
||||||
|
{
|
||||||
|
IEnumerable<Type> cmdletTypes;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
cmdletTypes = extension.GetCmdletTypes() ?? Enumerable.Empty<Type>();
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Logger?.LogWarning(ex, "Plugin PowerShell extension {Extension} failed to enumerate cmdlet types", extension.GetType().FullName);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var cmdletType in cmdletTypes)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var attribute = cmdletType.GetCustomAttribute<CmdletAttribute>();
|
||||||
|
|
||||||
|
if (attribute == null)
|
||||||
|
{
|
||||||
|
Logger?.LogWarning("Plugin cmdlet type {CmdletType} is missing a [Cmdlet] attribute and was skipped", cmdletType.FullName);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
var cmdletName = $"{attribute.VerbName}-{attribute.NounName}";
|
||||||
|
|
||||||
|
initialSessionState.Commands.Add(new SessionStateCmdletEntry(cmdletName, cmdletType, null));
|
||||||
|
|
||||||
|
Logger?.LogDebug("Registered plugin cmdlet {CmdletName} from {CmdletType}", cmdletName, cmdletType.FullName);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Logger?.LogWarning(ex, "Could not register plugin cmdlet {CmdletType}", cmdletType.FullName);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private IEnumerable<string> GetPluginModulePaths()
|
||||||
|
{
|
||||||
|
IEnumerable<IPluginPowerShellExtension> extensions;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
extensions = ServiceProvider.GetServices<IPluginPowerShellExtension>();
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Logger?.LogWarning(ex, "Could not resolve plugin PowerShell extensions");
|
||||||
|
yield break;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var extension in extensions)
|
||||||
|
{
|
||||||
|
IEnumerable<string> modulePaths;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
modulePaths = extension.GetModulePaths() ?? Enumerable.Empty<string>();
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Logger?.LogWarning(ex, "Plugin PowerShell extension {Extension} failed to enumerate module paths", extension.GetType().FullName);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var modulePath in modulePaths)
|
||||||
|
{
|
||||||
|
if (!string.IsNullOrWhiteSpace(modulePath))
|
||||||
|
yield return modulePath;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private static T ConvertResult<T>(object value)
|
private static T ConvertResult<T>(object value)
|
||||||
{
|
{
|
||||||
// Unwrap PSObject wrapper
|
// Unwrap PSObject wrapper
|
||||||
|
|
|
||||||
22
LANCommander.SamplePlugin/LANCommander.SamplePlugin.csproj
Normal file
22
LANCommander.SamplePlugin/LANCommander.SamplePlugin.csproj
Normal file
|
|
@ -0,0 +1,22 @@
|
||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
|
||||||
|
<PropertyGroup>
|
||||||
|
<TargetFramework>net10.0</TargetFramework>
|
||||||
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
|
<Nullable>enable</Nullable>
|
||||||
|
<!-- Not part of the app build graph; built on demand and dropped into <config>/Plugins/. -->
|
||||||
|
<IsPackable>false</IsPackable>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<!--
|
||||||
|
Host-provided contract assemblies are referenced with Private=false so the plugin binds against
|
||||||
|
the host's already-loaded copies at runtime (preserving type identity across the plugin's
|
||||||
|
AssemblyLoadContext) rather than shipping its own copies in the drop-in folder.
|
||||||
|
-->
|
||||||
|
<ItemGroup>
|
||||||
|
<ProjectReference Include="..\LANCommander.SDK\LANCommander.SDK.csproj" Private="false" />
|
||||||
|
<ProjectReference Include="..\LANCommander.Launcher.Plugins\LANCommander.Launcher.Plugins.csproj" Private="false" />
|
||||||
|
<ProjectReference Include="..\LANCommander.Server.Services\LANCommander.Server.Services.csproj" Private="false" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
</Project>
|
||||||
20
LANCommander.SamplePlugin/SampleCmdlet.cs
Normal file
20
LANCommander.SamplePlugin/SampleCmdlet.cs
Normal file
|
|
@ -0,0 +1,20 @@
|
||||||
|
using System.Management.Automation;
|
||||||
|
|
||||||
|
namespace LANCommander.SamplePlugin;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A cmdlet added by the sample plugin. Once registered via
|
||||||
|
/// <see cref="SamplePowerShellExtension"/> it is callable from any LANCommander script as
|
||||||
|
/// <c>Get-SampleGreeting -Name "World"</c>.
|
||||||
|
/// </summary>
|
||||||
|
[Cmdlet(VerbsCommon.Get, "SampleGreeting")]
|
||||||
|
public sealed class SampleGreetingCmdlet : PSCmdlet
|
||||||
|
{
|
||||||
|
[Parameter(Position = 0)]
|
||||||
|
public string Name { get; set; } = "World";
|
||||||
|
|
||||||
|
protected override void ProcessRecord()
|
||||||
|
{
|
||||||
|
WriteObject($"Hello, {Name}, from the LANCommander sample plugin!");
|
||||||
|
}
|
||||||
|
}
|
||||||
20
LANCommander.SamplePlugin/SampleMetadataProvider.cs
Normal file
20
LANCommander.SamplePlugin/SampleMetadataProvider.cs
Normal file
|
|
@ -0,0 +1,20 @@
|
||||||
|
using LANCommander.SDK.Models.Manifest;
|
||||||
|
using LANCommander.Server.Services.Providers.Metadata;
|
||||||
|
|
||||||
|
namespace LANCommander.SamplePlugin;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Minimal metadata provider added by the sample plugin. Returns no results; its purpose is to
|
||||||
|
/// prove that a plugin-registered <see cref="IMetadataProvider"/> is picked up by the server's
|
||||||
|
/// provider enumeration.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class SampleMetadataProvider : IMetadataProvider
|
||||||
|
{
|
||||||
|
public string ProviderName => "Sample Plugin Provider";
|
||||||
|
|
||||||
|
public Task<MetadataSearchResultsCollection<Game>?> SearchGamesAsync(string input, int limit = 10, int offset = 0)
|
||||||
|
=> Task.FromResult<MetadataSearchResultsCollection<Game>?>(
|
||||||
|
new MetadataSearchResultsCollection<Game>(new List<MetadataSearchResult<Game>>(), More: false, limit, offset));
|
||||||
|
|
||||||
|
public Task<Game?> GetGameAsync(string gameId) => Task.FromResult<Game?>(null);
|
||||||
|
}
|
||||||
56
LANCommander.SamplePlugin/SamplePlugin.cs
Normal file
56
LANCommander.SamplePlugin/SamplePlugin.cs
Normal file
|
|
@ -0,0 +1,56 @@
|
||||||
|
using LANCommander.Launcher.Plugins.Extensions;
|
||||||
|
using LANCommander.SamplePlugin;
|
||||||
|
using LANCommander.SDK.Plugins;
|
||||||
|
using LANCommander.SDK.Plugins.Events;
|
||||||
|
using LANCommander.Server.Services.Providers.Metadata;
|
||||||
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
|
||||||
|
[assembly: LANCommanderPlugin(typeof(SamplePlugin), Id = "com.lancommander.sampleplugin",
|
||||||
|
Hosts = PluginHost.Server | PluginHost.Launcher)]
|
||||||
|
|
||||||
|
namespace LANCommander.SamplePlugin;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Reference plugin exercising the framework's extension points: a server metadata provider, a
|
||||||
|
/// launcher settings section, a PowerShell cmdlet, and a lifecycle event subscription. Used as the
|
||||||
|
/// end-to-end smoke test for the plugin framework.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class SamplePlugin : IPlugin
|
||||||
|
{
|
||||||
|
public string Id => "com.lancommander.sampleplugin";
|
||||||
|
public string Name => "LANCommander Sample Plugin";
|
||||||
|
public string Version => "1.0.0";
|
||||||
|
public string Author => "LANCommander";
|
||||||
|
|
||||||
|
private IDisposable? _launchSubscription;
|
||||||
|
|
||||||
|
public void ConfigureServices(IServiceCollection services)
|
||||||
|
{
|
||||||
|
// Server: add an additional metadata provider that appears in the host's enumeration.
|
||||||
|
services.AddSingleton<IMetadataProvider, SampleMetadataProvider>();
|
||||||
|
|
||||||
|
// Launcher: add a settings section rendered by a code-built control.
|
||||||
|
services.AddSingleton<ISettingsPageExtension, SampleSettingsExtension>();
|
||||||
|
|
||||||
|
// Both hosts: add a PowerShell cmdlet callable from any script.
|
||||||
|
services.AddSingleton<IPluginPowerShellExtension, SamplePowerShellExtension>();
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task InitializeAsync(PluginContext context, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
// Subscribe to a lifecycle event; logs whenever a game is about to launch.
|
||||||
|
var eventBus = context.Services.GetRequiredService<IPluginEventBus>();
|
||||||
|
|
||||||
|
_launchSubscription = eventBus.Subscribe<GameBeforeLaunchEvent>((evt, ct) =>
|
||||||
|
{
|
||||||
|
context.Logger.LogInformation(
|
||||||
|
"[SamplePlugin] Game {GameId} is about to launch (action: {Action})", evt.GameId, evt.Action);
|
||||||
|
return Task.CompletedTask;
|
||||||
|
});
|
||||||
|
|
||||||
|
context.Logger.LogInformation("[SamplePlugin] Initialized on host {Host}", context.Host);
|
||||||
|
|
||||||
|
return Task.CompletedTask;
|
||||||
|
}
|
||||||
|
}
|
||||||
14
LANCommander.SamplePlugin/SamplePowerShellExtension.cs
Normal file
14
LANCommander.SamplePlugin/SamplePowerShellExtension.cs
Normal file
|
|
@ -0,0 +1,14 @@
|
||||||
|
using LANCommander.SDK.Plugins;
|
||||||
|
|
||||||
|
namespace LANCommander.SamplePlugin;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Registers the sample plugin's cmdlets (and optionally PowerShell modules) into the runspace used
|
||||||
|
/// by LANCommander scripts.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class SamplePowerShellExtension : IPluginPowerShellExtension
|
||||||
|
{
|
||||||
|
public IEnumerable<Type> GetCmdletTypes() => new[] { typeof(SampleGreetingCmdlet) };
|
||||||
|
|
||||||
|
public IEnumerable<string> GetModulePaths() => Array.Empty<string>();
|
||||||
|
}
|
||||||
37
LANCommander.SamplePlugin/SampleSettingsExtension.cs
Normal file
37
LANCommander.SamplePlugin/SampleSettingsExtension.cs
Normal file
|
|
@ -0,0 +1,37 @@
|
||||||
|
using Avalonia.Controls;
|
||||||
|
using Avalonia.Layout;
|
||||||
|
using LANCommander.Launcher.Plugins.Extensions;
|
||||||
|
|
||||||
|
namespace LANCommander.SamplePlugin;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Adds a settings section built entirely in code (no XAML / avares assets), which is the
|
||||||
|
/// recommended authoring path for plugin views since it avoids Avalonia asset resolution across the
|
||||||
|
/// plugin's AssemblyLoadContext.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class SampleSettingsExtension : ISettingsPageExtension
|
||||||
|
{
|
||||||
|
public string Title => "Sample Plugin";
|
||||||
|
|
||||||
|
public int Order => 0;
|
||||||
|
|
||||||
|
public Control BuildContent()
|
||||||
|
{
|
||||||
|
var panel = new StackPanel { Spacing = 8 };
|
||||||
|
|
||||||
|
panel.Children.Add(new TextBlock
|
||||||
|
{
|
||||||
|
Text = "This settings section was added by the sample plugin.",
|
||||||
|
TextWrapping = Avalonia.Media.TextWrapping.Wrap,
|
||||||
|
Opacity = 0.7,
|
||||||
|
});
|
||||||
|
|
||||||
|
panel.Children.Add(new CheckBox
|
||||||
|
{
|
||||||
|
Content = "Enable sample feature",
|
||||||
|
HorizontalAlignment = HorizontalAlignment.Left,
|
||||||
|
});
|
||||||
|
|
||||||
|
return panel;
|
||||||
|
}
|
||||||
|
}
|
||||||
3418
LANCommander.Server.Data.MySQL/Migrations/20260627014632_AddGameVersions.Designer.cs
generated
Normal file
3418
LANCommander.Server.Data.MySQL/Migrations/20260627014632_AddGameVersions.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -0,0 +1,202 @@
|
||||||
|
using System;
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace LANCommander.Server.Data.MySQL.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class AddGameVersions : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.AddColumn<Guid>(
|
||||||
|
name: "GameVersionId",
|
||||||
|
table: "Scripts",
|
||||||
|
type: "char(36)",
|
||||||
|
nullable: true,
|
||||||
|
collation: "ascii_general_ci");
|
||||||
|
|
||||||
|
migrationBuilder.AddColumn<Guid>(
|
||||||
|
name: "GameVersionId",
|
||||||
|
table: "SavePaths",
|
||||||
|
type: "char(36)",
|
||||||
|
nullable: true,
|
||||||
|
collation: "ascii_general_ci");
|
||||||
|
|
||||||
|
migrationBuilder.AddColumn<Guid>(
|
||||||
|
name: "GameVersionId",
|
||||||
|
table: "Archive",
|
||||||
|
type: "char(36)",
|
||||||
|
nullable: true,
|
||||||
|
collation: "ascii_general_ci");
|
||||||
|
|
||||||
|
migrationBuilder.AddColumn<Guid>(
|
||||||
|
name: "GameVersionId",
|
||||||
|
table: "Actions",
|
||||||
|
type: "char(36)",
|
||||||
|
nullable: true,
|
||||||
|
collation: "ascii_general_ci");
|
||||||
|
|
||||||
|
migrationBuilder.CreateTable(
|
||||||
|
name: "GameVersions",
|
||||||
|
columns: table => new
|
||||||
|
{
|
||||||
|
Id = table.Column<Guid>(type: "char(36)", nullable: false, collation: "ascii_general_ci"),
|
||||||
|
Version = table.Column<string>(type: "longtext", nullable: false)
|
||||||
|
.Annotation("MySql:CharSet", "utf8mb4"),
|
||||||
|
Changelog = table.Column<string>(type: "longtext", nullable: true)
|
||||||
|
.Annotation("MySql:CharSet", "utf8mb4"),
|
||||||
|
SortOrder = table.Column<int>(type: "int", nullable: false),
|
||||||
|
GameId = table.Column<Guid>(type: "char(36)", nullable: false, collation: "ascii_general_ci"),
|
||||||
|
CreatedOn = table.Column<DateTime>(type: "datetime(6)", nullable: false),
|
||||||
|
CreatedById = table.Column<Guid>(type: "char(36)", nullable: true, collation: "ascii_general_ci"),
|
||||||
|
UpdatedOn = table.Column<DateTime>(type: "datetime(6)", nullable: false),
|
||||||
|
UpdatedById = table.Column<Guid>(type: "char(36)", nullable: true, collation: "ascii_general_ci")
|
||||||
|
},
|
||||||
|
constraints: table =>
|
||||||
|
{
|
||||||
|
table.PrimaryKey("PK_GameVersions", x => x.Id);
|
||||||
|
table.ForeignKey(
|
||||||
|
name: "FK_GameVersions_Games_GameId",
|
||||||
|
column: x => x.GameId,
|
||||||
|
principalTable: "Games",
|
||||||
|
principalColumn: "Id",
|
||||||
|
onDelete: ReferentialAction.Cascade);
|
||||||
|
table.ForeignKey(
|
||||||
|
name: "FK_GameVersions_Users_CreatedById",
|
||||||
|
column: x => x.CreatedById,
|
||||||
|
principalTable: "Users",
|
||||||
|
principalColumn: "Id",
|
||||||
|
onDelete: ReferentialAction.SetNull);
|
||||||
|
table.ForeignKey(
|
||||||
|
name: "FK_GameVersions_Users_UpdatedById",
|
||||||
|
column: x => x.UpdatedById,
|
||||||
|
principalTable: "Users",
|
||||||
|
principalColumn: "Id",
|
||||||
|
onDelete: ReferentialAction.SetNull);
|
||||||
|
})
|
||||||
|
.Annotation("MySql:CharSet", "utf8mb4");
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_Scripts_GameVersionId",
|
||||||
|
table: "Scripts",
|
||||||
|
column: "GameVersionId");
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_SavePaths_GameVersionId",
|
||||||
|
table: "SavePaths",
|
||||||
|
column: "GameVersionId");
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_Archive_GameVersionId",
|
||||||
|
table: "Archive",
|
||||||
|
column: "GameVersionId",
|
||||||
|
unique: true);
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_Actions_GameVersionId",
|
||||||
|
table: "Actions",
|
||||||
|
column: "GameVersionId");
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_GameVersions_CreatedById",
|
||||||
|
table: "GameVersions",
|
||||||
|
column: "CreatedById");
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_GameVersions_GameId",
|
||||||
|
table: "GameVersions",
|
||||||
|
column: "GameId");
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_GameVersions_UpdatedById",
|
||||||
|
table: "GameVersions",
|
||||||
|
column: "UpdatedById");
|
||||||
|
|
||||||
|
migrationBuilder.AddForeignKey(
|
||||||
|
name: "FK_Actions_GameVersions_GameVersionId",
|
||||||
|
table: "Actions",
|
||||||
|
column: "GameVersionId",
|
||||||
|
principalTable: "GameVersions",
|
||||||
|
principalColumn: "Id");
|
||||||
|
|
||||||
|
migrationBuilder.AddForeignKey(
|
||||||
|
name: "FK_Archive_GameVersions_GameVersionId",
|
||||||
|
table: "Archive",
|
||||||
|
column: "GameVersionId",
|
||||||
|
principalTable: "GameVersions",
|
||||||
|
principalColumn: "Id");
|
||||||
|
|
||||||
|
migrationBuilder.AddForeignKey(
|
||||||
|
name: "FK_SavePaths_GameVersions_GameVersionId",
|
||||||
|
table: "SavePaths",
|
||||||
|
column: "GameVersionId",
|
||||||
|
principalTable: "GameVersions",
|
||||||
|
principalColumn: "Id");
|
||||||
|
|
||||||
|
migrationBuilder.AddForeignKey(
|
||||||
|
name: "FK_Scripts_GameVersions_GameVersionId",
|
||||||
|
table: "Scripts",
|
||||||
|
column: "GameVersionId",
|
||||||
|
principalTable: "GameVersions",
|
||||||
|
principalColumn: "Id");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropForeignKey(
|
||||||
|
name: "FK_Actions_GameVersions_GameVersionId",
|
||||||
|
table: "Actions");
|
||||||
|
|
||||||
|
migrationBuilder.DropForeignKey(
|
||||||
|
name: "FK_Archive_GameVersions_GameVersionId",
|
||||||
|
table: "Archive");
|
||||||
|
|
||||||
|
migrationBuilder.DropForeignKey(
|
||||||
|
name: "FK_SavePaths_GameVersions_GameVersionId",
|
||||||
|
table: "SavePaths");
|
||||||
|
|
||||||
|
migrationBuilder.DropForeignKey(
|
||||||
|
name: "FK_Scripts_GameVersions_GameVersionId",
|
||||||
|
table: "Scripts");
|
||||||
|
|
||||||
|
migrationBuilder.DropTable(
|
||||||
|
name: "GameVersions");
|
||||||
|
|
||||||
|
migrationBuilder.DropIndex(
|
||||||
|
name: "IX_Scripts_GameVersionId",
|
||||||
|
table: "Scripts");
|
||||||
|
|
||||||
|
migrationBuilder.DropIndex(
|
||||||
|
name: "IX_SavePaths_GameVersionId",
|
||||||
|
table: "SavePaths");
|
||||||
|
|
||||||
|
migrationBuilder.DropIndex(
|
||||||
|
name: "IX_Archive_GameVersionId",
|
||||||
|
table: "Archive");
|
||||||
|
|
||||||
|
migrationBuilder.DropIndex(
|
||||||
|
name: "IX_Actions_GameVersionId",
|
||||||
|
table: "Actions");
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "GameVersionId",
|
||||||
|
table: "Scripts");
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "GameVersionId",
|
||||||
|
table: "SavePaths");
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "GameVersionId",
|
||||||
|
table: "Archive");
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "GameVersionId",
|
||||||
|
table: "Actions");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
3436
LANCommander.Server.Data.MySQL/Migrations/20260630235700_RemoveArchiveChangelog.Designer.cs
generated
Normal file
3436
LANCommander.Server.Data.MySQL/Migrations/20260630235700_RemoveArchiveChangelog.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -0,0 +1,29 @@
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace LANCommander.Server.Data.MySQL.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class RemoveArchiveChangelog : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "Changelog",
|
||||||
|
table: "Archive");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.AddColumn<string>(
|
||||||
|
name: "Changelog",
|
||||||
|
table: "Archive",
|
||||||
|
type: "longtext",
|
||||||
|
nullable: true)
|
||||||
|
.Annotation("MySql:CharSet", "utf8mb4");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -193,6 +193,9 @@ namespace LANCommander.Server.Data.MySQL.Migrations
|
||||||
b.Property<Guid?>("GameId")
|
b.Property<Guid?>("GameId")
|
||||||
.HasColumnType("char(36)");
|
.HasColumnType("char(36)");
|
||||||
|
|
||||||
|
b.Property<Guid?>("GameVersionId")
|
||||||
|
.HasColumnType("char(36)");
|
||||||
|
|
||||||
b.Property<string>("Name")
|
b.Property<string>("Name")
|
||||||
.IsRequired()
|
.IsRequired()
|
||||||
.HasColumnType("longtext");
|
.HasColumnType("longtext");
|
||||||
|
|
@ -233,6 +236,8 @@ namespace LANCommander.Server.Data.MySQL.Migrations
|
||||||
|
|
||||||
b.HasIndex("GameId");
|
b.HasIndex("GameId");
|
||||||
|
|
||||||
|
b.HasIndex("GameVersionId");
|
||||||
|
|
||||||
b.HasIndex("ServerId");
|
b.HasIndex("ServerId");
|
||||||
|
|
||||||
b.HasIndex("ToolId");
|
b.HasIndex("ToolId");
|
||||||
|
|
@ -248,9 +253,6 @@ namespace LANCommander.Server.Data.MySQL.Migrations
|
||||||
.ValueGeneratedOnAdd()
|
.ValueGeneratedOnAdd()
|
||||||
.HasColumnType("char(36)");
|
.HasColumnType("char(36)");
|
||||||
|
|
||||||
b.Property<string>("Changelog")
|
|
||||||
.HasColumnType("longtext");
|
|
||||||
|
|
||||||
b.Property<long>("CompressedSize")
|
b.Property<long>("CompressedSize")
|
||||||
.HasColumnType("bigint");
|
.HasColumnType("bigint");
|
||||||
|
|
||||||
|
|
@ -263,6 +265,9 @@ namespace LANCommander.Server.Data.MySQL.Migrations
|
||||||
b.Property<Guid?>("GameId")
|
b.Property<Guid?>("GameId")
|
||||||
.HasColumnType("char(36)");
|
.HasColumnType("char(36)");
|
||||||
|
|
||||||
|
b.Property<Guid?>("GameVersionId")
|
||||||
|
.HasColumnType("char(36)");
|
||||||
|
|
||||||
b.Property<Guid?>("LastVersionId")
|
b.Property<Guid?>("LastVersionId")
|
||||||
.HasColumnType("char(36)");
|
.HasColumnType("char(36)");
|
||||||
|
|
||||||
|
|
@ -298,6 +303,9 @@ namespace LANCommander.Server.Data.MySQL.Migrations
|
||||||
|
|
||||||
b.HasIndex("GameId");
|
b.HasIndex("GameId");
|
||||||
|
|
||||||
|
b.HasIndex("GameVersionId")
|
||||||
|
.IsUnique();
|
||||||
|
|
||||||
b.HasIndex("LastVersionId");
|
b.HasIndex("LastVersionId");
|
||||||
|
|
||||||
b.HasIndex("RedistributableId");
|
b.HasIndex("RedistributableId");
|
||||||
|
|
@ -728,6 +736,48 @@ namespace LANCommander.Server.Data.MySQL.Migrations
|
||||||
b.ToTable("GameSaves");
|
b.ToTable("GameSaves");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("LANCommander.Server.Data.Models.GameVersion", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("char(36)");
|
||||||
|
|
||||||
|
b.Property<string>("Changelog")
|
||||||
|
.HasColumnType("longtext");
|
||||||
|
|
||||||
|
b.Property<Guid?>("CreatedById")
|
||||||
|
.HasColumnType("char(36)");
|
||||||
|
|
||||||
|
b.Property<DateTime>("CreatedOn")
|
||||||
|
.HasColumnType("datetime(6)");
|
||||||
|
|
||||||
|
b.Property<Guid>("GameId")
|
||||||
|
.HasColumnType("char(36)");
|
||||||
|
|
||||||
|
b.Property<int>("SortOrder")
|
||||||
|
.HasColumnType("int");
|
||||||
|
|
||||||
|
b.Property<Guid?>("UpdatedById")
|
||||||
|
.HasColumnType("char(36)");
|
||||||
|
|
||||||
|
b.Property<DateTime>("UpdatedOn")
|
||||||
|
.HasColumnType("datetime(6)");
|
||||||
|
|
||||||
|
b.Property<string>("Version")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("longtext");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("CreatedById");
|
||||||
|
|
||||||
|
b.HasIndex("GameId");
|
||||||
|
|
||||||
|
b.HasIndex("UpdatedById");
|
||||||
|
|
||||||
|
b.ToTable("GameVersions");
|
||||||
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("LANCommander.Server.Data.Models.Genre", b =>
|
modelBuilder.Entity("LANCommander.Server.Data.Models.Genre", b =>
|
||||||
{
|
{
|
||||||
b.Property<Guid>("Id")
|
b.Property<Guid>("Id")
|
||||||
|
|
@ -1323,6 +1373,9 @@ namespace LANCommander.Server.Data.MySQL.Migrations
|
||||||
b.Property<Guid?>("GameId")
|
b.Property<Guid?>("GameId")
|
||||||
.HasColumnType("char(36)");
|
.HasColumnType("char(36)");
|
||||||
|
|
||||||
|
b.Property<Guid?>("GameVersionId")
|
||||||
|
.HasColumnType("char(36)");
|
||||||
|
|
||||||
b.Property<bool>("IsRegex")
|
b.Property<bool>("IsRegex")
|
||||||
.HasColumnType("tinyint(1)");
|
.HasColumnType("tinyint(1)");
|
||||||
|
|
||||||
|
|
@ -1351,6 +1404,8 @@ namespace LANCommander.Server.Data.MySQL.Migrations
|
||||||
|
|
||||||
b.HasIndex("GameId");
|
b.HasIndex("GameId");
|
||||||
|
|
||||||
|
b.HasIndex("GameVersionId");
|
||||||
|
|
||||||
b.HasIndex("UpdatedById");
|
b.HasIndex("UpdatedById");
|
||||||
|
|
||||||
b.ToTable("SavePaths");
|
b.ToTable("SavePaths");
|
||||||
|
|
@ -1378,6 +1433,9 @@ namespace LANCommander.Server.Data.MySQL.Migrations
|
||||||
b.Property<Guid?>("GameId")
|
b.Property<Guid?>("GameId")
|
||||||
.HasColumnType("char(36)");
|
.HasColumnType("char(36)");
|
||||||
|
|
||||||
|
b.Property<Guid?>("GameVersionId")
|
||||||
|
.HasColumnType("char(36)");
|
||||||
|
|
||||||
b.Property<string>("Name")
|
b.Property<string>("Name")
|
||||||
.IsRequired()
|
.IsRequired()
|
||||||
.HasColumnType("longtext");
|
.HasColumnType("longtext");
|
||||||
|
|
@ -1412,6 +1470,8 @@ namespace LANCommander.Server.Data.MySQL.Migrations
|
||||||
|
|
||||||
b.HasIndex("GameId");
|
b.HasIndex("GameId");
|
||||||
|
|
||||||
|
b.HasIndex("GameVersionId");
|
||||||
|
|
||||||
b.HasIndex("RedistributableId");
|
b.HasIndex("RedistributableId");
|
||||||
|
|
||||||
b.HasIndex("ServerId");
|
b.HasIndex("ServerId");
|
||||||
|
|
@ -2189,6 +2249,11 @@ namespace LANCommander.Server.Data.MySQL.Migrations
|
||||||
.HasForeignKey("GameId")
|
.HasForeignKey("GameId")
|
||||||
.OnDelete(DeleteBehavior.Cascade);
|
.OnDelete(DeleteBehavior.Cascade);
|
||||||
|
|
||||||
|
b.HasOne("LANCommander.Server.Data.Models.GameVersion", "GameVersion")
|
||||||
|
.WithMany("Actions")
|
||||||
|
.HasForeignKey("GameVersionId")
|
||||||
|
.OnDelete(DeleteBehavior.ClientCascade);
|
||||||
|
|
||||||
b.HasOne("LANCommander.Server.Data.Models.Server", "Server")
|
b.HasOne("LANCommander.Server.Data.Models.Server", "Server")
|
||||||
.WithMany("Actions")
|
.WithMany("Actions")
|
||||||
.HasForeignKey("ServerId")
|
.HasForeignKey("ServerId")
|
||||||
|
|
@ -2208,6 +2273,8 @@ namespace LANCommander.Server.Data.MySQL.Migrations
|
||||||
|
|
||||||
b.Navigation("Game");
|
b.Navigation("Game");
|
||||||
|
|
||||||
|
b.Navigation("GameVersion");
|
||||||
|
|
||||||
b.Navigation("Server");
|
b.Navigation("Server");
|
||||||
|
|
||||||
b.Navigation("Tool");
|
b.Navigation("Tool");
|
||||||
|
|
@ -2227,6 +2294,11 @@ namespace LANCommander.Server.Data.MySQL.Migrations
|
||||||
.HasForeignKey("GameId")
|
.HasForeignKey("GameId")
|
||||||
.OnDelete(DeleteBehavior.Cascade);
|
.OnDelete(DeleteBehavior.Cascade);
|
||||||
|
|
||||||
|
b.HasOne("LANCommander.Server.Data.Models.GameVersion", "GameVersion")
|
||||||
|
.WithOne("Archive")
|
||||||
|
.HasForeignKey("LANCommander.Server.Data.Models.Archive", "GameVersionId")
|
||||||
|
.OnDelete(DeleteBehavior.ClientCascade);
|
||||||
|
|
||||||
b.HasOne("LANCommander.Server.Data.Models.Archive", "LastVersion")
|
b.HasOne("LANCommander.Server.Data.Models.Archive", "LastVersion")
|
||||||
.WithMany()
|
.WithMany()
|
||||||
.HasForeignKey("LastVersionId");
|
.HasForeignKey("LastVersionId");
|
||||||
|
|
@ -2256,6 +2328,8 @@ namespace LANCommander.Server.Data.MySQL.Migrations
|
||||||
|
|
||||||
b.Navigation("Game");
|
b.Navigation("Game");
|
||||||
|
|
||||||
|
b.Navigation("GameVersion");
|
||||||
|
|
||||||
b.Navigation("LastVersion");
|
b.Navigation("LastVersion");
|
||||||
|
|
||||||
b.Navigation("Redistributable");
|
b.Navigation("Redistributable");
|
||||||
|
|
@ -2523,6 +2597,31 @@ namespace LANCommander.Server.Data.MySQL.Migrations
|
||||||
b.Navigation("User");
|
b.Navigation("User");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("LANCommander.Server.Data.Models.GameVersion", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("LANCommander.Server.Data.Models.User", "CreatedBy")
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("CreatedById")
|
||||||
|
.OnDelete(DeleteBehavior.SetNull);
|
||||||
|
|
||||||
|
b.HasOne("LANCommander.Server.Data.Models.Game", "Game")
|
||||||
|
.WithMany("Versions")
|
||||||
|
.HasForeignKey("GameId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
b.HasOne("LANCommander.Server.Data.Models.User", "UpdatedBy")
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("UpdatedById")
|
||||||
|
.OnDelete(DeleteBehavior.SetNull);
|
||||||
|
|
||||||
|
b.Navigation("CreatedBy");
|
||||||
|
|
||||||
|
b.Navigation("Game");
|
||||||
|
|
||||||
|
b.Navigation("UpdatedBy");
|
||||||
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("LANCommander.Server.Data.Models.Genre", b =>
|
modelBuilder.Entity("LANCommander.Server.Data.Models.Genre", b =>
|
||||||
{
|
{
|
||||||
b.HasOne("LANCommander.Server.Data.Models.User", "CreatedBy")
|
b.HasOne("LANCommander.Server.Data.Models.User", "CreatedBy")
|
||||||
|
|
@ -2854,6 +2953,11 @@ namespace LANCommander.Server.Data.MySQL.Migrations
|
||||||
.HasForeignKey("GameId")
|
.HasForeignKey("GameId")
|
||||||
.OnDelete(DeleteBehavior.Cascade);
|
.OnDelete(DeleteBehavior.Cascade);
|
||||||
|
|
||||||
|
b.HasOne("LANCommander.Server.Data.Models.GameVersion", "GameVersion")
|
||||||
|
.WithMany("SavePaths")
|
||||||
|
.HasForeignKey("GameVersionId")
|
||||||
|
.OnDelete(DeleteBehavior.ClientCascade);
|
||||||
|
|
||||||
b.HasOne("LANCommander.Server.Data.Models.User", "UpdatedBy")
|
b.HasOne("LANCommander.Server.Data.Models.User", "UpdatedBy")
|
||||||
.WithMany()
|
.WithMany()
|
||||||
.HasForeignKey("UpdatedById")
|
.HasForeignKey("UpdatedById")
|
||||||
|
|
@ -2863,6 +2967,8 @@ namespace LANCommander.Server.Data.MySQL.Migrations
|
||||||
|
|
||||||
b.Navigation("Game");
|
b.Navigation("Game");
|
||||||
|
|
||||||
|
b.Navigation("GameVersion");
|
||||||
|
|
||||||
b.Navigation("UpdatedBy");
|
b.Navigation("UpdatedBy");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -2878,6 +2984,11 @@ namespace LANCommander.Server.Data.MySQL.Migrations
|
||||||
.HasForeignKey("GameId")
|
.HasForeignKey("GameId")
|
||||||
.OnDelete(DeleteBehavior.Cascade);
|
.OnDelete(DeleteBehavior.Cascade);
|
||||||
|
|
||||||
|
b.HasOne("LANCommander.Server.Data.Models.GameVersion", "GameVersion")
|
||||||
|
.WithMany("Scripts")
|
||||||
|
.HasForeignKey("GameVersionId")
|
||||||
|
.OnDelete(DeleteBehavior.ClientCascade);
|
||||||
|
|
||||||
b.HasOne("LANCommander.Server.Data.Models.Redistributable", "Redistributable")
|
b.HasOne("LANCommander.Server.Data.Models.Redistributable", "Redistributable")
|
||||||
.WithMany("Scripts")
|
.WithMany("Scripts")
|
||||||
.HasForeignKey("RedistributableId")
|
.HasForeignKey("RedistributableId")
|
||||||
|
|
@ -2902,6 +3013,8 @@ namespace LANCommander.Server.Data.MySQL.Migrations
|
||||||
|
|
||||||
b.Navigation("Game");
|
b.Navigation("Game");
|
||||||
|
|
||||||
|
b.Navigation("GameVersion");
|
||||||
|
|
||||||
b.Navigation("Redistributable");
|
b.Navigation("Redistributable");
|
||||||
|
|
||||||
b.Navigation("Server");
|
b.Navigation("Server");
|
||||||
|
|
@ -3243,6 +3356,19 @@ namespace LANCommander.Server.Data.MySQL.Migrations
|
||||||
b.Navigation("Scripts");
|
b.Navigation("Scripts");
|
||||||
|
|
||||||
b.Navigation("Servers");
|
b.Navigation("Servers");
|
||||||
|
|
||||||
|
b.Navigation("Versions");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("LANCommander.Server.Data.Models.GameVersion", b =>
|
||||||
|
{
|
||||||
|
b.Navigation("Actions");
|
||||||
|
|
||||||
|
b.Navigation("Archive");
|
||||||
|
|
||||||
|
b.Navigation("SavePaths");
|
||||||
|
|
||||||
|
b.Navigation("Scripts");
|
||||||
});
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("LANCommander.Server.Data.Models.Media", b =>
|
modelBuilder.Entity("LANCommander.Server.Data.Models.Media", b =>
|
||||||
|
|
|
||||||
3418
LANCommander.Server.Data.PostgreSQL/Migrations/20260627014314_AddGameVersions.Designer.cs
generated
Normal file
3418
LANCommander.Server.Data.PostgreSQL/Migrations/20260627014314_AddGameVersions.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -0,0 +1,195 @@
|
||||||
|
using System;
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace LANCommander.Server.Data.PostgreSQL.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class AddGameVersions : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.AddColumn<Guid>(
|
||||||
|
name: "GameVersionId",
|
||||||
|
table: "Scripts",
|
||||||
|
type: "uuid",
|
||||||
|
nullable: true);
|
||||||
|
|
||||||
|
migrationBuilder.AddColumn<Guid>(
|
||||||
|
name: "GameVersionId",
|
||||||
|
table: "SavePaths",
|
||||||
|
type: "uuid",
|
||||||
|
nullable: true);
|
||||||
|
|
||||||
|
migrationBuilder.AddColumn<Guid>(
|
||||||
|
name: "GameVersionId",
|
||||||
|
table: "Archive",
|
||||||
|
type: "uuid",
|
||||||
|
nullable: true);
|
||||||
|
|
||||||
|
migrationBuilder.AddColumn<Guid>(
|
||||||
|
name: "GameVersionId",
|
||||||
|
table: "Actions",
|
||||||
|
type: "uuid",
|
||||||
|
nullable: true);
|
||||||
|
|
||||||
|
migrationBuilder.CreateTable(
|
||||||
|
name: "GameVersions",
|
||||||
|
columns: table => new
|
||||||
|
{
|
||||||
|
Id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||||
|
Version = table.Column<string>(type: "text", nullable: false),
|
||||||
|
Changelog = table.Column<string>(type: "text", nullable: true),
|
||||||
|
SortOrder = table.Column<int>(type: "integer", nullable: false),
|
||||||
|
GameId = table.Column<Guid>(type: "uuid", nullable: false),
|
||||||
|
CreatedOn = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
|
||||||
|
CreatedById = table.Column<Guid>(type: "uuid", nullable: true),
|
||||||
|
UpdatedOn = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
|
||||||
|
UpdatedById = table.Column<Guid>(type: "uuid", nullable: true)
|
||||||
|
},
|
||||||
|
constraints: table =>
|
||||||
|
{
|
||||||
|
table.PrimaryKey("PK_GameVersions", x => x.Id);
|
||||||
|
table.ForeignKey(
|
||||||
|
name: "FK_GameVersions_Games_GameId",
|
||||||
|
column: x => x.GameId,
|
||||||
|
principalTable: "Games",
|
||||||
|
principalColumn: "Id",
|
||||||
|
onDelete: ReferentialAction.Cascade);
|
||||||
|
table.ForeignKey(
|
||||||
|
name: "FK_GameVersions_Users_CreatedById",
|
||||||
|
column: x => x.CreatedById,
|
||||||
|
principalTable: "Users",
|
||||||
|
principalColumn: "Id",
|
||||||
|
onDelete: ReferentialAction.SetNull);
|
||||||
|
table.ForeignKey(
|
||||||
|
name: "FK_GameVersions_Users_UpdatedById",
|
||||||
|
column: x => x.UpdatedById,
|
||||||
|
principalTable: "Users",
|
||||||
|
principalColumn: "Id",
|
||||||
|
onDelete: ReferentialAction.SetNull);
|
||||||
|
});
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_Scripts_GameVersionId",
|
||||||
|
table: "Scripts",
|
||||||
|
column: "GameVersionId");
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_SavePaths_GameVersionId",
|
||||||
|
table: "SavePaths",
|
||||||
|
column: "GameVersionId");
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_Archive_GameVersionId",
|
||||||
|
table: "Archive",
|
||||||
|
column: "GameVersionId",
|
||||||
|
unique: true);
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_Actions_GameVersionId",
|
||||||
|
table: "Actions",
|
||||||
|
column: "GameVersionId");
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_GameVersions_CreatedById",
|
||||||
|
table: "GameVersions",
|
||||||
|
column: "CreatedById");
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_GameVersions_GameId",
|
||||||
|
table: "GameVersions",
|
||||||
|
column: "GameId");
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_GameVersions_UpdatedById",
|
||||||
|
table: "GameVersions",
|
||||||
|
column: "UpdatedById");
|
||||||
|
|
||||||
|
migrationBuilder.AddForeignKey(
|
||||||
|
name: "FK_Actions_GameVersions_GameVersionId",
|
||||||
|
table: "Actions",
|
||||||
|
column: "GameVersionId",
|
||||||
|
principalTable: "GameVersions",
|
||||||
|
principalColumn: "Id");
|
||||||
|
|
||||||
|
migrationBuilder.AddForeignKey(
|
||||||
|
name: "FK_Archive_GameVersions_GameVersionId",
|
||||||
|
table: "Archive",
|
||||||
|
column: "GameVersionId",
|
||||||
|
principalTable: "GameVersions",
|
||||||
|
principalColumn: "Id");
|
||||||
|
|
||||||
|
migrationBuilder.AddForeignKey(
|
||||||
|
name: "FK_SavePaths_GameVersions_GameVersionId",
|
||||||
|
table: "SavePaths",
|
||||||
|
column: "GameVersionId",
|
||||||
|
principalTable: "GameVersions",
|
||||||
|
principalColumn: "Id");
|
||||||
|
|
||||||
|
migrationBuilder.AddForeignKey(
|
||||||
|
name: "FK_Scripts_GameVersions_GameVersionId",
|
||||||
|
table: "Scripts",
|
||||||
|
column: "GameVersionId",
|
||||||
|
principalTable: "GameVersions",
|
||||||
|
principalColumn: "Id");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropForeignKey(
|
||||||
|
name: "FK_Actions_GameVersions_GameVersionId",
|
||||||
|
table: "Actions");
|
||||||
|
|
||||||
|
migrationBuilder.DropForeignKey(
|
||||||
|
name: "FK_Archive_GameVersions_GameVersionId",
|
||||||
|
table: "Archive");
|
||||||
|
|
||||||
|
migrationBuilder.DropForeignKey(
|
||||||
|
name: "FK_SavePaths_GameVersions_GameVersionId",
|
||||||
|
table: "SavePaths");
|
||||||
|
|
||||||
|
migrationBuilder.DropForeignKey(
|
||||||
|
name: "FK_Scripts_GameVersions_GameVersionId",
|
||||||
|
table: "Scripts");
|
||||||
|
|
||||||
|
migrationBuilder.DropTable(
|
||||||
|
name: "GameVersions");
|
||||||
|
|
||||||
|
migrationBuilder.DropIndex(
|
||||||
|
name: "IX_Scripts_GameVersionId",
|
||||||
|
table: "Scripts");
|
||||||
|
|
||||||
|
migrationBuilder.DropIndex(
|
||||||
|
name: "IX_SavePaths_GameVersionId",
|
||||||
|
table: "SavePaths");
|
||||||
|
|
||||||
|
migrationBuilder.DropIndex(
|
||||||
|
name: "IX_Archive_GameVersionId",
|
||||||
|
table: "Archive");
|
||||||
|
|
||||||
|
migrationBuilder.DropIndex(
|
||||||
|
name: "IX_Actions_GameVersionId",
|
||||||
|
table: "Actions");
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "GameVersionId",
|
||||||
|
table: "Scripts");
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "GameVersionId",
|
||||||
|
table: "SavePaths");
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "GameVersionId",
|
||||||
|
table: "Archive");
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "GameVersionId",
|
||||||
|
table: "Actions");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
3436
LANCommander.Server.Data.PostgreSQL/Migrations/20260630234733_RemoveArchiveChangelog.Designer.cs
generated
Normal file
3436
LANCommander.Server.Data.PostgreSQL/Migrations/20260630234733_RemoveArchiveChangelog.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -0,0 +1,28 @@
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace LANCommander.Server.Data.PostgreSQL.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class RemoveArchiveChangelog : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "Changelog",
|
||||||
|
table: "Archive");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.AddColumn<string>(
|
||||||
|
name: "Changelog",
|
||||||
|
table: "Archive",
|
||||||
|
type: "text",
|
||||||
|
nullable: true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -193,6 +193,9 @@ namespace LANCommander.Server.Data.PostgreSQL.Migrations
|
||||||
b.Property<Guid?>("GameId")
|
b.Property<Guid?>("GameId")
|
||||||
.HasColumnType("uuid");
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<Guid?>("GameVersionId")
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
b.Property<string>("Name")
|
b.Property<string>("Name")
|
||||||
.IsRequired()
|
.IsRequired()
|
||||||
.HasColumnType("text");
|
.HasColumnType("text");
|
||||||
|
|
@ -233,6 +236,8 @@ namespace LANCommander.Server.Data.PostgreSQL.Migrations
|
||||||
|
|
||||||
b.HasIndex("GameId");
|
b.HasIndex("GameId");
|
||||||
|
|
||||||
|
b.HasIndex("GameVersionId");
|
||||||
|
|
||||||
b.HasIndex("ServerId");
|
b.HasIndex("ServerId");
|
||||||
|
|
||||||
b.HasIndex("ToolId");
|
b.HasIndex("ToolId");
|
||||||
|
|
@ -248,9 +253,6 @@ namespace LANCommander.Server.Data.PostgreSQL.Migrations
|
||||||
.ValueGeneratedOnAdd()
|
.ValueGeneratedOnAdd()
|
||||||
.HasColumnType("uuid");
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
b.Property<string>("Changelog")
|
|
||||||
.HasColumnType("text");
|
|
||||||
|
|
||||||
b.Property<long>("CompressedSize")
|
b.Property<long>("CompressedSize")
|
||||||
.HasColumnType("bigint");
|
.HasColumnType("bigint");
|
||||||
|
|
||||||
|
|
@ -263,6 +265,9 @@ namespace LANCommander.Server.Data.PostgreSQL.Migrations
|
||||||
b.Property<Guid?>("GameId")
|
b.Property<Guid?>("GameId")
|
||||||
.HasColumnType("uuid");
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<Guid?>("GameVersionId")
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
b.Property<Guid?>("LastVersionId")
|
b.Property<Guid?>("LastVersionId")
|
||||||
.HasColumnType("uuid");
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
|
@ -298,6 +303,9 @@ namespace LANCommander.Server.Data.PostgreSQL.Migrations
|
||||||
|
|
||||||
b.HasIndex("GameId");
|
b.HasIndex("GameId");
|
||||||
|
|
||||||
|
b.HasIndex("GameVersionId")
|
||||||
|
.IsUnique();
|
||||||
|
|
||||||
b.HasIndex("LastVersionId");
|
b.HasIndex("LastVersionId");
|
||||||
|
|
||||||
b.HasIndex("RedistributableId");
|
b.HasIndex("RedistributableId");
|
||||||
|
|
@ -728,6 +736,48 @@ namespace LANCommander.Server.Data.PostgreSQL.Migrations
|
||||||
b.ToTable("GameSaves");
|
b.ToTable("GameSaves");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("LANCommander.Server.Data.Models.GameVersion", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<string>("Changelog")
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.Property<Guid?>("CreatedById")
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<DateTime>("CreatedOn")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<Guid>("GameId")
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<int>("SortOrder")
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
b.Property<Guid?>("UpdatedById")
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<DateTime>("UpdatedOn")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<string>("Version")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("CreatedById");
|
||||||
|
|
||||||
|
b.HasIndex("GameId");
|
||||||
|
|
||||||
|
b.HasIndex("UpdatedById");
|
||||||
|
|
||||||
|
b.ToTable("GameVersions");
|
||||||
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("LANCommander.Server.Data.Models.Genre", b =>
|
modelBuilder.Entity("LANCommander.Server.Data.Models.Genre", b =>
|
||||||
{
|
{
|
||||||
b.Property<Guid>("Id")
|
b.Property<Guid>("Id")
|
||||||
|
|
@ -1323,6 +1373,9 @@ namespace LANCommander.Server.Data.PostgreSQL.Migrations
|
||||||
b.Property<Guid?>("GameId")
|
b.Property<Guid?>("GameId")
|
||||||
.HasColumnType("uuid");
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<Guid?>("GameVersionId")
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
b.Property<bool>("IsRegex")
|
b.Property<bool>("IsRegex")
|
||||||
.HasColumnType("boolean");
|
.HasColumnType("boolean");
|
||||||
|
|
||||||
|
|
@ -1351,6 +1404,8 @@ namespace LANCommander.Server.Data.PostgreSQL.Migrations
|
||||||
|
|
||||||
b.HasIndex("GameId");
|
b.HasIndex("GameId");
|
||||||
|
|
||||||
|
b.HasIndex("GameVersionId");
|
||||||
|
|
||||||
b.HasIndex("UpdatedById");
|
b.HasIndex("UpdatedById");
|
||||||
|
|
||||||
b.ToTable("SavePaths");
|
b.ToTable("SavePaths");
|
||||||
|
|
@ -1378,6 +1433,9 @@ namespace LANCommander.Server.Data.PostgreSQL.Migrations
|
||||||
b.Property<Guid?>("GameId")
|
b.Property<Guid?>("GameId")
|
||||||
.HasColumnType("uuid");
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<Guid?>("GameVersionId")
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
b.Property<string>("Name")
|
b.Property<string>("Name")
|
||||||
.IsRequired()
|
.IsRequired()
|
||||||
.HasColumnType("text");
|
.HasColumnType("text");
|
||||||
|
|
@ -1412,6 +1470,8 @@ namespace LANCommander.Server.Data.PostgreSQL.Migrations
|
||||||
|
|
||||||
b.HasIndex("GameId");
|
b.HasIndex("GameId");
|
||||||
|
|
||||||
|
b.HasIndex("GameVersionId");
|
||||||
|
|
||||||
b.HasIndex("RedistributableId");
|
b.HasIndex("RedistributableId");
|
||||||
|
|
||||||
b.HasIndex("ServerId");
|
b.HasIndex("ServerId");
|
||||||
|
|
@ -2189,6 +2249,11 @@ namespace LANCommander.Server.Data.PostgreSQL.Migrations
|
||||||
.HasForeignKey("GameId")
|
.HasForeignKey("GameId")
|
||||||
.OnDelete(DeleteBehavior.Cascade);
|
.OnDelete(DeleteBehavior.Cascade);
|
||||||
|
|
||||||
|
b.HasOne("LANCommander.Server.Data.Models.GameVersion", "GameVersion")
|
||||||
|
.WithMany("Actions")
|
||||||
|
.HasForeignKey("GameVersionId")
|
||||||
|
.OnDelete(DeleteBehavior.ClientCascade);
|
||||||
|
|
||||||
b.HasOne("LANCommander.Server.Data.Models.Server", "Server")
|
b.HasOne("LANCommander.Server.Data.Models.Server", "Server")
|
||||||
.WithMany("Actions")
|
.WithMany("Actions")
|
||||||
.HasForeignKey("ServerId")
|
.HasForeignKey("ServerId")
|
||||||
|
|
@ -2208,6 +2273,8 @@ namespace LANCommander.Server.Data.PostgreSQL.Migrations
|
||||||
|
|
||||||
b.Navigation("Game");
|
b.Navigation("Game");
|
||||||
|
|
||||||
|
b.Navigation("GameVersion");
|
||||||
|
|
||||||
b.Navigation("Server");
|
b.Navigation("Server");
|
||||||
|
|
||||||
b.Navigation("Tool");
|
b.Navigation("Tool");
|
||||||
|
|
@ -2227,6 +2294,11 @@ namespace LANCommander.Server.Data.PostgreSQL.Migrations
|
||||||
.HasForeignKey("GameId")
|
.HasForeignKey("GameId")
|
||||||
.OnDelete(DeleteBehavior.Cascade);
|
.OnDelete(DeleteBehavior.Cascade);
|
||||||
|
|
||||||
|
b.HasOne("LANCommander.Server.Data.Models.GameVersion", "GameVersion")
|
||||||
|
.WithOne("Archive")
|
||||||
|
.HasForeignKey("LANCommander.Server.Data.Models.Archive", "GameVersionId")
|
||||||
|
.OnDelete(DeleteBehavior.ClientCascade);
|
||||||
|
|
||||||
b.HasOne("LANCommander.Server.Data.Models.Archive", "LastVersion")
|
b.HasOne("LANCommander.Server.Data.Models.Archive", "LastVersion")
|
||||||
.WithMany()
|
.WithMany()
|
||||||
.HasForeignKey("LastVersionId");
|
.HasForeignKey("LastVersionId");
|
||||||
|
|
@ -2256,6 +2328,8 @@ namespace LANCommander.Server.Data.PostgreSQL.Migrations
|
||||||
|
|
||||||
b.Navigation("Game");
|
b.Navigation("Game");
|
||||||
|
|
||||||
|
b.Navigation("GameVersion");
|
||||||
|
|
||||||
b.Navigation("LastVersion");
|
b.Navigation("LastVersion");
|
||||||
|
|
||||||
b.Navigation("Redistributable");
|
b.Navigation("Redistributable");
|
||||||
|
|
@ -2523,6 +2597,31 @@ namespace LANCommander.Server.Data.PostgreSQL.Migrations
|
||||||
b.Navigation("User");
|
b.Navigation("User");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("LANCommander.Server.Data.Models.GameVersion", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("LANCommander.Server.Data.Models.User", "CreatedBy")
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("CreatedById")
|
||||||
|
.OnDelete(DeleteBehavior.SetNull);
|
||||||
|
|
||||||
|
b.HasOne("LANCommander.Server.Data.Models.Game", "Game")
|
||||||
|
.WithMany("Versions")
|
||||||
|
.HasForeignKey("GameId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
b.HasOne("LANCommander.Server.Data.Models.User", "UpdatedBy")
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("UpdatedById")
|
||||||
|
.OnDelete(DeleteBehavior.SetNull);
|
||||||
|
|
||||||
|
b.Navigation("CreatedBy");
|
||||||
|
|
||||||
|
b.Navigation("Game");
|
||||||
|
|
||||||
|
b.Navigation("UpdatedBy");
|
||||||
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("LANCommander.Server.Data.Models.Genre", b =>
|
modelBuilder.Entity("LANCommander.Server.Data.Models.Genre", b =>
|
||||||
{
|
{
|
||||||
b.HasOne("LANCommander.Server.Data.Models.User", "CreatedBy")
|
b.HasOne("LANCommander.Server.Data.Models.User", "CreatedBy")
|
||||||
|
|
@ -2854,6 +2953,11 @@ namespace LANCommander.Server.Data.PostgreSQL.Migrations
|
||||||
.HasForeignKey("GameId")
|
.HasForeignKey("GameId")
|
||||||
.OnDelete(DeleteBehavior.Cascade);
|
.OnDelete(DeleteBehavior.Cascade);
|
||||||
|
|
||||||
|
b.HasOne("LANCommander.Server.Data.Models.GameVersion", "GameVersion")
|
||||||
|
.WithMany("SavePaths")
|
||||||
|
.HasForeignKey("GameVersionId")
|
||||||
|
.OnDelete(DeleteBehavior.ClientCascade);
|
||||||
|
|
||||||
b.HasOne("LANCommander.Server.Data.Models.User", "UpdatedBy")
|
b.HasOne("LANCommander.Server.Data.Models.User", "UpdatedBy")
|
||||||
.WithMany()
|
.WithMany()
|
||||||
.HasForeignKey("UpdatedById")
|
.HasForeignKey("UpdatedById")
|
||||||
|
|
@ -2863,6 +2967,8 @@ namespace LANCommander.Server.Data.PostgreSQL.Migrations
|
||||||
|
|
||||||
b.Navigation("Game");
|
b.Navigation("Game");
|
||||||
|
|
||||||
|
b.Navigation("GameVersion");
|
||||||
|
|
||||||
b.Navigation("UpdatedBy");
|
b.Navigation("UpdatedBy");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -2878,6 +2984,11 @@ namespace LANCommander.Server.Data.PostgreSQL.Migrations
|
||||||
.HasForeignKey("GameId")
|
.HasForeignKey("GameId")
|
||||||
.OnDelete(DeleteBehavior.Cascade);
|
.OnDelete(DeleteBehavior.Cascade);
|
||||||
|
|
||||||
|
b.HasOne("LANCommander.Server.Data.Models.GameVersion", "GameVersion")
|
||||||
|
.WithMany("Scripts")
|
||||||
|
.HasForeignKey("GameVersionId")
|
||||||
|
.OnDelete(DeleteBehavior.ClientCascade);
|
||||||
|
|
||||||
b.HasOne("LANCommander.Server.Data.Models.Redistributable", "Redistributable")
|
b.HasOne("LANCommander.Server.Data.Models.Redistributable", "Redistributable")
|
||||||
.WithMany("Scripts")
|
.WithMany("Scripts")
|
||||||
.HasForeignKey("RedistributableId")
|
.HasForeignKey("RedistributableId")
|
||||||
|
|
@ -2902,6 +3013,8 @@ namespace LANCommander.Server.Data.PostgreSQL.Migrations
|
||||||
|
|
||||||
b.Navigation("Game");
|
b.Navigation("Game");
|
||||||
|
|
||||||
|
b.Navigation("GameVersion");
|
||||||
|
|
||||||
b.Navigation("Redistributable");
|
b.Navigation("Redistributable");
|
||||||
|
|
||||||
b.Navigation("Server");
|
b.Navigation("Server");
|
||||||
|
|
@ -3243,6 +3356,19 @@ namespace LANCommander.Server.Data.PostgreSQL.Migrations
|
||||||
b.Navigation("Scripts");
|
b.Navigation("Scripts");
|
||||||
|
|
||||||
b.Navigation("Servers");
|
b.Navigation("Servers");
|
||||||
|
|
||||||
|
b.Navigation("Versions");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("LANCommander.Server.Data.Models.GameVersion", b =>
|
||||||
|
{
|
||||||
|
b.Navigation("Actions");
|
||||||
|
|
||||||
|
b.Navigation("Archive");
|
||||||
|
|
||||||
|
b.Navigation("SavePaths");
|
||||||
|
|
||||||
|
b.Navigation("Scripts");
|
||||||
});
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("LANCommander.Server.Data.Models.Media", b =>
|
modelBuilder.Entity("LANCommander.Server.Data.Models.Media", b =>
|
||||||
|
|
|
||||||
3409
LANCommander.Server.Data.SQLite/Migrations/20260627014005_AddGameVersions.Designer.cs
generated
Normal file
3409
LANCommander.Server.Data.SQLite/Migrations/20260627014005_AddGameVersions.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -0,0 +1,195 @@
|
||||||
|
using System;
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace LANCommander.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class AddGameVersions : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.AddColumn<Guid>(
|
||||||
|
name: "GameVersionId",
|
||||||
|
table: "Scripts",
|
||||||
|
type: "TEXT",
|
||||||
|
nullable: true);
|
||||||
|
|
||||||
|
migrationBuilder.AddColumn<Guid>(
|
||||||
|
name: "GameVersionId",
|
||||||
|
table: "SavePaths",
|
||||||
|
type: "TEXT",
|
||||||
|
nullable: true);
|
||||||
|
|
||||||
|
migrationBuilder.AddColumn<Guid>(
|
||||||
|
name: "GameVersionId",
|
||||||
|
table: "Archive",
|
||||||
|
type: "TEXT",
|
||||||
|
nullable: true);
|
||||||
|
|
||||||
|
migrationBuilder.AddColumn<Guid>(
|
||||||
|
name: "GameVersionId",
|
||||||
|
table: "Actions",
|
||||||
|
type: "TEXT",
|
||||||
|
nullable: true);
|
||||||
|
|
||||||
|
migrationBuilder.CreateTable(
|
||||||
|
name: "GameVersions",
|
||||||
|
columns: table => new
|
||||||
|
{
|
||||||
|
Id = table.Column<Guid>(type: "TEXT", nullable: false),
|
||||||
|
Version = table.Column<string>(type: "TEXT", nullable: false),
|
||||||
|
Changelog = table.Column<string>(type: "TEXT", nullable: true),
|
||||||
|
SortOrder = table.Column<int>(type: "INTEGER", nullable: false),
|
||||||
|
GameId = table.Column<Guid>(type: "TEXT", nullable: false),
|
||||||
|
CreatedOn = table.Column<DateTime>(type: "TEXT", nullable: false),
|
||||||
|
CreatedById = table.Column<Guid>(type: "TEXT", nullable: true),
|
||||||
|
UpdatedOn = table.Column<DateTime>(type: "TEXT", nullable: false),
|
||||||
|
UpdatedById = table.Column<Guid>(type: "TEXT", nullable: true)
|
||||||
|
},
|
||||||
|
constraints: table =>
|
||||||
|
{
|
||||||
|
table.PrimaryKey("PK_GameVersions", x => x.Id);
|
||||||
|
table.ForeignKey(
|
||||||
|
name: "FK_GameVersions_Games_GameId",
|
||||||
|
column: x => x.GameId,
|
||||||
|
principalTable: "Games",
|
||||||
|
principalColumn: "Id",
|
||||||
|
onDelete: ReferentialAction.Cascade);
|
||||||
|
table.ForeignKey(
|
||||||
|
name: "FK_GameVersions_Users_CreatedById",
|
||||||
|
column: x => x.CreatedById,
|
||||||
|
principalTable: "Users",
|
||||||
|
principalColumn: "Id",
|
||||||
|
onDelete: ReferentialAction.SetNull);
|
||||||
|
table.ForeignKey(
|
||||||
|
name: "FK_GameVersions_Users_UpdatedById",
|
||||||
|
column: x => x.UpdatedById,
|
||||||
|
principalTable: "Users",
|
||||||
|
principalColumn: "Id",
|
||||||
|
onDelete: ReferentialAction.SetNull);
|
||||||
|
});
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_Scripts_GameVersionId",
|
||||||
|
table: "Scripts",
|
||||||
|
column: "GameVersionId");
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_SavePaths_GameVersionId",
|
||||||
|
table: "SavePaths",
|
||||||
|
column: "GameVersionId");
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_Archive_GameVersionId",
|
||||||
|
table: "Archive",
|
||||||
|
column: "GameVersionId",
|
||||||
|
unique: true);
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_Actions_GameVersionId",
|
||||||
|
table: "Actions",
|
||||||
|
column: "GameVersionId");
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_GameVersions_CreatedById",
|
||||||
|
table: "GameVersions",
|
||||||
|
column: "CreatedById");
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_GameVersions_GameId",
|
||||||
|
table: "GameVersions",
|
||||||
|
column: "GameId");
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_GameVersions_UpdatedById",
|
||||||
|
table: "GameVersions",
|
||||||
|
column: "UpdatedById");
|
||||||
|
|
||||||
|
migrationBuilder.AddForeignKey(
|
||||||
|
name: "FK_Actions_GameVersions_GameVersionId",
|
||||||
|
table: "Actions",
|
||||||
|
column: "GameVersionId",
|
||||||
|
principalTable: "GameVersions",
|
||||||
|
principalColumn: "Id");
|
||||||
|
|
||||||
|
migrationBuilder.AddForeignKey(
|
||||||
|
name: "FK_Archive_GameVersions_GameVersionId",
|
||||||
|
table: "Archive",
|
||||||
|
column: "GameVersionId",
|
||||||
|
principalTable: "GameVersions",
|
||||||
|
principalColumn: "Id");
|
||||||
|
|
||||||
|
migrationBuilder.AddForeignKey(
|
||||||
|
name: "FK_SavePaths_GameVersions_GameVersionId",
|
||||||
|
table: "SavePaths",
|
||||||
|
column: "GameVersionId",
|
||||||
|
principalTable: "GameVersions",
|
||||||
|
principalColumn: "Id");
|
||||||
|
|
||||||
|
migrationBuilder.AddForeignKey(
|
||||||
|
name: "FK_Scripts_GameVersions_GameVersionId",
|
||||||
|
table: "Scripts",
|
||||||
|
column: "GameVersionId",
|
||||||
|
principalTable: "GameVersions",
|
||||||
|
principalColumn: "Id");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropForeignKey(
|
||||||
|
name: "FK_Actions_GameVersions_GameVersionId",
|
||||||
|
table: "Actions");
|
||||||
|
|
||||||
|
migrationBuilder.DropForeignKey(
|
||||||
|
name: "FK_Archive_GameVersions_GameVersionId",
|
||||||
|
table: "Archive");
|
||||||
|
|
||||||
|
migrationBuilder.DropForeignKey(
|
||||||
|
name: "FK_SavePaths_GameVersions_GameVersionId",
|
||||||
|
table: "SavePaths");
|
||||||
|
|
||||||
|
migrationBuilder.DropForeignKey(
|
||||||
|
name: "FK_Scripts_GameVersions_GameVersionId",
|
||||||
|
table: "Scripts");
|
||||||
|
|
||||||
|
migrationBuilder.DropTable(
|
||||||
|
name: "GameVersions");
|
||||||
|
|
||||||
|
migrationBuilder.DropIndex(
|
||||||
|
name: "IX_Scripts_GameVersionId",
|
||||||
|
table: "Scripts");
|
||||||
|
|
||||||
|
migrationBuilder.DropIndex(
|
||||||
|
name: "IX_SavePaths_GameVersionId",
|
||||||
|
table: "SavePaths");
|
||||||
|
|
||||||
|
migrationBuilder.DropIndex(
|
||||||
|
name: "IX_Archive_GameVersionId",
|
||||||
|
table: "Archive");
|
||||||
|
|
||||||
|
migrationBuilder.DropIndex(
|
||||||
|
name: "IX_Actions_GameVersionId",
|
||||||
|
table: "Actions");
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "GameVersionId",
|
||||||
|
table: "Scripts");
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "GameVersionId",
|
||||||
|
table: "SavePaths");
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "GameVersionId",
|
||||||
|
table: "Archive");
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "GameVersionId",
|
||||||
|
table: "Actions");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
3427
LANCommander.Server.Data.SQLite/Migrations/20260630234322_RemoveArchiveChangelog.Designer.cs
generated
Normal file
3427
LANCommander.Server.Data.SQLite/Migrations/20260630234322_RemoveArchiveChangelog.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -0,0 +1,28 @@
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace LANCommander.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class RemoveArchiveChangelog : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "Changelog",
|
||||||
|
table: "Archive");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.AddColumn<string>(
|
||||||
|
name: "Changelog",
|
||||||
|
table: "Archive",
|
||||||
|
type: "TEXT",
|
||||||
|
nullable: true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -188,6 +188,9 @@ namespace LANCommander.Migrations
|
||||||
b.Property<Guid?>("GameId")
|
b.Property<Guid?>("GameId")
|
||||||
.HasColumnType("TEXT");
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<Guid?>("GameVersionId")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
b.Property<string>("Name")
|
b.Property<string>("Name")
|
||||||
.IsRequired()
|
.IsRequired()
|
||||||
.HasColumnType("TEXT");
|
.HasColumnType("TEXT");
|
||||||
|
|
@ -228,6 +231,8 @@ namespace LANCommander.Migrations
|
||||||
|
|
||||||
b.HasIndex("GameId");
|
b.HasIndex("GameId");
|
||||||
|
|
||||||
|
b.HasIndex("GameVersionId");
|
||||||
|
|
||||||
b.HasIndex("ServerId");
|
b.HasIndex("ServerId");
|
||||||
|
|
||||||
b.HasIndex("ToolId");
|
b.HasIndex("ToolId");
|
||||||
|
|
@ -243,9 +248,6 @@ namespace LANCommander.Migrations
|
||||||
.ValueGeneratedOnAdd()
|
.ValueGeneratedOnAdd()
|
||||||
.HasColumnType("TEXT");
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
b.Property<string>("Changelog")
|
|
||||||
.HasColumnType("TEXT");
|
|
||||||
|
|
||||||
b.Property<long>("CompressedSize")
|
b.Property<long>("CompressedSize")
|
||||||
.HasColumnType("INTEGER");
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
|
@ -258,6 +260,9 @@ namespace LANCommander.Migrations
|
||||||
b.Property<Guid?>("GameId")
|
b.Property<Guid?>("GameId")
|
||||||
.HasColumnType("TEXT");
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<Guid?>("GameVersionId")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
b.Property<Guid?>("LastVersionId")
|
b.Property<Guid?>("LastVersionId")
|
||||||
.HasColumnType("TEXT");
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
|
@ -293,6 +298,9 @@ namespace LANCommander.Migrations
|
||||||
|
|
||||||
b.HasIndex("GameId");
|
b.HasIndex("GameId");
|
||||||
|
|
||||||
|
b.HasIndex("GameVersionId")
|
||||||
|
.IsUnique();
|
||||||
|
|
||||||
b.HasIndex("LastVersionId");
|
b.HasIndex("LastVersionId");
|
||||||
|
|
||||||
b.HasIndex("RedistributableId");
|
b.HasIndex("RedistributableId");
|
||||||
|
|
@ -723,6 +731,48 @@ namespace LANCommander.Migrations
|
||||||
b.ToTable("GameSaves");
|
b.ToTable("GameSaves");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("LANCommander.Server.Data.Models.GameVersion", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("Changelog")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<Guid?>("CreatedById")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<DateTime>("CreatedOn")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<Guid>("GameId")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<int>("SortOrder")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<Guid?>("UpdatedById")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<DateTime>("UpdatedOn")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("Version")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("CreatedById");
|
||||||
|
|
||||||
|
b.HasIndex("GameId");
|
||||||
|
|
||||||
|
b.HasIndex("UpdatedById");
|
||||||
|
|
||||||
|
b.ToTable("GameVersions");
|
||||||
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("LANCommander.Server.Data.Models.Genre", b =>
|
modelBuilder.Entity("LANCommander.Server.Data.Models.Genre", b =>
|
||||||
{
|
{
|
||||||
b.Property<Guid>("Id")
|
b.Property<Guid>("Id")
|
||||||
|
|
@ -1316,6 +1366,9 @@ namespace LANCommander.Migrations
|
||||||
b.Property<Guid?>("GameId")
|
b.Property<Guid?>("GameId")
|
||||||
.HasColumnType("TEXT");
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<Guid?>("GameVersionId")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
b.Property<bool>("IsRegex")
|
b.Property<bool>("IsRegex")
|
||||||
.HasColumnType("INTEGER");
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
|
@ -1344,6 +1397,8 @@ namespace LANCommander.Migrations
|
||||||
|
|
||||||
b.HasIndex("GameId");
|
b.HasIndex("GameId");
|
||||||
|
|
||||||
|
b.HasIndex("GameVersionId");
|
||||||
|
|
||||||
b.HasIndex("UpdatedById");
|
b.HasIndex("UpdatedById");
|
||||||
|
|
||||||
b.ToTable("SavePaths");
|
b.ToTable("SavePaths");
|
||||||
|
|
@ -1371,6 +1426,9 @@ namespace LANCommander.Migrations
|
||||||
b.Property<Guid?>("GameId")
|
b.Property<Guid?>("GameId")
|
||||||
.HasColumnType("TEXT");
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<Guid?>("GameVersionId")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
b.Property<string>("Name")
|
b.Property<string>("Name")
|
||||||
.IsRequired()
|
.IsRequired()
|
||||||
.HasColumnType("TEXT");
|
.HasColumnType("TEXT");
|
||||||
|
|
@ -1405,6 +1463,8 @@ namespace LANCommander.Migrations
|
||||||
|
|
||||||
b.HasIndex("GameId");
|
b.HasIndex("GameId");
|
||||||
|
|
||||||
|
b.HasIndex("GameVersionId");
|
||||||
|
|
||||||
b.HasIndex("RedistributableId");
|
b.HasIndex("RedistributableId");
|
||||||
|
|
||||||
b.HasIndex("ServerId");
|
b.HasIndex("ServerId");
|
||||||
|
|
@ -2180,6 +2240,11 @@ namespace LANCommander.Migrations
|
||||||
.HasForeignKey("GameId")
|
.HasForeignKey("GameId")
|
||||||
.OnDelete(DeleteBehavior.Cascade);
|
.OnDelete(DeleteBehavior.Cascade);
|
||||||
|
|
||||||
|
b.HasOne("LANCommander.Server.Data.Models.GameVersion", "GameVersion")
|
||||||
|
.WithMany("Actions")
|
||||||
|
.HasForeignKey("GameVersionId")
|
||||||
|
.OnDelete(DeleteBehavior.ClientCascade);
|
||||||
|
|
||||||
b.HasOne("LANCommander.Server.Data.Models.Server", "Server")
|
b.HasOne("LANCommander.Server.Data.Models.Server", "Server")
|
||||||
.WithMany("Actions")
|
.WithMany("Actions")
|
||||||
.HasForeignKey("ServerId")
|
.HasForeignKey("ServerId")
|
||||||
|
|
@ -2199,6 +2264,8 @@ namespace LANCommander.Migrations
|
||||||
|
|
||||||
b.Navigation("Game");
|
b.Navigation("Game");
|
||||||
|
|
||||||
|
b.Navigation("GameVersion");
|
||||||
|
|
||||||
b.Navigation("Server");
|
b.Navigation("Server");
|
||||||
|
|
||||||
b.Navigation("Tool");
|
b.Navigation("Tool");
|
||||||
|
|
@ -2218,6 +2285,11 @@ namespace LANCommander.Migrations
|
||||||
.HasForeignKey("GameId")
|
.HasForeignKey("GameId")
|
||||||
.OnDelete(DeleteBehavior.Cascade);
|
.OnDelete(DeleteBehavior.Cascade);
|
||||||
|
|
||||||
|
b.HasOne("LANCommander.Server.Data.Models.GameVersion", "GameVersion")
|
||||||
|
.WithOne("Archive")
|
||||||
|
.HasForeignKey("LANCommander.Server.Data.Models.Archive", "GameVersionId")
|
||||||
|
.OnDelete(DeleteBehavior.ClientCascade);
|
||||||
|
|
||||||
b.HasOne("LANCommander.Server.Data.Models.Archive", "LastVersion")
|
b.HasOne("LANCommander.Server.Data.Models.Archive", "LastVersion")
|
||||||
.WithMany()
|
.WithMany()
|
||||||
.HasForeignKey("LastVersionId");
|
.HasForeignKey("LastVersionId");
|
||||||
|
|
@ -2247,6 +2319,8 @@ namespace LANCommander.Migrations
|
||||||
|
|
||||||
b.Navigation("Game");
|
b.Navigation("Game");
|
||||||
|
|
||||||
|
b.Navigation("GameVersion");
|
||||||
|
|
||||||
b.Navigation("LastVersion");
|
b.Navigation("LastVersion");
|
||||||
|
|
||||||
b.Navigation("Redistributable");
|
b.Navigation("Redistributable");
|
||||||
|
|
@ -2514,6 +2588,31 @@ namespace LANCommander.Migrations
|
||||||
b.Navigation("User");
|
b.Navigation("User");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("LANCommander.Server.Data.Models.GameVersion", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("LANCommander.Server.Data.Models.User", "CreatedBy")
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("CreatedById")
|
||||||
|
.OnDelete(DeleteBehavior.SetNull);
|
||||||
|
|
||||||
|
b.HasOne("LANCommander.Server.Data.Models.Game", "Game")
|
||||||
|
.WithMany("Versions")
|
||||||
|
.HasForeignKey("GameId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
b.HasOne("LANCommander.Server.Data.Models.User", "UpdatedBy")
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("UpdatedById")
|
||||||
|
.OnDelete(DeleteBehavior.SetNull);
|
||||||
|
|
||||||
|
b.Navigation("CreatedBy");
|
||||||
|
|
||||||
|
b.Navigation("Game");
|
||||||
|
|
||||||
|
b.Navigation("UpdatedBy");
|
||||||
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("LANCommander.Server.Data.Models.Genre", b =>
|
modelBuilder.Entity("LANCommander.Server.Data.Models.Genre", b =>
|
||||||
{
|
{
|
||||||
b.HasOne("LANCommander.Server.Data.Models.User", "CreatedBy")
|
b.HasOne("LANCommander.Server.Data.Models.User", "CreatedBy")
|
||||||
|
|
@ -2845,6 +2944,11 @@ namespace LANCommander.Migrations
|
||||||
.HasForeignKey("GameId")
|
.HasForeignKey("GameId")
|
||||||
.OnDelete(DeleteBehavior.Cascade);
|
.OnDelete(DeleteBehavior.Cascade);
|
||||||
|
|
||||||
|
b.HasOne("LANCommander.Server.Data.Models.GameVersion", "GameVersion")
|
||||||
|
.WithMany("SavePaths")
|
||||||
|
.HasForeignKey("GameVersionId")
|
||||||
|
.OnDelete(DeleteBehavior.ClientCascade);
|
||||||
|
|
||||||
b.HasOne("LANCommander.Server.Data.Models.User", "UpdatedBy")
|
b.HasOne("LANCommander.Server.Data.Models.User", "UpdatedBy")
|
||||||
.WithMany()
|
.WithMany()
|
||||||
.HasForeignKey("UpdatedById")
|
.HasForeignKey("UpdatedById")
|
||||||
|
|
@ -2854,6 +2958,8 @@ namespace LANCommander.Migrations
|
||||||
|
|
||||||
b.Navigation("Game");
|
b.Navigation("Game");
|
||||||
|
|
||||||
|
b.Navigation("GameVersion");
|
||||||
|
|
||||||
b.Navigation("UpdatedBy");
|
b.Navigation("UpdatedBy");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -2869,6 +2975,11 @@ namespace LANCommander.Migrations
|
||||||
.HasForeignKey("GameId")
|
.HasForeignKey("GameId")
|
||||||
.OnDelete(DeleteBehavior.Cascade);
|
.OnDelete(DeleteBehavior.Cascade);
|
||||||
|
|
||||||
|
b.HasOne("LANCommander.Server.Data.Models.GameVersion", "GameVersion")
|
||||||
|
.WithMany("Scripts")
|
||||||
|
.HasForeignKey("GameVersionId")
|
||||||
|
.OnDelete(DeleteBehavior.ClientCascade);
|
||||||
|
|
||||||
b.HasOne("LANCommander.Server.Data.Models.Redistributable", "Redistributable")
|
b.HasOne("LANCommander.Server.Data.Models.Redistributable", "Redistributable")
|
||||||
.WithMany("Scripts")
|
.WithMany("Scripts")
|
||||||
.HasForeignKey("RedistributableId")
|
.HasForeignKey("RedistributableId")
|
||||||
|
|
@ -2893,6 +3004,8 @@ namespace LANCommander.Migrations
|
||||||
|
|
||||||
b.Navigation("Game");
|
b.Navigation("Game");
|
||||||
|
|
||||||
|
b.Navigation("GameVersion");
|
||||||
|
|
||||||
b.Navigation("Redistributable");
|
b.Navigation("Redistributable");
|
||||||
|
|
||||||
b.Navigation("Server");
|
b.Navigation("Server");
|
||||||
|
|
@ -3234,6 +3347,19 @@ namespace LANCommander.Migrations
|
||||||
b.Navigation("Scripts");
|
b.Navigation("Scripts");
|
||||||
|
|
||||||
b.Navigation("Servers");
|
b.Navigation("Servers");
|
||||||
|
|
||||||
|
b.Navigation("Versions");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("LANCommander.Server.Data.Models.GameVersion", b =>
|
||||||
|
{
|
||||||
|
b.Navigation("Actions");
|
||||||
|
|
||||||
|
b.Navigation("Archive");
|
||||||
|
|
||||||
|
b.Navigation("SavePaths");
|
||||||
|
|
||||||
|
b.Navigation("Scripts");
|
||||||
});
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("LANCommander.Server.Data.Models.Media", b =>
|
modelBuilder.Entity("LANCommander.Server.Data.Models.Media", b =>
|
||||||
|
|
|
||||||
|
|
@ -75,6 +75,7 @@ namespace LANCommander.Server.Data
|
||||||
builder.ConfigureBaseRelationships<Collection>();
|
builder.ConfigureBaseRelationships<Collection>();
|
||||||
builder.ConfigureBaseRelationships<Company>();
|
builder.ConfigureBaseRelationships<Company>();
|
||||||
builder.ConfigureBaseRelationships<Game>();
|
builder.ConfigureBaseRelationships<Game>();
|
||||||
|
builder.ConfigureBaseRelationships<GameVersion>();
|
||||||
builder.ConfigureBaseRelationships<GameSave>();
|
builder.ConfigureBaseRelationships<GameSave>();
|
||||||
builder.ConfigureBaseRelationships<Genre>();
|
builder.ConfigureBaseRelationships<Genre>();
|
||||||
builder.ConfigureBaseRelationships<Key>();
|
builder.ConfigureBaseRelationships<Key>();
|
||||||
|
|
@ -264,6 +265,43 @@ namespace LANCommander.Server.Data
|
||||||
.OnDelete(DeleteBehavior.Cascade);
|
.OnDelete(DeleteBehavior.Cascade);
|
||||||
#endregion
|
#endregion
|
||||||
|
|
||||||
|
#region Game Version Relationships
|
||||||
|
builder.Entity<Game>()
|
||||||
|
.HasMany(g => g.Versions)
|
||||||
|
.WithOne(v => v.Game)
|
||||||
|
.IsRequired(true)
|
||||||
|
.OnDelete(DeleteBehavior.Cascade);
|
||||||
|
|
||||||
|
// GameVersion owns the version-scoped config. The same Archive/Script/Action/SavePath
|
||||||
|
// rows are also referenced by the Game (dual-write during the transition), which already
|
||||||
|
// declares a cascade path. A second cascade path here would be rejected by MySQL
|
||||||
|
// (multiple cascade paths), so the GameVersion side uses ClientCascade instead.
|
||||||
|
builder.Entity<GameVersion>()
|
||||||
|
.HasOne(v => v.Archive)
|
||||||
|
.WithOne(a => a.GameVersion)
|
||||||
|
.HasForeignKey<Archive>(a => a.GameVersionId)
|
||||||
|
.IsRequired(false)
|
||||||
|
.OnDelete(DeleteBehavior.ClientCascade);
|
||||||
|
|
||||||
|
builder.Entity<GameVersion>()
|
||||||
|
.HasMany(v => v.Scripts)
|
||||||
|
.WithOne(s => s.GameVersion)
|
||||||
|
.IsRequired(false)
|
||||||
|
.OnDelete(DeleteBehavior.ClientCascade);
|
||||||
|
|
||||||
|
builder.Entity<GameVersion>()
|
||||||
|
.HasMany(v => v.Actions)
|
||||||
|
.WithOne(a => a.GameVersion)
|
||||||
|
.IsRequired(false)
|
||||||
|
.OnDelete(DeleteBehavior.ClientCascade);
|
||||||
|
|
||||||
|
builder.Entity<GameVersion>()
|
||||||
|
.HasMany(v => v.SavePaths)
|
||||||
|
.WithOne(p => p.GameVersion)
|
||||||
|
.IsRequired(false)
|
||||||
|
.OnDelete(DeleteBehavior.ClientCascade);
|
||||||
|
#endregion
|
||||||
|
|
||||||
#region Media Relationships
|
#region Media Relationships
|
||||||
builder.Entity<Media>()
|
builder.Entity<Media>()
|
||||||
.HasOne(m => m.Thumbnail)
|
.HasOne(m => m.Thumbnail)
|
||||||
|
|
@ -528,6 +566,8 @@ namespace LANCommander.Server.Data
|
||||||
|
|
||||||
public DbSet<Game>? Games { get; set; }
|
public DbSet<Game>? Games { get; set; }
|
||||||
|
|
||||||
|
public DbSet<GameVersion>? GameVersions { get; set; }
|
||||||
|
|
||||||
public DbSet<Genre>? Genres { get; set; }
|
public DbSet<Genre>? Genres { get; set; }
|
||||||
|
|
||||||
public DbSet<Category>? Categories { get; set; }
|
public DbSet<Category>? Categories { get; set; }
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,4 @@
|
||||||
using AutoMapper.QueryableExtensions;
|
using LANCommander.Server.Data.Models;
|
||||||
using LANCommander.Server.Data.Models;
|
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using System;
|
using System;
|
||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,6 @@
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<PackageReference Include="AutoMapper" />
|
|
||||||
<PackageReference Include="Microsoft.AspNetCore.Http.Abstractions" />
|
<PackageReference Include="Microsoft.AspNetCore.Http.Abstractions" />
|
||||||
<PackageReference Include="Microsoft.AspNetCore.Identity.EntityFrameworkCore" />
|
<PackageReference Include="Microsoft.AspNetCore.Identity.EntityFrameworkCore" />
|
||||||
<PackageReference Include="Microsoft.EntityFrameworkCore" />
|
<PackageReference Include="Microsoft.EntityFrameworkCore" />
|
||||||
|
|
|
||||||
|
|
@ -33,5 +33,11 @@ namespace LANCommander.Server.Data.Models
|
||||||
[ForeignKey(nameof(ToolId))]
|
[ForeignKey(nameof(ToolId))]
|
||||||
[InverseProperty("Actions")]
|
[InverseProperty("Actions")]
|
||||||
public Tool? Tool { get; set; }
|
public Tool? Tool { get; set; }
|
||||||
|
|
||||||
|
public Guid? GameVersionId { get; set; }
|
||||||
|
[JsonIgnore]
|
||||||
|
[ForeignKey(nameof(GameVersionId))]
|
||||||
|
[InverseProperty(nameof(GameVersion.Actions))]
|
||||||
|
public GameVersion? GameVersion { get; set; }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -6,8 +6,6 @@ namespace LANCommander.Server.Data.Models
|
||||||
{
|
{
|
||||||
public class Archive : BaseModel
|
public class Archive : BaseModel
|
||||||
{
|
{
|
||||||
public string? Changelog { get; set; }
|
|
||||||
|
|
||||||
public string ObjectKey { get; set; }
|
public string ObjectKey { get; set; }
|
||||||
|
|
||||||
[Required]
|
[Required]
|
||||||
|
|
@ -37,6 +35,12 @@ namespace LANCommander.Server.Data.Models
|
||||||
[InverseProperty("Archives")]
|
[InverseProperty("Archives")]
|
||||||
public Tool? Tool { get; set; }
|
public Tool? Tool { get; set; }
|
||||||
|
|
||||||
|
public Guid? GameVersionId { get; set; }
|
||||||
|
[JsonIgnore]
|
||||||
|
[ForeignKey(nameof(GameVersionId))]
|
||||||
|
[InverseProperty(nameof(GameVersion.Archive))]
|
||||||
|
public GameVersion? GameVersion { get; set; }
|
||||||
|
|
||||||
[Display(Name = "Last Version")]
|
[Display(Name = "Last Version")]
|
||||||
public Archive? LastVersion { get; set; }
|
public Archive? LastVersion { get; set; }
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -42,6 +42,7 @@ namespace LANCommander.Server.Data.Models
|
||||||
public ICollection<Company>? Publishers { get; set; }
|
public ICollection<Company>? Publishers { get; set; }
|
||||||
public ICollection<Company>? Developers { get; set; }
|
public ICollection<Company>? Developers { get; set; }
|
||||||
public ICollection<Archive>? Archives { get; set; }
|
public ICollection<Archive>? Archives { get; set; }
|
||||||
|
public ICollection<GameVersion>? Versions { get; set; }
|
||||||
public ICollection<Script>? Scripts { get; set; }
|
public ICollection<Script>? Scripts { get; set; }
|
||||||
public ICollection<GameSave>? GameSaves { get; set; }
|
public ICollection<GameSave>? GameSaves { get; set; }
|
||||||
public ICollection<PlaySession>? PlaySessions { get; set; }
|
public ICollection<PlaySession>? PlaySessions { get; set; }
|
||||||
|
|
|
||||||
29
LANCommander.Server.Data/Models/GameVersion.cs
Normal file
29
LANCommander.Server.Data/Models/GameVersion.cs
Normal file
|
|
@ -0,0 +1,29 @@
|
||||||
|
using System.ComponentModel.DataAnnotations;
|
||||||
|
using System.ComponentModel.DataAnnotations.Schema;
|
||||||
|
using System.Text.Json.Serialization;
|
||||||
|
|
||||||
|
namespace LANCommander.Server.Data.Models
|
||||||
|
{
|
||||||
|
[Table("GameVersions")]
|
||||||
|
public class GameVersion : BaseModel
|
||||||
|
{
|
||||||
|
[Required]
|
||||||
|
public string Version { get; set; }
|
||||||
|
|
||||||
|
public string? Changelog { get; set; }
|
||||||
|
|
||||||
|
[Display(Name = "Sort Order")]
|
||||||
|
public int SortOrder { get; set; }
|
||||||
|
|
||||||
|
public Guid GameId { get; set; }
|
||||||
|
[JsonIgnore]
|
||||||
|
[ForeignKey(nameof(GameId))]
|
||||||
|
[InverseProperty(nameof(Models.Game.Versions))]
|
||||||
|
public Game Game { get; set; }
|
||||||
|
|
||||||
|
public Archive? Archive { get; set; }
|
||||||
|
public ICollection<Script>? Scripts { get; set; }
|
||||||
|
public ICollection<Action>? Actions { get; set; }
|
||||||
|
public ICollection<SavePath>? SavePaths { get; set; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -18,5 +18,11 @@ namespace LANCommander.Server.Data.Models
|
||||||
[ForeignKey(nameof(GameId))]
|
[ForeignKey(nameof(GameId))]
|
||||||
[InverseProperty("SavePaths")]
|
[InverseProperty("SavePaths")]
|
||||||
public Game? Game { get; set; }
|
public Game? Game { get; set; }
|
||||||
|
|
||||||
|
public Guid? GameVersionId { get; set; }
|
||||||
|
[JsonIgnore]
|
||||||
|
[ForeignKey(nameof(GameVersionId))]
|
||||||
|
[InverseProperty(nameof(GameVersion.SavePaths))]
|
||||||
|
public GameVersion? GameVersion { get; set; }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -39,5 +39,11 @@ namespace LANCommander.Server.Data.Models
|
||||||
[ForeignKey(nameof(ServerId))]
|
[ForeignKey(nameof(ServerId))]
|
||||||
[InverseProperty("Scripts")]
|
[InverseProperty("Scripts")]
|
||||||
public Server? Server { get; set; }
|
public Server? Server { get; set; }
|
||||||
|
|
||||||
|
public Guid? GameVersionId { get; set; }
|
||||||
|
[JsonIgnore]
|
||||||
|
[ForeignKey(nameof(GameVersionId))]
|
||||||
|
[InverseProperty(nameof(GameVersion.Scripts))]
|
||||||
|
public GameVersion? GameVersion { get; set; }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,4 @@
|
||||||
using System.IO.Compression;
|
using System.IO.Compression;
|
||||||
using AutoMapper;
|
|
||||||
using LANCommander.SDK.Enums;
|
using LANCommander.SDK.Enums;
|
||||||
using LANCommander.SDK.Helpers;
|
using LANCommander.SDK.Helpers;
|
||||||
using LANCommander.Server.Data.Models;
|
using LANCommander.Server.Data.Models;
|
||||||
|
|
@ -7,6 +6,7 @@ using LANCommander.Server.ImportExport.Exporters;
|
||||||
using LANCommander.Server.ImportExport.Importers;
|
using LANCommander.Server.ImportExport.Importers;
|
||||||
using LANCommander.Server.ImportExport.Models;
|
using LANCommander.Server.ImportExport.Models;
|
||||||
using LANCommander.Server.Services;
|
using LANCommander.Server.Services;
|
||||||
|
using LANCommander.Server.Services.Mappers;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using Microsoft.Extensions.Logging;
|
using Microsoft.Extensions.Logging;
|
||||||
using ZipArchive = System.IO.Compression.ZipArchive;
|
using ZipArchive = System.IO.Compression.ZipArchive;
|
||||||
|
|
@ -41,7 +41,7 @@ public class ExportContext(
|
||||||
ServerConsoleExporter serverConsoleExporter,
|
ServerConsoleExporter serverConsoleExporter,
|
||||||
ServerHttpPathExporter serverHttpPathExporter,
|
ServerHttpPathExporter serverHttpPathExporter,
|
||||||
TagExporter tagExporter,
|
TagExporter tagExporter,
|
||||||
IMapper mapper,
|
ManifestMapper manifestMapper,
|
||||||
ILogger<ExportContext> logger) : IDisposable
|
ILogger<ExportContext> logger) : IDisposable
|
||||||
{
|
{
|
||||||
public object Manifest { get; private set; }
|
public object Manifest { get; private set; }
|
||||||
|
|
@ -183,7 +183,7 @@ public class ExportContext(
|
||||||
.Include(g => g.Tools);
|
.Include(g => g.Tools);
|
||||||
}).GetAsync(gameId);
|
}).GetAsync(gameId);
|
||||||
|
|
||||||
var gameManifest = mapper.Map<SDK.Models.Manifest.Game>(game);
|
var gameManifest = manifestMapper.ToManifest(game);
|
||||||
|
|
||||||
DataRecord = game;
|
DataRecord = game;
|
||||||
Manifest = gameManifest;
|
Manifest = gameManifest;
|
||||||
|
|
@ -225,7 +225,7 @@ public class ExportContext(
|
||||||
.GetAsync(redistributableId);
|
.GetAsync(redistributableId);
|
||||||
|
|
||||||
DataRecord = redistributable;
|
DataRecord = redistributable;
|
||||||
Manifest = mapper.Map<SDK.Models.Manifest.Redistributable>(redistributable);
|
Manifest = manifestMapper.ToManifest(redistributable);
|
||||||
|
|
||||||
var exportItemInfo = new List<ExportItemInfo>();
|
var exportItemInfo = new List<ExportItemInfo>();
|
||||||
|
|
||||||
|
|
@ -252,7 +252,7 @@ public class ExportContext(
|
||||||
.GetAsync(serverId);
|
.GetAsync(serverId);
|
||||||
|
|
||||||
DataRecord = server;
|
DataRecord = server;
|
||||||
Manifest = mapper.Map<SDK.Models.Manifest.Server>(server);
|
Manifest = manifestMapper.ToManifest(server);
|
||||||
|
|
||||||
var exportItemInfo = new List<ExportItemInfo>();
|
var exportItemInfo = new List<ExportItemInfo>();
|
||||||
|
|
||||||
|
|
@ -278,7 +278,7 @@ public class ExportContext(
|
||||||
.GetAsync(toolId);
|
.GetAsync(toolId);
|
||||||
|
|
||||||
DataRecord = tool;
|
DataRecord = tool;
|
||||||
Manifest = mapper.Map<SDK.Models.Manifest.Tool>(tool);
|
Manifest = manifestMapper.ToManifest(tool);
|
||||||
|
|
||||||
var exportItemInfo = new List<ExportItemInfo>();
|
var exportItemInfo = new List<ExportItemInfo>();
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,13 @@
|
||||||
using LANCommander.SDK.Enums;
|
using LANCommander.SDK.Enums;
|
||||||
using LANCommander.Server.ImportExport.Models;
|
using LANCommander.Server.ImportExport.Models;
|
||||||
using LANCommander.Server.Services;
|
using LANCommander.Server.Services;
|
||||||
|
using LANCommander.Server.Services.Mappers;
|
||||||
using Action = LANCommander.SDK.Models.Manifest.Action;
|
using Action = LANCommander.SDK.Models.Manifest.Action;
|
||||||
|
|
||||||
namespace LANCommander.Server.ImportExport.Exporters;
|
namespace LANCommander.Server.ImportExport.Exporters;
|
||||||
|
|
||||||
public class ActionExporter(
|
public class ActionExporter(
|
||||||
|
ManifestMapper manifestMapper,
|
||||||
ActionService actionService) : BaseExporter<Action, Data.Models.Action>
|
ActionService actionService) : BaseExporter<Action, Data.Models.Action>
|
||||||
{
|
{
|
||||||
public override async Task<ExportItemInfo> GetExportInfoAsync(Data.Models.Action record) =>
|
public override async Task<ExportItemInfo> GetExportInfoAsync(Data.Models.Action record) =>
|
||||||
|
|
@ -15,6 +17,6 @@ public class ActionExporter(
|
||||||
|
|
||||||
public override async Task<Action> ExportAsync(Guid id)
|
public override async Task<Action> ExportAsync(Guid id)
|
||||||
{
|
{
|
||||||
return await actionService.GetAsync<Action>(id);
|
return await actionService.GetAsync(id, manifestMapper.ProjectToManifestAction);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -1,19 +1,19 @@
|
||||||
using AutoMapper;
|
|
||||||
using LANCommander.SDK.Enums;
|
using LANCommander.SDK.Enums;
|
||||||
using LANCommander.SDK.Models.Manifest;
|
using LANCommander.SDK.Models.Manifest;
|
||||||
using LANCommander.Server.ImportExport.Exceptions;
|
using LANCommander.Server.ImportExport.Exceptions;
|
||||||
using LANCommander.Server.ImportExport.Models;
|
using LANCommander.Server.ImportExport.Models;
|
||||||
using LANCommander.Server.Services;
|
using LANCommander.Server.Services;
|
||||||
|
using LANCommander.Server.Services.Mappers;
|
||||||
|
|
||||||
namespace LANCommander.Server.ImportExport.Exporters;
|
namespace LANCommander.Server.ImportExport.Exporters;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Implements exporting for archive records
|
/// Implements exporting for archive records
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="mapper">AutoMapper instance for mapping between models</param>
|
/// <param name="manifestMapper">Mapper for converting between data and manifest models</param>
|
||||||
/// <param name="archiveService">Service for archive operations</param>
|
/// <param name="archiveService">Service for archive operations</param>
|
||||||
public class ArchiveExporter(
|
public class ArchiveExporter(
|
||||||
IMapper mapper,
|
ManifestMapper manifestMapper,
|
||||||
ArchiveService archiveService) : BaseExporter<Archive, Data.Models.Archive>
|
ArchiveService archiveService) : BaseExporter<Archive, Data.Models.Archive>
|
||||||
{
|
{
|
||||||
public override async Task<ExportItemInfo> GetExportInfoAsync(Data.Models.Archive record)
|
public override async Task<ExportItemInfo> GetExportInfoAsync(Data.Models.Archive record)
|
||||||
|
|
@ -60,7 +60,7 @@ public class ArchiveExporter(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return mapper.Map<Archive>(entity);
|
return manifestMapper.ToManifest(entity);
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,13 @@
|
||||||
using AutoMapper;
|
|
||||||
using LANCommander.SDK.Enums;
|
using LANCommander.SDK.Enums;
|
||||||
using LANCommander.SDK.Models.Manifest;
|
using LANCommander.SDK.Models.Manifest;
|
||||||
using LANCommander.Server.ImportExport.Models;
|
using LANCommander.Server.ImportExport.Models;
|
||||||
using LANCommander.Server.Services;
|
using LANCommander.Server.Services;
|
||||||
|
using LANCommander.Server.Services.Mappers;
|
||||||
|
|
||||||
namespace LANCommander.Server.ImportExport.Exporters;
|
namespace LANCommander.Server.ImportExport.Exporters;
|
||||||
|
|
||||||
public class CollectionExporter(
|
public class CollectionExporter(
|
||||||
|
ManifestMapper manifestMapper,
|
||||||
CollectionService collectionService) : BaseExporter<Collection, Data.Models.Collection>
|
CollectionService collectionService) : BaseExporter<Collection, Data.Models.Collection>
|
||||||
{
|
{
|
||||||
public override async Task<ExportItemInfo> GetExportInfoAsync(Data.Models.Collection record)
|
public override async Task<ExportItemInfo> GetExportInfoAsync(Data.Models.Collection record)
|
||||||
|
|
@ -23,6 +24,6 @@ public class CollectionExporter(
|
||||||
|
|
||||||
public override async Task<Collection> ExportAsync(Guid id)
|
public override async Task<Collection> ExportAsync(Guid id)
|
||||||
{
|
{
|
||||||
return await collectionService.GetAsync<Collection>(id);
|
return await collectionService.GetAsync(id, manifestMapper.ProjectToManifestCollection);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -1,12 +1,13 @@
|
||||||
using AutoMapper;
|
|
||||||
using LANCommander.SDK.Enums;
|
using LANCommander.SDK.Enums;
|
||||||
using LANCommander.SDK.Models.Manifest;
|
using LANCommander.SDK.Models.Manifest;
|
||||||
using LANCommander.Server.ImportExport.Models;
|
using LANCommander.Server.ImportExport.Models;
|
||||||
using LANCommander.Server.Services;
|
using LANCommander.Server.Services;
|
||||||
|
using LANCommander.Server.Services.Mappers;
|
||||||
|
|
||||||
namespace LANCommander.Server.ImportExport.Exporters;
|
namespace LANCommander.Server.ImportExport.Exporters;
|
||||||
|
|
||||||
public class CustomFieldExporter(
|
public class CustomFieldExporter(
|
||||||
|
ManifestMapper manifestMapper,
|
||||||
GameCustomFieldService gameCustomFieldService) : BaseExporter<GameCustomField, Data.Models.GameCustomField>
|
GameCustomFieldService gameCustomFieldService) : BaseExporter<GameCustomField, Data.Models.GameCustomField>
|
||||||
{
|
{
|
||||||
public override async Task<ExportItemInfo> GetExportInfoAsync(Data.Models.GameCustomField record)
|
public override async Task<ExportItemInfo> GetExportInfoAsync(Data.Models.GameCustomField record)
|
||||||
|
|
@ -23,6 +24,6 @@ public class CustomFieldExporter(
|
||||||
|
|
||||||
public override async Task<GameCustomField> ExportAsync(Guid id)
|
public override async Task<GameCustomField> ExportAsync(Guid id)
|
||||||
{
|
{
|
||||||
return await gameCustomFieldService.GetAsync<GameCustomField>(id);
|
return await gameCustomFieldService.GetAsync(id, manifestMapper.ProjectToManifestGameCustomField);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -1,12 +1,13 @@
|
||||||
using AutoMapper;
|
|
||||||
using LANCommander.SDK.Enums;
|
using LANCommander.SDK.Enums;
|
||||||
using LANCommander.SDK.Models.Manifest;
|
using LANCommander.SDK.Models.Manifest;
|
||||||
using LANCommander.Server.ImportExport.Models;
|
using LANCommander.Server.ImportExport.Models;
|
||||||
using LANCommander.Server.Services;
|
using LANCommander.Server.Services;
|
||||||
|
using LANCommander.Server.Services.Mappers;
|
||||||
|
|
||||||
namespace LANCommander.Server.ImportExport.Exporters;
|
namespace LANCommander.Server.ImportExport.Exporters;
|
||||||
|
|
||||||
public class DeveloperExporter(
|
public class DeveloperExporter(
|
||||||
|
ManifestMapper manifestMapper,
|
||||||
CompanyService companyService) : BaseExporter<Company, Data.Models.Company>
|
CompanyService companyService) : BaseExporter<Company, Data.Models.Company>
|
||||||
{
|
{
|
||||||
public override async Task<ExportItemInfo> GetExportInfoAsync(Data.Models.Company record)
|
public override async Task<ExportItemInfo> GetExportInfoAsync(Data.Models.Company record)
|
||||||
|
|
@ -23,6 +24,6 @@ public class DeveloperExporter(
|
||||||
|
|
||||||
public override async Task<Company> ExportAsync(Guid id)
|
public override async Task<Company> ExportAsync(Guid id)
|
||||||
{
|
{
|
||||||
return await companyService.GetAsync<Company>(id);
|
return await companyService.GetAsync(id, manifestMapper.ProjectToManifestCompany);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -1,12 +1,13 @@
|
||||||
using AutoMapper;
|
|
||||||
using LANCommander.SDK.Enums;
|
using LANCommander.SDK.Enums;
|
||||||
using LANCommander.SDK.Models.Manifest;
|
using LANCommander.SDK.Models.Manifest;
|
||||||
using LANCommander.Server.ImportExport.Models;
|
using LANCommander.Server.ImportExport.Models;
|
||||||
using LANCommander.Server.Services;
|
using LANCommander.Server.Services;
|
||||||
|
using LANCommander.Server.Services.Mappers;
|
||||||
|
|
||||||
namespace LANCommander.Server.ImportExport.Exporters;
|
namespace LANCommander.Server.ImportExport.Exporters;
|
||||||
|
|
||||||
public class EngineExporter(
|
public class EngineExporter(
|
||||||
|
ManifestMapper manifestMapper,
|
||||||
EngineService engineService) : BaseExporter<Engine, Data.Models.Engine>
|
EngineService engineService) : BaseExporter<Engine, Data.Models.Engine>
|
||||||
{
|
{
|
||||||
public override async Task<ExportItemInfo> GetExportInfoAsync(Data.Models.Engine record)
|
public override async Task<ExportItemInfo> GetExportInfoAsync(Data.Models.Engine record)
|
||||||
|
|
@ -23,6 +24,6 @@ public class EngineExporter(
|
||||||
|
|
||||||
public override async Task<Engine> ExportAsync(Guid id)
|
public override async Task<Engine> ExportAsync(Guid id)
|
||||||
{
|
{
|
||||||
return await engineService.GetAsync<Engine>(id);
|
return await engineService.GetAsync(id, manifestMapper.ProjectToManifestEngine);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -1,4 +1,3 @@
|
||||||
using AutoMapper;
|
|
||||||
using LANCommander.SDK.Models.Manifest;
|
using LANCommander.SDK.Models.Manifest;
|
||||||
using LANCommander.Server.ImportExport.Models;
|
using LANCommander.Server.ImportExport.Models;
|
||||||
using LANCommander.Server.Services;
|
using LANCommander.Server.Services;
|
||||||
|
|
|
||||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue