From e7de1a77ffab3b3258bc766c8f554982d2588b38 Mon Sep 17 00:00:00 2001 From: Owen de Bree Date: Tue, 7 Jul 2026 17:12:47 +0200 Subject: [PATCH] refactor: prune audit bloat Remove stale plan docs and unused companion envelopes. Replace Blazor.LocalStorage with native JS interop storage. Delegate server-page actions to the shared management service. Centralize debounced file watchers. Use Enumerable.Chunk and shared SharpCompress extraction. --- Docs/Configuration.md | 28 +- Docs/QuasarArchitecture.md | 4 +- Docs/StateMachines/ConfigProfileChanges.md | 12 +- Docs/clone-feature-plan.md | 71 --- Docs/pluginsdk-integration-plan.md | 255 ---------- Docs/public-access-auth-rbac-plan.md | 481 ------------------ Docs/remote-host-ssh-plan.md | 64 --- .../QuasarCompanionRequestEnvelope.cs | 14 - .../QuasarCompanionResponseEnvelope.cs | 16 - .../Manifests/QuasarPluginManifest.cs | 8 - Quasar/Components/Pages/Analytics.razor | 6 +- Quasar/Components/Pages/Configs.razor | 53 +- Quasar/Components/Pages/Home.razor | 6 +- .../Components/Pages/ServerEditorDialog.razor | 1 + Quasar/Components/Pages/Servers.razor | 440 +--------------- .../WorldTemplateQuickImportDialog.razor | 1 + Quasar/Components/Pages/WorldTemplates.razor | 1 + Quasar/Program.cs | 2 +- Quasar/Quasar.csproj | 1 - Quasar/Services/Auth/RbacConfigCatalog.cs | 69 +-- .../Backup/QuasarBackupSettingsService.cs | 52 +- Quasar/Services/BrandingService.cs | 68 +-- Quasar/Services/BrowserStorageService.cs | 46 ++ Quasar/Services/DebouncedFileWatcher.cs | 115 +++++ Quasar/Services/DedicatedServerCatalog.cs | 57 +-- .../Services/Discord/DeathMessagesCatalog.cs | 69 +-- .../Services/Discord/DiscordOptionsCatalog.cs | 69 +-- .../GitHubUpdateCredentialsCatalog.cs | 60 +-- .../ManagedDedicatedServerRuntimeResolver.cs | 15 +- Quasar/Services/QuasarConfigMetadata.cs | 7 +- Quasar/Services/QuasarConfigProfileCatalog.cs | 57 +-- Quasar/Services/QuasarWorkshopModResolver.cs | 17 +- Quasar/Services/QuasarWorldTemplateCatalog.cs | 51 +- .../SteamWorkshopCredentialsCatalog.cs | 60 +-- Quasar/Services/ThemePreferenceService.cs | 12 +- Quasar/Services/WebServiceOptions.cs | 68 +-- Quasar/Services/WorldSandboxConfigEditor.cs | 6 +- .../WorldTemplateImportLocationService.cs | 10 +- Quasar/wwwroot/quasar-configs.js | 20 + admin-entity-list-plan.md | 205 -------- whitelabel-theme-configurator.md | 246 --------- 41 files changed, 347 insertions(+), 2496 deletions(-) delete mode 100644 Docs/clone-feature-plan.md delete mode 100644 Docs/pluginsdk-integration-plan.md delete mode 100644 Docs/public-access-auth-rbac-plan.md delete mode 100644 Docs/remote-host-ssh-plan.md delete mode 100644 Quasar.Plugin.Abstractions/Companion/QuasarCompanionRequestEnvelope.cs delete mode 100644 Quasar.Plugin.Abstractions/Companion/QuasarCompanionResponseEnvelope.cs create mode 100644 Quasar/Services/BrowserStorageService.cs create mode 100644 Quasar/Services/DebouncedFileWatcher.cs delete mode 100644 admin-entity-list-plan.md delete mode 100644 whitelabel-theme-configurator.md diff --git a/Docs/Configuration.md b/Docs/Configuration.md index 5e02a28a..fe6cdc1c 100644 --- a/Docs/Configuration.md +++ b/Docs/Configuration.md @@ -55,15 +55,25 @@ enabled. Turning it back off removes the flag from future starts. When a config profile with Workshop mods is opened, saved, or receives imported mods, Quasar checks declared Steam Workshop child/dependency metadata, adds -missing dependency mods, and marks dependency rows in the profile JSON. It does -not reorder the profile automatically during this check; the Mods tab provides -an **Auto Sort Dependencies** action that applies a topological dependency -order when the operator wants it. If Quasar finds a dependency listed after its -dependent, or Steam reports a circular dependency chain that prevents a clean -topological order, the UI shows a warning. The Steam Workshop API key -configured from the Mods tab is required for this automatic dependency check. -If the key is missing or Steam cannot be reached, Quasar keeps the current mod -list and shows a warning instead of blocking the save. +missing dependency mods, and marks dependency rows in the profile JSON and the +world's generated `Sandbox_config.sbc`. It does not reorder the profile +automatically during this check; the Mods tab provides an **Auto Sort +Dependencies** action that applies a topological dependency order when the +operator wants it. If Quasar finds a dependency listed after its dependent, or +Steam reports a circular dependency chain that prevents a clean topological +order, the UI shows a warning. The Steam Workshop API key configured from the +Mods tab is required for this automatic dependency check. If the key is missing +or Steam cannot be reached, Quasar keeps the current mod list and shows a +warning instead of blocking the save. + +Space Engineers Dedicated Server also has its own **Autodetect Dependencies** +setting. Quasar manages that setting in the profile instead of showing it as a +manual world option: it is disabled after a clean dependency check or a +successful auto-sort, so DS receives the exact generated `Sandbox_config.sbc` +mod list and load order. Quasar enables it as a fallback when the mod list is +manually changed, the Workshop API key is missing, dependency checks fail, or +dependency warnings remain after sorting. Auto-sort reorder notices do not keep +the fallback enabled when the final sorted order is otherwise valid. After a dependency check or auto-sort, the Mods tab also shows a collapsed, flattened dependency outline. Rows are tagged as root mods, dependency mods, diff --git a/Docs/QuasarArchitecture.md b/Docs/QuasarArchitecture.md index 48a1d6ce..e18b7522 100644 --- a/Docs/QuasarArchitecture.md +++ b/Docs/QuasarArchitecture.md @@ -252,7 +252,7 @@ The UI must support both: - light mode - dark mode -Theme preference should be stored in browser local storage using the `Blazor.LocalStorage` package so the user returns to the same mode on the next visit. +Theme preference should be stored in browser local storage through Quasar's native JS interop wrapper so the user returns to the same mode on the next visit. ## Discovery and Bootstrap @@ -905,7 +905,7 @@ As of this document: - initial runtime launch preparation now exists for isolated app-data roots, runtime config sync, `LastSession.sbl`, and enforced headless launch shaping - server definitions store a saves root (`WorldPath`) plus selected save folder (`WorldSaveName`). The server editor requires a selected save before save/start, lists existing saves from the saves root, and has an always-available Create From Template dialog that can create/import a world template before copying it into a new save. - neutral light/dark theming exists with local-storage persistence -- config editing is now migrated out of Python into Quasar-managed JSON profiles and rendered runtime artifacts. Profiles cover Quasar root settings, server password (rendered to DS-compatible hash/salt), and DS-visible SE session settings including block type world limits; on server start Quasar writes session settings and mods into the world's authoritative `Sandbox_config.sbc` as well as the runtime DS config. Profile mod order is preserved as the Space Engineers mod load order; the Mods tab lets operators reorder selected mods before saving. On profile open, save, and mod-import paths, Quasar uses Steam Workshop child/dependency metadata to add missing dependency mods and marks every dependency with an `IsDependency` profile flag without reordering the list. The Mods tab exposes an explicit Auto Sort Dependencies action that topologically sorts dependencies before dependents while keeping unrelated operator order stable. It warns when a dependency is currently after its dependent, when Steam metadata has a circular dependency chain, or when a cycle leaves a dependency order conflict after sorting. Dependency checks also return collapsed flattened outline rows for the Mods tab, tagged as root, dependency, already-listed, or cycle rows for inspection. World-template import flows can also read a template's current `Sandbox_config.sbc` and create a config profile from its session settings and mods. +- config editing is now migrated out of Python into Quasar-managed JSON profiles and rendered runtime artifacts. Profiles cover Quasar root settings, server password (rendered to DS-compatible hash/salt), and DS-visible SE session settings including block type world limits; on server start Quasar writes session settings and mods into the world's authoritative `Sandbox_config.sbc` as well as the runtime DS config. Profile mod order is preserved as the Space Engineers mod load order; the Mods tab lets operators reorder selected mods before saving. On profile open, save, and mod-import paths, Quasar uses Steam Workshop child/dependency metadata to add missing dependency mods and marks every dependency with an `IsDependency` profile flag without reordering the list. That flag is written back to `Sandbox_config.sbc` so DS autodetect treats dependency rows as dependencies instead of root mods. The Mods tab exposes an explicit Auto Sort Dependencies action that topologically sorts dependencies before dependents while keeping unrelated operator order stable. It warns when a dependency is currently after its dependent, when Steam metadata has a circular dependency chain, or when a cycle leaves a dependency order conflict after sorting. Dependency checks also return collapsed flattened outline rows for the Mods tab, tagged as root, dependency, already-listed, or cycle rows for inspection. Quasar hides the DS Autodetect Dependencies root setting and manages it from dependency state: disabled after a clean check/sort, enabled when manual mod edits invalidate the checked state or unresolved warnings remain. World-template import flows can also read a template's current `Sandbox_config.sbc` and create a config profile from its session settings and mods. - file watching/reload now exists for manual edits to Quasar-managed server/profile JSON - backup/restore now exists as versioned ZIP archives for Quasar configuration, server runtime state, and world-only data. Configuration backups cover servers, config profiles, world-template definitions, branding, and singleton settings files, with manual download/upload and semantic-version compatibility checks. The Backup page lists every configured server with per-row Back up server / Restore server / Back up world / Restore world actions; restore buttons use the latest matching stored archive for that server and backup kind. Automatic backup rules are configured separately for Quasar config, server backups, and world backups, each with its own schedule and retention. Quasar config backups include Quasar-managed catalog/config files only; server backups include the server definition plus non-cache Dedicated Server and Magnetar app data but not world saves; world backups include world save files and keep existing server/world config, using the latest Space Engineers `Backup` snapshot when present so backups can be taken while servers run. Restored server definitions from config or server backups are forced to `Off` goal state so restore cannot trigger a failed start loop before matching world files are restored. - per-server CPU affinity pinning now exists (cpuset strings applied via `taskset` on Linux and `Process.ProcessorAffinity` on Windows), enforced by the supervisor on process start and reconcile alongside process priority; Linux priority elevation can use the optional setuid `/usr/local/bin/quasar-renice` helper instead of granting `CAP_SYS_NICE` to the whole Quasar service diff --git a/Docs/StateMachines/ConfigProfileChanges.md b/Docs/StateMachines/ConfigProfileChanges.md index 26b0e852..893383a9 100644 --- a/Docs/StateMachines/ConfigProfileChanges.md +++ b/Docs/StateMachines/ConfigProfileChanges.md @@ -37,19 +37,23 @@ stateDiagram-v2 **Persistence.** `QuasarConfigProfileCatalog.UpsertAsync` normalizes and writes `{ProfilesDir}/{id}/profile.json` plus a `History/{timestamp}.json` snapshot -(atomic swap). External edits to the JSON are picked up by a debounced -file-watch reload (`ScheduleReload`). Mod arrays are order preserving; the saved +(atomic swap). External edits to the JSON are picked up by the shared debounced +file watcher. Mod arrays are order preserving; the saved order becomes the Space Engineers mod load order when Quasar rewrites `Sandbox_config.sbc` during server preparation. On profile open, before saving profile edits, and during world-template/server-editor mod imports, Quasar expands declared Steam Workshop dependencies and marks dependency rows with -`IsDependency` without reordering existing rows. The Mods tab's **Auto Sort +`IsDependency` without reordering existing rows. That flag is imported from and +written to `Sandbox_config.sbc` mod entries. The Mods tab's **Auto Sort Dependencies** action applies the topological sort explicitly. The resolver warns when a dependency/dependent pair is currently out of order, sees a circular dependency chain, or cannot satisfy a dependency edge after sorting. Successful dependency checks also refresh a collapsed, flattened dependency outline in the Mods tab; that view is derived UI state and is not saved into -the profile JSON. +the profile JSON. The DS `AutodetectDependencies` root setting is hidden from +the world-options UI and managed from these results: Quasar disables it after a +clean check or successful sort, and enables it when dependency state is +unchecked or unresolved warnings remain. **World-template import.** The world-template UI can derive a new config profile from a template's current `Sandbox_config.sbc`. It imports DS-visible session diff --git a/Docs/clone-feature-plan.md b/Docs/clone-feature-plan.md deleted file mode 100644 index 5c9fa091..00000000 --- a/Docs/clone-feature-plan.md +++ /dev/null @@ -1,71 +0,0 @@ -# Clone Feature Plan - -## Motivation - -Quasar supports Create, Edit, and Delete for servers, world templates, and config profiles. There is no way to duplicate an existing entity. Clone accelerates server setup — operators frequently spin up near-identical servers that differ only by name, or want to experiment with a config profile without touching the original. - ---- - -## Scope - -| Entity | Clone UX | Result | -|---|---|---| -| Config Profile | Icon button on sidebar tile | New profile with all settings copied, name appended with ` (Copy)` | -| Server | Button in action column | Editor dialog opens pre-filled in create mode; user must confirm a unique name | -| World Template | Button in table row | World files copied to new managed directory; auto-named `{Name} (Copy)` | - ---- - -## UX Per Entity - -### Config Profiles (`/configs`) - -A copy icon button sits next to the existing delete icon on each profile tile in the left sidebar. Clicking it immediately duplicates the profile with a `(Copy)` suffix on the name and selects the new profile in the editor. No dialog is needed — the profile can be renamed inline via the editor. - -Button is always enabled (profiles have no external dependencies that would block duplication). - -### Servers (`/servers`) - -A **Clone** button appears in the action column next to **Edit** and **Delete**, visible for both running and stopped servers. Cloning a running server is allowed — it only duplicates the definition, not the process. - -Clicking Clone opens the standard server editor dialog in create mode (`IsEditing: false`) with all fields pre-populated from the source server. The unique name is pre-filled as `{originalName}-copy`. The dialog validates the name for uniqueness before saving, so if `-copy` is already taken the user sees an inline error and can adjust. - -This reuses the existing `ServerEditorDialog` without modification. - -### World Templates (`/world-profiles`) - -A **Clone** button appears next to the **Delete** button in each table row. The button is disabled if the world files are missing or an import/clone is already in progress. - -Clicking Clone copies all world files from the existing managed directory into a new managed directory, creating a new profile named `{Name} (Copy)` with the same description. This is a file copy operation and may take a moment for large saves. - ---- - -## Service Layer - -No new catalog methods are needed. Each entity type reuses existing APIs: - -| Entity | Method reused | -|---|---| -| Config Profile | `GetProfile(id)` returns a deep clone; reassign `ConfigProfileId` and `Name`, then call `UpsertAsync()` | -| Server | `definition.Clone()` (public method on model); pass to `ShowEditorDialogAsync` with `isEditing: false` | -| World Template | `GetWorldDirectory(id)` as source path, then `ImportAsync(newName, desc, sourceDir, ct)` | - ---- - -## Edge Cases - -| Case | Handling | -|---|---| -| Clone server with name collision (`-copy` already exists) | Editor dialog shows validation error; user edits name before saving | -| Clone world template with missing files | Clone button is disabled when `WorldExists` is false | -| Clone world template while another import/clone is running | Clone button is disabled when `_importing` is true | -| Clone config profile assigned to servers | The clone starts with no assigned servers (new GUID); originals are unaffected | -| Clone running server | Allowed — duplicates the definition only, does not affect the running process | - ---- - -## Future Considerations - -- **Custom name prompt for world clone** — Currently auto-names as `{Name} (Copy)`. A lightweight dialog (similar to `WorldProfileQuickImportDialog`) could let the user set a name before the file copy begins. -- **Clone-chain naming** — If `{Name} (Copy)` already exists, subsequent clones stack as `{Name} (Copy) (Copy)`. Auto-incrementing suffixes (`(Copy 2)`, `(Copy 3)`) could be added to the catalog service. -- **Clone server with new world/config** — The editor dialog allows changing the config profile, selecting an existing save, or creating a save from a world template before the clone is saved. diff --git a/Docs/pluginsdk-integration-plan.md b/Docs/pluginsdk-integration-plan.md deleted file mode 100644 index 312bcbb2..00000000 --- a/Docs/pluginsdk-integration-plan.md +++ /dev/null @@ -1,255 +0,0 @@ -# Plan: Integrate Magnetar PluginSdk into Quasar - -> **Implementation note:** This plan is intentionally high-level. When executing, the AI should use the `se-dev-plugin-sdk` skill (`/home/owendb/Documents/GitHub/Magnetar/skills/se-dev-plugin-sdk/`) for authoritative SDK details, read the current state of all files before editing, and apply its own judgment where the plan is ambiguous or where code has changed since this document was written. Do not blindly follow line numbers or code snippets here — verify against the live codebase first. - -## Context - -Magnetar's `PluginSdk` lets SE plugins declare configuration via C# attributes. The SDK already produces two artifacts Quasar needs: a `ConfigSchemaData` JSON envelope (schema + defaults + values) and `QuasarLogSink` structured logs. The goal is to wire Quasar end-to-end so it can: -1. Discover which loaded plugins expose configs -2. Render a live editor UI from the schema (no plugin ships UI code) -3. Push config changes back to the running plugin -4. Display plugin logs in the Quasar log stream - -Current branch: `features/integration/pluginsdk` - ---- - -## Architecture Overview - -``` -Game Server - └── Plugin (IPlugin + IQuasarConfigProvider) - └── Quasar.Agent (AdminPlugin) - │ WebSocket (Magnetar.Protocol) - ▼ - Quasar (Blazor Server) - └── PluginConfigService → Blazor UI -``` - ---- - -## Step 1 — Add `IQuasarConfigProvider` to Magnetar.Protocol - -**File:** `Magnetar.Protocol/Bridge/IQuasarConfigProvider.cs` (new) - -```csharp -namespace Magnetar.Protocol.Bridge; - -public interface IQuasarConfigProvider -{ - string PluginId { get; } - string GetConfigJson(); // Returns ConfigStorage.SaveJson(config) - void ApplyConfigJson(string json); // Calls ConfigStorage.LoadJson + applies -} -``` - -Keep `Magnetar.Protocol` free of PluginSdk dependency — plugins call `ConfigStorage.SaveJson/LoadJson` internally. The interface only exchanges JSON strings. - ---- - -## Step 2 — Extend Wire Protocol - -### New models in `Magnetar.Protocol/Model/` - -**`PluginConfigData.cs`** -```csharp -public class PluginConfigData -{ - public string PluginId { get; set; } = string.Empty; - public string DisplayName { get; set; } = string.Empty; - public string ConfigJson { get; set; } = string.Empty; // full SaveJson envelope -} -``` - -**`PluginConfigSnapshot.cs`** -```csharp -public class PluginConfigSnapshot -{ - public List Plugins { get; set; } = []; -} -``` - -**`PluginConfigUpdateRequest.cs`** -```csharp -public class PluginConfigUpdateRequest -{ - public string PluginId { get; set; } = string.Empty; - public string ValuesJson { get; set; } = string.Empty; // partial values dict -} -``` - -### `WireMessageKind.cs` — add constants -```csharp -public const string PluginConfigSnapshot = "plugin-config-snapshot"; -public const string PluginConfigUpdate = "plugin-config-update"; -``` - -### `AgentWireMessage.cs` — add fields -```csharp -public PluginConfigSnapshot? PluginConfigSnapshot { get; set; } -public PluginConfigUpdateRequest? PluginConfigUpdateRequest { get; set; } -``` - ---- - -## Step 3 — Agent Side (Quasar.Agent) - -### `GameBridge.cs` -- Add `GetPluginConfigs()` method: - - Iterates `AppDomain.CurrentDomain.GetAssemblies()` to find loaded `IPlugin` types - - Casts to `IQuasarConfigProvider` where available (best-effort, skips non-implementing plugins) - - Returns `PluginConfigSnapshot` -- Add `ApplyPluginConfig(string pluginId, string valuesJson)`: - - Finds matching provider, calls `ApplyConfigJson()` -- Subscribe to `INotifyPropertyChanged.PropertyChanged` on each provider's config to detect drift and push updates - -### `AgentConnection.cs` -- On connect after `Hello`: send `PluginConfigSnapshot` -- In `ReceiveLoopAsync`: handle `WireMessageKind.PluginConfigUpdate` → call `bridge.ApplyPluginConfig()` -- On config change (PropertyChanged callback): send updated `PluginConfigSnapshot` - ---- - -## Step 4 — Quasar Service Layer - -### New service: `Quasar/Services/PluginSdk/PluginConfigService.cs` - -Pattern mirrors `DiscordOptionsCatalog` + `DiscordBotService`. Key responsibilities: -- `IHostedService` subscribing to `AgentRegistry` changes -- Maintain `Dictionary>` keyed by `agentId` -- Handle incoming `plugin-config-snapshot` messages from WebSocket -- Expose `UpdatePluginConfigAsync(agentId, pluginId, valuesJson)` — sends `plugin-config-update` back via `AgentRegistry` -- Emit `Changed` event for Blazor component reactivity - -### Wire into `Program.cs` -```csharp -builder.Services.AddSingleton(); -builder.Services.AddHostedService(sp => sp.GetRequiredService()); -``` - -### WebSocket handler (existing agent WS endpoint) -Add case for `WireMessageKind.PluginConfigSnapshot` → forward to `PluginConfigService`. - ---- - -## Step 5 — Quasar UI (Blazor) - -### Schema DTOs in `Quasar/Services/PluginSdk/` -Define POCOs matching `ConfigStorage.SaveJson()` output shape — no PluginSdk project reference needed: -- `PluginConfigEnvelope` (`schema`, `defaults`, `values`) -- `ConfigSchemaDto` (`layout`, `properties`, `structs`, `enums`) -- `ConfigPropertyDto` (`name`, `type`, `optionKind`, `container`, `min`, `max`, `label`, etc.) -- `LayoutContainerDto` (`id`, `parentId`, `kind`, `caption`) - -### `PluginConfigEditor.razor` (new Blazor component) -- Receives `PluginConfigEnvelope` as parameter -- Renders layout: tabs → `MudTabs`, sections → `MudExpansionPanel`, columns → `MudGrid` -- Per `optionKind` control: - - `bool` → `MudCheckBox` - - `int`/`long`/`float`/`double` → `MudNumericField` with min/max - - `string` → `MudTextField` - - `enum` → `MudSelect` - - `list`/`dict` → `MudDataGrid` (expandable, later phase) -- On field change: calls `PluginConfigService.UpdatePluginConfigAsync()` - -### Integrate into existing Plugins page -Extend `/Quasar/Pages/Plugins/` (or relevant existing page) with a tab per plugin that exposes `IQuasarConfigProvider`. - ---- - -## Step 6 — Logging (QuasarLogSink) — implemented via agent relay - -`QuasarLogSink` already exists in PluginSdk and outputs one structured JSON line -per entry. Quasar now relays those entries over the existing agent WebSocket so -managed servers can keep streaming plugin logs after Quasar restarts and -reconnects to a detached Magnetar daemon: - -1. **Activate the sink.** `DedicatedServerSupervisor.StartProcessAsync` sets the - `QUASAR_AGENT` env var (= the server unique name) on the DS child process. - Any non-empty value makes `LogEnvironment.IsManagedByQuasar()` return true, so - every SDK-using plugin selects `QuasarLogSink` and emits JSON on stdout. - (Note: while managed by Quasar, plugin logs route to stdout instead of the - game `MyLog` — this is the SDK's intended behavior.) -2. **Buffer and persist it in-process.** `Quasar.Agent.PluginLogOutbox` - subscribes to `LogEnvironment.LineEmitted`, appends a human-readable copy to - the active server Magnetar app-data `info_*.log`, keeps the raw JSON line in - a bounded retry buffer, and - drops entries whose structured `plugin` field is `Magnetar` before any batch - is sent to the control plane. -3. **Relay it.** `AgentConnection` drains the outbox into `PluginLogBatch` - messages on the existing WebSocket. Failed sends are requeued and retried on - the next connection. -4. **Parse it.** `AgentSocketHandler` resolves the server unique name from the - agent connection, calls `PluginLogStream.TryParseSinkLine`, and appends valid - `{timestamp,level,plugin,thread,message,data?,exception?}` entries. - `PluginLogStream` also rejects/hides `Magnetar` entries defensively. -5. **Keep readable logs in the instance log.** `DedicatedServerSupervisor.PumpStandardOutputAsync` - drains stdout so the managed process cannot block and still ignores - PluginSdk JSON lines for ordinary server-output handling. The agent-side - outbox formats those plugin stdout sink lines before writing to the server's - active Magnetar app-data `info_*.log`, so instance-local diagnostics stay readable - and include plugin output even when - the live Quasar panel receives the same entries through the WebSocket relay. -6. **Display it.** `PluginLogPanel.razor` (new component, on the Plugins page) - subscribes to `PluginLogStream.Changed` and renders recent entries - (time / level / server / plugin / message + exception) in a `MudTable`. - -**Files:** `Quasar/Services/PluginSdk/PluginLogEntry.cs` (new), -`Quasar/Services/PluginSdk/PluginLogStream.cs` (new), -`Quasar/Components/PluginLogPanel.razor` (new), -`Quasar.Agent/PluginLogOutbox.cs` (capture + `Magnetar` suppression), -`Quasar.Agent/AgentConnection.cs` (batch relay), -`Quasar/Services/AgentSocketHandler.cs` (ingest), -`Quasar/Services/DedicatedServerSupervisor.cs` (env var + stdout capture), -`Quasar/Program.cs` (register `PluginLogStream`), -`Quasar/Components/Pages/Plugins.razor` (embed panel). - ---- - -## Critical Files - -| File | Change | -|---|---| -| `Magnetar.Protocol/Bridge/IQuasarConfigProvider.cs` | New | -| `Magnetar.Protocol/Model/PluginConfigData.cs` | New | -| `Magnetar.Protocol/Model/PluginConfigSnapshot.cs` | New | -| `Magnetar.Protocol/Model/PluginConfigUpdateRequest.cs` | New | -| `Magnetar.Protocol/Transport/WireMessageKind.cs` | Add 2 constants | -| `Magnetar.Protocol/Transport/AgentWireMessage.cs` | Add 2 fields | -| `Quasar.Agent/GameBridge.cs` | Add plugin config discovery + apply | -| `Quasar.Agent/AgentConnection.cs` | Send snapshot on connect, handle update | -| `Quasar/Services/PluginSdk/PluginConfigService.cs` | New | -| `Quasar/Services/PluginSdk/PluginConfigDtos.cs` | New (schema DTOs) | -| `Quasar/Components/PluginConfigEditor.razor` | New | -| `Quasar/Program.cs` | Register `PluginConfigService` | - -Reference pattern: `Quasar/Services/Discord/` (IHostedService + options catalog + DI wiring) - ---- - -## Reused Utilities - -- `AtomicFileWriter` — for any config persistence on Quasar side -- `AgentRegistry` — existing service for routing messages to/from connected agents -- `MudBlazor` components — `MudTabs`, `MudCheckBox`, `MudNumericField`, `MudSelect` -- `ConfigStorage.SaveJson` / `LoadJson` — called by plugin implementations - ---- - -## Open Question - -**Plugin server discovery in the agent:** `GetPlugins()` today reads `MySandboxGame.ConfigDedicated.Plugins` (file paths only, no servers). To get running `IQuasarConfigProvider` servers, the agent could: -- Option A: Scan `AppDomain.CurrentDomain.GetAssemblies()` for `IPlugin` types and reflect for `IQuasarConfigProvider` -- Option B: Use `VRage.Plugins.MyPlugins.Plugins` if that collection is accessible - -Option A is safer (no VRage API assumptions). The scan runs once on connect and is cheap. - ---- - -## Verification - -1. **Build check:** All 4 projects compile after protocol additions -2. **Agent test:** Deploy agent to a server with a test plugin implementing `IQuasarConfigProvider` → verify `plugin-config-snapshot` message arrives in Quasar logs -3. **UI test:** Open Plugins page in Quasar → editor renders tabs/fields from schema -4. **Round-trip test:** Change a field in the editor → plugin's `PropertyChanged` fires on server side -5. **Existing features:** Discord integration, entity tracker, analytics still work (no regressions in DI wiring) diff --git a/Docs/public-access-auth-rbac-plan.md b/Docs/public-access-auth-rbac-plan.md deleted file mode 100644 index 78df7fcd..00000000 --- a/Docs/public-access-auth-rbac-plan.md +++ /dev/null @@ -1,481 +0,0 @@ -# Public Access Auth, RBAC, Steam Login, and Workshop Plan - -## Goal - -Add login support for public Quasar access while preserving the current low-friction local operator workflow. - -Localhost and trusted same-subnet access can bypass login by default. Requests from any other remote IP must authenticate. Steam should be the default login provider and default RBAC subject source. Generic external OIDC should be supported as an optional replacement provider. - -Role mappings must be easy to seed from `appsettings.json`, especially by SteamID. Fine-grain role and claim mapping can then be managed through a runtime security control panel. - -## Current State - -Quasar is a Blazor Server app targeting `net10.0`. It currently has no user authentication pipeline. `Program.cs` wires Razor components, MudBlazor, service singletons, health/discovery endpoints, one launcher-token-protected internal drain endpoint, and the raw `/ws/agent` WebSocket handler. - -Most Quasar runtime settings are file-backed JSON catalogs with atomic writes and filesystem watchers. Auth and RBAC should follow that shape. Quasar should not add local password users, local passkey registration, local TOTP, or a full ASP.NET Core Identity user database as the default auth model. - -## Proposed Architecture - -Use external-provider authentication with cookie sessions: - -- default provider: Steam -- optional provider: generic OIDC -- local/trusted-network bypass: synthetic Quasar principal -- RBAC: Quasar-owned roles, policies, and mappings -- runtime config: file-backed JSON catalog - -Steam is the default because it aligns with Space Engineers operators and enables seamless Steam Workshop integration in the mod selection UI. - -Quasar owns: - -- trusted network bypass rules -- role definitions -- policy definitions -- appsettings-seeded role mappings -- runtime RBAC mapping catalog -- Steam Workshop query limits/cache/filtering - -External providers own: - -- account authentication -- MFA/passkey/security challenges -- identity proofing - -Quasar should not store local passwords, local passkeys, local TOTP secrets, or recovery codes. If passkey/MFA support is needed, the selected external provider must supply it. For Steam, that means Steam account security/Steam Guard. For generic OIDC, that means the upstream identity provider's MFA/passkey policy. - -## Config Shape - -Initial `appsettings.json` shape: - -```json -{ - "Quasar": { - "Auth": { - "Enabled": true, - "RequireHttpsForPublicAccess": true, - "DefaultProvider": "Steam", - "TrustedNetworkBypass": { - "AllowLoopback": true, - "AllowSameSubnet": true, - "TrustedProxies": [], - "Roles": [ "admin" ] - }, - "Steam": { - "Enabled": true, - "AllowedSteamIds": [], - "RoleMappings": { - "admin": [ "76561198000000000" ], - "editor": [], - "viewer": [] - } - }, - "ExternalProviders": { - "Oidc": { - "Enabled": false, - "Authority": "", - "ClientId": "", - "ClientSecret": "", - "Scopes": [ "openid", "profile", "email" ], - "NameClaim": "name", - "SubjectClaim": "sub", - "EmailClaim": "email", - "RoleClaim": "roles", - "RoleMappings": { - "admin": [], - "editor": [], - "viewer": [] - } - } - }, - "Workshop": { - "Enabled": true, - "AppId": 244850, - "WebApiKey": "", - "PopularLimit": 50, - "SearchLimit": 50, - "RequiredTags": [ "Mod" ], - "MatchingFileType": "Items", - "PopularQueryType": "RankedByTotalUniqueSubscriptions", - "SearchQueryType": "RankedByTextSearch", - "CacheMaxAgeSeconds": 300, - "SearchDebounceMilliseconds": 350 - } - } - } -} -``` - -Steam provider protocol constants can stay in code because operators should not need to configure them: - -- Steam OpenID endpoint: `https://steamcommunity.com/openid/` -- SteamID claim type: `steamid` -- external login provider name: `Steam` - -Steam RBAC subject values should be configurable because initial setup needs to map known SteamIDs to Quasar roles. - -Runtime RBAC config shape: - -```json -{ - "subjectRoleMappings": [ - { - "provider": "Steam", - "subject": "76561198000000000", - "roles": [ "admin" ] - }, - { - "provider": "Oidc", - "subject": "user-or-group-subject", - "roles": [ "editor" ] - } - ], - "claimRoleMappings": [ - { - "provider": "Oidc", - "claim": "groups", - "value": "quasar-admins", - "roles": [ "admin" ] - } - ], - "policyOverrides": { - "CanControlServers": [ "admin", "editor" ], - "CanManageSecurity": [ "admin" ] - } -} -``` - -## Roles and Policies - -System roles: - -- `viewer`: read-only access to dashboards, metrics, players, configs, servers, plugins, analytics, and host status. -- `editor`: all viewer rights, plus edit configs, templates, plugins, servers, Discord settings, and normal operational changes. -- `admin`: full control, including user/RBAC mapping, trusted network bypass, external provider config, shutdown/drain controls, and security policy. - -Authorization should use policies instead of direct role checks in components: - -- `CanView` -- `CanEditConfigs` -- `CanEditServers` -- `CanControlServers` -- `CanManageDiscord` -- `CanManageAppearance` -- `CanManageSecurity` -- `CanShutdownQuasar` - -Default policy mapping: - -| Policy | Roles | -| --- | --- | -| `CanView` | `viewer`, `editor`, `admin` | -| `CanEditConfigs` | `editor`, `admin` | -| `CanEditServers` | `editor`, `admin` | -| `CanControlServers` | `editor`, `admin` | -| `CanManageDiscord` | `editor`, `admin` | -| `CanManageAppearance` | `editor`, `admin` | -| `CanManageSecurity` | `admin` | -| `CanShutdownQuasar` | `admin` | - -## Trusted Network Bypass - -Add an `ITrustedNetworkEvaluator` service. - -Rules: - -- loopback bypass allowed when `AllowLoopback=true` -- same-subnet bypass allowed when `AllowSameSubnet=true` -- all other IPs require login -- forwarded headers are ignored unless the proxy is explicitly listed in `TrustedProxies` -- public access with trusted-network bypass enabled must show a visible warning in the security control panel - -Same-subnet detection should compare `HttpContext.Connection.RemoteIpAddress` against local NIC unicast addresses and subnet masks. IPv4 and IPv6 should both be considered, but IPv4 can ship first if implementation scope needs trimming. - -The bypass should create a synthetic principal with auth type `QuasarTrustedNetwork`. The role set should come from `TrustedNetworkBypass:Roles`, defaulting to `admin` for early rollout. - -## Endpoint Protection - -Review every endpoint before enabling global auth. - -Likely endpoint treatment: - -| Endpoint | Auth behavior | -| --- | --- | -| `/` and Blazor routes | trusted bypass or authenticated external user | -| `/login`, `/logout`, auth callback paths | anonymous | -| `/api/health` | anonymous but safe fields only, or trusted/authenticated for full fields | -| `/api/discovery` | trusted/authenticated unless needed for LAN discovery | -| `/api/internal/drain` | launcher token plus trusted network | -| `/ws/agent` | separate agent authentication path, not user login | -| `/branding/*`, static assets | anonymous | - -Do not rely only on UI hiding. Mutating API/service actions need policy checks too. - -## Steam Login - -Steam is the default login provider. - -Steam browser login uses OpenID 2.0, not standard OpenID Connect. Quasar should use a dedicated Steam auth handler that verifies Steam OpenID responses and extracts the 64-bit SteamID from the claimed identifier. - -Steam login goals: - -- allow `Sign in with Steam` -- normalize the authenticated user into a Quasar principal -- expose normalized claims such as `provider=Steam`, `steamid`, and `steam_profile_url` -- map SteamID directly into roles through appsettings seed mappings -- map SteamID directly into roles through runtime RBAC mappings -- optionally restrict access to known SteamIDs through `AllowedSteamIds` -- avoid local account creation unless a lightweight remembered-profile cache is needed for display names/avatars - -Initial RBAC by SteamID should be simple: - -```json -{ - "Quasar": { - "Auth": { - "Steam": { - "RoleMappings": { - "admin": [ "76561198000000000" ], - "editor": [ "76561198000000001" ], - "viewer": [ "76561198000000002" ] - } - } - } - } -} -``` - -If no admin mapping exists and auth is enabled for public access, Quasar should show a startup warning and require trusted-network setup before public login can manage security. - -## Optional External OIDC - -Generic OIDC is the optional replacement path when an operator does not want Steam as the login provider. - -Implementation pieces: - -- configure `AddOpenIdConnect` when `ExternalProviders:Oidc:Enabled=true` -- allow `DefaultProvider=Oidc` -- map subject/name/email/role claims from configured claim names -- run `IClaimsTransformation` after login -- transform configured external claims into Quasar roles -- preserve original external claims for audit/debug views - -Claim mappings must support: - -- exact subject match -- exact claim/value match -- multiple roles per match -- multiple rules per provider -- runtime updates through RBAC config -- appsettings seed mappings for initial setup - -## Steam Workshop Integration - -Steam Workshop integration should support the mod selection screen without flooding Steam. - -Steam Workshop goals: - -- make the mod selection screen integrate cleanly with Steam Workshop -- query Space Engineers Workshop by default with `AppId=244850` -- keep all Steam Web API keys server-side -- cache Steam responses server-side -- avoid client-side direct Steam API calls -- cap the popular list at 50 items -- cap search results at 50 items -- debounce search input before calling Steam -- return cached popular results before making fresh calls when cache is valid -- fetch details only for items currently shown - -Workshop filtering: - -- popular list should query the first 50 most popular mods -- search should query the first 50 matching mods -- search should always pass `search_text` -- Steam query should use `filetype=Items` when querying published files -- Steam query should use `requiredtags=Mod` or the closest Space Engineers Workshop mod tag when supported -- if Steam cannot filter by exact mod type for a query, Quasar should post-filter returned details by tags/type before display -- collections, guides, screenshots, videos, and non-mod Workshop items should not appear in the mod picker - -Workshop query defaults: - -| Purpose | Steam query shape | -| --- | --- | -| Popular mods | `QueryFiles`, `query_type=RankedByTotalUniqueSubscriptions`, `numperpage=50`, `appid=244850`, `creator_appid=244850`, `filetype=Items`, `requiredtags=Mod` when valid | -| Search mods | `QueryFiles`, `query_type=RankedByTextSearch`, `search_text=`, `numperpage=50`, `appid=244850`, `creator_appid=244850`, `filetype=Items`, `requiredtags=Mod` when valid | -| Item details | `GetPublishedFileDetails` only for IDs shown or selected | - -Rate-limit posture: - -- one cached popular request per cache window -- one active search request per user query after debounce -- no infinite scroll in the first version -- no prefetch beyond the first 50 results -- no per-keystroke Steam calls -- exponential backoff after Steam errors or throttling -- show stale cached results when Steam is unavailable and cache exists - -Steam references: - -- Steam browser login uses OpenID 2.0 and returns a claimed SteamID: https://partner.steamgames.com/doc/features/auth?l=english -- Steam Workshop `QueryFiles` supports `numperpage`, `search_text`, `filetype`, tags, and query types: https://partner.steamgames.com/doc/webapi/ipublishedfileservice?l=english - -## Passkeys and MFA - -Quasar should not implement local passkeys or local MFA in the default model. - -MFA/passkey handling belongs to the selected external provider: - -- Steam login relies on Steam account security and Steam Guard. -- OIDC login relies on the upstream identity provider's MFA/passkey policy. -- Quasar can display provider and auth-time metadata when available. -- Quasar can require that `DefaultProvider=Oidc` uses an IdP policy that enforces MFA, but Quasar should not store local MFA secrets. - -## Security Control Panel - -Add `/settings/security`, guarded by `CanManageSecurity`. - -Tabs: - -- Steam users -- Roles -- Claim mappings -- Provider selection -- Steam/Workshop -- Trusted networks -- Audit - -Expected operations: - -- list remembered authenticated Steam/OIDC subjects -- assign/remove system roles -- edit SteamID-to-role mappings -- edit OIDC subject/claim-to-role mappings -- configure default provider selection -- configure OIDC fields when OIDC is enabled -- configure Workshop API key and limits -- configure trusted network bypass -- view effective permissions for a subject - -Safety constraints: - -- cannot remove the last admin mapping -- cannot disable auth while public access is enabled unless explicit dangerous confirmation is used -- cannot switch to OIDC without a valid admin mapping path -- cannot save invalid OIDC config without validation warning -- secrets should not be rendered back in clear text after save - -## Stages - -### Stage 1: Auth Options and RBAC Model - -- Add `QuasarAuthOptions`, `SteamAuthOptions`, `OidcAuthOptions`, `TrustedNetworkBypassOptions`, and `WorkshopOptions`. -- Add role constants, policy constants, and provider constants. -- Add Steam protocol constants in code. -- Add config validation with useful startup logs. -- Add mapping parser for appsettings role mappings. -- Add unit tests for mapping normalization and missing-admin warnings. - -### Stage 2: Authentication Pipeline - -- Register cookie authentication and authorization. -- Register Steam as the default challenge provider. -- Register OIDC only when enabled. -- Add `UseAuthentication()` and `UseAuthorization()`. -- Convert router to `AuthorizeRouteView`. -- Add login/logout/callback pages or endpoints. -- Verify anonymous public access redirects to Steam login by default. - -### Stage 3: Trusted Network Bypass - -- Add `ITrustedNetworkEvaluator`. -- Add trusted-network auth middleware or authentication handler. -- Add tests for loopback, same-subnet, remote public IP, IPv6, and trusted proxy behavior. -- Add visible app warning when same-subnet bypass is enabled. -- Ensure bypass does not apply to proxy-forwarded addresses unless proxy is trusted. - -### Stage 4: System Roles and Policies - -- Define system roles and default policy mappings. -- Apply SteamID role mappings from `appsettings.json`. -- Apply optional OIDC role mappings from `appsettings.json`. -- Add policy checks to pages and mutating operations. -- Add tests for viewer/editor/admin access boundaries. - -### Stage 5: Runtime RBAC Catalog - -- Add `RbacConfigCatalog` using Quasar's existing JSON catalog pattern. -- Store provider/subject role mappings, claim mappings, policy overrides, and history snapshots. -- Add last-admin protection. -- Add effective-permission evaluation service. -- Make runtime RBAC changes hot-reload via filesystem watcher. - -### Stage 6: Optional External OIDC - -- Add generic OIDC registration. -- Add configurable subject and claim mapping. -- Add external-login callback handling. -- Add `IClaimsTransformation` for Quasar roles. -- Add UI and logs for unmapped/unknown external users. -- Add tests using fake OIDC claims. - -### Stage 7: Steam Workshop Mod Browser - -- Add server-side `SteamWorkshopService`. -- Query popular mods with a hard `numperpage=50` cap. -- Query search results with a hard `numperpage=50` cap. -- Use `filetype=Items` and `requiredtags=Mod` when Steam accepts the filter. -- Post-filter non-mod results when Steam filtering is incomplete. -- Cache popular and search responses. -- Add search debounce and request cancellation. -- Update mod selection UI to show popular mods before search. -- Keep Steam API keys out of browser payloads and logs. -- Add tests for limit enforcement, tag filtering, cache behavior, and failed Steam calls. - -### Stage 8: Security Control Panel - -- Add `/settings/security`. -- Add nav entry visible only to admins. -- Implement users/roles/claims/provider/workshop/trusted-network/audit tabs. -- Add validation and confirmation dialogs for risky changes. -- Avoid nested-card UI; match current MudBlazor page style. - -### Stage 9: Audit and Hardening - -- Add security event logging: - - login success/failure - - trusted-network bypass login - - role changes - - claim mapping changes - - provider config changes - - Workshop API/config changes -- Add rate limiting for login callbacks and auth endpoints. -- Add antiforgery review for auth endpoints. -- Add secure cookie settings. -- Add optional session timeout and persistent-login controls. - -### Stage 10: Public Access Validation - -- Test direct LAN access. -- Test public remote access. -- Test reverse proxy access with trusted proxy config. -- Test Steam login and SteamID role mapping. -- Test OIDC login and role mapping. -- Test viewer/editor/admin behavior across all pages. -- Test Workshop popular/search limits and filtering. -- Verify existing agent WebSocket and launcher drain behavior remain intact. - -## Initial Execution Order - -Recommended first execution slice: - -1. Add auth option models and RBAC mapping models. -2. Add cookie auth with Steam as default provider. -3. Add login/logout/callback endpoints. -4. Add global auth enforcement with loopback bypass only. -5. Add same-subnet bypass after tests prove IP classification. -6. Add SteamID appsettings role mappings. -7. Add system roles/policies. -8. Add RBAC runtime catalog and admin panel. -9. Add optional external OIDC. -10. Add Steam Workshop mod browser. - -This sequence gets Steam-based public login and SteamID RBAC working first, then layers runtime control, optional OIDC replacement, and Workshop integration. diff --git a/Docs/remote-host-ssh-plan.md b/Docs/remote-host-ssh-plan.md deleted file mode 100644 index 36d7665a..00000000 --- a/Docs/remote-host-ssh-plan.md +++ /dev/null @@ -1,64 +0,0 @@ -# Remote Host SSH Plan - -## Goal - -Allow Quasar to start Space Engineers dedicated servers on prepared remote Linux hosts over SSH, keep the agent connection reachable through SSH port forwarding, and survive tunnel reconnects without losing bounded logs. - -## Assumptions - -- Admin prepares passwordless SSH outside Quasar. -- Host key is already trusted by the OS user running Quasar. -- Remote host has SteamCMD/runtime prerequisites or uses Quasar-managed runtime paths. -- Remote agent connects back only through the SSH tunnel; public inbound ports are not required for Quasar control traffic. - -## Model Additions - -- Host definition: name, hostname, SSH user, SSH port, remote base directory, labels, enabled flag. -- Server placement: local host or remote host id. -- Tunnel policy: local bind port, remote bind port, reconnect interval, max backoff. -- Remote log policy: buffer size, drop-oldest behavior, last uploaded offset. - -## Supervisor Flow - -1. Select host from server definition. -2. For local host, use existing process supervisor. -3. For remote host: - - Establish SSH control connection. - - Ensure remote directories exist. - - Upload or render server config files. - - Start SSH port forward for Quasar web/agent endpoint. - - Start remote server detached from SSH session. - - Track remote pid file and heartbeat file. -4. Agent connects through tunnel and becomes normal live-data source. -5. If tunnel drops, reconnect with backoff and keep process state as `STARTING` or `OPEN` based on last heartbeat age. -6. Stop sends agent graceful stop first, then remote process termination if needed. - -## Logging - -- stdout/stderr are not primary remote transport. -- Agent sends live logs through protocol. -- Remote side keeps a bounded file/ring buffer. -- On reconnect, agent sends entries newer than last acknowledged sequence. -- Buffer default: size capped by bytes and line count; drop oldest first. - -## UI - -- Hosts page lists local and remote hosts. -- Server editor gets Host dropdown. -- Server card shows host name and tunnel state. -- Remote errors surface in dashboard problem banner. - -## Security - -- No password storage in Quasar for first version. -- No host-key bypass. -- SSH command arguments must be generated, not shell-concatenated from user text. -- Remote paths validated and quoted. - -## Rollout - -1. Host catalog and UI. -2. SSH tunnel lifecycle service. -3. Remote process launcher with pid tracking. -4. Agent log replay protocol. -5. Full remote stop/restart/reconnect tests. diff --git a/Quasar.Plugin.Abstractions/Companion/QuasarCompanionRequestEnvelope.cs b/Quasar.Plugin.Abstractions/Companion/QuasarCompanionRequestEnvelope.cs deleted file mode 100644 index 90026255..00000000 --- a/Quasar.Plugin.Abstractions/Companion/QuasarCompanionRequestEnvelope.cs +++ /dev/null @@ -1,14 +0,0 @@ -namespace Quasar.Plugin.Abstractions.Companion; - -public sealed class QuasarCompanionRequestEnvelope -{ - public required string PluginId { get; init; } - - public required string Operation { get; init; } - - public int SchemaVersion { get; init; } = 1; - - public required string CorrelationId { get; init; } - - public required TPayload Payload { get; init; } -} diff --git a/Quasar.Plugin.Abstractions/Companion/QuasarCompanionResponseEnvelope.cs b/Quasar.Plugin.Abstractions/Companion/QuasarCompanionResponseEnvelope.cs deleted file mode 100644 index 8408832f..00000000 --- a/Quasar.Plugin.Abstractions/Companion/QuasarCompanionResponseEnvelope.cs +++ /dev/null @@ -1,16 +0,0 @@ -namespace Quasar.Plugin.Abstractions.Companion; - -public sealed class QuasarCompanionResponseEnvelope -{ - public int SchemaVersion { get; init; } = 1; - - public required string CorrelationId { get; init; } - - public bool Success { get; init; } - - public TPayload? Payload { get; init; } - - public string? Error { get; init; } - - public IReadOnlyList Warnings { get; init; } = []; -} diff --git a/Quasar.Plugin.Abstractions/Manifests/QuasarPluginManifest.cs b/Quasar.Plugin.Abstractions/Manifests/QuasarPluginManifest.cs index ad2d45ff..cf81c7d4 100644 --- a/Quasar.Plugin.Abstractions/Manifests/QuasarPluginManifest.cs +++ b/Quasar.Plugin.Abstractions/Manifests/QuasarPluginManifest.cs @@ -29,14 +29,6 @@ public IReadOnlyList CompanionPluginManifests init => _companionPluginManifests = value ?? []; } - [JsonIgnore] - [Obsolete("Use CompanionPluginManifests for owned companion plugin metadata.")] - public IReadOnlyList CompanionPlugins - { - get => _companionPluginManifests.Select(companion => companion.Id).Where(id => !string.IsNullOrWhiteSpace(id)).ToArray(); - init => _companionPluginManifests = value?.Select(id => new QuasarCompanionPluginManifest { Id = id }).ToArray() ?? []; - } - public IReadOnlyList Dependencies { get; init; } = []; private IReadOnlyList _companionPluginManifests = []; diff --git a/Quasar/Components/Pages/Analytics.razor b/Quasar/Components/Pages/Analytics.razor index 88020b34..d09489e5 100644 --- a/Quasar/Components/Pages/Analytics.razor +++ b/Quasar/Components/Pages/Analytics.razor @@ -7,7 +7,7 @@ @inject ProfilerStoreService ProfilerStore @inject PluginStatsStoreService PluginStatsStore @inject ISnackbar Snackbar -@inject ILocalStorageService LocalStorage +@inject BrowserStorageService BrowserStorage @inject IDialogService DialogService @inject ThemePreferenceService ThemePreference @inject IJSRuntime JS @@ -432,7 +432,7 @@ else { try { - var stored = await LocalStorage.GetItemAsync(StorageKey); + var stored = await BrowserStorage.GetJsonAsync(StorageKey); _config = NormalizeConfig(stored); _configLoaded = true; } @@ -453,7 +453,7 @@ else try { - await LocalStorage.SetItemAsync(StorageKey, _config); + await BrowserStorage.SetJsonAsync(StorageKey, _config); } catch (InvalidOperationException) { diff --git a/Quasar/Components/Pages/Configs.razor b/Quasar/Components/Pages/Configs.razor index a200f572..5e05988c 100644 --- a/Quasar/Components/Pages/Configs.razor +++ b/Quasar/Components/Pages/Configs.razor @@ -1028,6 +1028,7 @@ private IReadOnlyList CatalogEntries => PluginCatalog.GetEntries(); private List VisibleWorldOptions => QuasarConfigMetadata.Options + .Where(option => !option.Hidden) .Where(option => option.Matches(_worldSearch)) .OrderBy(option => QuasarConfigMetadata.Categories.First(category => category.Key == option.CategoryKey).Order) .ThenBy(option => option.Order) @@ -1103,11 +1104,15 @@ .Where(mod => MatchesModSearch(mod)) .ToList(); - private void RefreshModList(bool clearDependencyTree = true) + private void RefreshModList(bool clearDependencyTree = true, bool enableDependencyFallback = true) { _modListRenderVersion++; if (clearDependencyTree) + { _modDependencyTreeRows = []; + if (enableDependencyFallback) + EnableServerDependencyAutodetectFallback(_editor); + } } protected override void OnInitialized() @@ -1243,9 +1248,15 @@ private void QueueSelectedProfileDependencyCheck() { var version = ++_modDependencyCheckVersion; - if (_editor is null || _editor.Mods.Count == 0 || !WorkshopCredentials.HasWebApiKey) + if (_editor is null || _editor.Mods.Count == 0) return; + if (!WorkshopCredentials.HasWebApiKey) + { + EnableServerDependencyAutodetectFallback(_editor); + return; + } + _ = InvokeAsync(() => CheckSelectedProfileDependenciesAsync(version)); } @@ -1267,10 +1278,11 @@ return; } - ApplyProfileModDependencyResult(profile, result); + ApplyProfileModDependencyResult(profile, result, sortLoadOrder: false); } catch (Exception exception) { + EnableServerDependencyAutodetectFallback(_editor); Snackbar.Add($"Mod dependency check failed: {exception.Message}", Severity.Warning); } finally @@ -1287,6 +1299,7 @@ if (!WorkshopCredentials.HasWebApiKey) { + EnableServerDependencyAutodetectFallback(_editor); Snackbar.Add("Steam Workshop Web API key required for dependency sorting.", Severity.Warning); return; } @@ -1300,6 +1313,7 @@ } catch (Exception exception) { + EnableServerDependencyAutodetectFallback(_editor); Snackbar.Add($"Mod dependency sort failed: {exception.Message}", Severity.Error); } finally @@ -1315,14 +1329,16 @@ return; var result = await WorkshopMods.ResolveDependenciesAsync(profile.Mods, sortLoadOrder: sortLoadOrder); - ApplyProfileModDependencyResult(profile, result); + ApplyProfileModDependencyResult(profile, result, sortLoadOrder); } private void ApplyProfileModDependencyResult( QuasarConfigProfile profile, - QuasarModDependencyResolutionResult result) + QuasarModDependencyResolutionResult result, + bool sortLoadOrder) { profile.Mods = result.Mods.ToList(); + ApplyServerDependencyAutodetectPolicy(profile, result, sortLoadOrder); _modDependencyTreeRows = result.DependencyTreeRows; RefreshModList(clearDependencyTree: false); @@ -1333,6 +1349,31 @@ Snackbar.Add($"Mod dependency check: {SummarizeWarnings(result.Warnings)}", Severity.Warning); } + private static void ApplyServerDependencyAutodetectPolicy( + QuasarConfigProfile profile, + QuasarModDependencyResolutionResult result, + bool sortLoadOrder) + { + profile.RootSettings.AutodetectDependencies = RequiresServerDependencyAutodetectFallback(result, sortLoadOrder); + } + + private static bool RequiresServerDependencyAutodetectFallback( + QuasarModDependencyResolutionResult result, + bool sortLoadOrder) + { + return result.Warnings.Any(warning => + !sortLoadOrder || + !warning.StartsWith("Reordered dependency ", StringComparison.Ordinal)); + } + + private static void EnableServerDependencyAutodetectFallback(QuasarConfigProfile? profile) + { + if (profile is null || profile.Mods.Count == 0) + return; + + profile.RootSettings.AutodetectDependencies = true; + } + private async Task CloneCurrentProfileForCrossplayAsync() { if (_editor is null) @@ -1444,7 +1485,7 @@ _hasExplicitCategoryExpansion = true; _expandedCategoryKey = null; EnsureActiveConfigTabAllowed(); - RefreshModList(); + RefreshModList(enableDependencyFallback: false); QueueSelectedProfileDependencyCheck(); } diff --git a/Quasar/Components/Pages/Home.razor b/Quasar/Components/Pages/Home.razor index 190b34e4..6089092a 100644 --- a/Quasar/Components/Pages/Home.razor +++ b/Quasar/Components/Pages/Home.razor @@ -11,7 +11,7 @@ @inject IDialogService DialogService @inject ISnackbar Snackbar @inject NavigationManager Navigation -@inject ILocalStorageService LocalStorage +@inject BrowserStorageService BrowserStorage @(IsListView ? "Servers" : "Dashboard") @@ -737,7 +737,7 @@ else _serverViewLoadedFromStorage = true; try { - var stored = await LocalStorage.GetItemAsync(ServerViewStorageKey); + var stored = await BrowserStorage.GetStringAsync(ServerViewStorageKey); if (TryParseServerView(stored, out var storedView) && storedView != _serverView) { _serverView = storedView; @@ -840,7 +840,7 @@ else { try { - await LocalStorage.SetItemAsync(ServerViewStorageKey, view == DashboardServerView.List ? "list" : "cards"); + await BrowserStorage.SetStringAsync(ServerViewStorageKey, view == DashboardServerView.List ? "list" : "cards"); } catch (InvalidOperationException) { diff --git a/Quasar/Components/Pages/ServerEditorDialog.razor b/Quasar/Components/Pages/ServerEditorDialog.razor index 349f8063..7786b685 100644 --- a/Quasar/Components/Pages/ServerEditorDialog.razor +++ b/Quasar/Components/Pages/ServerEditorDialog.razor @@ -1183,6 +1183,7 @@ var result = await WorkshopMods.ResolveDependenciesAsync(profile.Mods); profile.Mods = result.Mods.ToList(); + profile.RootSettings.AutodetectDependencies = result.Warnings.Count > 0; if (result.AddedDependencyCount > 0) Snackbar.Add($"Added {result.AddedDependencyCount} dependency mod(s).", Severity.Success); diff --git a/Quasar/Components/Pages/Servers.razor b/Quasar/Components/Pages/Servers.razor index 6bcabee7..8dbddab2 100644 --- a/Quasar/Components/Pages/Servers.razor +++ b/Quasar/Components/Pages/Servers.razor @@ -178,14 +178,6 @@ @code { private readonly HashSet _expanded = new(StringComparer.OrdinalIgnoreCase); - private bool _creatingWorldTemplate; - - private enum CloneWorldMode - { - CopyWorld, - KeepSelectedWorld, - } - [Parameter] public EventCallback ConfigProfileSelected { get; set; } @@ -269,61 +261,11 @@ private Task OpenCreateDialogAsync() => ServerActions.OpenCreateDialogAsync(); - private async Task OpenCloneDialogAsync(DedicatedServerDefinition definition) - { - var cloned = definition.Clone(); - cloned.DisplayName = NormalizeWhitespace($"{GetDisplayName(definition)} Copy"); - cloned.UniqueName = MakeCopyIdentifier(definition.UniqueName); - cloned.OriginalUniqueName = string.Empty; - cloned.GoalState = DedicatedServerGoalState.Off; - cloned.AutoStart = false; - cloned.ServerPort = AllocateNextPort(); - cloned.DedicatedServerAppDataPath = string.Empty; - cloned.MagnetarAppDataPath = string.Empty; - cloned.WorldPath = string.Empty; - cloned.WorldSaveName = string.Empty; - cloned.ConfigFilePath = string.Empty; - - var created = await ShowEditorDialogAsync(cloned, isEditing: false, isClone: true); - if (created is null) - return; - - if (!EnsureCloneUsesIndependentPaths(definition, created)) - return; - - var cloneWorldMode = await ChooseCloneWorldModeAsync(definition); - if (cloneWorldMode is null) - return; - - if (!await SaveDefinitionAsync(created, string.Empty)) - return; - - var saved = ServerCatalog.GetServer(created.UniqueName) ?? created; - try - { - var worldMessage = cloneWorldMode == CloneWorldMode.CopyWorld - ? await CopyCloneWorldStateAsync(definition, saved) - : KeepCloneWorldState(definition, saved); - Snackbar.Add($"Server cloned. {worldMessage}", Severity.Success); - } - catch (OperationCanceledException exception) - { - Snackbar.Add($"Server cloned; {exception.Message}", Severity.Info); - } - catch (Exception exception) - { - Snackbar.Add($"Server cloned, but world preparation failed: {exception.Message}", Severity.Error); - } - } + private Task OpenCloneDialogAsync(DedicatedServerDefinition definition) => + ServerActions.OpenCloneDialogAsync(definition); - private async Task OpenEditDialogAsync(DedicatedServerDefinition definition) - { - var updated = await ShowEditorDialogAsync(definition, isEditing: true, isClone: false); - if (updated is null) - return; - - await SaveDefinitionAsync(updated, "Server saved."); - } + private Task OpenEditDialogAsync(DedicatedServerDefinition definition) => + ServerActions.OpenEditDialogAsync(definition); private async Task OpenConfigProfileAsync(string configProfileId) { @@ -348,297 +290,11 @@ ok ? Severity.Info : Severity.Warning); } - private async Task OpenConsoleDialogAsync(string uniqueName) - { - var parameters = new DialogParameters - { - [nameof(ServerConsoleDialog.UniqueName)] = uniqueName, - }; - - var options = new DialogOptions - { - CloseOnEscapeKey = true, - FullWidth = true, - MaxWidth = MaxWidth.Large, - }; - - await DialogService.ShowAsync($"Console — {uniqueName}", parameters, options); - } - - private async Task CreateWorldTemplateAsync(DedicatedServerDefinition definition) - { - if (!CanCreateWorldTemplate(definition)) - { - Snackbar.Add("Stop the server before creating a world template from its current state.", Severity.Warning); - return; - } - - var worldPath = definition.GetWorldSavePath(); - if (string.IsNullOrWhiteSpace(worldPath) || !Directory.Exists(worldPath)) - { - Snackbar.Add($"World save not found for '{definition.UniqueName}'.", Severity.Error); - return; - } - - if (!File.Exists(Path.Combine(worldPath, "Sandbox.sbc"))) - { - Snackbar.Add($"World save '{worldPath}' does not contain Sandbox.sbc.", Severity.Error); - return; - } - - var defaultName = $"{GetDisplayName(definition)} Snapshot {DateTime.Now:yyyy-MM-dd HHmm}"; - var parameters = new DialogParameters - { - [nameof(WorldTemplateFromServerDialog.DefaultName)] = defaultName, - [nameof(WorldTemplateFromServerDialog.DefaultDescription)] = $"Created from stopped server '{definition.UniqueName}'.", - [nameof(WorldTemplateFromServerDialog.WorldPath)] = worldPath, - }; - - var dialog = await DialogService.ShowAsync( - "Create World Template", - parameters, - new DialogOptions { CloseOnEscapeKey = true, FullWidth = true, MaxWidth = MaxWidth.Small }); - - var result = await dialog.Result; - if (result is null || result.Canceled || result.Data is not WorldTemplateFromServerDialog.TemplateRequest request) - return; - - _creatingWorldTemplate = true; - try - { - var template = await WorldTemplates.ImportAsync(request.Name, request.Description, worldPath); - Snackbar.Add($"World template '{template.Name}' created.", Severity.Success); - } - catch (Exception exception) - { - Snackbar.Add(exception.Message, Severity.Error); - } - finally - { - _creatingWorldTemplate = false; - } - } - - private async Task ShowEditorDialogAsync(DedicatedServerDefinition definition, bool isEditing, bool isClone) - { - var parameters = new DialogParameters - { - [nameof(ServerEditorDialog.Definition)] = definition.Clone(), - [nameof(ServerEditorDialog.IsEditing)] = isEditing, - [nameof(ServerEditorDialog.IsClone)] = isClone, - [nameof(ServerEditorDialog.UniqueNameLocked)] = isEditing && IsRunning(definition.UniqueName), - }; + private Task OpenConsoleDialogAsync(string uniqueName) => + ServerActions.OpenConsoleDialogAsync(uniqueName); - var options = new DialogOptions - { - CloseOnEscapeKey = true, - FullWidth = true, - MaxWidth = MaxWidth.ExtraLarge, - }; - - var title = isEditing - ? "Edit Server" - : isClone - ? "Clone Server" - : "Create Server"; - var dialog = await DialogService.ShowAsync(title, parameters, options); - var result = await dialog.Result; - if (result is null || result.Canceled || result.Data is not DedicatedServerDefinition updated) - return null; - - return updated; - } - - private async Task ChooseCloneWorldModeAsync(DedicatedServerDefinition source) - { - var sourceRunning = IsRunning(source.UniqueName); - var message = sourceRunning - ? $"Clone '{GetDisplayName(source)}' with its current world state? The source is running, so Quasar will copy from the latest Space Engineers Backup snapshot instead of the live world folder. Choose 'Keep Save' to use the save already selected in the editor." - : $"Clone '{GetDisplayName(source)}' with its current world state? Choose 'Keep Save' to use the save already selected in the editor."; - - var result = await DialogService.ShowMessageBoxAsync( - "Clone world state?", - message, - yesText: "Copy World", - noText: "Keep Save", - cancelText: "Cancel"); - - if (result is null) - return null; - - return result.Value ? CloneWorldMode.CopyWorld : CloneWorldMode.KeepSelectedWorld; - } - - private async Task CopyCloneWorldStateAsync(DedicatedServerDefinition source, DedicatedServerDefinition target) - { - EnsureCloneStorageIsIndependent(source, target); - await ConfirmExistingCloneWorldPathDeleteAsync(target, "copy the source world into the clone"); - - var sourceWorldPath = source.GetWorldSavePath(); - if (string.IsNullOrWhiteSpace(sourceWorldPath) || !Directory.Exists(sourceWorldPath)) - throw new InvalidOperationException($"Source world save not found for '{source.UniqueName}'."); - - if (IsRunning(source.UniqueName)) - { - var backupPath = FindLatestWorldBackupDirectory(sourceWorldPath); - if (backupPath is null) - throw new InvalidOperationException($"No Space Engineers Backup snapshot exists under '{Path.Combine(sourceWorldPath, "Backup")}'. Stop the source server or let it create a backup before cloning world state."); - - await CopyWorldDirectoryAsync(backupPath, target.GetWorldSavePath()); - return $"World copied from latest Backup snapshot '{Path.GetFileName(backupPath)}'."; - } - - await CopyStoppedWorldStateAsync(sourceWorldPath, target); - return "World copied from the stopped source server."; - } - - private string KeepCloneWorldState(DedicatedServerDefinition source, DedicatedServerDefinition target) - { - EnsureCloneStorageIsIndependent(source, target); - - return "Selected clone world save kept unchanged."; - } - - private async Task CopyStoppedWorldStateAsync(string sourceWorldPath, DedicatedServerDefinition target) - { - if (!File.Exists(Path.Combine(sourceWorldPath, "Sandbox.sbc"))) - throw new InvalidOperationException($"Source world save '{sourceWorldPath}' does not contain Sandbox.sbc."); - - await CopyWorldDirectoryAsync(sourceWorldPath, target.GetWorldSavePath()); - } - - private static string? FindLatestWorldBackupDirectory(string worldPath) - { - var backupDirectory = Path.Combine(worldPath, "Backup"); - if (!Directory.Exists(backupDirectory)) - return null; - - return Directory - .EnumerateDirectories(backupDirectory) - .Where(directory => File.Exists(Path.Combine(directory, "Sandbox.sbc"))) - .OrderByDescending(Directory.GetLastWriteTimeUtc) - .FirstOrDefault(); - } - - private static Task CopyWorldDirectoryAsync(string sourceWorldPath, string targetWorldPath) => - Task.Run(() => - { - if (!string.IsNullOrWhiteSpace(targetWorldPath) && Directory.Exists(targetWorldPath)) - Directory.Delete(targetWorldPath, recursive: true); - - CopyDirectory(sourceWorldPath, targetWorldPath, ShouldSkipCopiedWorldFile); - }); - - private async Task ConfirmExistingCloneWorldPathDeleteAsync(DedicatedServerDefinition target, string action) - { - var targetWorldPath = target.GetWorldSavePath(); - if (string.IsNullOrWhiteSpace(targetWorldPath) || !Directory.Exists(targetWorldPath)) - return; - - var confirmed = await ConfirmDestructiveActionAsync( - "Delete existing clone world folder?", - $"The clone's world save already exists: {targetWorldPath}. Quasar must delete that folder to {action}.", - "Delete Folder"); - - if (!confirmed) - throw new OperationCanceledException("Clone world preparation canceled."); - } - - private bool EnsureCloneUsesIndependentPaths(DedicatedServerDefinition source, DedicatedServerDefinition clone) - { - if (PathsEqual(source.DedicatedServerAppDataPath, clone.DedicatedServerAppDataPath)) - { - Snackbar.Add("Clone DS app-data path matches the source. Clear the path override or choose a different folder.", Severity.Error); - return false; - } - - if (PathsEqual(source.GetWorldSavePath(), clone.GetWorldSavePath())) - { - Snackbar.Add("Clone world save path matches the source. Choose a different save.", Severity.Error); - return false; - } - - if (PathsEqual(source.ConfigFilePath, clone.ConfigFilePath)) - { - Snackbar.Add("Clone rendered config path matches the source. Clear the path override or choose a different file.", Severity.Error); - return false; - } - - return true; - } - - private static void EnsureCloneStorageIsIndependent(DedicatedServerDefinition source, DedicatedServerDefinition target) - { - if (PathsEqual(source.DedicatedServerAppDataPath, target.DedicatedServerAppDataPath)) - throw new InvalidOperationException("Clone DS app-data path still matches the source."); - - if (PathsEqual(source.GetWorldSavePath(), target.GetWorldSavePath())) - throw new InvalidOperationException("Clone world save path still matches the source."); - - if (PathsEqual(source.ConfigFilePath, target.ConfigFilePath)) - throw new InvalidOperationException("Clone rendered config path still matches the source."); - } - - private static void CopyDirectory(string sourceDirectory, string targetDirectory, Func shouldSkipFile) - { - Directory.CreateDirectory(targetDirectory); - - foreach (var directory in Directory.EnumerateDirectories(sourceDirectory, "*", SearchOption.AllDirectories)) - { - var relative = Path.GetRelativePath(sourceDirectory, directory); - Directory.CreateDirectory(Path.Combine(targetDirectory, relative)); - } - - foreach (var file in Directory.EnumerateFiles(sourceDirectory, "*", SearchOption.AllDirectories)) - { - if (shouldSkipFile(file)) - continue; - - var relative = Path.GetRelativePath(sourceDirectory, file); - var destination = Path.Combine(targetDirectory, relative); - Directory.CreateDirectory(Path.GetDirectoryName(destination)!); - File.Copy(file, destination, overwrite: true); - } - } - - private static bool ShouldSkipCopiedWorldFile(string path) => - Path.GetFileName(path).StartsWith("Sandbox_config.sbc", StringComparison.OrdinalIgnoreCase); - - private static bool PathsEqual(string? left, string? right) - { - if (string.IsNullOrWhiteSpace(left) || string.IsNullOrWhiteSpace(right)) - return false; - - return string.Equals( - Path.GetFullPath(left.Trim()).TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar), - Path.GetFullPath(right.Trim()).TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar), - OperatingSystem.IsWindows() ? StringComparison.OrdinalIgnoreCase : StringComparison.Ordinal); - } - - private async Task SaveDefinitionAsync(DedicatedServerDefinition definition, string successMessage) - { - var previousUniqueName = definition.OriginalUniqueName; - if (!string.IsNullOrWhiteSpace(previousUniqueName) && - !string.Equals(previousUniqueName, definition.UniqueName, StringComparison.OrdinalIgnoreCase) && - IsRunning(previousUniqueName)) - { - Snackbar.Add("Stop the server before renaming it.", Severity.Warning); - return false; - } - - try - { - await ServerCatalog.UpsertAsync(definition); - if (!string.IsNullOrWhiteSpace(successMessage)) - Snackbar.Add(successMessage, Severity.Success); - return true; - } - catch (Exception exception) - { - Snackbar.Add(exception.Message, Severity.Error); - return false; - } - } + private Task CreateWorldTemplateAsync(DedicatedServerDefinition definition) => + ServerActions.CreateWorldTemplateAsync(definition); private async Task StartAsync(string uniqueName) { @@ -734,44 +390,7 @@ } } - private async Task DeleteAsync(string uniqueName) - { - if (IsRunning(uniqueName)) - { - Snackbar.Add("Stop the server before deleting its definition.", Severity.Warning); - return; - } - - var folder = MagnetarPaths.GetQuasarServerDirectory(uniqueName); - - var confirmParameters = new DialogParameters - { - ["Slug"] = uniqueName, - ["FolderPath"] = folder, - ["Confirm"] = true, - }; - var confirmDialog = await DialogService.ShowAsync( - "Delete server?", - confirmParameters, - new DialogOptions { CloseOnEscapeKey = true, FullWidth = true, MaxWidth = MaxWidth.Small }); - var confirmResult = await confirmDialog.Result; - if (confirmResult is null || confirmResult.Canceled) - return; - - await ServerCatalog.DeleteAsync(uniqueName); - Registry.PruneDisconnectedByUniqueName(uniqueName); - - var removedParameters = new DialogParameters - { - ["Slug"] = uniqueName, - ["FolderPath"] = folder, - ["Confirm"] = false, - }; - await DialogService.ShowAsync( - "Server removed", - removedParameters, - new DialogOptions { CloseOnEscapeKey = true, FullWidth = true, MaxWidth = MaxWidth.Small }); - } + private Task DeleteAsync(string uniqueName) => ServerActions.DeleteAsync(uniqueName); private string GetAttachmentStatus(string uniqueName) { @@ -958,14 +577,8 @@ return state == DedicatedServerProcessState.Running; } - private bool CanCreateWorldTemplate(DedicatedServerDefinition definition) - { - if (_creatingWorldTemplate || definition.GoalState != DedicatedServerGoalState.Off) - return false; - - var state = GetRuntime(definition.UniqueName)?.State ?? DedicatedServerProcessState.Stopped; - return state == DedicatedServerProcessState.Stopped; - } + private bool CanCreateWorldTemplate(DedicatedServerDefinition definition) => + ServerActions.CanCreateWorldTemplate(definition); private DedicatedServerRuntimeSnapshot? GetRuntime(string uniqueName) { @@ -978,35 +591,4 @@ _ = InvokeAsync(StateHasChanged); } - private string MakeCopyIdentifier(string uniqueName) - { - var baseName = string.IsNullOrWhiteSpace(uniqueName) ? "server" : uniqueName.Trim(); - var candidate = $"{baseName}-copy"; - var used = ServerCatalog.GetServers() - .Select(server => server.UniqueName) - .ToHashSet(StringComparer.OrdinalIgnoreCase); - var suffix = 2; - - while (used.Contains(candidate)) - candidate = $"{baseName}-copy-{suffix++}"; - - return candidate; - } - - private static string NormalizeWhitespace(string value) => - System.Text.RegularExpressions.Regex.Replace(value.Trim(), @"\s+", " "); - - private int AllocateNextPort() - { - var used = ServerCatalog.GetServers() - .Select(server => server.ServerPort) - .Where(port => port > 0) - .ToHashSet(); - - var port = 27016; - while (used.Contains(port) && port < 65535) - port++; - - return port; - } } diff --git a/Quasar/Components/Pages/WorldTemplateQuickImportDialog.razor b/Quasar/Components/Pages/WorldTemplateQuickImportDialog.razor index 434bf2ed..2ab1f788 100644 --- a/Quasar/Components/Pages/WorldTemplateQuickImportDialog.razor +++ b/Quasar/Components/Pages/WorldTemplateQuickImportDialog.razor @@ -566,6 +566,7 @@ var result = await WorkshopMods.ResolveDependenciesAsync(profile.Mods); profile.Mods = result.Mods.ToList(); + profile.RootSettings.AutodetectDependencies = result.Warnings.Count > 0; if (result.AddedDependencyCount > 0) Snackbar.Add($"Added {result.AddedDependencyCount} dependency mod(s).", Severity.Success); diff --git a/Quasar/Components/Pages/WorldTemplates.razor b/Quasar/Components/Pages/WorldTemplates.razor index 007a8a8d..52bcd26f 100644 --- a/Quasar/Components/Pages/WorldTemplates.razor +++ b/Quasar/Components/Pages/WorldTemplates.razor @@ -396,6 +396,7 @@ var result = await WorkshopMods.ResolveDependenciesAsync(profile.Mods); profile.Mods = result.Mods.ToList(); + profile.RootSettings.AutodetectDependencies = result.Warnings.Count > 0; if (result.AddedDependencyCount > 0) Snackbar.Add($"Added {result.AddedDependencyCount} dependency mod(s).", Severity.Success); diff --git a/Quasar/Program.cs b/Quasar/Program.cs index 7ddee279..4465d984 100644 --- a/Quasar/Program.cs +++ b/Quasar/Program.cs @@ -135,7 +135,6 @@ public static void Main(string[] args) .SetApplicationName("Quasar") .PersistKeysToFileSystem(new DirectoryInfo(dataProtectionKeyringDirectory)); builder.Services.AddHttpClient(); - builder.Services.AddLocalStorageServices(); builder.Services.AddMudServices(configuration => { configuration.SnackbarConfiguration.PositionClass = Defaults.Classes.Position.BottomStart; @@ -164,6 +163,7 @@ public static void Main(string[] args) builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); + builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); diff --git a/Quasar/Quasar.csproj b/Quasar/Quasar.csproj index 8194a998..c7d47226 100644 --- a/Quasar/Quasar.csproj +++ b/Quasar/Quasar.csproj @@ -7,7 +7,6 @@ - diff --git a/Quasar/Services/Auth/RbacConfigCatalog.cs b/Quasar/Services/Auth/RbacConfigCatalog.cs index 374cc6b6..54b9b7e7 100644 --- a/Quasar/Services/Auth/RbacConfigCatalog.cs +++ b/Quasar/Services/Auth/RbacConfigCatalog.cs @@ -1,6 +1,7 @@ using System.Text.Json; using System.Text.Json.Serialization; using Magnetar.Protocol.Runtime; +using Quasar.Services; namespace Quasar.Services.Auth; @@ -16,8 +17,7 @@ public sealed class RbacConfigCatalog : IDisposable private readonly ILogger _logger; private RbacConfig _config; private string _snapshot; - private FileSystemWatcher? _watcher; - private CancellationTokenSource? _reloadDebounce; + private DebouncedFileWatcher? _watcher; public RbacConfigCatalog(ILogger logger) { @@ -32,8 +32,6 @@ public RbacConfigCatalog(ILogger logger) public void Dispose() { _watcher?.Dispose(); - _reloadDebounce?.Cancel(); - _reloadDebounce?.Dispose(); } public RbacConfig GetConfig() @@ -99,68 +97,7 @@ private RbacConfig LoadConfig() private void StartWatching() { - var path = GetPath(); - var directory = Path.GetDirectoryName(path); - if (string.IsNullOrWhiteSpace(directory)) - return; - - Directory.CreateDirectory(directory); - - _watcher = new FileSystemWatcher(directory) - { - IncludeSubdirectories = false, - NotifyFilter = NotifyFilters.FileName | NotifyFilters.LastWrite | NotifyFilters.CreationTime | NotifyFilters.Size, - Filter = Path.GetFileName(path), - }; - - _watcher.Changed += HandleWatchedFileChanged; - _watcher.Created += HandleWatchedFileChanged; - _watcher.Deleted += HandleWatchedFileChanged; - _watcher.Renamed += HandleWatchedFileChanged; - _watcher.EnableRaisingEvents = true; - } - - private void HandleWatchedFileChanged(object sender, FileSystemEventArgs args) - { - if (!IsTrackedPath(args.FullPath)) - return; - - ScheduleReload(); - } - - private bool IsTrackedPath(string path) - { - if (string.IsNullOrWhiteSpace(path)) - return false; - - return string.Equals( - Path.GetFullPath(path), - Path.GetFullPath(GetPath()), - StringComparison.OrdinalIgnoreCase); - } - - private void ScheduleReload() - { - CancellationTokenSource debounce; - lock (_sync) - { - _reloadDebounce?.Cancel(); - _reloadDebounce?.Dispose(); - _reloadDebounce = new CancellationTokenSource(); - debounce = _reloadDebounce; - } - - _ = Task.Run(async () => - { - try - { - await Task.Delay(TimeSpan.FromMilliseconds(250), debounce.Token); - ReloadFromDisk(); - } - catch (OperationCanceledException) - { - } - }, CancellationToken.None); + _watcher = DebouncedFileWatcher.WatchFile(GetPath(), ReloadFromDisk); } private void ReloadFromDisk() diff --git a/Quasar/Services/Backup/QuasarBackupSettingsService.cs b/Quasar/Services/Backup/QuasarBackupSettingsService.cs index 6bafdfa5..657fa601 100644 --- a/Quasar/Services/Backup/QuasarBackupSettingsService.cs +++ b/Quasar/Services/Backup/QuasarBackupSettingsService.cs @@ -3,6 +3,7 @@ using System.Text.Json.Serialization; using Magnetar.Protocol.Runtime; using Quasar.Models; +using Quasar.Services; namespace Quasar.Services.Backup; @@ -26,8 +27,7 @@ public sealed class QuasarBackupSettingsService : IDisposable private readonly WebServiceOptions _options; private QuasarBackupSettings _settings; private string _snapshot; - private FileSystemWatcher? _watcher; - private CancellationTokenSource? _reloadDebounce; + private DebouncedFileWatcher? _watcher; public QuasarBackupSettingsService( ILogger logger, @@ -154,8 +154,6 @@ private async Task PersistAsync(QuasarBackupSettings normalized, CancellationTok public void Dispose() { _watcher?.Dispose(); - _reloadDebounce?.Cancel(); - _reloadDebounce?.Dispose(); } private QuasarBackupSettings LoadSettings() @@ -179,51 +177,7 @@ private QuasarBackupSettings LoadSettings() private void StartWatching() { - var path = MagnetarPaths.GetQuasarBackupSettingsPath(); - var directory = Path.GetDirectoryName(path); - if (string.IsNullOrWhiteSpace(directory)) - return; - - Directory.CreateDirectory(directory); - - _watcher = new FileSystemWatcher(directory) - { - IncludeSubdirectories = false, - NotifyFilter = NotifyFilters.FileName | NotifyFilters.LastWrite | NotifyFilters.CreationTime | NotifyFilters.Size, - Filter = Path.GetFileName(path), - }; - - _watcher.Changed += HandleWatchedFileChanged; - _watcher.Created += HandleWatchedFileChanged; - _watcher.Deleted += HandleWatchedFileChanged; - _watcher.Renamed += HandleWatchedFileChanged; - _watcher.EnableRaisingEvents = true; - } - - private void HandleWatchedFileChanged(object sender, FileSystemEventArgs args) => ScheduleReload(); - - private void ScheduleReload() - { - CancellationTokenSource debounce; - lock (_sync) - { - _reloadDebounce?.Cancel(); - _reloadDebounce?.Dispose(); - _reloadDebounce = new CancellationTokenSource(); - debounce = _reloadDebounce; - } - - _ = Task.Run(async () => - { - try - { - await Task.Delay(TimeSpan.FromMilliseconds(250), debounce.Token); - ReloadFromDisk(); - } - catch (OperationCanceledException) - { - } - }, CancellationToken.None); + _watcher = DebouncedFileWatcher.WatchFile(MagnetarPaths.GetQuasarBackupSettingsPath(), ReloadFromDisk); } private void ReloadFromDisk() diff --git a/Quasar/Services/BrandingService.cs b/Quasar/Services/BrandingService.cs index d59f3980..7ae411e2 100644 --- a/Quasar/Services/BrandingService.cs +++ b/Quasar/Services/BrandingService.cs @@ -26,8 +26,7 @@ public sealed class BrandingService : IDisposable private readonly string _brandingAssetsDirectory; private BrandingSettings _settings; private string _snapshot; - private FileSystemWatcher? _watcher; - private CancellationTokenSource? _reloadDebounce; + private DebouncedFileWatcher? _watcher; public BrandingService(ILogger logger) { @@ -127,8 +126,6 @@ public async Task ResetToDefaultAsync(CancellationToken cancellationToken = defa public void Dispose() { _watcher?.Dispose(); - _reloadDebounce?.Cancel(); - _reloadDebounce?.Dispose(); } private async Task WriteAssetAsync(string baseName, Stream data, string extension, CancellationToken cancellationToken) @@ -212,68 +209,7 @@ private BrandingSettings LoadSettings() private void StartWatching() { - var path = MagnetarPaths.GetQuasarBrandingPath(); - var directory = Path.GetDirectoryName(path); - if (string.IsNullOrWhiteSpace(directory)) - return; - - Directory.CreateDirectory(directory); - - _watcher = new FileSystemWatcher(directory) - { - IncludeSubdirectories = false, - NotifyFilter = NotifyFilters.FileName | NotifyFilters.LastWrite | NotifyFilters.CreationTime | NotifyFilters.Size, - Filter = Path.GetFileName(path), - }; - - _watcher.Changed += HandleWatchedFileChanged; - _watcher.Created += HandleWatchedFileChanged; - _watcher.Deleted += HandleWatchedFileChanged; - _watcher.Renamed += HandleWatchedFileChanged; - _watcher.EnableRaisingEvents = true; - } - - private void HandleWatchedFileChanged(object sender, FileSystemEventArgs args) - { - if (!IsTrackedPath(args.FullPath)) - return; - - ScheduleReload(); - } - - private static bool IsTrackedPath(string path) - { - if (string.IsNullOrWhiteSpace(path)) - return false; - - return string.Equals( - Path.GetFullPath(path), - Path.GetFullPath(MagnetarPaths.GetQuasarBrandingPath()), - StringComparison.OrdinalIgnoreCase); - } - - private void ScheduleReload() - { - CancellationTokenSource debounce; - lock (_sync) - { - _reloadDebounce?.Cancel(); - _reloadDebounce?.Dispose(); - _reloadDebounce = new CancellationTokenSource(); - debounce = _reloadDebounce; - } - - _ = Task.Run(async () => - { - try - { - await Task.Delay(TimeSpan.FromMilliseconds(250), debounce.Token); - ReloadFromDisk(); - } - catch (OperationCanceledException) - { - } - }, CancellationToken.None); + _watcher = DebouncedFileWatcher.WatchFile(MagnetarPaths.GetQuasarBrandingPath(), ReloadFromDisk); } private void ReloadFromDisk() diff --git a/Quasar/Services/BrowserStorageService.cs b/Quasar/Services/BrowserStorageService.cs new file mode 100644 index 00000000..b8ab8b0e --- /dev/null +++ b/Quasar/Services/BrowserStorageService.cs @@ -0,0 +1,46 @@ +using System.Text.Json; +using Microsoft.JSInterop; + +namespace Quasar.Services; + +public sealed class BrowserStorageService(IJSRuntime js) +{ + private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web); + + public async ValueTask GetStringAsync(string key) + { + var value = await GetRawAsync(key); + if (string.IsNullOrEmpty(value)) + return value; + + if (value[0] == '"') + { + try + { + return JsonSerializer.Deserialize(value, JsonOptions); + } + catch (JsonException) + { + } + } + + return value; + } + + public async ValueTask GetJsonAsync(string key) + { + var value = await GetRawAsync(key); + return string.IsNullOrWhiteSpace(value) + ? default + : JsonSerializer.Deserialize(value, JsonOptions); + } + + public ValueTask SetStringAsync(string key, string value) => + js.InvokeVoidAsync("quasarConfigs.setLocalStorage", key, value); + + public ValueTask SetJsonAsync(string key, T value) => + SetStringAsync(key, JsonSerializer.Serialize(value, JsonOptions)); + + private ValueTask GetRawAsync(string key) => + js.InvokeAsync("quasarConfigs.getLocalStorage", key); +} diff --git a/Quasar/Services/DebouncedFileWatcher.cs b/Quasar/Services/DebouncedFileWatcher.cs new file mode 100644 index 00000000..976ef1b7 --- /dev/null +++ b/Quasar/Services/DebouncedFileWatcher.cs @@ -0,0 +1,115 @@ +namespace Quasar.Services; + +public sealed class DebouncedFileWatcher : IDisposable +{ + private static readonly TimeSpan Delay = TimeSpan.FromMilliseconds(250); + + private readonly object _sync = new(); + private readonly FileSystemWatcher _watcher; + private readonly Func _isTrackedPath; + private readonly Action _changed; + private CancellationTokenSource? _debounce; + + private DebouncedFileWatcher( + string directory, + string filter, + bool includeSubdirectories, + Func isTrackedPath, + Action changed) + { + _isTrackedPath = isTrackedPath; + _changed = changed; + _watcher = new FileSystemWatcher(directory) + { + IncludeSubdirectories = includeSubdirectories, + NotifyFilter = NotifyFilters.FileName | NotifyFilters.LastWrite | NotifyFilters.CreationTime | NotifyFilters.Size, + Filter = filter, + }; + + _watcher.Changed += HandleChanged; + _watcher.Created += HandleChanged; + _watcher.Deleted += HandleChanged; + _watcher.Renamed += HandleChanged; + _watcher.EnableRaisingEvents = true; + } + + public static DebouncedFileWatcher? WatchFile(string path, Action changed) + { + var fullPath = Path.GetFullPath(path); + var directory = Path.GetDirectoryName(fullPath); + if (string.IsNullOrWhiteSpace(directory)) + return null; + + Directory.CreateDirectory(directory); + return new DebouncedFileWatcher( + directory, + Path.GetFileName(fullPath), + includeSubdirectories: false, + candidate => PathsEqual(candidate, fullPath), + changed); + } + + public static DebouncedFileWatcher WatchDirectory( + string directory, + string filter, + bool includeSubdirectories, + Func isTrackedPath, + Action changed) + { + Directory.CreateDirectory(directory); + return new DebouncedFileWatcher(directory, filter, includeSubdirectories, isTrackedPath, changed); + } + + public void Dispose() + { + _watcher.Dispose(); + + lock (_sync) + { + _debounce?.Cancel(); + _debounce?.Dispose(); + _debounce = null; + } + } + + private void HandleChanged(object sender, FileSystemEventArgs args) + { + if (!IsTracked(args.FullPath) + && (args is not RenamedEventArgs renamed || !IsTracked(renamed.OldFullPath))) + { + return; + } + + Schedule(); + } + + private bool IsTracked(string path) => + !string.IsNullOrWhiteSpace(path) && _isTrackedPath(path); + + private void Schedule() + { + CancellationTokenSource debounce; + lock (_sync) + { + _debounce?.Cancel(); + _debounce?.Dispose(); + _debounce = new CancellationTokenSource(); + debounce = _debounce; + } + + _ = Task.Run(async () => + { + try + { + await Task.Delay(Delay, debounce.Token); + _changed(); + } + catch (OperationCanceledException) + { + } + }, CancellationToken.None); + } + + private static bool PathsEqual(string left, string right) => + string.Equals(Path.GetFullPath(left), right, StringComparison.OrdinalIgnoreCase); +} diff --git a/Quasar/Services/DedicatedServerCatalog.cs b/Quasar/Services/DedicatedServerCatalog.cs index 73c7ccdc..168c8437 100644 --- a/Quasar/Services/DedicatedServerCatalog.cs +++ b/Quasar/Services/DedicatedServerCatalog.cs @@ -21,8 +21,7 @@ public sealed class DedicatedServerCatalog : IDisposable private readonly ILogger _logger; private List _servers; private string _snapshot; - private FileSystemWatcher? _watcher; - private CancellationTokenSource? _reloadDebounce; + private DebouncedFileWatcher? _watcher; public DedicatedServerCatalog(ILogger logger) { @@ -37,8 +36,6 @@ public DedicatedServerCatalog(ILogger logger) public void Dispose() { _watcher?.Dispose(); - _reloadDebounce?.Cancel(); - _reloadDebounce?.Dispose(); } public IReadOnlyList GetServers() @@ -473,28 +470,12 @@ private static bool IsPathWithinRoot(string fullPath, string fullRoot) private void StartWatching() { var directory = MagnetarPaths.GetQuasarServersDirectory(); - Directory.CreateDirectory(directory); - - _watcher = new FileSystemWatcher(directory) - { - IncludeSubdirectories = true, - NotifyFilter = NotifyFilters.FileName | NotifyFilters.LastWrite | NotifyFilters.CreationTime | NotifyFilters.Size, - Filter = "*.json", - }; - - _watcher.Changed += HandleWatchedFileChanged; - _watcher.Created += HandleWatchedFileChanged; - _watcher.Deleted += HandleWatchedFileChanged; - _watcher.Renamed += HandleWatchedFileChanged; - _watcher.EnableRaisingEvents = true; - } - - private void HandleWatchedFileChanged(object sender, FileSystemEventArgs args) - { - if (!IsTrackedServerPath(args.FullPath)) - return; - - ScheduleReload(); + _watcher = DebouncedFileWatcher.WatchDirectory( + directory, + "*.json", + includeSubdirectories: true, + IsTrackedServerPath, + ReloadFromDisk); } private static bool IsTrackedServerPath(string path) @@ -505,30 +486,6 @@ private static bool IsTrackedServerPath(string path) return string.Equals(Path.GetFileName(path), "server.json", StringComparison.OrdinalIgnoreCase); } - private void ScheduleReload() - { - CancellationTokenSource debounce; - lock (_sync) - { - _reloadDebounce?.Cancel(); - _reloadDebounce?.Dispose(); - _reloadDebounce = new CancellationTokenSource(); - debounce = _reloadDebounce; - } - - _ = Task.Run(async () => - { - try - { - await Task.Delay(TimeSpan.FromMilliseconds(250), debounce.Token); - ReloadFromDisk(); - } - catch (OperationCanceledException) - { - } - }, CancellationToken.None); - } - private void ReloadFromDisk() { List reloaded; diff --git a/Quasar/Services/Discord/DeathMessagesCatalog.cs b/Quasar/Services/Discord/DeathMessagesCatalog.cs index f6ac9920..bd0ae941 100644 --- a/Quasar/Services/Discord/DeathMessagesCatalog.cs +++ b/Quasar/Services/Discord/DeathMessagesCatalog.cs @@ -1,6 +1,7 @@ using System.Text.Json; using System.Text.Json.Serialization; using Magnetar.Protocol.Runtime; +using Quasar.Services; namespace Quasar.Services.Discord; @@ -16,8 +17,7 @@ public sealed class DeathMessagesCatalog : IDisposable private readonly ILogger _logger; private DeathMessagesConfig _config; private string _snapshot; - private FileSystemWatcher? _watcher; - private CancellationTokenSource? _reloadDebounce; + private DebouncedFileWatcher? _watcher; public DeathMessagesCatalog(ILogger logger) { @@ -32,8 +32,6 @@ public DeathMessagesCatalog(ILogger logger) public void Dispose() { _watcher?.Dispose(); - _reloadDebounce?.Cancel(); - _reloadDebounce?.Dispose(); } public DeathMessagesConfig GetConfig() @@ -93,68 +91,7 @@ private DeathMessagesConfig LoadOrCreateConfig() private void StartWatching() { - var path = MagnetarPaths.GetQuasarDeathMessagesPath(); - var directory = Path.GetDirectoryName(path); - if (string.IsNullOrWhiteSpace(directory)) - return; - - Directory.CreateDirectory(directory); - - _watcher = new FileSystemWatcher(directory) - { - IncludeSubdirectories = false, - NotifyFilter = NotifyFilters.FileName | NotifyFilters.LastWrite | NotifyFilters.CreationTime | NotifyFilters.Size, - Filter = Path.GetFileName(path), - }; - - _watcher.Changed += HandleWatchedFileChanged; - _watcher.Created += HandleWatchedFileChanged; - _watcher.Deleted += HandleWatchedFileChanged; - _watcher.Renamed += HandleWatchedFileChanged; - _watcher.EnableRaisingEvents = true; - } - - private void HandleWatchedFileChanged(object sender, FileSystemEventArgs args) - { - if (!IsTrackedPath(args.FullPath)) - return; - - ScheduleReload(); - } - - private bool IsTrackedPath(string path) - { - if (string.IsNullOrWhiteSpace(path)) - return false; - - return string.Equals( - Path.GetFullPath(path), - Path.GetFullPath(MagnetarPaths.GetQuasarDeathMessagesPath()), - StringComparison.OrdinalIgnoreCase); - } - - private void ScheduleReload() - { - CancellationTokenSource debounce; - lock (_sync) - { - _reloadDebounce?.Cancel(); - _reloadDebounce?.Dispose(); - _reloadDebounce = new CancellationTokenSource(); - debounce = _reloadDebounce; - } - - _ = Task.Run(async () => - { - try - { - await Task.Delay(TimeSpan.FromMilliseconds(250), debounce.Token); - ReloadFromDisk(); - } - catch (OperationCanceledException) - { - } - }, CancellationToken.None); + _watcher = DebouncedFileWatcher.WatchFile(MagnetarPaths.GetQuasarDeathMessagesPath(), ReloadFromDisk); } private void ReloadFromDisk() diff --git a/Quasar/Services/Discord/DiscordOptionsCatalog.cs b/Quasar/Services/Discord/DiscordOptionsCatalog.cs index 63c7efeb..147c235e 100644 --- a/Quasar/Services/Discord/DiscordOptionsCatalog.cs +++ b/Quasar/Services/Discord/DiscordOptionsCatalog.cs @@ -1,6 +1,7 @@ using System.Text.Json; using System.Text.Json.Serialization; using Magnetar.Protocol.Runtime; +using Quasar.Services; namespace Quasar.Services.Discord; @@ -16,8 +17,7 @@ public sealed class DiscordOptionsCatalog : IDisposable private readonly ILogger _logger; private DiscordOptions _options; private string _snapshot; - private FileSystemWatcher? _watcher; - private CancellationTokenSource? _reloadDebounce; + private DebouncedFileWatcher? _watcher; public DiscordOptionsCatalog(ILogger logger) { @@ -32,8 +32,6 @@ public DiscordOptionsCatalog(ILogger logger) public void Dispose() { _watcher?.Dispose(); - _reloadDebounce?.Cancel(); - _reloadDebounce?.Dispose(); } public DiscordOptions GetOptions() @@ -84,68 +82,7 @@ private DiscordOptions LoadOptions() private void StartWatching() { - var path = MagnetarPaths.GetQuasarDiscordOptionsPath(); - var directory = Path.GetDirectoryName(path); - if (string.IsNullOrWhiteSpace(directory)) - return; - - Directory.CreateDirectory(directory); - - _watcher = new FileSystemWatcher(directory) - { - IncludeSubdirectories = false, - NotifyFilter = NotifyFilters.FileName | NotifyFilters.LastWrite | NotifyFilters.CreationTime | NotifyFilters.Size, - Filter = Path.GetFileName(path), - }; - - _watcher.Changed += HandleWatchedFileChanged; - _watcher.Created += HandleWatchedFileChanged; - _watcher.Deleted += HandleWatchedFileChanged; - _watcher.Renamed += HandleWatchedFileChanged; - _watcher.EnableRaisingEvents = true; - } - - private void HandleWatchedFileChanged(object sender, FileSystemEventArgs args) - { - if (!IsTrackedPath(args.FullPath)) - return; - - ScheduleReload(); - } - - private bool IsTrackedPath(string path) - { - if (string.IsNullOrWhiteSpace(path)) - return false; - - return string.Equals( - Path.GetFullPath(path), - Path.GetFullPath(MagnetarPaths.GetQuasarDiscordOptionsPath()), - StringComparison.OrdinalIgnoreCase); - } - - private void ScheduleReload() - { - CancellationTokenSource debounce; - lock (_sync) - { - _reloadDebounce?.Cancel(); - _reloadDebounce?.Dispose(); - _reloadDebounce = new CancellationTokenSource(); - debounce = _reloadDebounce; - } - - _ = Task.Run(async () => - { - try - { - await Task.Delay(TimeSpan.FromMilliseconds(250), debounce.Token); - ReloadFromDisk(); - } - catch (OperationCanceledException) - { - } - }, CancellationToken.None); + _watcher = DebouncedFileWatcher.WatchFile(MagnetarPaths.GetQuasarDiscordOptionsPath(), ReloadFromDisk); } private void ReloadFromDisk() diff --git a/Quasar/Services/GitHubUpdateCredentialsCatalog.cs b/Quasar/Services/GitHubUpdateCredentialsCatalog.cs index c4b2ecfd..9b373244 100644 --- a/Quasar/Services/GitHubUpdateCredentialsCatalog.cs +++ b/Quasar/Services/GitHubUpdateCredentialsCatalog.cs @@ -23,8 +23,7 @@ public sealed class GitHubUpdateCredentialsCatalog : IDisposable private readonly IDataProtector _protector; private GitHubUpdateCredentials _credentials; private string _snapshot; - private FileSystemWatcher? _watcher; - private CancellationTokenSource? _reloadDebounce; + private DebouncedFileWatcher? _watcher; public GitHubUpdateCredentialsCatalog( ILogger logger, @@ -85,8 +84,6 @@ public Task ClearAsync(CancellationToken cancellationToken = default) => public void Dispose() { _watcher?.Dispose(); - _reloadDebounce?.Cancel(); - _reloadDebounce?.Dispose(); } private GitHubUpdateCredentials LoadCredentials() @@ -115,60 +112,7 @@ private GitHubUpdateCredentials LoadCredentials() private void StartWatching() { - var path = MagnetarPaths.GetQuasarGitHubUpdateCredentialsPath(); - var directory = Path.GetDirectoryName(path); - if (string.IsNullOrWhiteSpace(directory)) - return; - - Directory.CreateDirectory(directory); - - _watcher = new FileSystemWatcher(directory) - { - IncludeSubdirectories = false, - NotifyFilter = NotifyFilters.FileName | NotifyFilters.LastWrite | NotifyFilters.CreationTime | NotifyFilters.Size, - Filter = Path.GetFileName(path), - }; - - _watcher.Changed += HandleWatchedFileChanged; - _watcher.Created += HandleWatchedFileChanged; - _watcher.Deleted += HandleWatchedFileChanged; - _watcher.Renamed += HandleWatchedFileChanged; - _watcher.EnableRaisingEvents = true; - } - - private void HandleWatchedFileChanged(object sender, FileSystemEventArgs args) - { - if (!string.Equals( - Path.GetFullPath(args.FullPath), - Path.GetFullPath(MagnetarPaths.GetQuasarGitHubUpdateCredentialsPath()), - StringComparison.OrdinalIgnoreCase)) - return; - - ScheduleReload(); - } - - private void ScheduleReload() - { - CancellationTokenSource debounce; - lock (_sync) - { - _reloadDebounce?.Cancel(); - _reloadDebounce?.Dispose(); - _reloadDebounce = new CancellationTokenSource(); - debounce = _reloadDebounce; - } - - _ = Task.Run(async () => - { - try - { - await Task.Delay(TimeSpan.FromMilliseconds(250), debounce.Token); - ReloadFromDisk(); - } - catch (OperationCanceledException) - { - } - }, CancellationToken.None); + _watcher = DebouncedFileWatcher.WatchFile(MagnetarPaths.GetQuasarGitHubUpdateCredentialsPath(), ReloadFromDisk); } private void ReloadFromDisk() diff --git a/Quasar/Services/ManagedDedicatedServerRuntimeResolver.cs b/Quasar/Services/ManagedDedicatedServerRuntimeResolver.cs index 387c3664..c6e23c15 100644 --- a/Quasar/Services/ManagedDedicatedServerRuntimeResolver.cs +++ b/Quasar/Services/ManagedDedicatedServerRuntimeResolver.cs @@ -1624,26 +1624,17 @@ private static void ExtractArchive(string archivePath, string destinationRoot) ExtractZipArchive(archivePath, destinationRoot); return; case ArchiveKind.TarGz: - ExtractReaderArchive(archivePath, destinationRoot); + ExtractSharpCompressArchive(archivePath, destinationRoot); return; case ArchiveKind.SevenZip: - ExtractSevenZipArchive(archivePath, destinationRoot); + ExtractSharpCompressArchive(archivePath, destinationRoot); return; default: throw new InvalidOperationException($"Unsupported Magnetar archive format: {archivePath}"); } } - private static void ExtractReaderArchive(string archivePath, string destinationRoot) - { - using var archive = ArchiveFactory.OpenArchive(archivePath, new ReaderOptions()); - foreach (var entry in archive.Entries) - { - ExtractSharpCompressEntry(entry, destinationRoot); - } - } - - private static void ExtractSevenZipArchive(string archivePath, string destinationRoot) + private static void ExtractSharpCompressArchive(string archivePath, string destinationRoot) { using var archive = ArchiveFactory.OpenArchive(archivePath, new ReaderOptions()); foreach (var entry in archive.Entries) diff --git a/Quasar/Services/QuasarConfigMetadata.cs b/Quasar/Services/QuasarConfigMetadata.cs index 0df5d3dc..63bbaf48 100644 --- a/Quasar/Services/QuasarConfigMetadata.cs +++ b/Quasar/Services/QuasarConfigMetadata.cs @@ -59,6 +59,8 @@ public sealed class QuasarConfigOptionDefinition public string SearchAliases { get; init; } = string.Empty; + public bool Hidden { get; init; } + private string SearchBlob => string.Join( ' ', @@ -119,7 +121,7 @@ public static class QuasarConfigMetadata RootDecimal("WatcherSimulationSpeedMinimum", "WatcherSimulationSpeedMinimum", "automation", "Min Simulation Speed", 90, min: 0, max: 1.1, step: 0.01), RootInt("ManualActionDelay", "ManualActionDelay", "automation", "Manual Action Delay (min)", 100, min: 0), RootText("ManualActionChatMessage", "ManualActionChatMessage", "automation", "Manual Action Chat Message", 110, helperText: "Use {0} for minute countdown."), - RootBool("AutodetectDependencies", "AutodetectDependencies", "automation", "Autodetect Dependencies", 120), + RootBool("AutodetectDependencies", "AutodetectDependencies", "automation", "Autodetect Dependencies", 120, hidden: true), RootBool("SaveChatToLog", "SaveChatToLog", "moderation", "Save Chat To Log", 10), RootSelectText("NetworkType", "NetworkType", "moderation", "Network Type", 20, [new(nameof(QuasarNetworkType.Steam), "Steam"), new(nameof(QuasarNetworkType.EOS), "EOS")], helperText: "Controls Steam or EOS networking and mod source resolution.", searchAliases: "steam eos epic mods workshop"), @@ -368,7 +370,7 @@ private static string FormatSelectInteger(QuasarConfigOptionDefinition option, o return intValue.ToString(CultureInfo.InvariantCulture); } - private static QuasarConfigOptionDefinition RootBool(string propertyName, string elementName, string categoryKey, string label, int order, string helperText = "", string searchAliases = "") => + private static QuasarConfigOptionDefinition RootBool(string propertyName, string elementName, string categoryKey, string label, int order, string helperText = "", string searchAliases = "", bool hidden = false) => new() { Scope = QuasarConfigOptionScope.Root, @@ -380,6 +382,7 @@ private static QuasarConfigOptionDefinition RootBool(string propertyName, string Order = order, HelperText = helperText, SearchAliases = searchAliases, + Hidden = hidden, }; private static QuasarConfigOptionDefinition RootInt(string propertyName, string elementName, string categoryKey, string label, int order, double? min = null, double? max = null, double? step = 1, string helperText = "", string searchAliases = "") => diff --git a/Quasar/Services/QuasarConfigProfileCatalog.cs b/Quasar/Services/QuasarConfigProfileCatalog.cs index 21a78a44..80b848e7 100644 --- a/Quasar/Services/QuasarConfigProfileCatalog.cs +++ b/Quasar/Services/QuasarConfigProfileCatalog.cs @@ -17,8 +17,7 @@ public sealed class QuasarConfigProfileCatalog : IDisposable private readonly ILogger _logger; private List _profiles; private string _snapshot; - private FileSystemWatcher? _watcher; - private CancellationTokenSource? _reloadDebounce; + private DebouncedFileWatcher? _watcher; public QuasarConfigProfileCatalog(ILogger logger) { @@ -33,8 +32,6 @@ public QuasarConfigProfileCatalog(ILogger logger) public void Dispose() { _watcher?.Dispose(); - _reloadDebounce?.Cancel(); - _reloadDebounce?.Dispose(); } public IReadOnlyList GetProfiles() @@ -336,28 +333,12 @@ private static QuasarConfigProfile Clone(QuasarConfigProfile profile) private void StartWatching() { var directory = GetProfilesDirectory(); - Directory.CreateDirectory(directory); - - _watcher = new FileSystemWatcher(directory) - { - IncludeSubdirectories = true, - NotifyFilter = NotifyFilters.FileName | NotifyFilters.LastWrite | NotifyFilters.CreationTime | NotifyFilters.Size, - Filter = "*.json", - }; - - _watcher.Changed += HandleWatchedFileChanged; - _watcher.Created += HandleWatchedFileChanged; - _watcher.Deleted += HandleWatchedFileChanged; - _watcher.Renamed += HandleWatchedFileChanged; - _watcher.EnableRaisingEvents = true; - } - - private void HandleWatchedFileChanged(object sender, FileSystemEventArgs args) - { - if (!IsTrackedProfilePath(args.FullPath)) - return; - - ScheduleReload(); + _watcher = DebouncedFileWatcher.WatchDirectory( + directory, + "*.json", + includeSubdirectories: true, + IsTrackedProfilePath, + ReloadFromDisk); } private static bool IsTrackedProfilePath(string path) @@ -368,30 +349,6 @@ private static bool IsTrackedProfilePath(string path) return string.Equals(Path.GetFileName(path), "profile.json", StringComparison.OrdinalIgnoreCase); } - private void ScheduleReload() - { - CancellationTokenSource debounce; - lock (_sync) - { - _reloadDebounce?.Cancel(); - _reloadDebounce?.Dispose(); - _reloadDebounce = new CancellationTokenSource(); - debounce = _reloadDebounce; - } - - _ = Task.Run(async () => - { - try - { - await Task.Delay(TimeSpan.FromMilliseconds(250), debounce.Token); - ReloadFromDisk(); - } - catch (OperationCanceledException) - { - } - }, CancellationToken.None); - } - private void ReloadFromDisk() { List reloaded; diff --git a/Quasar/Services/QuasarWorkshopModResolver.cs b/Quasar/Services/QuasarWorkshopModResolver.cs index ee9454c1..be9ad161 100644 --- a/Quasar/Services/QuasarWorkshopModResolver.cs +++ b/Quasar/Services/QuasarWorkshopModResolver.cs @@ -274,7 +274,7 @@ private async Task>> GetCollectionChildrenAsync( CancellationToken cancellationToken) { var results = new Dictionary>(); - foreach (var batch in Batch(workshopIds)) + foreach (var batch in workshopIds.Chunk(BatchSize)) { var content = BuildIndexedFormContent("collectioncount", batch, "publishedfileids"); var payload = await PostAsync( @@ -306,7 +306,7 @@ private async Task> GetPublishedFileD CancellationToken cancellationToken) { var results = new Dictionary(); - foreach (var batch in Batch(workshopIds)) + foreach (var batch in workshopIds.Chunk(BatchSize)) { var content = BuildIndexedFormContent("itemcount", batch, "publishedfileids"); var payload = await PostAsync( @@ -330,7 +330,7 @@ private async Task> GetPublishedFileD CancellationToken cancellationToken) { var results = new Dictionary(); - foreach (var batch in Batch(workshopIds)) + foreach (var batch in workshopIds.Chunk(BatchSize)) { var query = new Dictionary { @@ -342,7 +342,7 @@ private async Task> GetPublishedFileD ["return_details"] = "true", }; - for (var index = 0; index < batch.Count; index++) + for (var index = 0; index < batch.Length; index++) query[$"publishedfileids[{index}]"] = batch[index].ToString(CultureInfo.InvariantCulture); var url = QueryHelpers.AddQueryString( @@ -922,15 +922,6 @@ private static List ExpandCandidateIds( return expanded; } - private static IReadOnlyList> Batch(IReadOnlyList source) - { - var batches = new List>(); - for (var index = 0; index < source.Count; index += BatchSize) - batches.Add(source.Skip(index).Take(BatchSize).ToList()); - - return batches; - } - private static List> BuildIndexedFormContent( string countKey, IReadOnlyList values, diff --git a/Quasar/Services/QuasarWorldTemplateCatalog.cs b/Quasar/Services/QuasarWorldTemplateCatalog.cs index 7f810735..f56de5a9 100644 --- a/Quasar/Services/QuasarWorldTemplateCatalog.cs +++ b/Quasar/Services/QuasarWorldTemplateCatalog.cs @@ -17,8 +17,7 @@ public sealed class QuasarWorldTemplateCatalog : IDisposable private readonly ILogger _logger; private List _templates; private string _snapshot; - private FileSystemWatcher? _watcher; - private CancellationTokenSource? _reloadDebounce; + private DebouncedFileWatcher? _watcher; public QuasarWorldTemplateCatalog(ILogger logger) { @@ -33,8 +32,6 @@ public QuasarWorldTemplateCatalog(ILogger logger) public void Dispose() { _watcher?.Dispose(); - _reloadDebounce?.Cancel(); - _reloadDebounce?.Dispose(); } public IReadOnlyList GetTemplates() @@ -280,46 +277,12 @@ private static QuasarWorldTemplate Clone(QuasarWorldTemplate template) => private void StartWatching() { var directory = MagnetarPaths.GetQuasarWorldTemplatesDirectory(); - Directory.CreateDirectory(directory); - - _watcher = new FileSystemWatcher(directory) - { - IncludeSubdirectories = true, - NotifyFilter = NotifyFilters.FileName | NotifyFilters.LastWrite | NotifyFilters.CreationTime | NotifyFilters.Size, - Filter = "template.json", - }; - - _watcher.Changed += HandleWatchedFileChanged; - _watcher.Created += HandleWatchedFileChanged; - _watcher.Deleted += HandleWatchedFileChanged; - _watcher.Renamed += HandleWatchedFileChanged; - _watcher.EnableRaisingEvents = true; - } - - private void HandleWatchedFileChanged(object sender, FileSystemEventArgs args) => ScheduleReload(); - - private void ScheduleReload() - { - CancellationTokenSource debounce; - lock (_sync) - { - _reloadDebounce?.Cancel(); - _reloadDebounce?.Dispose(); - _reloadDebounce = new CancellationTokenSource(); - debounce = _reloadDebounce; - } - - _ = Task.Run(async () => - { - try - { - await Task.Delay(TimeSpan.FromMilliseconds(250), debounce.Token); - ReloadFromDisk(); - } - catch (OperationCanceledException) - { - } - }, CancellationToken.None); + _watcher = DebouncedFileWatcher.WatchDirectory( + directory, + "template.json", + includeSubdirectories: true, + path => true, + ReloadFromDisk); } private void ReloadFromDisk() diff --git a/Quasar/Services/SteamWorkshopCredentialsCatalog.cs b/Quasar/Services/SteamWorkshopCredentialsCatalog.cs index c4da915e..3fc38e8f 100644 --- a/Quasar/Services/SteamWorkshopCredentialsCatalog.cs +++ b/Quasar/Services/SteamWorkshopCredentialsCatalog.cs @@ -23,8 +23,7 @@ public sealed class SteamWorkshopCredentialsCatalog : IDisposable private readonly IDataProtector _protector; private SteamWorkshopCredentials _credentials; private string _snapshot; - private FileSystemWatcher? _watcher; - private CancellationTokenSource? _reloadDebounce; + private DebouncedFileWatcher? _watcher; public SteamWorkshopCredentialsCatalog( ILogger logger, @@ -82,8 +81,6 @@ public async Task SaveAsync(SteamWorkshopCredentials credentials, CancellationTo public void Dispose() { _watcher?.Dispose(); - _reloadDebounce?.Cancel(); - _reloadDebounce?.Dispose(); } private SteamWorkshopCredentials LoadCredentials() @@ -112,60 +109,7 @@ private SteamWorkshopCredentials LoadCredentials() private void StartWatching() { - var path = MagnetarPaths.GetQuasarWorkshopOptionsPath(); - var directory = Path.GetDirectoryName(path); - if (string.IsNullOrWhiteSpace(directory)) - return; - - Directory.CreateDirectory(directory); - - _watcher = new FileSystemWatcher(directory) - { - IncludeSubdirectories = false, - NotifyFilter = NotifyFilters.FileName | NotifyFilters.LastWrite | NotifyFilters.CreationTime | NotifyFilters.Size, - Filter = Path.GetFileName(path), - }; - - _watcher.Changed += HandleWatchedFileChanged; - _watcher.Created += HandleWatchedFileChanged; - _watcher.Deleted += HandleWatchedFileChanged; - _watcher.Renamed += HandleWatchedFileChanged; - _watcher.EnableRaisingEvents = true; - } - - private void HandleWatchedFileChanged(object sender, FileSystemEventArgs args) - { - if (!string.Equals( - Path.GetFullPath(args.FullPath), - Path.GetFullPath(MagnetarPaths.GetQuasarWorkshopOptionsPath()), - StringComparison.OrdinalIgnoreCase)) - return; - - ScheduleReload(); - } - - private void ScheduleReload() - { - CancellationTokenSource debounce; - lock (_sync) - { - _reloadDebounce?.Cancel(); - _reloadDebounce?.Dispose(); - _reloadDebounce = new CancellationTokenSource(); - debounce = _reloadDebounce; - } - - _ = Task.Run(async () => - { - try - { - await Task.Delay(TimeSpan.FromMilliseconds(250), debounce.Token); - ReloadFromDisk(); - } - catch (OperationCanceledException) - { - } - }, CancellationToken.None); + _watcher = DebouncedFileWatcher.WatchFile(MagnetarPaths.GetQuasarWorkshopOptionsPath(), ReloadFromDisk); } private void ReloadFromDisk() diff --git a/Quasar/Services/ThemePreferenceService.cs b/Quasar/Services/ThemePreferenceService.cs index 0601b040..797a3ee2 100644 --- a/Quasar/Services/ThemePreferenceService.cs +++ b/Quasar/Services/ThemePreferenceService.cs @@ -8,14 +8,14 @@ public enum ThemeMode { System, Light, Dark } public sealed class ThemePreferenceService { private const string StorageKey = "quasar.theme.mode"; - private readonly ILocalStorageService _localStorage; + private readonly BrowserStorageService _storage; private readonly BrandingService _brandingService; private readonly IJSRuntime _js; private bool _initialized; - public ThemePreferenceService(ILocalStorageService localStorage, BrandingService brandingService, IJSRuntime js) + public ThemePreferenceService(BrowserStorageService storage, BrandingService brandingService, IJSRuntime js) { - _localStorage = localStorage; + _storage = storage; _brandingService = brandingService; _js = js; } @@ -34,12 +34,12 @@ public ThemePreferenceService(ILocalStorageService localStorage, BrandingService var previousDarkMode = IsDarkMode; try { - var stored = await _localStorage.GetItemAsync(StorageKey); + var stored = await _storage.GetStringAsync(StorageKey); if (string.IsNullOrEmpty(stored)) { var systemDark = await GetSystemDarkModeAsync(); - await _localStorage.SetItemAsync(StorageKey, "system"); + await _storage.SetStringAsync(StorageKey, "system"); Mode = ThemeMode.System; IsDarkMode = systemDark; } @@ -108,7 +108,7 @@ public async Task SetModeAsync(ThemeMode mode) ThemeMode.Dark => "dark", _ => "system", }; - await _localStorage.SetItemAsync(StorageKey, value); + await _storage.SetStringAsync(StorageKey, value); } catch (InvalidOperationException) { diff --git a/Quasar/Services/WebServiceOptions.cs b/Quasar/Services/WebServiceOptions.cs index 7714f125..c34f72fb 100644 --- a/Quasar/Services/WebServiceOptions.cs +++ b/Quasar/Services/WebServiceOptions.cs @@ -268,8 +268,7 @@ public sealed class DataHandlingConsentCatalog : IDisposable private readonly ILogger _logger; private DataHandlingConsentSettings _settings; private string _snapshot; - private FileSystemWatcher? _watcher; - private CancellationTokenSource? _reloadDebounce; + private DebouncedFileWatcher? _watcher; public DataHandlingConsentCatalog(ILogger logger) { @@ -286,8 +285,6 @@ public DataHandlingConsentCatalog(ILogger logger) public void Dispose() { _watcher?.Dispose(); - _reloadDebounce?.Cancel(); - _reloadDebounce?.Dispose(); _saveGate.Dispose(); } @@ -354,68 +351,7 @@ private DataHandlingConsentSettings LoadSettings() private void StartWatching() { - var path = SettingsPath; - var directory = Path.GetDirectoryName(path); - if (string.IsNullOrWhiteSpace(directory)) - return; - - Directory.CreateDirectory(directory); - - _watcher = new FileSystemWatcher(directory) - { - IncludeSubdirectories = false, - NotifyFilter = NotifyFilters.FileName | NotifyFilters.LastWrite | NotifyFilters.CreationTime | NotifyFilters.Size, - Filter = Path.GetFileName(path), - }; - - _watcher.Changed += HandleWatchedFileChanged; - _watcher.Created += HandleWatchedFileChanged; - _watcher.Deleted += HandleWatchedFileChanged; - _watcher.Renamed += HandleWatchedFileChanged; - _watcher.EnableRaisingEvents = true; - } - - private void HandleWatchedFileChanged(object sender, FileSystemEventArgs args) - { - if (!IsTrackedPath(args.FullPath)) - return; - - ScheduleReload(); - } - - private bool IsTrackedPath(string path) - { - if (string.IsNullOrWhiteSpace(path)) - return false; - - return string.Equals( - Path.GetFullPath(path), - Path.GetFullPath(SettingsPath), - StringComparison.OrdinalIgnoreCase); - } - - private void ScheduleReload() - { - CancellationTokenSource debounce; - lock (_sync) - { - _reloadDebounce?.Cancel(); - _reloadDebounce?.Dispose(); - _reloadDebounce = new CancellationTokenSource(); - debounce = _reloadDebounce; - } - - _ = Task.Run(async () => - { - try - { - await Task.Delay(TimeSpan.FromMilliseconds(250), debounce.Token); - ReloadFromDisk(); - } - catch (OperationCanceledException) - { - } - }, CancellationToken.None); + _watcher = DebouncedFileWatcher.WatchFile(SettingsPath, ReloadFromDisk); } private void ReloadFromDisk() diff --git a/Quasar/Services/WorldSandboxConfigEditor.cs b/Quasar/Services/WorldSandboxConfigEditor.cs index 0bd5539e..5479ac7a 100644 --- a/Quasar/Services/WorldSandboxConfigEditor.cs +++ b/Quasar/Services/WorldSandboxConfigEditor.cs @@ -63,6 +63,9 @@ private static IReadOnlyList ReadMods(XElement root) { WorkshopId = workshopId, DisplayName = friendlyName, + IsDependency = bool.TryParse( + ElementIgnoreCase(item, "IsDependency")?.Value, + out var isDependency) && isDependency, }); } @@ -367,7 +370,8 @@ private static void ApplyMods(XElement root, IReadOnlyList m new XAttribute("FriendlyName", friendlyName), new XElement("Name", $"{idString}.sbm"), new XElement("PublishedFileId", idString), - new XElement("PublishedServiceName", "Steam"))); + new XElement("PublishedServiceName", "Steam"), + new XElement("IsDependency", mod.IsDependency ? "true" : "false"))); } } diff --git a/Quasar/Services/WorldTemplateImportLocationService.cs b/Quasar/Services/WorldTemplateImportLocationService.cs index 45c76d94..8c91d721 100644 --- a/Quasar/Services/WorldTemplateImportLocationService.cs +++ b/Quasar/Services/WorldTemplateImportLocationService.cs @@ -23,12 +23,12 @@ private static readonly (string Label, string RelativePath)[] DedicatedServerCon ]; private readonly ManagedRuntimeOptions _options; - private readonly ILocalStorageService _localStorage; + private readonly BrowserStorageService _storage; - public WorldTemplateImportLocationService(ManagedRuntimeOptions options, ILocalStorageService localStorage) + public WorldTemplateImportLocationService(ManagedRuntimeOptions options, BrowserStorageService storage) { _options = options; - _localStorage = localStorage; + _storage = storage; } public IReadOnlyList GetContentShortcuts() @@ -120,7 +120,7 @@ public async Task RememberAsync(string path) { var resolved = FileBrowserService.ResolvePath(path); if (Directory.Exists(resolved)) - await _localStorage.SetItemAsync(StorageKey, resolved); + await _storage.SetStringAsync(StorageKey, resolved); } catch (InvalidOperationException) { @@ -134,7 +134,7 @@ private async Task TryGetStoredPathAsync() { try { - return await _localStorage.GetItemAsync(StorageKey) ?? string.Empty; + return await _storage.GetStringAsync(StorageKey) ?? string.Empty; } catch (InvalidOperationException) { diff --git a/Quasar/wwwroot/quasar-configs.js b/Quasar/wwwroot/quasar-configs.js index f0c6d90b..fe3f0ea1 100644 --- a/Quasar/wwwroot/quasar-configs.js +++ b/Quasar/wwwroot/quasar-configs.js @@ -2,6 +2,26 @@ window.quasarConfigs = window.quasarConfigs || { getSystemDarkMode() { return !!(window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches); }, + getLocalStorage(key) { + try { + return window.localStorage ? window.localStorage.getItem(key) : null; + } catch { + return null; + } + }, + setLocalStorage(key, value) { + try { + if (!window.localStorage) { + return; + } + if (value === null || value === undefined) { + window.localStorage.removeItem(key); + } else { + window.localStorage.setItem(key, String(value)); + } + } catch { + } + }, getViewportWidth() { return Math.max(320, Math.floor(window.innerWidth || document.documentElement.clientWidth || document.body.clientWidth || 1280)); }, diff --git a/admin-entity-list-plan.md b/admin-entity-list-plan.md deleted file mode 100644 index 6cd1ea07..00000000 --- a/admin-entity-list-plan.md +++ /dev/null @@ -1,205 +0,0 @@ -# Plan: Admin Entity List Management System - -> **Research note:** Implementation details for SE types should be verified -> using the `se-dev-game-book` and `se-dev-server-book` skills before coding. -> Deep investigation was intentionally deferred — this is a high-level design -> document. Use `se-dev-game-code` / `se-dev-server-code` skills to read live -> decompiled source for any load-bearing SE API calls. - ---- - -## Context - -Space Engineers Dedicated Server tracks all active world entities through -`MyEntities` (static manager class). Admins currently have no visibility into -the live entity list from the Quasar web UI. This feature adds a real-time -entity browser: list, filter, inspect, and delete entities per server, with -full admin controls. - -**Out of scope (later stage):** 3D render preview / world-space visualization -of entity positions. The entity viewer / minimap render is a follow-on feature. -Note this in the roadmap — the entity data model should include `WorldAABB` / -`WorldMatrix` from the start so the renderer can consume it without a schema -change later. - ---- - -## SE Entity Model (high level) - -All live entities descend from `MyEntity`. The types relevant to admin -management: - -| Type | Description | Key fields | -|---|---|---| -| `MyCubeGrid` | Ship / station grid | `EntityId`, `DisplayName`, `BlocksCount`, PCU, `IsStatic`, `GridSizeEnum` (Large/Small), owner/built-by steam IDs, `WorldAABB` | -| `MyCharacter` | Player character / corpse | `EntityId`, `DisplayName`, `ControllerInfo.Controller.Player`, position | -| `MyFloatingObject` | Loose items in space | `EntityId`, item definition, position | -| `MyVoxelBase` | Asteroids / planets | `EntityId`, `StorageName`, size | - -Querying is done via `MyEntities.GetEntities()` which returns a -`HashSet`. **Must be called on the game thread** — use -`MySandboxGame.Static.Invoke()` or `MyAPIGateway.Utilities` marshal helpers. -Deletions also require the game thread. - -> **Verify:** Exact API surface for thread-safe entity enumeration and removal. -> Use `se-dev-game-book` → MyCubeGrid, MyEntities, MyCharacter entries. -> Cross-check Torch plugin patterns via `se-dev-torch` skill. - ---- - -## Architecture - -``` -[SE Server + Magnetar Agent] - ├── EntityQueryHandler ← new handler in Quasar.Agent - └── EntityDeleteHandler ← new handler in Quasar.Agent - - ↕ WebSocket (existing Magnetar protocol) - -[Quasar Web] - ├── Protocol messages ← new request/response types in Magnetar.Protocol - ├── EntityService ← singleton, per-server entity state cache - └── Entities.razor ← new page at /servers/{id}/entities -``` - -The existing WebSocket agent pipeline already handles request/response -message dispatch — new handlers follow the same pattern as health/player -management handlers already in `Quasar.Agent`. - ---- - -## Components to Build - -### 1. Protocol Messages — `Magnetar.Protocol` - -New message types (follow existing pattern): - -- `EntityListRequest` — filter params: type filter (All / Grids / Characters / - Floats / Voxels), search string, page/limit -- `EntityListResponse` — list of `EntitySummary` DTOs -- `EntityDeleteRequest` — `EntityId` (long), optional confirmation token -- `EntityDeleteResponse` — success/error - -**`EntitySummary` DTO fields:** -``` -EntityId long -DisplayName string -TypeTag string // "Grid" | "Character" | "Float" | "Voxel" -SubType string // e.g. "LargeStatic" | "LargeShip" | "SmallShip" -BlockCount int? // grids only -Pcu int? // grids only -OwnerSteamId ulong? // grids only -Position Vector3D // world position (centre of AABB) -WorldAabb BoundingBoxD // for future renderer use -LastSeen DateTime -``` - -### 2. Agent Handlers — `Quasar.Agent` - -**`EntityQueryHandler`** -- Marshals onto game thread via `MySandboxGame.Static.Invoke` -- Calls `MyEntities.GetEntities()`, filters by type, maps to `EntitySummary` -- Returns paginated `EntityListResponse` - -**`EntityDeleteHandler`** -- Validates entity ID exists -- Calls `MyEntities.RemoveEntity()` on game thread -- Returns success/error response -- Logs admin action with steamId of requester - -> **Verify thread marshalling pattern** against existing agent handlers -> (health/player handlers) and against `se-dev-server-code` for safe -> `MyEntities.RemoveEntity` usage. - -### 3. Quasar Service — `EntityService.cs` - -Singleton service in `Quasar` project. - -- `GetEntitiesAsync(workerId, filter)` — sends `EntityListRequest` over - WebSocket, awaits response -- `DeleteEntityAsync(workerId, entityId)` — sends `EntityDeleteRequest` -- Optional: short TTL cache (5–10 s) to avoid hammering the agent on rapid - UI refreshes -- Exposes `event Action? Changed` if push-based updates are added later - (not required for v1 — poll on page load + manual refresh) - -### 4. UI Page — `Entities.razor` - -Route: `@page "/servers/{WorkerId}/entities"` - -Layout (consistent with existing Quasar pages): - -``` -PageTitle: "Entities — {serverName}" -MudText h4: "Entities" -MudText body2: "Live entity list for this server." - -Toolbar row: - MudSelect: Type filter (All / Grids / Characters / Floating / Voxels) - MudTextField: Search (name / ID) - MudButton: Refresh - MudText: "Last updated: {timestamp}" - -MudDataGrid: - Columns: Type | Name | Sub-type | Blocks | PCU | Owner | Position | Actions - Actions column: MudIconButton Delete (with confirmation dialog) - Sortable: Name, BlockCount, PCU - Virtual scrolling or pagination (server-side page if > 500 entities) - -Empty state: "No entities found" / "Server offline" -``` - -No render/minimap preview in this page. Position is shown as text coordinates -only. The `WorldAabb` field is stored in the DTO for future use by the -renderer. - -**Delete flow:** -1. Click delete icon → `MudMessageBox` confirmation ("Delete {name}?") -2. Call `EntityService.DeleteEntityAsync` -3. Snackbar success/error -4. Refresh entity list - -### 5. NavMenu Entry - -Add "Entities" link under each server context, or as a top-level nav item -under the existing "Servers" section. Exact placement TBD based on nav -information architecture. - ---- - -## Future: 3D Render Preview - -> **Deferred — do not implement in this phase.** - -A future stage will add a 3D or 2D minimap render of entity positions using -`WorldAabb` / `WorldMatrix` data already captured in `EntitySummary`. Options: - -- Three.js / Babylon.js canvas panel embedded in the Entities page -- Top-down 2D SVG grid showing grid positions by size and type -- Click-to-focus entity from render view - -The `EntitySummary` DTO is designed to support this without schema changes. - ---- - -## Implementation Order - -1. Protocol messages (`EntitySummary`, request/response types) -2. Agent handlers (query + delete, game-thread marshalling) -3. `EntityService` in Quasar (WebSocket send/receive wiring) -4. `Entities.razor` UI page -5. NavMenu integration -6. Manual test: connect to live server, list entities, delete a test grid - ---- - -## Key References for Implementation - -| Resource | Purpose | -|---|---| -| `se-dev-game-book` skill | `MyEntities`, `MyCubeGrid`, `MyCharacter` type docs | -| `se-dev-server-book` skill | Server-side entity lifecycle, game thread rules | -| `se-dev-torch` skill | Thread marshalling patterns from Torch plugin examples | -| `se-dev-server-code` skill | Read live decompiled source for exact API verification | -| Existing `PlayerManagement` handlers | Pattern reference for agent handler structure | -| `Magnetar.Protocol` existing messages | Pattern for new request/response types | diff --git a/whitelabel-theme-configurator.md b/whitelabel-theme-configurator.md deleted file mode 100644 index 6bca9830..00000000 --- a/whitelabel-theme-configurator.md +++ /dev/null @@ -1,246 +0,0 @@ -# Plan: MudBlazor Whitelabel / Theme Configurator - -## Context -Quasar currently has a static theme (`QuasarTheme.cs`) with hardcoded light/dark palettes, and hardcoded branding (app name, subtitle, logos, favicon) scattered across `MainLayout.razor` and `App.razor`. The goal is a persistent, UI-driven theme and branding configurator — custom colors, logos, favicon — with the existing "Quasar Default" still available as a preset. Follows the existing file-based JSON storage pattern (no database). - ---- - -## Architecture - -``` -BrandingService (singleton) - └── loads/saves /branding.json - └── exposes BuildMudTheme() → MudTheme - └── fires Changed event → MainLayout calls StateHasChanged - -ThemePreferenceService (scoped, minimal change) - └── Theme property delegates to BrandingService.BuildMudTheme() - -MainLayout.razor - └── subscribes to BrandingService.Changed for live re-render - └── reads BrandingService.Settings for app name, subtitle, logo paths - -BrandingHeadContent.razor (new, inside MainLayout render tree) - └── with reactive favicon href - -Appearance.razor (new settings page /settings/appearance) - └── preset picker + color pickers + file upload -``` - ---- - -## Files to Create - -### 1. `Quasar/Models/BrandingSettings.cs` -Data contract serialized to `branding.json`. - -```csharp -public sealed class BrandingSettings -{ - public string? PresetId { get; set; } // "quasar" | "midnight" | "slate" | null (custom) - public string AppName { get; set; } = "Quasar"; - public string AppSubtitle { get; set; } = "Supervisor control plane"; - public string? LogoLightPath { get; set; } // e.g. "/branding/logo-light.png?v=..." - public string? LogoDarkPath { get; set; } - public string? FaviconPath { get; set; } // includes cache-bust query string - public ThemePalette LightPalette { get; set; } = ThemePalette.QuasarLight(); - public ThemePalette DarkPalette { get; set; } = ThemePalette.QuasarDark(); - - public BrandingSettings Clone() { ... } - public static BrandingSettings Normalize(BrandingSettings? s) { ... } -} - -public sealed class ThemePalette -{ - // Same fields as QuasarTheme.cs palette: Primary, PrimaryContrastText, - // Secondary, Background, BackgroundGray, Surface, DrawerBackground, - // DrawerText, DrawerIcon, AppbarBackground, AppbarText, TextPrimary, - // TextSecondary, LinesDefault, LinesInputs, TableLines, Divider, - // DividerLight, Info, InfoContrastText, Success, SuccessContrastText, - // Warning, WarningContrastText, Error, ErrorContrastText - // All string hex values. - - public static ThemePalette QuasarLight() { /* copy from QuasarTheme.Default.PaletteLight */ } - public static ThemePalette QuasarDark() { /* copy from QuasarTheme.Default.PaletteDark */ } - public ThemePalette Clone() { ... } - public PaletteLight ToMudPaletteLight() { /* maps fields to new PaletteLight { ... } */ } - public PaletteDark ToMudPaletteDark() { /* maps fields to new PaletteDark { ... } */ } -} -``` - -`Normalize` fills null fields from `QuasarLight()` / `QuasarDark()` defaults. - -### 2. `Quasar/Services/BrandingPresets.cs` -```csharp -public sealed record BrandingPresetDefinition(string Id, string DisplayName); - -public static class BrandingPresets -{ - public static IReadOnlyList All { get; } = [ ... ]; - - // "quasar": reads directly from QuasarTheme.Default — single source of truth - // "midnight": Primary #1e3a5f/#93c5fd, Background #f0f4ff/#0f1729 - // "slate": Primary #475569/#cbd5e1, Background #f8fafc/#1e293b - public static ThemePalette GetLightPalette(string presetId) { ... } - public static ThemePalette GetDarkPalette(string presetId) { ... } -} -``` - -### 3. `Quasar/Services/BrandingService.cs` -Singleton. Mirrors the `DiscordOptionsCatalog` file-watch pattern. - -- Constructor: `ILogger`, `IWebHostEnvironment` -- Stores data at `MagnetarPaths.GetQuasarBrandingPath()` -- Uploads to `{WebRootPath}/branding/` (created on first use) -- Key members: - - `BrandingSettings Settings { get; }` — live reference (lock-protected) - - `MudTheme BuildMudTheme()` — builds from current settings, copies `LayoutProperties` from `QuasarTheme.Default` - - `event Action? Changed` - - `Task SaveAsync(BrandingSettings settings)` - - `Task SaveLogoAsync(bool isDark, Stream data, string ext, CancellationToken ct)` — writes file, appends `?v={timestamp}` to path in settings, saves - - `Task SaveFaviconAsync(Stream data, string ext, CancellationToken ct)` — same pattern - - `Task ResetToDefaultAsync(CancellationToken ct)` — saves `Normalize(null)` (preset "quasar") -- FileSystemWatcher debounced reload on `branding.json` changes - -### 4. `Quasar/Components/Layout/BrandingHeadContent.razor` -Reactive favicon — placed inside `MainLayout.razor` render tree so it participates in Blazor Server's interactive render. - -```razor -@inject BrandingService BrandingService -@implements IDisposable - - - - - -@code { - protected override void OnInitialized() => BrandingService.Changed += OnChanged; - public void Dispose() => BrandingService.Changed -= OnChanged; - void OnChanged() => _ = InvokeAsync(StateHasChanged); -} -``` - -The `?v=...` timestamp in `FaviconPath` busts browser favicon cache without a full page reload. - -### 5. `Quasar/Components/Pages/Appearance.razor` -Route: `@page "/settings/appearance"` - -Layout: -``` -MudText h4 "Appearance" - -MudGrid - [col 4] Branding card - MudTextField: App Name - MudTextField: Subtitle - - [col 8] Preset Theme card - MudSelect: preset → applies palette to draft immediately - - [col 12] Colors card - MudTabs: Light / Dark - [each tab] MudGrid of MudColorPicker bound to draft palette fields - Group: Identity (Primary, Secondary) - Group: Surfaces (Background, BackgroundGray, Surface) - Group: Navigation (Drawer*, Appbar*) - Group: Text (TextPrimary, TextSecondary) - Group: Lines (LinesDefault, LinesInputs, TableLines, Divider, DividerLight) - Group: Status (Info, Success, Warning, Error + contrast texts) - - [col 6] Logo card - img preview (light logo) - MudFileUpload accept=".png,.jpg,.jpeg,.webp,.svg": light logo - img preview (dark logo) - MudFileUpload: dark logo - - [col 6] Favicon card - img preview - MudFileUpload accept=".ico,.png": favicon - - [col 12] Action row - MudButton Filled Primary: "Save Changes" - MudButton Text: "Reset to Quasar Default" -``` - -Draft state: `_draft = BrandingService.GetSettings()` clone on init. Preset selection copies palettes into draft. Color picker changes null out `_draft.PresetId`. Save calls `BrandingService.SaveAsync(_draft)`. File uploads call the relevant `Save*Async` directly (no draft needed — immediate effect). - ---- - -## Files to Modify - -### 6. `Magnetar.Protocol/Runtime/MagnetarPaths.cs` -Add two methods after `GetQuasarDiscordOptionsPath()`: -```csharp -public static string GetQuasarBrandingPath() => - Path.Combine(GetQuasarDirectory(), "branding.json"); - -public static string GetQuasarBrandingDirectory() => - Path.Combine(GetQuasarDirectory(), "Branding"); -``` - -### 7. `Quasar/Services/ThemePreferenceService.cs` -- Add `BrandingService _brandingService` field, inject via constructor -- Change `Theme` property: `public MudTheme Theme => _brandingService.BuildMudTheme();` -- No event subscription needed — `MainLayout` drives re-render via its own subscription - -### 8. `Quasar/Components/Layout/MainLayout.razor` -- Add `@inject BrandingService BrandingService` and `@implements IDisposable` -- Add `` inside the layout markup -- Replace hardcoded "Quasar" → `@BrandingService.Settings.AppName` -- Replace hardcoded "Supervisor control plane" → `@BrandingService.Settings.AppSubtitle` -- Replace logo `src` → `@(_isDarkMode ? (BrandingService.Settings.LogoDarkPath ?? "/Quasar.png") : (BrandingService.Settings.LogoLightPath ?? "/Quasar-inverted-transparent.png"))` -- Add in `@code`: subscribe `BrandingService.Changed += OnBrandingChanged` in `OnInitialized`, `Dispose()` unsubscribes, `OnBrandingChanged` calls `InvokeAsync(StateHasChanged)` - -### 9. `Quasar/Components/App.razor` -Remove the two hardcoded `` lines (lines 12-13). The favicon is now rendered dynamically by `BrandingHeadContent` via `HeadOutlet`. Optionally keep one as a static fallback for pre-JS render. - -### 10. `Quasar/Components/Layout/NavMenu.razor` -Add a "Settings" section at the bottom of the ``: -```razor - - - Settings - - - - Appearance - - -``` - -### 11. `Quasar/Program.cs` -Add before `ThemePreferenceService` registration: -```csharp -builder.Services.AddSingleton(); -``` -`IWebHostEnvironment` is already in DI — no extra registration. - ---- - -## Key Design Decisions - -| Decision | Rationale | -|---|---| -| File-based JSON (`branding.json`) | Matches all existing storage (no DB in project) | -| `ThemePalette` maps exact `QuasarTheme.cs` fields | No duplication; `QuasarLight()` factory reads from `QuasarTheme.Default` | -| `BrandingService` singleton, `ThemePreferenceService` scoped | Follows existing registration pattern; no event lifetime risk since `ThemePreferenceService` doesn't subscribe | -| Logos/favicon in `wwwroot/branding/` | Served by static file middleware; runtime writes work with `MapStaticAssets()` | -| `?v={timestamp}` in favicon URL | Cache-busting without page reload; stored in `FaviconPath` field | -| `BrandingHeadContent.razor` in MainLayout render tree | Only way to make `HeadOutlet` reactive in Blazor Server | -| Draft pattern in Appearance.razor | User can edit freely, save atomically | - ---- - -## Verification - -1. **Build**: `dotnet build Quasar/Quasar.csproj` — no errors -2. **Default state**: Start app, verify existing Quasar branding unchanged (no `branding.json` = defaults) -3. **Preset switch**: Navigate to `/settings/appearance`, select "Midnight Blue", Save → all palette colors update live across all open tabs -4. **Custom color**: Change Primary color, Save → AppBar and buttons update without page reload -5. **Logo upload**: Upload a PNG → logo in AppBar updates immediately -6. **Favicon upload**: Upload a `.ico` → browser tab favicon updates (may need one tab refresh for aggressive browser caching) -7. **Branding text**: Change App Name → AppBar title updates live -8. **Reset**: Click "Reset to Quasar Default" → all branding reverts -9. **Persistence**: Restart app → custom theme/branding persists from `branding.json` -10. **Preset accuracy**: Verify "Quasar Default" palette exactly matches original `QuasarTheme.Default` colors