From 502360dc0af39f73e378f903858ea5b2b1a28c73 Mon Sep 17 00:00:00 2001 From: Owen de Bree Date: Sun, 21 Jun 2026 00:21:08 +0200 Subject: [PATCH] Fix predefined world select UX --- Docs/LinuxDeploymentAndUpdates.md | 9 +- Docs/QuasarArchitecture.md | 9 +- Docs/Reference/data/manifest.json | 16 +-- .../files/Quasar.Bootstrap/Program.cs.md | 10 +- .../WorldTemplateQuickImportDialog.razor.md | 4 +- .../Components/Pages/WorldTemplates.razor.md | 4 +- .../Updates/QuasarUpdateService.cs.md | 4 +- .../WorldTemplateImportLocationService.cs.md | 4 +- .../Reference/files/Quasar/wwwroot/app.css.md | 2 +- Docs/StateMachines/SelfUpdateAndRelease.md | 2 +- Docs/WindowsDeploymentAndUpdates.md | 5 +- Quasar.Bootstrap/Program.cs | 109 ++++++++++++++++-- .../WorldTemplateQuickImportDialog.razor | 7 +- Quasar/Components/Pages/WorldTemplates.razor | 7 +- .../WorldTemplateImportLocationService.cs | 47 +++++++- 15 files changed, 184 insertions(+), 55 deletions(-) diff --git a/Docs/LinuxDeploymentAndUpdates.md b/Docs/LinuxDeploymentAndUpdates.md index 7820b6a..aa0c06f 100644 --- a/Docs/LinuxDeploymentAndUpdates.md +++ b/Docs/LinuxDeploymentAndUpdates.md @@ -141,10 +141,11 @@ self-update loop when a source-built launcher reports stale version metadata. If `/settings/updates` has already detected a Bootstrap update and Quasar is running under Bootstrap, the **Force activate** button writes a -`Updates/bootstrap-update-request.json` request. Bootstrap watches for that file, -consumes it, and runs the same verified self-update path immediately instead of -waiting for the next 15-minute monitor tick. Managed Magnetar servers stay -running; the web UI reconnects after the launcher restarts. +`Updates/bootstrap-update-request.json` request containing the detected +version and platform asset. Bootstrap watches for that file, consumes it, and +runs the same verified self-update path for that requested release immediately +instead of waiting for the next 15-minute monitor tick. Managed Magnetar servers +stay running; the web UI reconnects after the launcher restarts. ## Install diff --git a/Docs/QuasarArchitecture.md b/Docs/QuasarArchitecture.md index 30ef592..182c7d1 100644 --- a/Docs/QuasarArchitecture.md +++ b/Docs/QuasarArchitecture.md @@ -485,8 +485,8 @@ Linux-first cutover ownership: - Bootstrap self-update drains only when the primary release asset is actually newer than the running launcher's normalized release identity - `/settings/updates` can also write `Updates/bootstrap-update-request.json` - to ask Bootstrap to run the self-update path immediately when a launcher - update is already detected + with the detected version and asset to ask Bootstrap to run the self-update + path for that requested launcher release immediately This implies a two-layer deployment: @@ -536,8 +536,9 @@ Practical guarantee: Bootstrap updates normally activate from Bootstrap's own update monitor. When the Updates page has detected a newer launcher asset and the worker is running under Bootstrap, an admin can force activation from the UI. The worker writes a -request file under `Updates/`; Bootstrap consumes it with a watcher and runs the -same checksum-verified self-update path immediately. +request file under `Updates/` containing the detected version and asset; +Bootstrap consumes it with a watcher and runs the same checksum-verified +self-update path for that requested release immediately. ### Future proxy update flow diff --git a/Docs/Reference/data/manifest.json b/Docs/Reference/data/manifest.json index cc456ef..661e69b 100644 --- a/Docs/Reference/data/manifest.json +++ b/Docs/Reference/data/manifest.json @@ -424,8 +424,8 @@ "path": "Quasar.Bootstrap/Program.cs", "name": "Program.cs", "ext": ".cs", - "size": 83171, - "sha256": "9ee4a08e03e5d1e05be1b80d34122d198a83ea4de9d4f8834bdcdad2e4ba8afc", + "size": 86406, + "sha256": "e489e9690e86fd4dc2fb06b2bfb6acf77281f8b499d54d50e6137b2dbea27d87", "module": "Quasar.Bootstrap", "tier": 1, "status": "pending" @@ -944,8 +944,8 @@ "path": "Quasar/Components/Pages/WorldTemplateQuickImportDialog.razor", "name": "WorldTemplateQuickImportDialog.razor", "ext": ".razor", - "size": 22441, - "sha256": "b64cbfc5295ae8afe92a398c97ff7633295ea4e42419ee19f1c11b2fb7903aa8", + "size": 22497, + "sha256": "0a136a75e52ad78fbbd607c04ba6c8838f9fac17a1d489bbfadeb066492e49d1", "module": "Quasar.Components", "tier": 2, "status": "pending" @@ -954,8 +954,8 @@ "path": "Quasar/Components/Pages/WorldTemplates.razor", "name": "WorldTemplates.razor", "ext": ".razor", - "size": 16948, - "sha256": "3ff78df70ba81029e4ac0c0cdaa06567921c26a3632d6113b7ebbf4e47c4096d", + "size": 17004, + "sha256": "4308323d98f7cbffd85c1d3c1b586f9d0a29ad09a6cc8e6b7dc716a598eb14c6", "module": "Quasar.Components", "tier": 2, "status": "pending" @@ -2014,8 +2014,8 @@ "path": "Quasar/Services/WorldTemplateImportLocationService.cs", "name": "WorldTemplateImportLocationService.cs", "ext": ".cs", - "size": 8228, - "sha256": "d64db611f8d7b2bc464dd223163bff39405aa84fb61a02a16d119e16447d5e96", + "size": 10034, + "sha256": "1a89ae1b139e4f18e772f3559dec8191469447c91a3d8d5a4a04d63857acd013", "module": "Quasar.Services.Core", "tier": 1, "status": "pending" diff --git a/Docs/Reference/files/Quasar.Bootstrap/Program.cs.md b/Docs/Reference/files/Quasar.Bootstrap/Program.cs.md index 377eb4e..4d8538e 100644 --- a/Docs/Reference/files/Quasar.Bootstrap/Program.cs.md +++ b/Docs/Reference/files/Quasar.Bootstrap/Program.cs.md @@ -33,8 +33,8 @@ Entry point and core logic for the Quasar launcher. It implements three CLI comm | `StartAsync` | Creates dirs, downloads an initial UI worker into `ManagedRuntime/WebService/` when no packaged/active worker exists, ensures an active-release pointer exists, activates it, starts `FileSystemWatcher`s on the active pointer and Bootstrap update request file. | | `StopAsync` | Sets `_isStopping`, stops the pointer watcher, Bootstrap update request watcher, and launcher update monitor, drains/retires the current worker, stopping managed servers only when `!PreserveServersOnShutdown`. | | `StartBootstrapUpdateMonitor` / `RunBootstrapUpdateMonitorAsync` | Starts the self-upgrade loop on Linux and Windows when updates are enabled; checks after 30 s and then every configured update interval. | -| `StartWatchingBootstrapUpdateRequests` / `QueueBootstrapUpdateRequest` | Watches `Updates/bootstrap-update-request.json`, debounces file events, deletes the request, logs the admin-triggered activation, and calls the same Bootstrap self-upgrade path immediately. | -| `TryUpgradeBootstrapAsync` / `ResolveBootstrapPayloadDirectory` / `ApplyBootstrapUpdate` | Serializes self-upgrade attempts, finds an actually newer non-draft release containing the platform `BootstrapAssetName`, verifies `SHA256SUMS`, extracts it, accepts either a flat launcher archive or one single top-level installer directory, skips drain/restart if the downloaded launcher is byte-identical to the installed launcher, preserves existing `appsettings.json`, replaces launcher files, drains the UI worker without stopping managed servers, then restarts: on Linux exits with code 75 so systemd restarts the updated launcher; on Windows spawns a detached `Quasar.exe serve --quiet` and exits 0 (Scheduled Task restart-on-failure is the safety net). | +| `StartWatchingBootstrapUpdateRequests` / `QueueBootstrapUpdateRequest` | Watches `Updates/bootstrap-update-request.json`, debounces file events, reads the requested Bootstrap version/asset, deletes the request, logs the admin-triggered activation, and calls the Bootstrap self-upgrade path for that requested release immediately. | +| `TryUpgradeBootstrapAsync` / `ResolveBootstrapPayloadDirectory` / `ApplyBootstrapUpdate` | Serializes self-upgrade attempts, finds either the latest allowed non-draft release containing the platform `BootstrapAssetName` (periodic monitor) or the exact version/asset from a worker request (force activation), verifies `SHA256SUMS`, extracts it, accepts either a flat launcher archive or one single top-level installer directory, skips drain/restart if the downloaded launcher is byte-identical to the installed launcher, preserves existing `appsettings.json`, replaces launcher files, drains the UI worker without stopping managed servers, then restarts: on Linux exits with code 75 so systemd restarts the updated launcher; on Windows spawns a detached `Quasar.exe serve --quiet` and exits 0 (Scheduled Task restart-on-failure is the safety net). | | `IsReleasePointerUsable` / `IsKnownReleasePath` | Validates active-release pointers. In service mode, Bootstrap rejects stale pointers to arbitrary external build directories and only trusts packaged `WebService/`, managed web releases, staged legacy updates, or explicit environment-configured worker paths. | | `ActivateCurrentReleaseAsync` | Under `_activationLock`: drains the current worker without stopping managed servers, starts the new worker, waits for `/api/health` (60 s), swaps it in, then prunes inactive managed web-release directories. | | `StartWorkerAsync` | Copies install-directory `appsettings.json` into the worker directory, launches the worker with env vars (`QUASAR_MODE=service`, `QUASAR_LAUNCHER_TOKEN`, `QUASAR_BOOTSTRAP_VERSION`, `QUASAR_INSTALL_DIR`, `QUASAR_PRESERVE_SERVERS_ON_SHUTDOWN`, foreground console-logging), and pumps stdout/stderr in foreground. | @@ -42,7 +42,7 @@ Entry point and core logic for the Quasar launcher. It implements three CLI comm | `DrainAndRetireWorkerAsync` | POSTs `/api/internal/drain?delaySeconds=&stopServers=` with `X-Quasar-Launcher-Token`, waits for exit, force-kills on timeout. | | `HandleWorkerExited` | On unexpected worker exit (not stopping), restarts via `ActivateCurrentReleaseAsync(force: true)`. | | `TryConsumeLauncherShutdownRequest` | Detects and deletes the worker-written `launcher-shutdown-request` file, allowing UI-driven Quasar shutdown to exit Bootstrap without restarting the worker. | -| `TryConsumeBootstrapUpdateRequest` | Detects and deletes the worker-written Bootstrap update request file before running immediate self-upgrade. | +| `TryConsumeBootstrapUpdateRequest` | Detects, deserializes, and deletes the worker-written Bootstrap update request file before running immediate self-upgrade for the requested release. | | `HandleReleasePointerChanged` | Debounces pointer file changes 250 ms then re-activates. | | `TryMigrateStagedActiveRelease` | Migrates legacy active pointers that still target `Updates/Staged/` into `ManagedRuntime/WebService/` before launch. | | `TryBuildInitialReleasePointer` | Resolves worker by priority: `QUASAR_WEB_EXE`/`MAGNETAR_WEB_EXE` → `QUASAR_WEB_DLL`/`MAGNETAR_WEB_DLL` → packaged `WebService/Quasar(.exe)` → ancestor-walk for `Quasar.dll`/`Quasar.exe`. | @@ -56,5 +56,5 @@ Entry point and core logic for the Quasar launcher. It implements three CLI comm ## Notes - The `Quasar.Bootstrap` named mutex serializes spawn attempts across processes on a machine. - `IsCurrentBootstrapAssembly` / `IsCurrentBootstrapExecutable` prevent pointing the worker at the bootstrap itself; RID-targeted DLL paths are rejected when no sibling `runtimeconfig.json` exists (avoids libhostpolicy failures from the `obj/` tree). -- `PreserveServersOnShutdown` and `QUASAR_INSTALL_DIR` are propagated to the worker so the launcher and worker agree on shutdown policy and the update service can sync resolved appsettings back to the stable install directory. A worker-written `launcher-shutdown-request` file lets Bootstrap exit cleanly for full Quasar shutdown while preserving servers; a worker-written `Updates/bootstrap-update-request.json` file lets the Updates page ask Bootstrap to run launcher self-update immediately. -- Initial UI worker download scans GitHub releases for the newest non-draft release containing the configured UI asset (`WebAssetName`, OS-selected), extracts it into `ManagedRuntime/WebService/`, and validates the extracted web layout before activation; launcher self-upgrade scans the primary Quasar release stream for the platform `BootstrapAssetName` and compares against the normalized release identity, not raw `AssemblyVersion`. Launcher self-upgrade strips the single `quasar-installer-*` archive directory when present, while still accepting older flat launcher archives. If version metadata is stale but the installed launcher already matches the downloaded update byte-for-byte, Bootstrap logs and skips the worker drain/restart instead of repeating the same self-update every check. Both periodic and request-file-triggered Bootstrap updates share a semaphore and the same verified install path. Both scans honor `Quasar:Updates:IncludePrerelease`, including the data-directory override written by the Updates page after Bootstrap restarts. Service-mode active-release pointer validation prevents a previously written local `bin/Debug` worker path from overriding the installed packaged worker. +- `PreserveServersOnShutdown` and `QUASAR_INSTALL_DIR` are propagated to the worker so the launcher and worker agree on shutdown policy and the update service can sync resolved appsettings back to the stable install directory. A worker-written `launcher-shutdown-request` file lets Bootstrap exit cleanly for full Quasar shutdown while preserving servers; a worker-written `Updates/bootstrap-update-request.json` file identifies the detected target version/asset and lets the Updates page ask Bootstrap to run launcher self-update immediately. +- Initial UI worker download scans GitHub releases for the newest non-draft release containing the configured UI asset (`WebAssetName`, OS-selected), extracts it into `ManagedRuntime/WebService/`, and validates the extracted web layout before activation; periodic launcher self-upgrade scans the primary Quasar release stream for the platform `BootstrapAssetName` and compares against the normalized release identity, not raw `AssemblyVersion`. Request-file-triggered Bootstrap updates resolve the exact version/asset requested by the worker, so force activation can apply a candidate the running launcher has not selected from its own periodic stream yet. Launcher self-upgrade strips the single `quasar-installer-*` archive directory when present, while still accepting older flat launcher archives. If version metadata is stale but the installed launcher already matches the downloaded update byte-for-byte, Bootstrap logs and skips the worker drain/restart instead of repeating the same self-update every check. Both periodic and request-file-triggered Bootstrap updates share a semaphore and the same verified install path. Periodic scans honor `Quasar:Updates:IncludePrerelease`, including the data-directory override written by the Updates page after Bootstrap restarts. Service-mode active-release pointer validation prevents a previously written local `bin/Debug` worker path from overriding the installed packaged worker. diff --git a/Docs/Reference/files/Quasar/Components/Pages/WorldTemplateQuickImportDialog.razor.md b/Docs/Reference/files/Quasar/Components/Pages/WorldTemplateQuickImportDialog.razor.md index c995fe9..0520264 100644 --- a/Docs/Reference/files/Quasar/Components/Pages/WorldTemplateQuickImportDialog.razor.md +++ b/Docs/Reference/files/Quasar/Components/Pages/WorldTemplateQuickImportDialog.razor.md @@ -17,13 +17,13 @@ MudBlazor dialog for adding a Space Engineers world template without leaving the - **`[Parameter]` `InitialConfigProfileId`** — preselects the currently selected server-editor config profile for the "existing profile" mod path. - **Key UI** - Step 1 `MudTabs` card with separate Predefined Worlds and Custom Import panels; hidden panels are not kept alive. - - Predefined Worlds tab lists discovered installed DS templates with search, Refresh, source/category display, and per-row Add buttons. The table uses `installed-world-template-*` classes so the left Add column remains visible while long source paths truncate within the dialog width. + - Predefined Worlds tab lists discovered installed DS templates with search, Refresh, short relative source/category display, and per-row Add buttons. The table uses `installed-world-template-*` classes so the untitled left Add column remains visible while long source paths truncate within the dialog width. - Custom Import tab contains the original `MudForm` and `@bind-IsValid` details form: required Name, optional multi-line Description, and source world path text field + "Browse" button that opens `FolderPickerDialog`. - Step 2 mod handling view when source mods are found, with radio options for creating a profile, importing into an existing profile, or doing nothing. The info alert explains that Quasar writes the selected profile's session settings and mods into the active world's `Sandbox_config.sbc` on server start. - Mod preview table listing display name and Workshop ID. - Back / Cancel / primary action buttons; the primary Continue button is only shown for Custom Import or the mod-handling step, while Predefined Worlds uses row-level Add buttons. Primary text shows "Importing..." while `_importing` is true. - **`OpenFolderPickerAsync`** — opens `FolderPickerDialog` with the current path, remembered last path, or first existing DS content shortcut; applies and remembers the selected path on non-cancelled result. -- **`ImportPredefinedTemplateAsync`** — copies display name, description, and source path from an `InstalledWorldTemplateSource`, then follows the same mod-detection/import flow as Custom Import. +- **`ImportPredefinedTemplateAsync`** — copies display name, description, and absolute source path from an `InstalledWorldTemplateSource`, then follows the same mod-detection/import flow as Custom Import. - **`ContinueAsync` / `ContinueFromDetailsAsync`** — validates custom details, reads source mods via `WorldSandboxConfigEditor.ReadMods`, advances to mod handling when mods exist, or imports immediately when no mods are present. - **`ImportAsync`** — validates selected mod action, imports the world template, applies the profile action, and closes with `Ok(WorldTemplateQuickImportResult)`. - **`ApplyModActionAsync`** — merges mods into an existing profile or creates a new profile preloaded with mods; the create path opens `ConfigsPageDialog` full-screen on the new profile so it can be edited before returning to the server editor. diff --git a/Docs/Reference/files/Quasar/Components/Pages/WorldTemplates.razor.md b/Docs/Reference/files/Quasar/Components/Pages/WorldTemplates.razor.md index 68110ae..ee8edb4 100644 --- a/Docs/Reference/files/Quasar/Components/Pages/WorldTemplates.razor.md +++ b/Docs/Reference/files/Quasar/Components/Pages/WorldTemplates.razor.md @@ -10,7 +10,7 @@ Routable page at `/world-templates` for managing reusable Space Engineers world - **`[Inject]`:** `QuasarWorldTemplateCatalog WorldTemplateCatalog`, `ISnackbar Snackbar`, `IDialogService DialogService`, `WorldTemplateImportLocationService ImportLocations` - **Key UI** - Left panel (xl:5) — `MudTabs` import card with separate Predefined Worlds and Custom Import panels; panels are not kept alive when hidden. - - Predefined Worlds tab — shows discovered installed Space Engineers templates from DS `Content/CustomWorlds`, `Content/QuickStarts`, and `Content/Scenarios`, with search, Refresh, source/category display, and per-row Add buttons. The table uses `installed-world-template-*` classes so the left action column stays fixed and the source path truncates inside narrow docked containers. + - Predefined Worlds tab — shows discovered installed Space Engineers templates from DS `Content/CustomWorlds`, `Content/QuickStarts`, and `Content/Scenarios`, with search, Refresh, short relative source/category display, and per-row Add buttons. The table uses `installed-world-template-*` classes so the untitled left action column stays fixed and the source path truncates inside narrow docked containers. - Custom Import tab — `MudTextField` controls for name, description, and source path with a "Browse" folder-picker button, plus Import (shows "Importing…" while `_importing`) and Clear buttons. - Right panel (xl:7) — `MudTable` with Clone/Delete actions, Size and Updated metadata, Name, and Description. - **`WorldTemplateRow` (private sealed record)** — `(QuasarWorldTemplate Template, bool WorldExists, long FileSizeMb)`. @@ -35,4 +35,4 @@ Routable page at `/world-templates` for managing reusable Space Engineers world ## Notes - Directory size is recomputed inline on every render by walking all files, which can be slow for large templates. - A missing world directory surfaces as a warning chip and disables Clone, but does not auto-remove the catalog entry. -- The predefined-world source column is intentionally ellipsized so long DS paths cannot push the Add button outside the docked import panel. +- The predefined-world source column displays `SourceDisplayPath` instead of the absolute source path and still ellipsizes so long nested scenario paths cannot push the Add button outside the docked import panel. diff --git a/Docs/Reference/files/Quasar/Services/Updates/QuasarUpdateService.cs.md b/Docs/Reference/files/Quasar/Services/Updates/QuasarUpdateService.cs.md index abd61e4..a1e1ab0 100644 --- a/Docs/Reference/files/Quasar/Services/Updates/QuasarUpdateService.cs.md +++ b/Docs/Reference/files/Quasar/Services/Updates/QuasarUpdateService.cs.md @@ -22,7 +22,7 @@ Namespace: `Quasar.Services.Updates` | `StageWebUpdateAsync(ct)` / `StageWebUpdateAsync(forceAppSettingsOverride, ct)` | Downloads the selected web asset, resolves/verifies its SHA-256 checksum from `SHA256SUMS`, extracts it into `Updates/Staged/`, validates required web layout files, resolves staged `appsettings.json`, and marks it staged. Current-version staging is rejected; older selected releases can be staged for rollback. | | `ReadAppSettingsConflictTextAsync(ct)` / `ResolveAppSettingsConflictAsync(text, ct)` | Supports the Updates page conflict editor by reading the staged conflict file, validating the resolved JSON, persisting it, and marking the release staged once conflict markers are gone. | | `ActivateStagedWebUpdateAsync(ct)` | Copies the staged payload into `ManagedRuntime/WebService/`, syncs the resolved active `appsettings.json` back to the install directory when `QUASAR_INSTALL_DIR` is known, writes `QuasarActiveReleasePointer` to the active-release path so Bootstrap can swap workers, and clears old staged payloads. Staged older UI releases are valid rollback targets. | -| `RequestBootstrapUpdateActivationAsync(ct)` | Validates that a newer Bootstrap candidate exists and the worker is launcher-managed, then writes `Updates/bootstrap-update-request.json` for Bootstrap to consume and publishes an activating status message. | +| `RequestBootstrapUpdateActivationAsync(ct)` | Validates that a newer Bootstrap candidate exists and the worker is launcher-managed, then writes `Updates/bootstrap-update-request.json` with the detected version/asset for Bootstrap to consume and publishes an activating status message. | | `ExecuteAsync(stoppingToken)` | Runs an initial delayed check and repeats every configured interval while enabled. | | `GetReleasesAsync(ct)` / `BuildCandidates(...)` | Calls GitHub releases API (`per_page=100`), ignores drafts, optionally includes prereleases, and maps matching release assets into UI/Bootstrap candidates. | | `PersistUpdateBooleanAsync(...)` / `GetOrCreateObject(...)` | Preserves or creates the data-directory `appsettings.json` object graph and atomically writes update-page boolean settings. | @@ -46,4 +46,4 @@ Private nested DTOs `GitHubRelease` and `GitHubAsset` model the small subset of ## Notes -UI-worker activation stays explicit from the Updates page on both Linux and Windows. Auto-stage mode only downloads/stages the newer selected UI release; manual mode queues releases until the operator stages one. Launcher updates are reported in the UI; normally Bootstrap installs them automatically from the platform asset (`quasar-installer-linux.tar.gz` on Linux, `quasar-installer-windows.zip` on Windows), but a launcher-managed worker can request immediate activation by writing `Updates/bootstrap-update-request.json`. Bootstrap consumes that request and runs its normal verified self-update path, restarting on Linux via systemd exit-75 or on Windows by spawning a detached replacement launcher. Staged UI payloads are rejected before activation when core Blazor/MudBlazor/app static assets are missing or `appsettings.json` has unresolved conflict markers; older staged UI payloads are allowed so the operator can roll back the worker. Active UI releases live outside `Updates/Staged`, so the Updates folder only contains transient staged payloads plus the active pointer, the Bootstrap update request file, and the stored `appsettings.json` merge base. The prerelease switch affects the running worker immediately; Bootstrap reads the persisted data-directory override after its next restart. +UI-worker activation stays explicit from the Updates page on both Linux and Windows. Auto-stage mode only downloads/stages the newer selected UI release; manual mode queues releases until the operator stages one. Launcher updates are reported in the UI; normally Bootstrap installs them automatically from the platform asset (`quasar-installer-linux.tar.gz` on Linux, `quasar-installer-windows.zip` on Windows), but a launcher-managed worker can request immediate activation by writing `Updates/bootstrap-update-request.json` with the detected target version and asset. Bootstrap consumes that request and runs its normal verified self-update path for that requested release, restarting on Linux via systemd exit-75 or on Windows by spawning a detached replacement launcher. Staged UI payloads are rejected before activation when core Blazor/MudBlazor/app static assets are missing or `appsettings.json` has unresolved conflict markers; older staged UI payloads are allowed so the operator can roll back the worker. Active UI releases live outside `Updates/Staged`, so the Updates folder only contains transient staged payloads plus the active pointer, the Bootstrap update request file, and the stored `appsettings.json` merge base. The prerelease switch affects the running worker immediately; forced Bootstrap requests carry the detected candidate, while Bootstrap reads the persisted data-directory override for periodic checks after its next restart. diff --git a/Docs/Reference/files/Quasar/Services/WorldTemplateImportLocationService.cs.md b/Docs/Reference/files/Quasar/Services/WorldTemplateImportLocationService.cs.md index 2caf40f..16b04b4 100644 --- a/Docs/Reference/files/Quasar/Services/WorldTemplateImportLocationService.cs.md +++ b/Docs/Reference/files/Quasar/Services/WorldTemplateImportLocationService.cs.md @@ -11,12 +11,12 @@ Namespace: `Quasar.Services` **`WorldTemplateImportLocationService`** — sealed scoped service. -**`InstalledWorldTemplateSource`** — record returned for optional preset imports: `Category`, `DisplayName`, `SourcePath`, `Description`. +**`InstalledWorldTemplateSource`** — record returned for optional preset imports: `Category`, `DisplayName`, `SourcePath`, `SourceDisplayPath`, `Description`. | Member | Description | |---|---| | `GetContentShortcuts()` | Returns existing DS content shortcut chips, ordered as `Content/CustomWorlds`, `Content/QuickStarts`, `Content/Scenarios`. Roots are resolved from `ManagedRuntimeOptions.DedicatedServerInstallDirectory`, an optional `DedicatedServer64OverridePath` parent, and the managed default `MagnetarPaths.GetQuasarManagedDedicatedServerInstallDirectory()`. | -| `GetInstalledWorldTemplates()` | Scans the same DS content roots for folders containing `Sandbox.sbc`, including recursive scenario worlds. Xbox scenario variants are skipped, names are derived from `SessionName` when useful, and results are deduped by full source path. | +| `GetInstalledWorldTemplates()` | Scans the same DS content roots for folders containing `Sandbox.sbc`, including recursive scenario worlds. Xbox scenario variants are skipped, names are derived from `SessionName` when useful, generic platform folders such as `PC` are ignored when falling back to folder names, short relative source paths are computed for display, and results are deduped by full source path. | | `GetInitialPathAsync(currentPath)` | Uses the current source path when present; otherwise uses the stored last source folder if it still exists; otherwise falls back to the first existing DS content shortcut or an empty string so `FolderPickerDialog` falls back to the user profile. | | `RememberAsync(path)` | Resolves and stores the selected folder in `localStorage` key `quasar.worldTemplates.lastSourceFolder` when it exists. JS interop disconnect/prerender errors are ignored. | diff --git a/Docs/Reference/files/Quasar/wwwroot/app.css.md b/Docs/Reference/files/Quasar/wwwroot/app.css.md index 1b674a4..e364689 100644 --- a/Docs/Reference/files/Quasar/wwwroot/app.css.md +++ b/Docs/Reference/files/Quasar/wwwroot/app.css.md @@ -40,7 +40,7 @@ Global stylesheet for the Quasar Blazor Server UI. Overrides MudBlazor's elevati - `.chat-console-card`, `.chat-server-select`, `.admin-chat-list`, `.admin-chat-row` — full-page chat console sizing, server-select minimum width, and bounded scrollable chat rows for `Chat.razor` - `.players-list-card`, `.players-list-stack`, `.players-table`, and descendant table selectors — force the known-player table stack and MudBlazor table/container to consume full available width - `.world-template-browse-button` — 1rem top margin -- `.installed-world-template-table`, `.installed-world-template-name-cell`, `.installed-world-template-source-cell`, `.installed-world-template-action-cell`, `.installed-world-template-name-text`, `.installed-world-template-source-stack`, `.installed-world-template-source-text` — fixed-layout predefined-world tables where the left Add action stays in a fixed action column and long source/category text ellipsizes instead of forcing horizontal overflow +- `.installed-world-template-table`, `.installed-world-template-name-cell`, `.installed-world-template-source-cell`, `.installed-world-template-action-cell`, `.installed-world-template-name-text`, `.installed-world-template-source-stack`, `.installed-world-template-source-text` — fixed-layout predefined-world tables where the untitled left Add action stays in a fixed action column and long source/category text ellipsizes instead of forcing horizontal overflow - `.branding-logo-preview` (+ `-dark`, `-light`) and `.branding-favicon-preview` — bordered preview containers for logo/favicon images **Responsive adjustments:** diff --git a/Docs/StateMachines/SelfUpdateAndRelease.md b/Docs/StateMachines/SelfUpdateAndRelease.md index 2962917..c9775fc 100644 --- a/Docs/StateMachines/SelfUpdateAndRelease.md +++ b/Docs/StateMachines/SelfUpdateAndRelease.md @@ -85,7 +85,7 @@ stateDiagram-v2 | `Draining` | Pointer change detected; the launcher posts `/api/internal/drain` (authenticated with the per-session launcher token) and waits for graceful exit. | | `Retired` / `ForceKilled` | Old worker exited within the grace window, or was killed after timeout. | | `Restarting` | Worker exited unexpectedly (not a launcher request); relaunched with `force`. | -| `SelfUpgrade` | A newer Bootstrap asset was applied by the periodic monitor or by a consumed `Updates/bootstrap-update-request.json` request from the Updates page: Linux exits **75** so systemd restarts it; Windows spawns a detached `Quasar.exe serve --quiet` replacement and exits **0**. | +| `SelfUpgrade` | A newer Bootstrap asset was applied by the periodic monitor or by a consumed `Updates/bootstrap-update-request.json` request from the Updates page; forced requests target the detected version and platform asset. Linux exits **75** so systemd restarts it; Windows spawns a detached `Quasar.exe serve --quiet` replacement and exits **0**. | The pointer is `Updates/active-release.json` ([`QuasarActiveReleasePointer`](../../Magnetar.Protocol/Runtime/QuasarActiveReleasePointer.cs)), diff --git a/Docs/WindowsDeploymentAndUpdates.md b/Docs/WindowsDeploymentAndUpdates.md index 972da22..fad64de 100644 --- a/Docs/WindowsDeploymentAndUpdates.md +++ b/Docs/WindowsDeploymentAndUpdates.md @@ -117,8 +117,9 @@ stale version metadata. If `/settings/updates` has already detected a Bootstrap update and Quasar is running under Bootstrap, the **Force activate** button writes a -`Updates\bootstrap-update-request.json` request. Bootstrap watches for that file, -consumes it, and runs the same verified self-update path immediately instead of +`Updates\bootstrap-update-request.json` request containing the detected version +and platform asset. Bootstrap watches for that file, consumes it, and runs the +same verified self-update path for that requested release immediately instead of waiting for the next 15-minute monitor tick. Managed Magnetar servers stay running; the web UI reconnects after the launcher restarts. diff --git a/Quasar.Bootstrap/Program.cs b/Quasar.Bootstrap/Program.cs index 2ceb796..f477499 100644 --- a/Quasar.Bootstrap/Program.cs +++ b/Quasar.Bootstrap/Program.cs @@ -925,11 +925,21 @@ private void QueueBootstrapUpdateRequest() try { await Task.Delay(TimeSpan.FromMilliseconds(250), debounce.Token); - if (!TryConsumeBootstrapUpdateRequest()) + if (!TryConsumeBootstrapUpdateRequest(out var request)) return; - _logger.LogInformation("Bootstrap update activation requested by Quasar worker."); - await TryUpgradeBootstrapAsync(CancellationToken.None).ConfigureAwait(false); + if (string.IsNullOrWhiteSpace(request.Version)) + { + _logger.LogInformation("Bootstrap update activation requested by Quasar worker."); + } + else + { + _logger.LogInformation( + "Bootstrap update activation requested by Quasar worker for {Version}.", + request.Version); + } + + await TryUpgradeBootstrapAsync(CancellationToken.None, request).ConfigureAwait(false); } catch (OperationCanceledException) when (debounce.IsCancellationRequested) { @@ -973,7 +983,7 @@ private async Task RunBootstrapUpdateMonitorAsync(CancellationToken cancellation } } - private async Task TryUpgradeBootstrapAsync(CancellationToken cancellationToken) + private async Task TryUpgradeBootstrapAsync(CancellationToken cancellationToken, BootstrapUpdateRequest? request = null) { if (_isStopping || _isRestartingForBootstrapUpdate) return; @@ -984,16 +994,46 @@ private async Task TryUpgradeBootstrapAsync(CancellationToken cancellationToken) if (_isStopping || _isRestartingForBootstrapUpdate) return; - var release = await GetLatestReleaseWithAssetAsync(_options.BootstrapAssetName, cancellationToken).ConfigureAwait(false); + var requestedVersion = QuasarReleaseVersion.Normalize(request?.Version ?? string.Empty); + var assetName = string.IsNullOrWhiteSpace(request?.AssetName) + ? _options.BootstrapAssetName + : request.AssetName.Trim(); + var release = string.IsNullOrWhiteSpace(requestedVersion) + ? await GetLatestReleaseWithAssetAsync(assetName, cancellationToken).ConfigureAwait(false) + : await GetReleaseWithAssetAsync( + assetName, + requestedVersion, + includePrerelease: true, + cancellationToken).ConfigureAwait(false); if (release is null) + { + if (!string.IsNullOrWhiteSpace(requestedVersion)) + { + _logger.LogWarning( + "Requested Bootstrap release {Version} with asset {AssetName} was not found.", + requestedVersion, + assetName); + } + return; + } var version = QuasarReleaseVersion.Normalize(release.TagName); if (!IsNewerVersion(version, _options.Version)) + { + if (!string.IsNullOrWhiteSpace(requestedVersion)) + { + _logger.LogInformation( + "Requested Bootstrap release {Version} is not newer than running Bootstrap {CurrentVersion}.", + version, + _options.Version); + } + return; + } var asset = release.Assets.FirstOrDefault(asset => - string.Equals(asset.Name, _options.BootstrapAssetName, StringComparison.OrdinalIgnoreCase)); + string.Equals(asset.Name, assetName, StringComparison.OrdinalIgnoreCase)); if (asset is null || string.IsNullOrWhiteSpace(asset.BrowserDownloadUrl)) return; @@ -1234,7 +1274,18 @@ private async Task EnsureInitialWebReleaseAvailableAsync(CancellationToken cance } } - private async Task GetLatestReleaseWithAssetAsync(string assetName, CancellationToken cancellationToken) + private Task GetLatestReleaseWithAssetAsync(string assetName, CancellationToken cancellationToken) => + GetReleaseWithAssetAsync( + assetName, + requestedVersion: string.Empty, + includePrerelease: _options.UpdatesIncludePrerelease, + cancellationToken); + + private async Task GetReleaseWithAssetAsync( + string assetName, + string requestedVersion, + bool includePrerelease, + CancellationToken cancellationToken) { var url = $"https://api.github.com/repos/{_options.UpdatesOwner}/{_options.UpdatesRepository}/releases?per_page=100"; using var response = await _downloadClient.GetAsync(url, cancellationToken).ConfigureAwait(false); @@ -1242,11 +1293,24 @@ private async Task EnsureInitialWebReleaseAvailableAsync(CancellationToken cance await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false); var releases = await JsonSerializer.DeserializeAsync>(stream, JsonOptions, cancellationToken).ConfigureAwait(false); - return releases? + var matchingReleases = releases? .Where(release => !release.Draft) - .Where(release => _options.UpdatesIncludePrerelease || !release.Prerelease) - .FirstOrDefault(release => release.Assets.Any(asset => + .Where(release => includePrerelease || !release.Prerelease) + .Where(release => release.Assets.Any(asset => string.Equals(asset.Name, assetName, StringComparison.OrdinalIgnoreCase))); + + if (matchingReleases is null) + return null; + + if (string.IsNullOrWhiteSpace(requestedVersion)) + return matchingReleases.FirstOrDefault(); + + requestedVersion = QuasarReleaseVersion.Normalize(requestedVersion); + return matchingReleases.FirstOrDefault(release => + string.Equals( + QuasarReleaseVersion.Normalize(release.TagName), + requestedVersion, + StringComparison.OrdinalIgnoreCase)); } private async Task> GetChecksumsAsync(GitHubRelease release, CancellationToken cancellationToken) @@ -1737,12 +1801,24 @@ private static bool TryConsumeLauncherShutdownRequest() return true; } - private static bool TryConsumeBootstrapUpdateRequest() + private static bool TryConsumeBootstrapUpdateRequest(out BootstrapUpdateRequest request) { + request = new BootstrapUpdateRequest(); var path = MagnetarPaths.GetQuasarBootstrapUpdateRequestPath(); if (!File.Exists(path)) return false; + try + { + var text = File.ReadAllText(path); + request = JsonSerializer.Deserialize(text, JsonOptions) + ?? new BootstrapUpdateRequest(); + } + catch + { + request = new BootstrapUpdateRequest(); + } + try { File.Delete(path); @@ -2174,6 +2250,17 @@ private sealed class GitHubAsset public string BrowserDownloadUrl { get; set; } = string.Empty; } + private sealed class BootstrapUpdateRequest + { + public string Version { get; set; } = string.Empty; + + public string AssetName { get; set; } = string.Empty; + + public string WorkerVersion { get; set; } = string.Empty; + + public DateTimeOffset? RequestedAtUtc { get; set; } + } + private sealed record WorkerProcessHandle(Process Process, Uri BaseUri, QuasarActiveReleasePointer Release); } diff --git a/Quasar/Components/Pages/WorldTemplateQuickImportDialog.razor b/Quasar/Components/Pages/WorldTemplateQuickImportDialog.razor index 2bc3485..786f25b 100644 --- a/Quasar/Components/Pages/WorldTemplateQuickImportDialog.razor +++ b/Quasar/Components/Pages/WorldTemplateQuickImportDialog.razor @@ -45,12 +45,12 @@ { - Actions + Name Source - + @context.Category - @context.SourcePath + @context.SourceDisplayPath @@ -362,6 +362,7 @@ return Contains(template.DisplayName, search) || Contains(template.Category, search) || + Contains(template.SourceDisplayPath, search) || Contains(template.SourcePath, search); } diff --git a/Quasar/Components/Pages/WorldTemplates.razor b/Quasar/Components/Pages/WorldTemplates.razor index f0c4afe..641c9d6 100644 --- a/Quasar/Components/Pages/WorldTemplates.razor +++ b/Quasar/Components/Pages/WorldTemplates.razor @@ -51,12 +51,12 @@ { - Actions + Name Source - + @context.Category - @context.SourcePath + @context.SourceDisplayPath @@ -358,6 +358,7 @@ return Contains(template.DisplayName, search) || Contains(template.Category, search) || + Contains(template.SourceDisplayPath, search) || Contains(template.SourcePath, search); } diff --git a/Quasar/Services/WorldTemplateImportLocationService.cs b/Quasar/Services/WorldTemplateImportLocationService.cs index 709bf18..45c76d9 100644 --- a/Quasar/Services/WorldTemplateImportLocationService.cs +++ b/Quasar/Services/WorldTemplateImportLocationService.cs @@ -8,6 +8,7 @@ public sealed record InstalledWorldTemplateSource( string Category, string DisplayName, string SourcePath, + string SourceDisplayPath, string Description); public sealed class WorldTemplateImportLocationService @@ -80,11 +81,13 @@ public IReadOnlyList GetInstalledWorldTemplates() continue; var displayName = BuildInstalledTemplateName(label, relative, sandboxPath); + var sourceDisplayPath = BuildInstalledTemplateSourceDisplayPath(relative); templates.Add(new InstalledWorldTemplateSource( Category: label, DisplayName: displayName, SourcePath: fullPath, - Description: $"Installed Space Engineers {label} template from {relative}.")); + SourceDisplayPath: sourceDisplayPath, + Description: $"Installed Space Engineers {label} template from {sourceDisplayPath}.")); } } } @@ -186,11 +189,11 @@ private static bool ShouldSkipInstalledTemplate(string relativePath) private static string BuildInstalledTemplateName(string category, string relativePath, string sandboxPath) { - var segments = relativePath.Split( - [Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar], - StringSplitOptions.RemoveEmptyEntries); - var folderName = segments.LastOrDefault() ?? Path.GetFileName(Path.GetDirectoryName(sandboxPath)) ?? "World"; + var segments = SplitRelativePath(relativePath); + var folderName = GetInstalledTemplateFolderName(segments, sandboxPath); var sessionName = ReadSessionName(sandboxPath); + if (IsGenericInstalledTemplateSegment(sessionName)) + sessionName = string.Empty; if (string.Equals(category, "DS Scenarios", StringComparison.OrdinalIgnoreCase) && segments.Length > 0) { @@ -207,6 +210,40 @@ private static string BuildInstalledTemplateName(string category, string relativ return string.IsNullOrWhiteSpace(sessionName) ? folderName : sessionName; } + private static string BuildInstalledTemplateSourceDisplayPath(string relativePath) + { + var segments = SplitRelativePath(relativePath) + .Where(segment => !IsGenericInstalledTemplateSegment(segment) && + !string.Equals(segment, "Worlds", StringComparison.OrdinalIgnoreCase)) + .ToArray(); + + return segments.Length == 0 + ? relativePath.Replace(Path.DirectorySeparatorChar, '/').Replace(Path.AltDirectorySeparatorChar, '/') + : string.Join("/", segments); + } + + private static string GetInstalledTemplateFolderName(IReadOnlyList segments, string sandboxPath) + { + var meaningfulFolderName = segments + .Where(segment => !IsGenericInstalledTemplateSegment(segment) && + !string.Equals(segment, "Worlds", StringComparison.OrdinalIgnoreCase)) + .LastOrDefault(); + if (!string.IsNullOrWhiteSpace(meaningfulFolderName)) + return meaningfulFolderName; + + return segments.LastOrDefault() ?? Path.GetFileName(Path.GetDirectoryName(sandboxPath)) ?? "World"; + } + + private static string[] SplitRelativePath(string relativePath) => + relativePath.Split( + [Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar], + StringSplitOptions.RemoveEmptyEntries); + + private static bool IsGenericInstalledTemplateSegment(string value) => + string.Equals(value, "PC", StringComparison.OrdinalIgnoreCase) || + string.Equals(value, "XBox", StringComparison.OrdinalIgnoreCase) || + string.Equals(value, "Xbox", StringComparison.OrdinalIgnoreCase); + private static string ReadSessionName(string sandboxPath) { try