diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index 58ea32a..d604205 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -291,6 +291,15 @@ jobs:
DS64: ${{ github.workspace }}/ds64
run: scripts/package-linux-release.sh
+ - name: Verify Linux installer archive root
+ run: |
+ set -euo pipefail
+ roots="$(tar -tzf artifacts/linux/quasar-installer-linux.tar.gz | awk -F/ 'NF { print $1 }' | sort -u)"
+ if [ "$roots" != "Quasar" ]; then
+ echo "ERROR: expected quasar-installer-linux.tar.gz root directory to be Quasar; found: $roots" >&2
+ exit 1
+ fi
+
- name: Upload Linux archives
uses: actions/upload-artifact@v4
with:
@@ -430,6 +439,27 @@ jobs:
DS64: ${{ github.workspace }}\ds64
run: scripts/package-windows-release.ps1
+ - name: Verify Windows installer archive root
+ shell: pwsh
+ run: |
+ $ErrorActionPreference = 'Stop'
+ Add-Type -AssemblyName System.IO.Compression.FileSystem
+ $zip = [System.IO.Compression.ZipFile]::OpenRead((Resolve-Path 'artifacts/windows/quasar-installer-windows.zip').Path)
+ try {
+ $roots = @(
+ $zip.Entries |
+ Where-Object { -not [string]::IsNullOrWhiteSpace($_.FullName) } |
+ ForEach-Object { ($_.FullName -replace '\\', '/').Split('/')[0] } |
+ Sort-Object -Unique
+ )
+ }
+ finally {
+ $zip.Dispose()
+ }
+ if ($roots.Count -ne 1 -or $roots[0] -ne 'Quasar') {
+ throw "expected quasar-installer-windows.zip root directory to be Quasar; found: $($roots -join ', ')"
+ }
+
- name: Upload Windows archives
uses: actions/upload-artifact@v4
with:
@@ -494,7 +524,7 @@ jobs:
| **Linux** (x64) | **`quasar-installer-linux.tar.gz`** |
| **Windows** (x64) | **`quasar-installer-windows.zip`** |
- Download the archive with **installer** in its name. It contains one top-level folder with the Quasar **Bootstrap launcher**, the install/uninstall scripts, and a default `appsettings.json`. Follow the [Linux](https://github.com/CometWorks/quasar/blob/main/Docs/LinuxDeploymentAndUpdates.md) or [Windows](https://github.com/CometWorks/quasar/blob/main/Docs/WindowsDeploymentAndUpdates.md) deployment guide to install it. The web UI is available on http://127.0.0.1:8080 by default (the web server listens on all interfaces at 0.0.0.0:8080).
+ Download the archive with **installer** in its name. It contains one top-level `Quasar` folder with the Quasar **Bootstrap launcher**, the install/uninstall scripts, and a default `appsettings.json`. Follow the [Linux](https://github.com/CometWorks/quasar/blob/main/Docs/LinuxDeploymentAndUpdates.md) or [Windows](https://github.com/CometWorks/quasar/blob/main/Docs/WindowsDeploymentAndUpdates.md) deployment guide to install it. The web UI is available on http://127.0.0.1:8080 by default (the web server listens on all interfaces at 0.0.0.0:8080).
The other two archives (`quasar-web-linux-x64.tar.gz` and `quasar-web-win-x64.zip`) are updates that Quasar downloads and applies automatically — you never download them by hand.
diff --git a/Directory.Build.props b/Directory.Build.props
index cdcdb53..fb03844 100644
--- a/Directory.Build.props
+++ b/Directory.Build.props
@@ -6,7 +6,7 @@
Quasar.Agent and Magnetar.Protocol disable generated version attributes
because SHA-256, not release metadata, is the deploy drift signal for
the in-server DLLs. -->
- 1.0.0
+ 1.0.1
$(Version)
$(Version)
diff --git a/Docs/Configuration.md b/Docs/Configuration.md
index 4999532..ec0b73d 100644
--- a/Docs/Configuration.md
+++ b/Docs/Configuration.md
@@ -60,11 +60,14 @@ worker** read JSON config from these locations, later ones overriding earlier:
preserve this file during Bootstrap self-updates. UI-worker activation updates
it from the staged, resolved `appsettings.json` so Bootstrap and the managed
worker keep the same base settings.
-2. The Quasar **data directory** `appsettings.json` — the recommended place for
- persistent local overrides because it is never touched by updates:
- - Windows: `%APPDATA%\Quasar\appsettings.json`
- - Linux: `~/.config/Quasar/appsettings.json` by default for `install.sh`
- systemd installs (or `$QUASAR_DATA_DIR/appsettings.json`)
+2. The Quasar **data directory** `appsettings.json`. Bootstrap uses the install
+ root as the default data directory, so this is normally the same file as item
+ 1. Set `QUASAR_DATA_DIR` (or `--data-dir
` on Linux installs) to keep
+ persistent local overrides in a separate directory.
+
+When Bootstrap starts without a custom `QUASAR_DATA_DIR`, it migrates legacy
+default data roots (`~/.config/Quasar` on Linux/macOS,
+`%APPDATA%\Quasar` on Windows) into the install root.
The shipped defaults are defined in [`Quasar/appsettings.json`](../Quasar/appsettings.json).
diff --git a/Docs/LinuxDeploymentAndUpdates.md b/Docs/LinuxDeploymentAndUpdates.md
index e84a28b..d9155b0 100644
--- a/Docs/LinuxDeploymentAndUpdates.md
+++ b/Docs/LinuxDeploymentAndUpdates.md
@@ -8,7 +8,7 @@ web UI worker.
`scripts/package-linux-release.sh` produces:
- `quasar-installer-linux.tar.gz`
- - top-level `quasar-installer-linux/` directory
+ - top-level `Quasar/` directory
- `Quasar` Bootstrap launcher
- `install.sh`
- `uninstall.sh`
@@ -63,19 +63,19 @@ metadata is normalized to `major.minor.build`.
## First Start
-The default systemd user service runs Bootstrap from
-`~/.local/share/Quasar/Quasar serve --quiet` and sets `QUASAR_DATA_DIR` to the
-user's Quasar data directory. It also sets `QUASAR_SYSTEMD_SERVICE` and
-`QUASAR_SYSTEMD_SCOPE` so the web UI's **Shutdown Quasar** action can request
-`systemctl --user stop quasar.service` instead of only exiting the launcher.
-A machine-wide service is still available with `install.sh --system`.
+The default systemd user service runs Bootstrap from the extracted install root
+and sets `QUASAR_DATA_DIR` to that same directory. It also sets
+`QUASAR_SYSTEMD_SERVICE` and `QUASAR_SYSTEMD_SCOPE` so the web UI's **Shutdown
+Quasar** action can request `systemctl --user stop quasar.service` instead of
+only exiting the launcher. A machine-wide service is still available with
+`install.sh --system`.
If Bootstrap has no usable `Updates/active-release.json` and no packaged
`WebService/Quasar`, it downloads the latest Linux web asset from GitHub,
extracts it under:
```text
-~/.config/Quasar/ManagedRuntime/WebService/
+/ManagedRuntime/WebService/
```
Then it writes `Updates/active-release.json` pointing at the managed active
@@ -88,6 +88,9 @@ pointer that targets a random external build directory. Only packaged
configured `QUASAR_WEB_EXE` / `QUASAR_WEB_DLL` workers are trusted. If Bootstrap
finds an older active pointer that still targets `Updates/Staged/`, it
migrates that release into `ManagedRuntime/WebService/` before launch.
+On startup, Bootstrap also migrates a legacy default data root at
+`~/.config/Quasar` into the install root unless `QUASAR_DATA_DIR` points to a
+custom directory.
## UI Worker Updates
@@ -103,7 +106,7 @@ a matching `SHA256SUMS` entry for the downloaded asset.
Staging also resolves `appsettings.json`. Quasar uses the stored release base in
the data directory (`$QUASAR_DATA_DIR/Updates/appsettings.base.json`) as the
merge base, applies local values from the install directory
-(`~/.local/share/Quasar` by default), and writes the resolved file into the staged worker. If the merge
+(`` by default), and writes the resolved file into the staged worker. If the merge
conflicts, auto-staging stops with a warning and `/settings/updates` shows a
git-style conflict editor. Resolve and save the JSON there, or choose **Force
release defaults** to stage the release file without local appsettings values.
@@ -149,7 +152,7 @@ Bootstrap checks the primary Quasar release stream every 15 minutes by default.
When it finds an actually newer `quasar-installer-linux.tar.gz` asset (semver
core and prerelease compared against the running launcher's release identity), it
verifies the release's `SHA256SUMS` entry, extracts the archive, strips the
-single top-level installer directory, replaces the installed launcher files,
+single top-level `Quasar` directory, replaces the installed launcher files,
drains the UI worker, and exits with a failure code so systemd restarts the
updated launcher. Existing `appsettings.json` is preserved.
Bootstrap must not drain the worker for a release whose normalized version is
@@ -183,19 +186,22 @@ first adds Microsoft's Debian 13 package feed with
selected .NET package.
```bash
-tar -xzf quasar-installer-linux.tar.gz -C /tmp
-/tmp/quasar-installer-linux/install.sh # publish to ~/.local/share/Quasar and install user quasar.service
-/tmp/quasar-installer-linux/install.sh --start # also start the user service immediately
+mkdir -p ~/.local/share/Quasar
+tar -xzf quasar-installer-linux.tar.gz -C ~/.local/share/Quasar --strip-components=1
+~/.local/share/Quasar/install.sh # install user quasar.service
+~/.local/share/Quasar/install.sh --start # also start the user service immediately
```
-`install.sh` publishes Quasar to `~/.local/share/Quasar`, creates the Quasar
-data directory at `~/.config/Quasar` by default, and installs a user
-`quasar.service`. Use `--system` with `sudo` for a machine-wide service, or
-`--data-dir ` to place Quasar state elsewhere. The generated service sets
-`HOME` and `QUASAR_DATA_DIR` explicitly so Bootstrap and the worker never fall
-back to the install directory for update/runtime state. It also records the unit
-name/scope in `QUASAR_SYSTEMD_SERVICE` and `QUASAR_SYSTEMD_SCOPE`; with those
-set, the UI shutdown button asks systemd to stop the installed unit. The
+For extracted release installers, `install.sh` uses the script directory as the
+default install directory and the default Quasar data directory. Source installs
+keep using `~/.local/share/Quasar` as the default install root, with state stored
+there as well. Use `--system` with `sudo` for a machine-wide service,
+`--install-dir ` to copy Quasar elsewhere, or `--data-dir ` to place
+Quasar state elsewhere. The generated service sets `HOME` and `QUASAR_DATA_DIR`
+explicitly so Bootstrap and the worker agree on the update/runtime state root.
+It also records the unit name/scope in `QUASAR_SYSTEMD_SERVICE` and
+`QUASAR_SYSTEMD_SCOPE`; with those set, the UI shutdown button asks systemd to
+stop the installed unit. The
installer enables the service but does not start or restart it unless `--start`
is passed; start it later with `systemctl --user restart quasar.service`. When
installing from source instead of an extracted release archive, the installer
@@ -213,7 +219,7 @@ the whole Quasar service. The installer can build and install a narrow setuid
root helper when the feature is needed:
```bash
-/tmp/quasar-installer-linux/install.sh --install-renice-helper --no-build --no-enable
+/tmp/Quasar/install.sh --install-renice-helper --no-build --no-enable
```
The helper is installed as `/usr/local/bin/quasar-renice`, accepts only Quasar's
@@ -223,7 +229,7 @@ names before calling `setpriority`.
```bash
~/.local/share/Quasar/uninstall.sh # remove the user systemd service
-~/.local/share/Quasar/uninstall.sh --purge # also remove ~/.local/share/Quasar
+~/.local/share/Quasar/uninstall.sh --purge # also remove the install/data root
```
`uninstall.sh` runs `systemctl stop quasar.service` before disabling and removing
@@ -238,7 +244,7 @@ For the web UI host/port (including how to change the listening port, default
Update defaults live in `Quasar:Updates`. Packaged defaults come from the install
directory, and operator overrides can live in the Quasar data directory
-(`~/.config/Quasar/appsettings.json` by default for Linux systemd installs, or
+(`/appsettings.json` by default for Linux systemd installs, or
`QUASAR_DATA_DIR/appsettings.json` when overridden). The worker and Bootstrap
both read that data-directory file on startup.
diff --git a/Docs/QuasarArchitecture.md b/Docs/QuasarArchitecture.md
index 74c2ba2..cfca418 100644
--- a/Docs/QuasarArchitecture.md
+++ b/Docs/QuasarArchitecture.md
@@ -468,8 +468,8 @@ Required model:
Expected layout:
- active runtime under a versioned release directory
-- active managed web releases under `~/.config/Quasar/ManagedRuntime/WebService//`
-- transient staged payloads under `~/.config/Quasar/Updates/Staged/`
+- active managed web releases under `/ManagedRuntime/WebService//`
+- transient staged payloads under `/Updates/Staged/`
- stable release pointer / manifest for the currently active version
- release identity from `AssemblyInformationalVersion` and the active-release
pointer, not from numeric `AssemblyVersion`
@@ -540,6 +540,10 @@ under Bootstrap, an admin can force activation from the UI. The worker writes a
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.
+Bootstrap also owns default data-root migration: if no custom `QUASAR_DATA_DIR`
+is set, or it still points at the legacy default AppData path, Bootstrap moves
+the legacy root into the launcher install root and passes that root to the
+worker.
The Updates page also shows installed managed-runtime versions independently of
Quasar self-update state: Quasar UI/Bootstrap, Magnetar, and the Space Engineers
@@ -551,6 +555,8 @@ the Quasar check button. Managed Magnetar is checked on startup and every hour
after startup, with a separate manual Magnetar check button. The managed
Dedicated Server is checked during startup readiness and can be forced through
its own manual check button; the action runs SteamCMD `app_update 298740 validate`.
+Quasar owns the SteamCMD process tree for these checks and terminates it if the
+worker is stopping or the check is otherwise cancelled.
### Future proxy update flow
@@ -878,9 +884,9 @@ As of this document:
- the Analytics dashboard renders metrics as client-side uPlot canvas charts: the browser fetches compact, timeline-aligned series from a JSON HTTP endpoint (`/api/analytics/series`, backed by `AnalyticsSeriesService`, which selects the RRD consolidation tier by span — raw ≤2h, 1-minute ≤24h, 1-hour beyond — and drops empty buckets); profiler game-loop timing buckets (frame, update, physics, scripts, network, other) and extensive profiler top grids/entity types are surfaced as additional chart panels through the same endpoint via `ProfilerAnalyticsMetrics` and `ProfilerEntryAnalyticsMetrics`; the same page edits each server/agent profiler mode with user-facing labels ("Simple, low overhead" for `SafeContinuous`, "Extensive, deep detail" for `DeepContinuous`) and pushes live changes through `ServerCommandType.SetProfilerMode`; the previous inline `ProfilerSummaryCard` tables and the `blocks`/`floating-objects` scalar metrics have been removed
- deep per-server profiler telemetry now exists: `Quasar.Agent` runs a continuous in-process profiler with `SafeContinuous` enabled by default, with per-server persisted `AgentProfilerMode` values and a global `Quasar:AgentProfilerMode` / `QUASAR_AGENT_PROFILER_MODE` fallback for older definitions. Safe mode uses Harmony prefix/postfix timing only for named high-level paths: frame/update, programmable-block script, physics, replication/network/session, GPS, and block-limit work. It deliberately avoids broad entity update method patching and detailed network-event hooks so the always-on default stays low overhead. Deep mode adds detailed network-event method hooks plus Magnetar-compatible Harmony IL call-site transpilers for `MySession.Update` / `UpdateComponents`, session component calls, replication simulation, entity update dispatch, parallel waits/callbacks, and Havok physics stepping internals. Runtime mode changes reconfigure Harmony patches so Safe, Deep, and Off can be selected without restarting the server. Hot-path measurements use numeric call-site ids and rolling accumulators, split main-thread vs off-thread time, and publish one-second windows with bounded top-lists for grids, scripts, entity types, system methods, physics detail, and network/replication/session work where the active patch depth can observe them. Patch failures are logged and the agent keeps the remaining profiler surface; entity call-site misses stay at high-level timing rather than adding broad method wrapping. Each `ProfilerSnapshot` rides the regular agent snapshot, is validated, and is kept in a small recent in-memory `ProfilerStoreService` ring (~720 samples per server, about 12 minutes at one snapshot per second), then surfaced on the Analytics page as game-loop timing and top grid/entity-type chart panels
- Discord per-server options now include chat relay and simspeed alert rules. Discord-to-game chat is injected as `[Discord] : ` so in-game readers see the Discord sender, and `DiscordChatRelayService` suppresses the matching game-history echo before it can post back to Discord as the server/bot author. `DiscordSimSpeedAlertService` evaluates fresh raw metric samples for connected/running agents on the registry change path, sending alerts through the configured simspeed channel or the server's analytics channel. Baseline rules detect sharp sample-to-sample drops across every unseen raw sample pair and sustained low average simspeed, and the Discord page exposes thresholds, windows, cooldowns, and per-rule enable switches. `DiscordBotService` also publishes aggregate managed-server state through Discord presence: the bot status reflects unhealthy/faulted vs active vs idle server instances, and its activity text shows active/total servers, player count, and issue/warning counts.
-- a unified GitHub-release-based update/publish pipeline now exists covering both Linux and Windows in a single combined release (`.github/workflows/release.yml`): each build produces `quasar-installer-linux.tar.gz` / `quasar-web-linux-x64.tar.gz` (Linux) and `quasar-installer-windows.zip` / `quasar-web-win-x64.zip` (Windows) under one tag; tag pushes and `main` publish full releases while pull requests publish draft prereleases; installer archives contain a single top-level `quasar-installer-*` directory for clean manual extraction; the release carries one combined `SHA256SUMS` covering every archive; release identity is normalized from `AssemblyInformationalVersion` and the active-release pointer (not numeric `AssemblyVersion`); four-part build tags such as `1.0.0.37` are canonical and numeric prerelease aliases such as `1.0.0-37` normalize to them; every downloaded asset is verified against `SHA256SUMS`; the UI stages web updates and queues them for explicit activation from `/settings/updates`; Bootstrap self-upgrades from the launcher stream only when an actually-newer asset appears (see [Linux Deployment and Updates](LinuxDeploymentAndUpdates.md) and [Windows Deployment and Updates](WindowsDeploymentAndUpdates.md))
+- a unified GitHub-release-based update/publish pipeline now exists covering both Linux and Windows in a single combined release (`.github/workflows/release.yml`): each build produces `quasar-installer-linux.tar.gz` / `quasar-web-linux-x64.tar.gz` (Linux) and `quasar-installer-windows.zip` / `quasar-web-win-x64.zip` (Windows) under one tag; tag pushes and `main` publish full releases while pull requests publish draft prereleases; installer archives contain a single top-level `Quasar` directory for clean manual extraction; the release carries one combined `SHA256SUMS` covering every archive; release identity is normalized from `AssemblyInformationalVersion` and the active-release pointer (not numeric `AssemblyVersion`); four-part build tags such as `1.0.0.37` are canonical and numeric prerelease aliases such as `1.0.0-37` normalize to them; every downloaded asset is verified against `SHA256SUMS`; the UI stages web updates and queues them for explicit activation from `/settings/updates`; Bootstrap self-upgrades from the launcher stream only when an actually-newer asset appears (see [Linux Deployment and Updates](LinuxDeploymentAndUpdates.md) and [Windows Deployment and Updates](WindowsDeploymentAndUpdates.md))
- `Quasar.Bootstrap` runs as the stable launcher that owns the public port on both Linux (systemd service) and Windows (Scheduled Task): it activates web releases through the `Updates/active-release.json` pointer after staged payloads are promoted into `ManagedRuntime/WebService//`, and performs worker cutover by draining the old worker and starting the managed one on the same port — a launcher, not yet a reverse proxy — so the public endpoint stays stable across the short listener gap while managed Magnetar servers keep running; on Linux, the UI shutdown action prefers `systemctl --user stop quasar.service` / `systemctl stop quasar.service` when the installed unit is detectable, falling back to the worker-written shutdown request that makes Bootstrap exit without respawning; on Linux the launcher exits with code 75 so systemd restarts it for self-upgrade; on Windows the launcher spawns a detached replacement `Quasar.exe serve --quiet` and exits 0, with the Scheduled Task restart-on-failure as the safety net
-- Windows deployment exists via `install.ps1`/`uninstall.ps1`: `install.ps1` publishes to `%ProgramFiles%\Quasar` and registers a Scheduled Task (`Quasar`) that starts at boot and restarts the launcher on failure; the task runs with `QUASAR_MODE=Service` and `QUASAR_OPEN_BROWSER_ON_START=false` mirroring the Linux systemd environment
+- Windows deployment exists via `install.ps1`/`uninstall.ps1`: extracted release installs default to the installer root, source installs default to `%ProgramFiles%\Quasar`, and the installer registers a Scheduled Task (`Quasar`) that starts at boot and restarts the launcher on failure; the task runs Bootstrap directly with `serve --quiet --service`
- staged relaunch now persists supervisor runtime state so managed DS processes survive worker turnover
- obsolete `webui/` is removed from the repository
- per-server isolated app-data path handling groundwork exists
diff --git a/Docs/QuickStart.md b/Docs/QuickStart.md
index 5dd4fbb..556bc20 100644
--- a/Docs/QuickStart.md
+++ b/Docs/QuickStart.md
@@ -13,7 +13,7 @@ Grab the latest release from GitHub. Each release contains platform archives:
```bash
tar -xzf quasar-installer-linux.tar.gz
-cd quasar-installer-linux
+cd Quasar
./Quasar serve
```
@@ -21,7 +21,7 @@ cd quasar-installer-linux
```cmd
Expand-Archive quasar-installer-windows.zip -DestinationPath C:\quasar
-cd C:\quasar\quasar-installer-windows
+cd C:\quasar\Quasar
Quasar.exe serve
```
@@ -42,14 +42,16 @@ commands needed before installing the .NET packages.
**Linux — systemd**
```bash
-tar -xzf quasar-installer-linux.tar.gz -C /tmp
-/tmp/quasar-installer-linux/install.sh --start # installs to ~/.local/share/Quasar and starts quasar.service
+mkdir -p ~/.local/share/Quasar
+tar -xzf quasar-installer-linux.tar.gz -C ~/.local/share/Quasar --strip-components=1
+~/.local/share/Quasar/install.sh --start # installs in place and starts quasar.service
```
-The Linux installer defaults to a user systemd service, stores Bootstrap under
-`~/.local/share/Quasar`, creates `~/.config/Quasar`, and writes that data path to
-the unit as `QUASAR_DATA_DIR`. Pass `--system` with `sudo` for a machine-wide
-service, or `--data-dir ` to store Quasar state elsewhere.
+The Linux installer defaults to a user systemd service, uses the extracted
+folder as the install and data root, and writes that path to the unit as
+`QUASAR_DATA_DIR`. Pass `--system` with `sudo` for a machine-wide service,
+`--install-dir ` to copy Quasar elsewhere, or `--data-dir ` to store
+Quasar state elsewhere.
When Quasar is running from the installed user service, the UI **Shutdown
Quasar** action requests `systemctl --user stop quasar.service` and leaves
managed servers detached by default.
@@ -66,7 +68,7 @@ To remove:
```bash
~/.local/share/Quasar/uninstall.sh # stop and remove the user service
-~/.local/share/Quasar/uninstall.sh --purge # also delete ~/.local/share/Quasar
+~/.local/share/Quasar/uninstall.sh --purge # also delete the install folder
```
The uninstall script stops `quasar.service` before removing it.
@@ -79,18 +81,19 @@ For release assets, auto-update behaviour, and advanced configuration see
Run from an **elevated PowerShell**:
```powershell
-Expand-Archive quasar-installer-windows.zip -DestinationPath "$env:ProgramFiles\QuasarSetup"
-cd "$env:ProgramFiles\QuasarSetup\quasar-installer-windows"
-.\install.ps1 -Start # installs to %ProgramFiles%\Quasar and starts the task
+Expand-Archive quasar-installer-windows.zip -DestinationPath C:\quasar
+cd C:\quasar\Quasar
+.\install.ps1 -Start # installs in place and starts the task
```
-The task starts at boot, restarts on failure, and runs as `SYSTEM` by default.
-Pass `-User ` to run as a specific service account instead.
+The task starts at boot, restarts on failure, and runs as the installing user by
+default. Quasar state is stored in the same folder by default. Pass
+`-InstallDir ` to copy Quasar elsewhere, or `-User ` to run as a
+specific service account instead.
To remove:
```powershell
-cd "$env:ProgramFiles\Quasar"
.\uninstall.ps1 # stop and remove the task
.\uninstall.ps1 -Purge # also delete the install directory
```
diff --git a/Docs/Reference/Index.md b/Docs/Reference/Index.md
index 5018dc4..1696cab 100644
--- a/Docs/Reference/Index.md
+++ b/Docs/Reference/Index.md
@@ -25,7 +25,7 @@ Every documented source file (210 total), alphabetical by path. See the [TOC](TO
| [Magnetar.Protocol/Model/PluginRuntimeInfo.cs](files/Magnetar.Protocol/Model/PluginRuntimeInfo.cs.md) | [Magnetar.Protocol](Modules/Magnetar.Protocol.md) | class | Lightweight DTO describing a single loaded plugin reported in `AgentSnapshot.Plugins`. Used by the Quasar UI to display the plugin roster and its load state. |
| [Magnetar.Protocol/Model/ProfilerSnapshot.cs](files/Magnetar.Protocol/Model/ProfilerSnapshot.cs.md) | [Magnetar.Protocol](Modules/Magnetar.Protocol.md) | DTO classes | Wire DTOs for Quasar's continuous profiler telemetry. `ProfilerSnapshot` is embedded in `AgentSnapshot` and carries one completed rolling calculation window: frame range, frame count, per-frame game-loop timing buckets, and bounded top lists for grids, programmable blocks, entities, system methods, physics, and network/replication/session work. |
| [Magnetar.Protocol/Model/ServerMetrics.cs](files/Magnetar.Protocol/Model/ServerMetrics.cs.md) | [Magnetar.Protocol](Modules/Magnetar.Protocol.md) | class | DTO carrying real-time performance and status metrics for a running SE dedicated server. Embedded in every `AgentSnapshot` so the Quasar dashboard can display server health at a glance. |
-| [Magnetar.Protocol/Runtime/MagnetarPaths.cs](files/Magnetar.Protocol/Runtime/MagnetarPaths.cs.md) | [Magnetar.Protocol](Modules/Magnetar.Protocol.md) | class (static) | Central, shared resolver for every on-disk path Quasar and its helpers use. All locations hang off a single Quasar data root (`~/.config/Quasar` on Linux/macOS, `%APPDATA%\Quasar` on Windows), overridable via the `QUASAR_DATA_DIR` environment variable. Used by the supervisor, bootstrap launcher, and other components so they agree on file layout. |
+| [Magnetar.Protocol/Runtime/MagnetarPaths.cs](files/Magnetar.Protocol/Runtime/MagnetarPaths.cs.md) | [Magnetar.Protocol](Modules/Magnetar.Protocol.md) | class (static) | Central, shared resolver for every on-disk path Quasar and its helpers use. All locations hang off a single Quasar data root, overridable via the `QUASAR_DATA_DIR` environment variable. The library fallback is the OS application-data root (`~/.config/Quasar` on Linux/macOS, `%APPDATA%\Quasar` on Windows), but installed Bootstrap sets `QUASAR_DATA_DIR` to the launcher install root after migrating that legacy default, so the supervisor and launcher agree on the install-root layout. |
| [Magnetar.Protocol/Runtime/QuasarActiveReleasePointer.cs](files/Magnetar.Protocol/Runtime/QuasarActiveReleasePointer.cs.md) | [Magnetar.Protocol](Modules/Magnetar.Protocol.md) | class | Sealed DTO persisted to `Updates/active-release.json` (path from `MagnetarPaths.GetQuasarActiveReleasePath()`). Quasar.Bootstrap reads this file to determine which release binary to launch as the Quasar supervisor. |
| [Magnetar.Protocol/Runtime/QuasarReleaseVersion.cs](files/Magnetar.Protocol/Runtime/QuasarReleaseVersion.cs.md) | [Magnetar.Protocol](Modules/Magnetar.Protocol.md) | class (static) | Shared release-version helper used by the Quasar worker and Bootstrap launcher. It reads `AssemblyInformationalVersion`, normalizes GitHub tag names, and compares release versions with prerelease-aware ordering so update checks only report a candidate when it is actually newer. |
| [Magnetar.Protocol/Runtime/QuasarWebReleaseLayout.cs](files/Magnetar.Protocol/Runtime/QuasarWebReleaseLayout.cs.md) | [Magnetar.Protocol](Modules/Magnetar.Protocol.md) | class (static) | Shared layout validator for Linux Quasar web-release payloads. It rejects staged or downloaded web archives that are missing the worker executable or static assets required for the Blazor/MudBlazor UI to load. |
@@ -47,7 +47,7 @@ Every documented source file (210 total), alphabetical by path. See the [TOC](TO
| [Quasar.Agent/Quasar.Agent.csproj](files/Quasar.Agent/Quasar.Agent.csproj.md) | [Quasar.Agent](Modules/Quasar.Agent.md) | project file | MSBuild project file for `Quasar.Agent`, a `netstandard2.0` class library (x64-only) that produces `Quasar.Agent.dll` — the Magnetar/Space Engineers plugin assembly. All game and PluginSdk references are `Private="False"` (provided by the host at runtime). Harmony is a package dependency because the agent applies profiler patches in-process. |
| [Quasar.Agent/StopCommand.cs](files/Quasar.Agent/StopCommand.cs.md) | [Quasar.Agent](Modules/Quasar.Agent.md) | class | Quasar-owned PluginSdk command modules for root in-game admin lifecycle commands. `StopCommand` handles `!stop` by reporting `AdminStop` and calling `ServerControl.SaveAndQuit()`. `RestartCommand` handles `!restart` by reporting `AdminRestart` and then using save-and-quit so Quasar tracks `Restarting` and performs the relaunch. `QuitCommand` handles `!quit` by reporting `AdminStop` and calling `ServerControl.QuitWithoutSaving()` for immediate no-save shutdown. |
| [Quasar.Agent/WebServiceLocator.cs](files/Quasar.Agent/WebServiceLocator.cs.md) | [Quasar.Agent](Modules/Quasar.Agent.md) | class | `WebServiceLocator` resolves the base URI of the running Quasar web service. It reads the `WebServiceDiscoveryManifest` written by the supervisor, health-checks the `/api/health` endpoint, and if no healthy instance is found, attempts to launch `Quasar.Bootstrap` to start one — using a named mutex (`Quasar.Bootstrap`) to avoid concurrent spawn races. It then polls for up to 30 s for the service to become healthy. |
-| [Quasar.Bootstrap/Program.cs](files/Quasar.Bootstrap/Program.cs.md) | [Quasar.Bootstrap](Modules/Quasar.Bootstrap.md) | class | Entry point and core logic for the Quasar launcher. It implements three CLI commands (`ensure-running`, `serve`, `activate-release`) and the supporting types `BootstrapOptions` (host/port + update + preserve-servers policy from `appsettings.json`) and `LauncherCoordinator` (an `IHostedService` that supervises the Quasar worker process, watches the active-release pointer and Bootstrap update request files, downloads an initial UI worker from the UI release stream when needed, hot-reloads activated UI releases, and self-upgrades the launcher from the primary Quasar release stream). |
+| [Quasar.Bootstrap/Program.cs](files/Quasar.Bootstrap/Program.cs.md) | [Quasar.Bootstrap](Modules/Quasar.Bootstrap.md) | class | Entry point and core logic for the Quasar launcher. It implements three CLI commands (`ensure-running`, `serve`, `activate-release`), applies the install-root default data directory and legacy AppData migration before any path lookup, and includes the supporting types `BootstrapOptions` (host/port + update + preserve-servers policy from `appsettings.json`) and `LauncherCoordinator` (an `IHostedService` that supervises the Quasar worker process, watches the active-release pointer and Bootstrap update request files, downloads an initial UI worker from the UI release stream when needed, hot-reloads activated UI releases, and self-upgrades the launcher from the primary Quasar release stream). |
| [Quasar.Bootstrap/Properties/launchSettings.json](files/Quasar.Bootstrap/Properties/launchSettings.json.md) | [Quasar.Bootstrap](Modules/Quasar.Bootstrap.md) | JSON config | Visual Studio / `dotnet run` launch settings for the Bootstrap project. Defines a single `Dev` profile that invokes `ensure-running --open-browser` under `ASPNETCORE_ENVIRONMENT=Development`, so a developer can start the full supervisor stack (and have the browser open automatically) with a single run. |
| [Quasar.Bootstrap/Quasar.Bootstrap.csproj](files/Quasar.Bootstrap/Quasar.Bootstrap.csproj.md) | [Quasar.Bootstrap](Modules/Quasar.Bootstrap.md) | project file | MSBuild project file for `Quasar.Bootstrap`, a `net10.0` console executable that targets `linux-x64` and `win-x64`. RID-targeted publish restores and publishes the `Quasar` worker as a single-file sub-app into a `WebService/` subfolder. |
| [Quasar/Components/App.razor](files/Quasar/Components/App.razor.md) | [Quasar.Components](Modules/Quasar.Components.md) | Blazor component | The root HTML document component for the Blazor Server application. It renders the full `` skeleton, wires MudBlazor, ApexCharts and app CSS, loads the Blazor WebAssembly/server JS runtime, and hosts `` and `` as the two top-level interactive components. |
diff --git a/Docs/Reference/Modules/Magnetar.Protocol.md b/Docs/Reference/Modules/Magnetar.Protocol.md
index 9942588..f45f4bc 100644
--- a/Docs/Reference/Modules/Magnetar.Protocol.md
+++ b/Docs/Reference/Modules/Magnetar.Protocol.md
@@ -29,7 +29,7 @@ Shared `netstandard2.0` contract library referenced by the Quasar supervisor, th
| [Magnetar.Protocol/Model/PluginRuntimeInfo.cs](../files/Magnetar.Protocol/Model/PluginRuntimeInfo.cs.md) | class | Lightweight DTO describing a single loaded plugin reported in `AgentSnapshot.Plugins`. Used by the Quasar UI to display the plugin roster and its load state. |
| [Magnetar.Protocol/Model/ProfilerSnapshot.cs](../files/Magnetar.Protocol/Model/ProfilerSnapshot.cs.md) | DTO classes | Wire DTOs for Quasar's continuous profiler telemetry. `ProfilerSnapshot` is embedded in `AgentSnapshot` and carries one completed rolling calculation window: frame range, frame count, per-frame game-loop timing buckets, and bounded top lists for grids, programmable blocks, entities, system methods, physics, and network/replication/session work. |
| [Magnetar.Protocol/Model/ServerMetrics.cs](../files/Magnetar.Protocol/Model/ServerMetrics.cs.md) | class | DTO carrying real-time performance and status metrics for a running SE dedicated server. Embedded in every `AgentSnapshot` so the Quasar dashboard can display server health at a glance. |
-| [Magnetar.Protocol/Runtime/MagnetarPaths.cs](../files/Magnetar.Protocol/Runtime/MagnetarPaths.cs.md) | class (static) | Central, shared resolver for every on-disk path Quasar and its helpers use. All locations hang off a single Quasar data root (`~/.config/Quasar` on Linux/macOS, `%APPDATA%\Quasar` on Windows), overridable via the `QUASAR_DATA_DIR` environment variable. Used by the supervisor, bootstrap launcher, and other components so they agree on file layout. |
+| [Magnetar.Protocol/Runtime/MagnetarPaths.cs](../files/Magnetar.Protocol/Runtime/MagnetarPaths.cs.md) | class (static) | Central, shared resolver for every on-disk path Quasar and its helpers use. All locations hang off a single Quasar data root, overridable via the `QUASAR_DATA_DIR` environment variable. The library fallback is the OS application-data root (`~/.config/Quasar` on Linux/macOS, `%APPDATA%\Quasar` on Windows), but installed Bootstrap sets `QUASAR_DATA_DIR` to the launcher install root after migrating that legacy default, so the supervisor and launcher agree on the install-root layout. |
| [Magnetar.Protocol/Runtime/QuasarActiveReleasePointer.cs](../files/Magnetar.Protocol/Runtime/QuasarActiveReleasePointer.cs.md) | class | Sealed DTO persisted to `Updates/active-release.json` (path from `MagnetarPaths.GetQuasarActiveReleasePath()`). Quasar.Bootstrap reads this file to determine which release binary to launch as the Quasar supervisor. |
| [Magnetar.Protocol/Runtime/QuasarReleaseVersion.cs](../files/Magnetar.Protocol/Runtime/QuasarReleaseVersion.cs.md) | class (static) | Shared release-version helper used by the Quasar worker and Bootstrap launcher. It reads `AssemblyInformationalVersion`, normalizes GitHub tag names, and compares release versions with prerelease-aware ordering so update checks only report a candidate when it is actually newer. |
| [Magnetar.Protocol/Runtime/QuasarWebReleaseLayout.cs](../files/Magnetar.Protocol/Runtime/QuasarWebReleaseLayout.cs.md) | class (static) | Shared layout validator for Linux Quasar web-release payloads. It rejects staged or downloaded web archives that are missing the worker executable or static assets required for the Blazor/MudBlazor UI to load. |
diff --git a/Docs/Reference/Modules/Quasar.Bootstrap.md b/Docs/Reference/Modules/Quasar.Bootstrap.md
index 39311e0..e74490b 100644
--- a/Docs/Reference/Modules/Quasar.Bootstrap.md
+++ b/Docs/Reference/Modules/Quasar.Bootstrap.md
@@ -8,7 +8,7 @@ A small `net10.0` helper whose job is to make sure the Quasar web service is run
| File | Kind | Summary |
| --- | --- | --- |
-| [Quasar.Bootstrap/Program.cs](../files/Quasar.Bootstrap/Program.cs.md) | class | Entry point and core logic for the Quasar launcher. It implements three CLI commands (`ensure-running`, `serve`, `activate-release`) and the supporting types `BootstrapOptions` (host/port + update + preserve-servers policy from `appsettings.json`) and `LauncherCoordinator` (an `IHostedService` that supervises the Quasar worker process, watches the active-release pointer and Bootstrap update request files, downloads an initial UI worker from the UI release stream when needed, hot-reloads activated UI releases, and self-upgrades the launcher from the primary Quasar release stream). |
+| [Quasar.Bootstrap/Program.cs](../files/Quasar.Bootstrap/Program.cs.md) | class | Entry point and core logic for the Quasar launcher. It implements three CLI commands (`ensure-running`, `serve`, `activate-release`), applies the install-root default data directory and legacy AppData migration before any path lookup, and includes the supporting types `BootstrapOptions` (host/port + update + preserve-servers policy from `appsettings.json`) and `LauncherCoordinator` (an `IHostedService` that supervises the Quasar worker process, watches the active-release pointer and Bootstrap update request files, downloads an initial UI worker from the UI release stream when needed, hot-reloads activated UI releases, and self-upgrades the launcher from the primary Quasar release stream). |
| [Quasar.Bootstrap/Properties/launchSettings.json](../files/Quasar.Bootstrap/Properties/launchSettings.json.md) | JSON config | Visual Studio / `dotnet run` launch settings for the Bootstrap project. Defines a single `Dev` profile that invokes `ensure-running --open-browser` under `ASPNETCORE_ENVIRONMENT=Development`, so a developer can start the full supervisor stack (and have the browser open automatically) with a single run. |
| [Quasar.Bootstrap/Quasar.Bootstrap.csproj](../files/Quasar.Bootstrap/Quasar.Bootstrap.csproj.md) | project file | MSBuild project file for `Quasar.Bootstrap`, a `net10.0` console executable that targets `linux-x64` and `win-x64`. RID-targeted publish restores and publishes the `Quasar` worker as a single-file sub-app into a `WebService/` subfolder. |
diff --git a/Docs/Reference/data/manifest.json b/Docs/Reference/data/manifest.json
index ba19ba5..7526826 100644
--- a/Docs/Reference/data/manifest.json
+++ b/Docs/Reference/data/manifest.json
@@ -214,8 +214,8 @@
"path": "Magnetar.Protocol/Runtime/MagnetarPaths.cs",
"name": "MagnetarPaths.cs",
"ext": ".cs",
- "size": 9431,
- "sha256": "6e824b87f8b9d17ea2b5df145bcbb4039ea9c8363eacf911b056f76396a79da1",
+ "size": 9588,
+ "sha256": "72657ef5526d7d3b6ae3565d8e58ef5688bb40f4c8c34d0f2a0c590e53d8fc14",
"module": "Magnetar.Protocol",
"tier": 1,
"status": "pending"
@@ -434,8 +434,8 @@
"path": "Quasar.Bootstrap/Program.cs",
"name": "Program.cs",
"ext": ".cs",
- "size": 86396,
- "sha256": "5d5c408e229675b6d6f8b952e7ba4f8e119b68de0f246169483b0956935a5c90",
+ "size": 95399,
+ "sha256": "e6606b7369b3f9f4ab529ddd1774620981d407fffecd09ec70679e133e4a5db7",
"module": "Quasar.Bootstrap",
"tier": 1,
"status": "pending"
@@ -924,8 +924,8 @@
"path": "Quasar/Components/Pages/SteamWorkshopApiKeyDialog.razor",
"name": "SteamWorkshopApiKeyDialog.razor",
"ext": ".razor",
- "size": 3725,
- "sha256": "33dea2e47c1b007b0122bec7af1001722103e6ebc979deff4bc89ec415fcfd24",
+ "size": 3748,
+ "sha256": "d145344e73662ab3a566b75cce35f62a5b24ea096cb111d520ccefa934b03bfc",
"module": "Quasar.Components",
"tier": 2,
"status": "pending"
@@ -1734,8 +1734,8 @@
"path": "Quasar/Services/ManagedDedicatedServerRuntimeResolver.cs",
"name": "ManagedDedicatedServerRuntimeResolver.cs",
"ext": ".cs",
- "size": 81027,
- "sha256": "72d495fc130099179afc801d59264176bcb1c9dd92d0ab432d555695e189c472",
+ "size": 83127,
+ "sha256": "ec3829ff27c94c9de350fe58d5f5aa4db84003f4af9132ce7e3b5fb01fc3364b",
"module": "Quasar.Services.Core",
"tier": 1,
"status": "pending"
diff --git a/Docs/Reference/data/module_index.json b/Docs/Reference/data/module_index.json
index 3272c00..559bef2 100644
--- a/Docs/Reference/data/module_index.json
+++ b/Docs/Reference/data/module_index.json
@@ -152,7 +152,7 @@
"name": "MagnetarPaths.cs",
"kind": "class (static)",
"tier": 1,
- "summary": "Central, shared resolver for every on-disk path Quasar and its helpers use. All locations hang off a single Quasar data root (`~/.config/Quasar` on Linux/macOS, `%APPDATA%\\Quasar` on Windows), overridable via the `QUASAR_DATA_DIR` environment variable. Used by the supervisor, bootstrap launcher, and other components so they agree on file layout."
+ "summary": "Central, shared resolver for every on-disk path Quasar and its helpers use. All locations hang off a single Quasar data root, overridable via the `QUASAR_DATA_DIR` environment variable. The library fallback is the OS application-data root (`~/.config/Quasar` on Linux/macOS, `%APPDATA%\\Quasar` on Windows), but installed Bootstrap sets `QUASAR_DATA_DIR` to the launcher install root after migrating that legacy default, so the supervisor and launcher agree on the install-root layout."
},
{
"path": "Magnetar.Protocol/Runtime/QuasarActiveReleasePointer.cs",
@@ -310,7 +310,7 @@
"name": "Program.cs",
"kind": "class",
"tier": 1,
- "summary": "Entry point and core logic for the Quasar launcher. It implements three CLI commands (`ensure-running`, `serve`, `activate-release`) and the supporting types `BootstrapOptions` (host/port + update + preserve-servers policy from `appsettings.json`) and `LauncherCoordinator` (an `IHostedService` that supervises the Quasar worker process, watches the active-release pointer and Bootstrap update request files, downloads an initial UI worker from the UI release stream when needed, hot-reloads activated UI releases, and self-upgrades the launcher from the primary Quasar release stream)."
+ "summary": "Entry point and core logic for the Quasar launcher. It implements three CLI commands (`ensure-running`, `serve`, `activate-release`), applies the install-root default data directory and legacy AppData migration before any path lookup, and includes the supporting types `BootstrapOptions` (host/port + update + preserve-servers policy from `appsettings.json`) and `LauncherCoordinator` (an `IHostedService` that supervises the Quasar worker process, watches the active-release pointer and Bootstrap update request files, downloads an initial UI worker from the UI release stream when needed, hot-reloads activated UI releases, and self-upgrades the launcher from the primary Quasar release stream)."
},
{
"path": "Quasar.Bootstrap/Properties/launchSettings.json",
diff --git a/Docs/Reference/files/Magnetar.Protocol/Runtime/MagnetarPaths.cs.md b/Docs/Reference/files/Magnetar.Protocol/Runtime/MagnetarPaths.cs.md
index ce4d3e3..e4e61ab 100644
--- a/Docs/Reference/files/Magnetar.Protocol/Runtime/MagnetarPaths.cs.md
+++ b/Docs/Reference/files/Magnetar.Protocol/Runtime/MagnetarPaths.cs.md
@@ -3,12 +3,12 @@
**Module:** Magnetar.Protocol **Kind:** class (static) **Tier:** 1
## Summary
-Central, shared resolver for every on-disk path Quasar and its helpers use. All locations hang off a single Quasar data root (`~/.config/Quasar` on Linux/macOS, `%APPDATA%\Quasar` on Windows), overridable via the `QUASAR_DATA_DIR` environment variable. Used by the supervisor, bootstrap launcher, and other components so they agree on file layout.
+Central, shared resolver for every on-disk path Quasar and its helpers use. All locations hang off a single Quasar data root, overridable via the `QUASAR_DATA_DIR` environment variable. The library fallback is the OS application-data root (`~/.config/Quasar` on Linux/macOS, `%APPDATA%\Quasar` on Windows), but installed Bootstrap sets `QUASAR_DATA_DIR` to the launcher install root after migrating that legacy default, so the supervisor and launcher agree on the install-root layout.
## Structure
Namespace `Magnetar.Protocol.Runtime`; `public static class MagnetarPaths`. Pure path-composition helpers (no I/O); user-supplied name segments are run through `SanitizePathSegment`.
-- Root: `GetQuasarDirectory()` (env override → `ApplicationData` → `AppContext.BaseDirectory`), `GetRuntimeDirectory()` (back-compat alias of the root).
+- Root: `GetQuasarDirectory()` (env override → `ApplicationData` → `AppContext.BaseDirectory`), `GetRuntimeDirectory()` (back-compat alias of the root). Installed Bootstrap supplies the env override before using this resolver.
- Web-service manifest: `GetWebServiceDirectory()` (the root itself), `GetWebServiceManifestPath()` → `service-manifest.json`.
- Supervisor files: `GetQuasarLogDirectory()`, `GetQuasarServerLogDirectory(uniqueName)` → `Logs/Magnetars//`, `GetQuasarSupervisorStatePath()`, `GetQuasarKnownPlayersPath()` → `known-players.json`, `GetQuasarKnownPlayerSettingsPath()` → `known-player-settings.json`, `GetQuasarDiscordOptionsPath()`, `GetQuasarDataHandlingConsentPath()` → `data-handling-consent.json`, `GetQuasarBrandingPath()`, `GetQuasarBrandingDirectory()` → data-root `Branding/` asset storage, compatibility overload `GetQuasarBrandingDirectory(webRootPath)`, `GetQuasarDeathMessagesPath()`, `GetQuasarWorkshopOptionsPath()`, `GetQuasarDataProtectionKeyringDirectory()`, `GetQuasarBackupSettingsPath()` → `backup-settings.json`, `GetQuasarBackupsDirectory()` → default `Backups/` storage folder used when `Quasar:BackupDirectory` is empty.
- Per-Magnetar server data (`Magnetars//`): `GetQuasarServersDirectory()`, `GetQuasarServerDirectory()`, `GetQuasarServerDedicatedServerAppDataDirectory()` (DS `-path`), `GetQuasarServerMagnetarAppDataDirectory()` (DS `-config`), `GetQuasarServerDefinitionPath()` → `server.json`, `GetQuasarServerHistoryDirectory()`, `GetQuasarServerAnalyticsPath()` → `analytics.jsonl`.
@@ -24,4 +24,4 @@ Namespace `Magnetar.Protocol.Runtime`; `public static class MagnetarPaths`. Pure
- `System`, `System.IO` (BCL only).
## Notes
-Cross-platform by design; the `QUASAR_DATA_DIR` override is the single switch to relocate all state (e.g. containerised/multi-tenant deployments). Runtime branding assets live under this data root rather than web `wwwroot`, so custom logos/favicons survive web-service release updates. Name segments (server unique names, world template ids, managed web release versions) must be sanitized before becoming directory names — `SanitizePathSegment` is private, so callers go through the typed helpers. `GetLegacyQuasarWorldProfilesDirectory()` exists only for migration.
+Cross-platform by design; the `QUASAR_DATA_DIR` override is the single switch to relocate all state (e.g. containerised/multi-tenant deployments). Runtime branding assets live under this data root rather than web `wwwroot`, so custom logos/favicons survive web-service release updates. Name segments (server unique names, world template ids, managed web release versions) must be sanitized before becoming directory names — `SanitizePathSegment` is private, so callers go through the typed helpers. `GetLegacyQuasarWorldProfilesDirectory()` exists only for world-profile migration; Bootstrap handles the older AppData-to-install-root migration before this resolver is used.
diff --git a/Docs/Reference/files/Quasar.Bootstrap/Program.cs.md b/Docs/Reference/files/Quasar.Bootstrap/Program.cs.md
index 4d8538e..c8130c1 100644
--- a/Docs/Reference/files/Quasar.Bootstrap/Program.cs.md
+++ b/Docs/Reference/files/Quasar.Bootstrap/Program.cs.md
@@ -3,16 +3,16 @@
**Module:** Quasar.Bootstrap **Kind:** class **Tier:** 1
## Summary
-Entry point and core logic for the Quasar launcher. It implements three CLI commands (`ensure-running`, `serve`, `activate-release`) and the supporting types `BootstrapOptions` (host/port + update + preserve-servers policy from `appsettings.json`) and `LauncherCoordinator` (an `IHostedService` that supervises the Quasar worker process, watches the active-release pointer and Bootstrap update request files, downloads an initial UI worker from the UI release stream when needed, hot-reloads activated UI releases, and self-upgrades the launcher from the primary Quasar release stream).
+Entry point and core logic for the Quasar launcher. It implements three CLI commands (`ensure-running`, `serve`, `activate-release`), applies the install-root default data directory and legacy AppData migration before any path lookup, and includes the supporting types `BootstrapOptions` (host/port + update + preserve-servers policy from `appsettings.json`) and `LauncherCoordinator` (an `IHostedService` that supervises the Quasar worker process, watches the active-release pointer and Bootstrap update request files, downloads an initial UI worker from the UI release stream when needed, hot-reloads activated UI releases, and self-upgrades the launcher from the primary Quasar release stream).
## Structure
**Namespace:** `Quasar.Bootstrap`
-**Top-level types:** `Program` (internal static), `BootstrapOptions` (internal sealed), `LauncherCoordinator` (internal sealed, `IHostedService`/`IDisposable`), `LauncherForegroundOptions` (sealed record), `WorkerProcessHandle` (sealed record nested in coordinator).
+**Top-level types:** `Program` (internal static), `BootstrapDataDirectoryMigration` (internal static), `BootstrapDataDirectoryMigrationResult` (readonly record struct), `BootstrapOptions` (internal sealed), `LauncherCoordinator` (internal sealed, `IHostedService`/`IDisposable`), `LauncherForegroundOptions` (sealed record), `WorkerProcessHandle` (sealed record nested in coordinator).
### `Program` (static)
| Member | Description |
|---|---|
-| `Main(args)` | Parses flags (`--quiet`, `--open-browser`, `--force`, `--foreground`/`--console`), picks command (default `ensure-running`), decides foreground vs detached based on an attached interactive console. |
+| `Main(args)` | Applies install-root data-directory migration/defaulting, parses flags (`--quiet`, `--open-browser`, `--force`, `--foreground`/`--console`), picks command (default `ensure-running`), decides foreground vs detached based on an attached interactive console. |
| `EnsureRunningAsync` | Returns existing healthy service (or `--force`-kills it); acquires `Quasar.Bootstrap` named mutex; fails fast if the port is bound by a non-Quasar process; in foreground runs `ServeAsync` directly (optionally opening a browser once healthy); otherwise spawns a detached `serve` process and polls health (60×1 s). |
| `ServeAsync` | Builds a `LauncherCoordinator`, starts it, blocks on Ctrl+C, then drains/stops. |
| `ActivateReleaseAsync` | Writes a `QuasarActiveReleasePointer` (from `--file`/`--working-dir`/`--args`/`--version`), then `EnsureRunningAsync`. |
@@ -22,6 +22,13 @@ Entry point and core logic for the Quasar launcher. It implements three CLI comm
| `IsHeadless` / `TryOpenBrowser` / `TryStartBrowserCommand` | Display-server detection + cross-platform best-effort browser launch (Linux: xdg-open/gio/sensible-browser). |
| `StartDetachedProcess` | Spawns the detached worker with redirected (drained) stdout/stderr so output does not bleed into the parent terminal. |
+### `BootstrapDataDirectoryMigration` (static)
+- Runs once at process start before `MagnetarPaths` is used.
+- Treats a blank `QUASAR_DATA_DIR`, a legacy default data root (`~/.config/Quasar` / `%APPDATA%\Quasar`), or an install-root value as the default path policy.
+- Uses `AppContext.BaseDirectory` as the target data root, recursively copies legacy default root contents into it, rewrites migrated `Updates/active-release.json` file/working-directory paths from the old root to the new root, removes copied legacy files/directories when possible, then sets `QUASAR_DATA_DIR` for the current process and child worker.
+- Refuses the migration and falls back to the legacy root if the launcher install root is inside the legacy root, preventing recursive self-copy.
+- Leaves custom `QUASAR_DATA_DIR` values untouched.
+
### `BootstrapOptions` (sealed)
- Reads the `Quasar` (fallback `MagnetarWeb`) config section from `appsettings.json` / `appsettings.{env}.json`, searched in `AppContext.BaseDirectory`, a `WebService` sibling, the Quasar data directory (`MagnetarPaths.GetQuasarDirectory()`), and up to 8 ancestor `Quasar/` source dirs.
- Properties: `Host` (default `127.0.0.1`), `AdvertisedHost` (remaps `0.0.0.0`/`*`/`+` → `127.0.0.1`), `Port` (default 8080), `PreserveServersOnShutdown` (default true; env `QUASAR_PRESERVE_SERVERS_ON_SHUTDOWN` or `PreserveManagedServersOnShutdown`), update owner/repository/prerelease settings, `LinuxWebAssetName`/`LinuxBootstrapAssetName` (Linux defaults), `WindowsWebAssetName`/`WindowsBootstrapAssetName` (Windows defaults), computed `WebAssetName`/`BootstrapAssetName` (OS-selected), `UpdatesCheckInterval`, Bootstrap `Version`, `BaseUrl`, `ListenUrl`; const `SupervisorName = "Quasar"`.
@@ -30,14 +37,14 @@ Entry point and core logic for the Quasar launcher. It implements three CLI comm
| Member | Description |
|---|---|
| `IsReady` / `GetHealthPayload()` / `GetManifest()` | Worker liveness, health summary object (status/workerId/hostId/hostName/baseUrl/active worker version+url), and `WebServiceDiscoveryManifest`. |
-| `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. |
+| `StartAsync` | Logs any data-root migration result, 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, 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. |
+| `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_DATA_DIR`, `QUASAR_PRESERVE_SERVERS_ON_SHUTDOWN`, foreground console-logging), and pumps stdout/stderr in foreground. |
| `SyncInstallAppSettingsToWorker` | Keeps the managed worker's base `appsettings.json` aligned with the stable launcher/install directory before process start. |
| `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)`. |
@@ -56,5 +63,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 identifies the detected target version/asset and lets the Updates page ask Bootstrap to run launcher self-update immediately.
+- `PreserveServersOnShutdown`, `QUASAR_INSTALL_DIR`, and `QUASAR_DATA_DIR` are propagated to the worker so the launcher and worker agree on shutdown policy, install root, and update/runtime state root. 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/SteamWorkshopApiKeyDialog.razor.md b/Docs/Reference/files/Quasar/Components/Pages/SteamWorkshopApiKeyDialog.razor.md
index 78d7a58..4ef4691 100644
--- a/Docs/Reference/files/Quasar/Components/Pages/SteamWorkshopApiKeyDialog.razor.md
+++ b/Docs/Reference/files/Quasar/Components/Pages/SteamWorkshopApiKeyDialog.razor.md
@@ -12,7 +12,7 @@ Small MudBlazor dialog for entering or updating the Steam Web API key used serve
- `string CurrentWebApiKey` — pre-populates the field when editing an existing key.
- **Key UI**
- Explanatory `MudText` plus an outlined info alert with a prominent bold `MudLink` to `https://steamcommunity.com/dev/apikey`.
- - Platform-specific storage text: Windows uses `%APPDATA%\Quasar`/profile ACLs; Linux/macOS uses `~/.config/Quasar` and owner-only credentials-file permissions when supported; all platforms mention the `QUASAR_DATA_DIR` override and Data Protection keyring dependency.
+ - Platform-specific storage text: Windows/Linux/macOS report the Quasar install directory as the default storage root, all platforms mention the `QUASAR_DATA_DIR` override and Data Protection keyring dependency, and Linux/macOS mention owner-only credentials-file permissions when supported.
- `MudTextField` with `InputType.Password` and a key adornment icon.
- Cancel / Save buttons; Save is disabled while the field is blank.
- **`Save`** — trims the key, returns `DialogResult.Ok(key)`.
diff --git a/Docs/Reference/files/Quasar/Services/ManagedDedicatedServerRuntimeResolver.cs.md b/Docs/Reference/files/Quasar/Services/ManagedDedicatedServerRuntimeResolver.cs.md
index 1b58fe2..ae4afb6 100644
--- a/Docs/Reference/files/Quasar/Services/ManagedDedicatedServerRuntimeResolver.cs.md
+++ b/Docs/Reference/files/Quasar/Services/ManagedDedicatedServerRuntimeResolver.cs.md
@@ -28,10 +28,10 @@ Namespace: `Quasar.Services`
| `GetWindowsMagnetarLauncherFileName(runtime)` | Maps `NetFramework48` → `MagnetarLegacy.exe`, otherwise `MagnetarInterim.exe`. |
| `FindWindowsMagnetarSource(extractRoot)` | Locates the archive's `Magnetar/` folder by the `MagnetarInterim.exe` that has a sibling `Libraries/` directory. |
| `ResolveDedicatedServer64PathAsync(...)` | Priority order: path inferred from a DS executable → `DedicatedServer64OverridePath` option → directory adjacent to the launcher → managed steamcmd install (if `PreferManagedDedicatedServerInstall`) → well-known Steam install locations. Throws if none valid. |
-| `TryEnsureManagedDedicatedServerInstallAsync(ct, steamCmdPath?, progress?)` | Runs `steamcmd +app_update 298740 validate`; on non-Windows forces Windows platform type; locked by `_dedicatedServerInstallLock`; tries up to three attempts before returning failure; falls back to a prior valid install on final failure and reports Dedicated Server download/install phase with attempt count when a progress sink is provided. |
+| `TryEnsureManagedDedicatedServerInstallAsync(ct, steamCmdPath?, progress?)` | Runs `steamcmd +app_update 298740 validate`; on non-Windows forces Windows platform type; locked by `_dedicatedServerInstallLock`; tries up to three attempts before returning failure; falls back to a prior valid install on final failure and reports Dedicated Server download/install phase with attempt count when a progress sink is provided. SteamCMD waits are linked to host shutdown and kill the whole process tree on cancellation. |
| `ResolveSteamCmdPathAsync(ct)` | `SteamCmdPath` option → managed install dir → `PATH` → `TryEnsureManagedSteamCmdInstallAsync`. |
| `EnsureManagedSteamCmdInstallAsync(progress?, ct)` | Downloads/extracts managed SteamCMD when missing, reports archive percentage when content length is available, sets exec bits, and is locked by `_steamCmdInstallLock`. |
-| `RunSteamCmdAsync(...)` | Runs SteamCMD commands such as `+quit` for native-runtime preparation and throws with trimmed stdout/stderr on failure. |
+| `RunSteamCmdAsync(...)` | Runs SteamCMD commands such as `+quit` for native-runtime preparation, links waits to host shutdown, kills the process tree on cancellation, and throws with trimmed stdout/stderr on failure. |
| `ResolveNativeLibrarySearchPaths()` | Linux-only helper that prefers Quasar's managed SteamCMD `linux64/` runtime folder when it contains `steamclient.so`, `libtier0_s.so`, and `libvstdlib_s.so`; the supervisor prepends this path to `LD_LIBRARY_PATH` so Steam GameServer init can find Steam's native runtime on fresh headless hosts. |
| `CopyToFileWithProgressAsync(...)` | Streams an HTTP response body to disk and reports integer percentage when `Content-Length` is known, otherwise reports indeterminate progress. |
| `ExtractArchive / DetectArchiveKind` | Dispatches to BCL `ZipArchive` or SharpCompress (`.tar.gz`, `.7z`) by 8-byte magic header + extension. |
@@ -55,7 +55,8 @@ Internal enum `ArchiveKind` (`Unknown`, `Zip`, `TarGz`, `SevenZip`). Private Mag
- SharpCompress — `ArchiveFactory`, `IArchiveEntry`, `ReaderOptions`
- BCL `System.IO.Compression.ZipArchive`, `System.Diagnostics.Process`
- `IHttpClientFactory` (5-minute download timeout)
+- `IHostApplicationLifetime` (host shutdown token for SteamCMD ownership)
## Notes
-Each install operation has its own `SemaphoreSlim(1,1)` so multiple servers starting at once cannot trigger duplicate installs. Magnetar checks always attempt to resolve the current configured archive source unless a successful GitHub release resolution is still inside its five-minute cooldown; if the installed marker already matches the latest GitHub release tag + asset name, the archive is not downloaded again. Dedicated Server checks use SteamCMD `app_update 298740 validate` and report the detected DS version when available. The DS version is read first from `SpaceEngineers.Game.SpaceEngineersGame.SE_VERSION` inside `SpaceEngineers.Game.dll`, formatted the same way as the game build number (for example `1.209.024`), and includes the server build suffix when `SERVER_BUILD_NUMBER` is present. File-version fallbacks ignore placeholder assembly versions such as `1.0.0`. If the latest check fails while a launcher already exists, Quasar logs the failure and continues with the installed runtime instead of blocking a server start. On Linux the Magnetar launcher is resolved to the actual apphost binary under `Bin/` rather than the wrapper script, so Quasar starts it directly (Bin/ as working directory) and the tracked PID is the server's own — essential for cross-restart adoption. The two OS layouts differ: Windows extracts a single `Magnetar/` folder holding both launcher exes plus a `Libraries/` subfolder; Linux stages the Interim build behind a top-level wrapper with the apphost under `Bin/`. On Windows the per-server `ManagedRuntime` selects `MagnetarInterim.exe` (.NET 10) or `MagnetarLegacy.exe` (.NET Framework 4.8); on non-Windows hosts a `NetFramework48` selection is silently downgraded to `DotNet10`. On Linux/macOS, SteamCMD uses `+@sSteamCmdForcePlatformType windows` to fetch the Windows DS binaries, and exec bits are applied via `File.SetUnixFileMode`; Quasar-managed SteamCMD's `linux64/` runtime is prepared during startup readiness and preferred for `NativeLibrarySearchPaths`. `DedicatedServer64` validation requires the launcher plus core assemblies (`SpaceEngineers.Game.dll`, `VRage.dll`, `Sandbox.Game.dll`) so thin or corrupt DS folders are rejected earlier. Archive entries that resolve outside the extraction root are rejected.
+Each install operation has its own `SemaphoreSlim(1,1)` so multiple servers starting at once cannot trigger duplicate installs. Magnetar checks always attempt to resolve the current configured archive source unless a successful GitHub release resolution is still inside its five-minute cooldown; if the installed marker already matches the latest GitHub release tag + asset name, the archive is not downloaded again. Dedicated Server checks use SteamCMD `app_update 298740 validate` and report the detected DS version when available; Quasar owns those SteamCMD processes by linking their waits to caller cancellation plus `ApplicationStopping` and killing the whole process tree on cancellation so shell wrappers do not survive shutdown. The post-kill wait is bounded to ten seconds so host shutdown cannot hang on an unreaped SteamCMD child. The DS version is read first from `SpaceEngineers.Game.SpaceEngineersGame.SE_VERSION` inside `SpaceEngineers.Game.dll`, formatted the same way as the game build number (for example `1.209.024`), and includes the server build suffix when `SERVER_BUILD_NUMBER` is present. File-version fallbacks ignore placeholder assembly versions such as `1.0.0`. If the latest check fails while a launcher already exists, Quasar logs the failure and continues with the installed runtime instead of blocking a server start. On Linux the Magnetar launcher is resolved to the actual apphost binary under `Bin/` rather than the wrapper script, so Quasar starts it directly (Bin/ as working directory) and the tracked PID is the server's own — essential for cross-restart adoption. The two OS layouts differ: Windows extracts a single `Magnetar/` folder holding both launcher exes plus a `Libraries/` subfolder; Linux stages the Interim build behind a top-level wrapper with the apphost under `Bin/`. On Windows the per-server `ManagedRuntime` selects `MagnetarInterim.exe` (.NET 10) or `MagnetarLegacy.exe` (.NET Framework 4.8); on non-Windows hosts a `NetFramework48` selection is silently downgraded to `DotNet10`. On Linux/macOS, SteamCMD uses `+@sSteamCmdForcePlatformType windows` to fetch the Windows DS binaries, and exec bits are applied via `File.SetUnixFileMode`; Quasar-managed SteamCMD's `linux64/` runtime is prepared during startup readiness and preferred for `NativeLibrarySearchPaths`. `DedicatedServer64` validation requires the launcher plus core assemblies (`SpaceEngineers.Game.dll`, `VRage.dll`, `Sandbox.Game.dll`) so thin or corrupt DS folders are rejected earlier. Archive entries that resolve outside the extraction root are rejected.
diff --git a/Docs/WindowsDeploymentAndUpdates.md b/Docs/WindowsDeploymentAndUpdates.md
index a1a2da1..af2b5f5 100644
--- a/Docs/WindowsDeploymentAndUpdates.md
+++ b/Docs/WindowsDeploymentAndUpdates.md
@@ -13,7 +13,7 @@ out of scope; see `Docs/QuasarArchitecture.md`).
Linux packager, under `artifacts/windows/`:
- `quasar-installer-windows.zip`
- - top-level `quasar-installer-windows\` directory
+ - top-level `Quasar\` directory
- `Quasar.exe` Bootstrap launcher
- `install.ps1`
- `uninstall.ps1`
@@ -30,7 +30,7 @@ carries one combined `SHA256SUMS` (regenerated by the release job over every Lin
and Windows archive). Its entries are ` `, matching the
format the updater verifies. The web archive is created without a base
directory, symmetric with the worker's `ZipFile.ExtractToDirectory`; the
-installer archive includes its `quasar-installer-windows\` base directory so a
+installer archive includes its `Quasar\` base directory so a
manual extract does not spill files into the current folder.
Version normalization is identical to `scripts/package-linux-release.sh`: the same
@@ -65,21 +65,24 @@ Windows archives in that combined release.
## First Start
The Scheduled Task runs Bootstrap as `Quasar.exe serve --quiet --service` from
-the install directory (default `%ProgramFiles%\Quasar`). Bootstrap is the direct
-task action — no `cmd.exe` wrapper — so Task Scheduler's job object covers the
-Bootstrap process itself, and stopping the task terminates it cleanly.
+the install directory (the extracted installer root by default). Bootstrap is
+the direct task action — no `cmd.exe` wrapper — so Task Scheduler's job object
+covers the Bootstrap process itself, and stopping the task terminates it cleanly.
If Bootstrap has no usable `Updates/active-release.json` and no packaged
`WebService/Quasar.exe`, it downloads the latest Windows web asset from GitHub and
extracts it under:
```text
-%APPDATA%\Quasar\ManagedRuntime\WebService\
+\ManagedRuntime\WebService\
```
Then it writes `Updates/active-release.json` pointing at the managed active
worker. `Updates\Staged\` is reserved for not-yet-activated update payloads. The
downloaded archive must match the release's `SHA256SUMS` entry before extraction.
+On startup, Bootstrap also migrates a legacy default data root at
+`%APPDATA%\Quasar` into the install root unless `QUASAR_DATA_DIR` points to a
+custom directory.
## UI Worker Updates
@@ -90,12 +93,12 @@ default and lists selectable `quasar-web-win-x64.zip` releases on
automatically after its `SHA256SUMS` entry is verified; with it disabled, releases
remain queued until the operator stages the selected version. Activation is
explicit; the UI copies the staged payload into
-`%APPDATA%\Quasar\ManagedRuntime\WebService\`, clears stale staged
+`\ManagedRuntime\WebService\`, clears stale staged
payloads, and Bootstrap drains the old worker, starts the managed `Quasar.exe` on
the same port, and leaves managed Magnetar servers running.
Staging also resolves `appsettings.json`. Quasar uses the stored release base in
-the data directory (`%APPDATA%\Quasar\Updates\appsettings.base.json`) as the
+the data directory (`\Updates\appsettings.base.json` by default) as the
merge base, applies local values from the install directory, and writes the
resolved file into the staged worker. If the merge conflicts, auto-staging stops
with a warning and `/settings/updates` shows a git-style conflict editor. Resolve
@@ -124,7 +127,7 @@ for a restart to verify or refresh the DS install.
Bootstrap checks the primary Quasar release stream every 15 minutes. When it
finds a genuinely newer `quasar-installer-windows.zip`, it verifies the
-`SHA256SUMS` entry, extracts the archive, strips the single top-level installer
+`SHA256SUMS` entry, extracts the archive, strips the single top-level `Quasar`
directory, and replaces the installed launcher files (renaming a running `.exe`
is permitted on Windows). Existing `appsettings.json` is preserved.
If the downloaded launcher is byte-identical to the installed launcher, Bootstrap
@@ -153,7 +156,7 @@ the Scheduled Task, and exits with install instructions if it is missing.
```powershell
# From an extracted quasar-installer-windows.zip, in an elevated PowerShell:
-.\install.ps1 # install to %ProgramFiles%\Quasar and register the task
+.\install.ps1 # install in place and register the task
.\install.ps1 -Start # also start the task immediately
```
@@ -162,7 +165,8 @@ and restarts the launcher on failure. Bootstrap is the direct task executable
(`Quasar.exe serve --quiet --service`); service mode is signalled via the
`--service` flag rather than environment variables, which also ensures Task
Scheduler's job object covers Bootstrap so stopping the task terminates it. It
-runs as `SYSTEM` by default; pass `-User ` for a specific service account.
+runs as the installing user by default; pass `-InstallDir ` to copy Quasar
+elsewhere or `-User ` for a specific service account.
```powershell
.\uninstall.ps1 # stop and remove the task
diff --git a/Magnetar.Protocol/Runtime/MagnetarPaths.cs b/Magnetar.Protocol/Runtime/MagnetarPaths.cs
index d4d3085..b1c0fe1 100644
--- a/Magnetar.Protocol/Runtime/MagnetarPaths.cs
+++ b/Magnetar.Protocol/Runtime/MagnetarPaths.cs
@@ -6,8 +6,10 @@ namespace Magnetar.Protocol.Runtime;
public static class MagnetarPaths
{
// -------------------------------------------------------------------------
- // Root — everything lives under ~/.config/Quasar (Linux / macOS) or
- // %APPDATA%\Quasar (Windows). Override with QUASAR_DATA_DIR.
+ // Root — everything lives under QUASAR_DATA_DIR when set. Bootstrap sets it
+ // to the launcher install root for packaged installs after migrating legacy
+ // ~/.config/Quasar (Linux/macOS) or %APPDATA%\Quasar (Windows) data.
+ // Without Bootstrap, fall back to the OS application-data directory.
// -------------------------------------------------------------------------
public static string GetQuasarDirectory()
@@ -87,7 +89,7 @@ public static string GetQuasarBackupsDirectory() =>
Path.Combine(GetQuasarDirectory(), "Backups");
// -------------------------------------------------------------------------
- // Magnetar server data (~/.config/Quasar/Magnetars//)
+ // Magnetar server data (/Magnetars//)
// -------------------------------------------------------------------------
/// Directory that contains one sub-folder per Magnetar server.
@@ -121,7 +123,7 @@ public static string GetQuasarServerAnalyticsPath(string uniqueName) =>
Path.Combine(GetQuasarServerDirectory(uniqueName), "analytics.jsonl");
// -------------------------------------------------------------------------
- // World templates (~/.config/Quasar/WorldTemplates//)
+ // World templates (/WorldTemplates//)
// -------------------------------------------------------------------------
public static string GetQuasarWorldTemplatesDirectory() =>
diff --git a/Quasar.Bootstrap/Program.cs b/Quasar.Bootstrap/Program.cs
index 2246e8e..f454480 100644
--- a/Quasar.Bootstrap/Program.cs
+++ b/Quasar.Bootstrap/Program.cs
@@ -28,6 +28,8 @@ internal static class Program
public static async Task Main(string[] args)
{
+ BootstrapDataDirectoryMigration.ApplyInstallRootDefault();
+
var quiet = args.Any(static arg => string.Equals(arg, "--quiet", StringComparison.OrdinalIgnoreCase));
var openBrowser = args.Any(static arg => string.Equals(arg, "--open-browser", StringComparison.OrdinalIgnoreCase));
var force = args.Any(static arg => string.Equals(arg, "--force", StringComparison.OrdinalIgnoreCase));
@@ -556,6 +558,221 @@ private static bool IsDotNetHost(string processPath)
}
}
+internal static class BootstrapDataDirectoryMigration
+{
+ private const string DataDirectoryEnvironmentVariable = "QUASAR_DATA_DIR";
+
+ public static BootstrapDataDirectoryMigrationResult LastResult { get; private set; } = BootstrapDataDirectoryMigrationResult.Empty;
+
+ public static void ApplyInstallRootDefault()
+ {
+ LastResult = BootstrapDataDirectoryMigrationResult.Empty;
+
+ var installRoot = NormalizeDirectory(AppContext.BaseDirectory);
+ if (string.IsNullOrWhiteSpace(installRoot))
+ return;
+
+ var legacyRoot = GetLegacyQuasarDirectory();
+ var configuredRoot = Environment.GetEnvironmentVariable(DataDirectoryEnvironmentVariable);
+ if (IsCustomDataDirectory(configuredRoot, installRoot, legacyRoot))
+ return;
+
+ var targetRoot = string.IsNullOrWhiteSpace(configuredRoot) || IsSamePath(configuredRoot, legacyRoot)
+ ? installRoot
+ : NormalizeDirectory(configuredRoot);
+
+ if (string.IsNullOrWhiteSpace(targetRoot))
+ targetRoot = installRoot;
+
+ if (!string.IsNullOrWhiteSpace(legacyRoot) &&
+ !IsSamePath(legacyRoot, targetRoot) &&
+ IsPathUnder(targetRoot, legacyRoot))
+ {
+ Environment.SetEnvironmentVariable(DataDirectoryEnvironmentVariable, legacyRoot);
+ LastResult = new BootstrapDataDirectoryMigrationResult(
+ legacyRoot,
+ targetRoot,
+ Migrated: false,
+ ErrorMessage: "Install root is inside the legacy data directory.");
+ return;
+ }
+
+ try
+ {
+ Directory.CreateDirectory(targetRoot);
+ var migrated = false;
+ if (!string.IsNullOrWhiteSpace(legacyRoot) &&
+ !IsSamePath(legacyRoot, targetRoot) &&
+ Directory.Exists(legacyRoot))
+ {
+ MergeDirectoryContents(legacyRoot, targetRoot);
+ TryRewriteMigratedActiveReleasePointer(legacyRoot, targetRoot);
+ TryDeleteDirectoryIfEmpty(legacyRoot);
+ migrated = true;
+ }
+
+ Environment.SetEnvironmentVariable(DataDirectoryEnvironmentVariable, targetRoot);
+ LastResult = new BootstrapDataDirectoryMigrationResult(legacyRoot, targetRoot, migrated, string.Empty);
+ }
+ catch (Exception exception)
+ {
+ if (!string.IsNullOrWhiteSpace(configuredRoot))
+ Environment.SetEnvironmentVariable(DataDirectoryEnvironmentVariable, configuredRoot);
+
+ LastResult = new BootstrapDataDirectoryMigrationResult(
+ legacyRoot,
+ targetRoot,
+ Migrated: false,
+ ErrorMessage: exception.Message);
+ }
+ }
+
+ private static bool IsCustomDataDirectory(string? configuredRoot, string installRoot, string legacyRoot)
+ {
+ return !string.IsNullOrWhiteSpace(configuredRoot) &&
+ !IsSamePath(configuredRoot, installRoot) &&
+ !IsSamePath(configuredRoot, legacyRoot);
+ }
+
+ private static string GetLegacyQuasarDirectory()
+ {
+ var appData = Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData);
+ return string.IsNullOrWhiteSpace(appData)
+ ? string.Empty
+ : NormalizeDirectory(Path.Combine(appData, "Quasar"));
+ }
+
+ private static void TryRewriteMigratedActiveReleasePointer(string legacyRoot, string targetRoot)
+ {
+ var pointerPath = Path.Combine(targetRoot, "Updates", "active-release.json");
+ if (!File.Exists(pointerPath))
+ return;
+
+ try
+ {
+ var pointer = JsonSerializer.Deserialize(
+ File.ReadAllText(pointerPath),
+ LauncherCoordinator.JsonOptions);
+ if (pointer is null)
+ return;
+
+ var fileName = RewriteMigratedPath(pointer.FileName, legacyRoot, targetRoot);
+ var workingDirectory = RewriteMigratedPath(pointer.WorkingDirectory, legacyRoot, targetRoot);
+ if (string.Equals(fileName, pointer.FileName, StringComparison.Ordinal) &&
+ string.Equals(workingDirectory, pointer.WorkingDirectory, StringComparison.Ordinal))
+ {
+ return;
+ }
+
+ var rewritten = new QuasarActiveReleasePointer
+ {
+ Version = pointer.Version,
+ FileName = fileName,
+ Arguments = pointer.Arguments,
+ WorkingDirectory = workingDirectory,
+ ActivatedAtUtc = pointer.ActivatedAtUtc,
+ };
+ File.WriteAllText(pointerPath, JsonSerializer.Serialize(rewritten, LauncherCoordinator.JsonOptions));
+ }
+ catch
+ {
+ }
+ }
+
+ private static string RewriteMigratedPath(string value, string legacyRoot, string targetRoot)
+ {
+ if (string.IsNullOrWhiteSpace(value) || !Path.IsPathFullyQualified(value))
+ return value;
+
+ if (IsSamePath(value, legacyRoot))
+ return targetRoot;
+
+ if (!IsPathUnder(value, legacyRoot))
+ return value;
+
+ var relativePath = Path.GetRelativePath(NormalizeDirectory(legacyRoot), NormalizeDirectory(value));
+ return Path.Combine(targetRoot, relativePath);
+ }
+
+ private static void MergeDirectoryContents(string sourceDirectory, string destinationDirectory)
+ {
+ Directory.CreateDirectory(destinationDirectory);
+
+ foreach (var sourcePath in Directory.EnumerateFiles(sourceDirectory))
+ {
+ var destinationPath = Path.Combine(destinationDirectory, Path.GetFileName(sourcePath));
+ Directory.CreateDirectory(Path.GetDirectoryName(destinationPath)!);
+ File.Copy(sourcePath, destinationPath, overwrite: true);
+ TryDeleteFile(sourcePath);
+ }
+
+ foreach (var sourceChildDirectory in Directory.EnumerateDirectories(sourceDirectory))
+ {
+ var destinationChildDirectory = Path.Combine(destinationDirectory, Path.GetFileName(sourceChildDirectory));
+ MergeDirectoryContents(sourceChildDirectory, destinationChildDirectory);
+ TryDeleteDirectoryIfEmpty(sourceChildDirectory);
+ }
+ }
+
+ private static void TryDeleteFile(string path)
+ {
+ try
+ {
+ if (File.Exists(path))
+ File.Delete(path);
+ }
+ catch
+ {
+ }
+ }
+
+ private static void TryDeleteDirectoryIfEmpty(string path)
+ {
+ try
+ {
+ if (Directory.Exists(path) && !Directory.EnumerateFileSystemEntries(path).Any())
+ Directory.Delete(path);
+ }
+ catch
+ {
+ }
+ }
+
+ private static string NormalizeDirectory(string? path)
+ {
+ if (string.IsNullOrWhiteSpace(path))
+ return string.Empty;
+
+ return Path.GetFullPath(path.Trim()).TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar);
+ }
+
+ private static bool IsSamePath(string? left, string? right)
+ {
+ if (string.IsNullOrWhiteSpace(left) || string.IsNullOrWhiteSpace(right))
+ return false;
+
+ var comparison = OperatingSystem.IsWindows() ? StringComparison.OrdinalIgnoreCase : StringComparison.Ordinal;
+ return string.Equals(NormalizeDirectory(left), NormalizeDirectory(right), comparison);
+ }
+
+ private static bool IsPathUnder(string path, string possibleParent)
+ {
+ var normalizedPath = NormalizeDirectory(path) + Path.DirectorySeparatorChar;
+ var normalizedParent = NormalizeDirectory(possibleParent) + Path.DirectorySeparatorChar;
+ var comparison = OperatingSystem.IsWindows() ? StringComparison.OrdinalIgnoreCase : StringComparison.Ordinal;
+ return normalizedPath.StartsWith(normalizedParent, comparison);
+ }
+}
+
+internal readonly record struct BootstrapDataDirectoryMigrationResult(
+ string LegacyPath,
+ string TargetPath,
+ bool Migrated,
+ string ErrorMessage)
+{
+ public static BootstrapDataDirectoryMigrationResult Empty { get; } = new(string.Empty, string.Empty, false, string.Empty);
+}
+
internal sealed class BootstrapOptions
{
public const string SupervisorName = "Quasar";
@@ -810,6 +1027,23 @@ public WebServiceDiscoveryManifest GetManifest()
public async Task StartAsync(CancellationToken cancellationToken)
{
_logger.LogInformation("Starting Quasar...");
+ var migration = BootstrapDataDirectoryMigration.LastResult;
+ if (migration.Migrated)
+ {
+ _logger.LogInformation(
+ "Migrated legacy Quasar data directory from {LegacyPath} to {TargetPath}.",
+ migration.LegacyPath,
+ migration.TargetPath);
+ }
+ else if (!string.IsNullOrWhiteSpace(migration.ErrorMessage))
+ {
+ _logger.LogWarning(
+ "Failed migrating legacy Quasar data directory from {LegacyPath} to {TargetPath}: {Message}",
+ migration.LegacyPath,
+ migration.TargetPath,
+ migration.ErrorMessage);
+ }
+
Directory.CreateDirectory(MagnetarPaths.GetWebServiceDirectory());
Directory.CreateDirectory(MagnetarPaths.GetQuasarUpdatesDirectory());
await EnsureInitialWebReleaseAvailableAsync(cancellationToken).ConfigureAwait(false);
@@ -1598,6 +1832,7 @@ private async Task ActivateSpecificReleaseAsync(QuasarActiveReleasePointer point
startInfo.Environment["QUASAR_LAUNCHER_TOKEN"] = _launcherToken;
startInfo.Environment["QUASAR_BOOTSTRAP_VERSION"] = _options.Version;
startInfo.Environment["QUASAR_INSTALL_DIR"] = AppContext.BaseDirectory;
+ startInfo.Environment["QUASAR_DATA_DIR"] = MagnetarPaths.GetQuasarDirectory();
startInfo.Environment["QUASAR_PRESERVE_SERVERS_ON_SHUTDOWN"] = _options.PreserveServersOnShutdown ? "true" : "false";
if (_foregroundOptions.IsForeground)
startInfo.Environment["QUASAR_CONSOLE_LOGGING"] = "true";
diff --git a/Quasar/Components/Pages/SteamWorkshopApiKeyDialog.razor b/Quasar/Components/Pages/SteamWorkshopApiKeyDialog.razor
index ad35297..4a4edb9 100644
--- a/Quasar/Components/Pages/SteamWorkshopApiKeyDialog.razor
+++ b/Quasar/Components/Pages/SteamWorkshopApiKeyDialog.razor
@@ -75,10 +75,10 @@
private static string GetPlatformStorageLocation()
{
if (OperatingSystem.IsWindows())
- return "%APPDATA%\\Quasar, or QUASAR_DATA_DIR when that override is set";
+ return "the Quasar install directory, or QUASAR_DATA_DIR when that override is set";
if (OperatingSystem.IsLinux() || OperatingSystem.IsMacOS())
- return "~/.config/Quasar, or QUASAR_DATA_DIR when that override is set";
+ return "the Quasar install directory, or QUASAR_DATA_DIR when that override is set";
return "the Quasar data directory, or QUASAR_DATA_DIR when that override is set";
}
diff --git a/Quasar/Services/ManagedDedicatedServerRuntimeResolver.cs b/Quasar/Services/ManagedDedicatedServerRuntimeResolver.cs
index 7eadd9a..347d234 100644
--- a/Quasar/Services/ManagedDedicatedServerRuntimeResolver.cs
+++ b/Quasar/Services/ManagedDedicatedServerRuntimeResolver.cs
@@ -18,6 +18,7 @@ public sealed class ManagedDedicatedServerRuntimeResolver
{
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web);
private static readonly TimeSpan MagnetarReleaseCheckCooldown = TimeSpan.FromMinutes(5);
+ private static readonly TimeSpan SteamCmdKillWaitTimeout = TimeSpan.FromSeconds(10);
private const string MagnetarLauncherName = "MagnetarInterim";
private const string MagnetarReleaseMarkerFileName = ".quasar-magnetar-release.json";
private const string DedicatedServerAppId = "298740";
@@ -65,6 +66,7 @@ public sealed class ManagedDedicatedServerRuntimeResolver
private readonly ILogger _logger;
private readonly IHttpClientFactory _httpClientFactory;
private readonly ManagedRuntimeOptions _options;
+ private readonly IHostApplicationLifetime _lifetime;
private readonly SemaphoreSlim _magnetarInstallLock = new(1, 1);
private readonly SemaphoreSlim _steamCmdInstallLock = new(1, 1);
private readonly SemaphoreSlim _dedicatedServerInstallLock = new(1, 1);
@@ -75,11 +77,13 @@ public sealed class ManagedDedicatedServerRuntimeResolver
public ManagedDedicatedServerRuntimeResolver(
ILogger logger,
IHttpClientFactory httpClientFactory,
- ManagedRuntimeOptions options)
+ ManagedRuntimeOptions options,
+ IHostApplicationLifetime lifetime)
{
_logger = logger;
_httpClientFactory = httpClientFactory;
_options = options;
+ _lifetime = lifetime;
}
public async Task ResolveAsync(
@@ -1091,21 +1095,20 @@ private async Task TryEnsureManagedDedicatedServerInstallAsync(
return string.Empty;
}
- var stdoutTask = process.StandardOutput.ReadToEndAsync(cancellationToken);
- var stderrTask = process.StandardError.ReadToEndAsync(cancellationToken);
- await process.WaitForExitAsync(cancellationToken);
- var stdout = await stdoutTask;
- var stderr = await stderrTask;
+ var result = await WaitForSteamCmdProcessAsync(
+ process,
+ $"managed DS install attempt {attempt}/{DedicatedServerInstallMaxAttempts}",
+ cancellationToken);
- if (process.ExitCode != 0)
+ if (result.ExitCode != 0)
{
_logger.LogWarning(
"steamcmd failed installing/updating managed DS on attempt {Attempt}/{MaxAttempts}. ExitCode={ExitCode}. Stdout={Stdout}. Stderr={Stderr}",
attempt,
DedicatedServerInstallMaxAttempts,
- process.ExitCode,
- TrimForLog(stdout),
- TrimForLog(stderr));
+ result.ExitCode,
+ TrimForLog(result.Stdout),
+ TrimForLog(result.Stderr));
if (attempt < DedicatedServerInstallMaxAttempts)
continue;
@@ -1315,16 +1318,76 @@ private async Task RunSteamCmdAsync(
throw new InvalidOperationException($"Failed starting steamcmd while {action}.", exception);
}
- var stdoutTask = process.StandardOutput.ReadToEndAsync(cancellationToken);
- var stderrTask = process.StandardError.ReadToEndAsync(cancellationToken);
- await process.WaitForExitAsync(cancellationToken);
+ var result = await WaitForSteamCmdProcessAsync(process, action, cancellationToken);
+
+ if (result.ExitCode != 0)
+ {
+ throw new InvalidOperationException(
+ $"steamcmd failed while {action}. ExitCode={result.ExitCode}. Stdout={TrimForLog(result.Stdout)}. Stderr={TrimForLog(result.Stderr)}");
+ }
+ }
+
+ private async Task WaitForSteamCmdProcessAsync(
+ Process process,
+ string action,
+ CancellationToken cancellationToken)
+ {
+ using var linkedCancellation = CancellationTokenSource.CreateLinkedTokenSource(
+ cancellationToken,
+ _lifetime.ApplicationStopping);
+
+ var stdoutTask = process.StandardOutput.ReadToEndAsync();
+ var stderrTask = process.StandardError.ReadToEndAsync();
+
+ try
+ {
+ await process.WaitForExitAsync(linkedCancellation.Token);
+ }
+ catch (OperationCanceledException)
+ {
+ KillSteamCmdProcessTree(process, action);
+ await WaitForKilledSteamCmdAsync(process, action);
+ throw;
+ }
+
var stdout = await stdoutTask;
var stderr = await stderrTask;
+ return new SteamCmdProcessResult(process.ExitCode, stdout, stderr);
+ }
- if (process.ExitCode != 0)
+ private void KillSteamCmdProcessTree(Process process, string action)
+ {
+ try
{
- throw new InvalidOperationException(
- $"steamcmd failed while {action}. ExitCode={process.ExitCode}. Stdout={TrimForLog(stdout)}. Stderr={TrimForLog(stderr)}");
+ if (process.HasExited)
+ return;
+
+ _logger.LogInformation("Stopping steamcmd after cancellation while {Action}.", action);
+ process.Kill(entireProcessTree: true);
+ }
+ catch (InvalidOperationException)
+ {
+ }
+ catch (Exception exception)
+ {
+ _logger.LogWarning(exception, "Failed stopping steamcmd after cancellation while {Action}.", action);
+ }
+ }
+
+ private async Task WaitForKilledSteamCmdAsync(Process process, string action)
+ {
+ try
+ {
+ using var timeout = new CancellationTokenSource(SteamCmdKillWaitTimeout);
+ await process.WaitForExitAsync(timeout.Token);
+ }
+ catch (OperationCanceledException)
+ {
+ _logger.LogWarning("Timed out waiting for killed steamcmd while {Action}.", action);
+ }
+ catch (Exception exception)
+ {
+ _logger.LogDebug(exception, "Failed waiting for killed steamcmd while {Action}.", action);
}
}
@@ -1918,6 +1981,8 @@ private sealed class GitHubAsset
[JsonPropertyName("browser_download_url")]
public string BrowserDownloadUrl { get; set; } = string.Empty;
}
+
+ private sealed record SteamCmdProcessResult(int ExitCode, string Stdout, string Stderr);
}
internal enum ArchiveKind
diff --git a/install.sh b/install.sh
index dbd2e02..7307dbe 100755
--- a/install.sh
+++ b/install.sh
@@ -6,6 +6,7 @@ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
SERVICE_NAME="quasar"
INSTALL_DIR=""
DATA_DIR=""
+PACKAGED_INSTALL=false
CONFIGURATION="Release"
RUNTIME="linux-x64"
ENABLE_SERVICE=true
@@ -22,8 +23,9 @@ usage() {
cat < Install directory (default: /.local/share/Quasar)
- --data-dir Quasar data directory (default: /.config/Quasar)
+ --install-dir Install directory (default: extracted installer root; source installs use /.local/share/Quasar)
+ --data-dir Quasar data directory (default: install directory)
--service-name systemd service name (default: quasar)
--user User to run Quasar as (system installs only; default: sudo caller)
--user-service Install a user systemd service (default)
@@ -417,7 +419,11 @@ while [[ $# -gt 0 ]]; do
esac
done
-if [[ "$SKIP_BUILD" == "false" && -x "$SCRIPT_DIR/Quasar" && ! -f "$SCRIPT_DIR/Quasar.Bootstrap/Quasar.Bootstrap.csproj" ]]; then
+if [[ -x "$SCRIPT_DIR/Quasar" && ! -f "$SCRIPT_DIR/Quasar.Bootstrap/Quasar.Bootstrap.csproj" ]]; then
+ PACKAGED_INSTALL=true
+fi
+
+if [[ "$SKIP_BUILD" == "false" && "$PACKAGED_INSTALL" == "true" ]]; then
SKIP_BUILD=true
fi
@@ -464,10 +470,14 @@ if [[ -z "$RUN_HOME" ]]; then
exit 1
fi
if [[ -z "$INSTALL_DIR" ]]; then
- INSTALL_DIR="$RUN_HOME/.local/share/Quasar"
+ if [[ "$PACKAGED_INSTALL" == "true" ]]; then
+ INSTALL_DIR="$SCRIPT_DIR"
+ else
+ INSTALL_DIR="$RUN_HOME/.local/share/Quasar"
+ fi
fi
if [[ -z "$DATA_DIR" ]]; then
- DATA_DIR="$RUN_HOME/.config/Quasar"
+ DATA_DIR="$INSTALL_DIR"
fi
INSTALL_DIR="$(realpath -m "$INSTALL_DIR")"
DATA_DIR="$(realpath -m "$DATA_DIR")"
@@ -486,11 +496,6 @@ case "$DATA_DIR" in
;;
esac
-if [[ "$DATA_DIR" == "$INSTALL_DIR" || "$DATA_DIR" == "$INSTALL_DIR"/* ]]; then
- echo "Data directory must not be inside the install directory: $DATA_DIR" >&2
- exit 1
-fi
-
require_dotnet_installation
write_service_unit() {
@@ -578,6 +583,20 @@ restart_service() {
fi
}
+copy_if_different() {
+ local source="$1"
+ local destination="$2"
+ if [[ ! -f "$source" ]]; then
+ return
+ fi
+
+ if [[ "$(realpath -m "$source")" == "$(realpath -m "$destination")" ]]; then
+ return
+ fi
+
+ cp -a "$source" "$destination"
+}
+
cleanup_old_opt_install() {
local old_install_dir="/opt/quasar"
if [[ "$INSTALL_MODE" != "user" || "$INSTALL_DIR" == "$old_install_dir" || ! -x "$old_install_dir/uninstall.sh" ]]; then
@@ -665,10 +684,18 @@ if [[ "$SKIP_BUILD" == "true" ]]; then
echo "Existing publish output not found: $SOURCE_DIR" >&2
exit 1
fi
- find "$SOURCE_DIR" -mindepth 1 -maxdepth 1 \
- ! -name "quasar-*.tar.gz" \
- ! -name "quasar-*.zip" \
- -exec cp -a -- {} "$PUBLISH_DIR/" \;
+ if [[ "$(realpath -m "$SOURCE_DIR")" == "$INSTALL_DIR" ]]; then
+ for source_entry in Quasar appsettings.json install.sh uninstall.sh quasar-renice.c README.md WebService; do
+ if [[ -e "$SOURCE_DIR/$source_entry" ]]; then
+ cp -a -- "$SOURCE_DIR/$source_entry" "$PUBLISH_DIR/"
+ fi
+ done
+ else
+ find "$SOURCE_DIR" -mindepth 1 -maxdepth 1 \
+ ! -name "quasar-*.tar.gz" \
+ ! -name "quasar-*.zip" \
+ -exec cp -a -- {} "$PUBLISH_DIR/" \;
+ fi
else
BUILD_VERSION="$(resolve_build_version)"
NUGET_VERSION="$(normalize_nuget_version "$BUILD_VERSION")"
@@ -685,18 +712,16 @@ else
install -d -m 0755 "$INSTALL_DIR"
install -d -m 0755 "$DATA_DIR"
fi
-find "$INSTALL_DIR" -mindepth 1 -maxdepth 1 -exec rm -rf -- {} +
-cp -a "$PUBLISH_DIR/." "$INSTALL_DIR/"
-if [[ -f "$SCRIPT_DIR/install.sh" ]]; then
- cp -a "$SCRIPT_DIR/install.sh" "$INSTALL_DIR/install.sh"
-fi
-if [[ -f "$SCRIPT_DIR/uninstall.sh" ]]; then
- cp -a "$SCRIPT_DIR/uninstall.sh" "$INSTALL_DIR/uninstall.sh"
+if [[ "$DATA_DIR" != "$INSTALL_DIR" && "$DATA_DIR" != "$INSTALL_DIR"/* ]]; then
+ find "$INSTALL_DIR" -mindepth 1 -maxdepth 1 -exec rm -rf -- {} +
fi
+cp -a "$PUBLISH_DIR/." "$INSTALL_DIR/"
+copy_if_different "$SCRIPT_DIR/install.sh" "$INSTALL_DIR/install.sh"
+copy_if_different "$SCRIPT_DIR/uninstall.sh" "$INSTALL_DIR/uninstall.sh"
if [[ -f "$SCRIPT_DIR/tools/quasar-renice.c" ]]; then
- cp -a "$SCRIPT_DIR/tools/quasar-renice.c" "$INSTALL_DIR/quasar-renice.c"
+ copy_if_different "$SCRIPT_DIR/tools/quasar-renice.c" "$INSTALL_DIR/quasar-renice.c"
elif [[ -f "$SCRIPT_DIR/quasar-renice.c" ]]; then
- cp -a "$SCRIPT_DIR/quasar-renice.c" "$INSTALL_DIR/quasar-renice.c"
+ copy_if_different "$SCRIPT_DIR/quasar-renice.c" "$INSTALL_DIR/quasar-renice.c"
fi
if [[ "${EUID}" -eq 0 ]]; then
chown -R "$RUN_USER:$RUN_GROUP" "$INSTALL_DIR"
diff --git a/scripts/install.ps1 b/scripts/install.ps1
index a950be3..09fab03 100644
--- a/scripts/install.ps1
+++ b/scripts/install.ps1
@@ -8,7 +8,7 @@
[CmdletBinding()]
param(
- [string]$InstallDir = "$env:ProgramFiles\Quasar",
+ [string]$InstallDir,
[string]$TaskName = 'Quasar',
[string]$Configuration = 'Release',
[string]$Runtime = 'win-x64',
@@ -75,13 +75,23 @@ if (-not $principalCheck.IsInRole([System.Security.Principal.WindowsBuiltInRole]
$localExe = Join-Path $ScriptDir 'Quasar.exe'
$bootstrapProject = Join-Path $RepoDir 'Quasar.Bootstrap\Quasar.Bootstrap.csproj'
+$packagedInstall = (Test-Path -LiteralPath $localExe) -and -not (Test-Path -LiteralPath $bootstrapProject)
$skipBuild = $NoBuild.IsPresent
-if (-not $skipBuild -and (Test-Path -LiteralPath $localExe) -and -not (Test-Path -LiteralPath $bootstrapProject)) {
+if (-not $skipBuild -and $packagedInstall) {
# Running next to an extracted release zip: install those binaries directly.
$skipBuild = $true
}
+if ([string]::IsNullOrWhiteSpace($InstallDir)) {
+ if ($packagedInstall) {
+ $InstallDir = $ScriptDir
+ }
+ else {
+ $InstallDir = "$env:ProgramFiles\Quasar"
+ }
+}
+
function Normalize-VersionComponent {
param([string]$Value)
if ([string]::IsNullOrEmpty($Value) -or $Value -notmatch '^[0-9]+$') { return '0' }
@@ -195,7 +205,6 @@ try {
Write-Host "Installing Quasar to $InstallDir..."
New-Item -ItemType Directory -Force -Path $InstallDir | Out-Null
- Get-ChildItem -LiteralPath $InstallDir -Force | Remove-Item -Recurse -Force
Get-ChildItem -LiteralPath $staging -Force |
ForEach-Object { Copy-Item -LiteralPath $_.FullName -Destination (Join-Path $InstallDir $_.Name) -Recurse -Force }
}
@@ -287,6 +296,7 @@ Installed Quasar.
Scheduled task: $TaskName
Install dir: $InstallDir
+Data dir: $InstallDir
Run as: $runAs
Web UI: $uiUrl
diff --git a/scripts/package-linux-release.sh b/scripts/package-linux-release.sh
index be6a62a..8451be2 100755
--- a/scripts/package-linux-release.sh
+++ b/scripts/package-linux-release.sh
@@ -11,7 +11,7 @@ ASSEMBLY_FILE_VERSION="1.0.0"
NUGET_VERSION="$VERSION"
WEB_ARCHIVE_NAME="quasar-web-linux-x64.tar.gz"
INSTALLER_ARCHIVE_NAME="quasar-installer-linux.tar.gz"
-INSTALLER_ROOT_NAME="quasar-installer-linux"
+INSTALLER_ROOT_NAME="Quasar"
normalize_version_component() {
local value="${1:-0}"
diff --git a/scripts/package-windows-release.ps1 b/scripts/package-windows-release.ps1
index f8e1164..32f64c1 100644
--- a/scripts/package-windows-release.ps1
+++ b/scripts/package-windows-release.ps1
@@ -24,7 +24,7 @@ $Version = if ($env:VERSION) { $env:VERSION } else { '' }
$DefaultAssemblyFileVersion = '1.0.0'
$WebArchiveName = 'quasar-web-win-x64.zip'
$InstallerArchiveName = 'quasar-installer-windows.zip'
-$InstallerRootName = 'quasar-installer-windows'
+$InstallerRootName = 'Quasar'
function Normalize-VersionComponent {
param([string]$Value)
diff --git a/scripts/readme-install-linux.md b/scripts/readme-install-linux.md
index 01365bd..fa7f702 100644
--- a/scripts/readme-install-linux.md
+++ b/scripts/readme-install-linux.md
@@ -1,14 +1,14 @@
## Install and run on Linux (x64)
You downloaded **`quasar-installer-linux.tar.gz`**. It contains one
-`quasar-installer-linux/` folder with the Quasar launcher (`Quasar`), the
+`Quasar/` folder with the Quasar launcher (`Quasar`), the
`install.sh` / `uninstall.sh` scripts, and a default `appsettings.json`.
### Run in the foreground
```bash
tar -xzf quasar-installer-linux.tar.gz
-cd quasar-installer-linux
+cd Quasar
./Quasar serve
```
@@ -22,14 +22,15 @@ port is configurable — see [Configuration](Docs/Configuration.md).
Install the **.NET 10 runtime** before running `install.sh`.
```bash
-tar -xzf quasar-installer-linux.tar.gz -C /tmp
-/tmp/quasar-installer-linux/install.sh --start
+mkdir -p ~/.local/share/Quasar
+tar -xzf quasar-installer-linux.tar.gz -C ~/.local/share/Quasar --strip-components=1
+~/.local/share/Quasar/install.sh --start
```
-This installs Quasar to `~/.local/share/Quasar`, creates the user's
-`~/.config/Quasar` data directory, and starts the user `quasar.service`. Pass
-`--system` with `sudo` for a machine-wide service, or `--data-dir ` to
-store Quasar state elsewhere. The web UI is then served at
+This installs Quasar in the extracted folder, keeps Quasar state in the same
+folder by default, and starts the user `quasar.service`. Pass `--system` with
+`sudo` for a machine-wide service, `--install-dir ` to copy it elsewhere,
+or `--data-dir ` to store Quasar state elsewhere. The web UI is then served at
`http://localhost:8080`. In the installed user service, the UI **Shutdown
Quasar** action requests `systemctl --user stop quasar.service`. Manage the
service with:
@@ -44,7 +45,7 @@ systemctl --user restart quasar.service
```bash
~/.local/share/Quasar/uninstall.sh # stop and remove the user service
-~/.local/share/Quasar/uninstall.sh --purge # also delete ~/.local/share/Quasar
+~/.local/share/Quasar/uninstall.sh --purge # also delete the install folder
```
The uninstall script stops `quasar.service` before removing it.
diff --git a/scripts/readme-install-windows.md b/scripts/readme-install-windows.md
index d318ca7..0816f4f 100644
--- a/scripts/readme-install-windows.md
+++ b/scripts/readme-install-windows.md
@@ -1,13 +1,13 @@
## Install and run on Windows (x64)
You downloaded **`quasar-installer-windows.zip`**. It contains one
-`quasar-installer-windows\` folder with the Quasar launcher (`Quasar.exe`), the
+`Quasar\` folder with the Quasar launcher (`Quasar.exe`), the
`install.ps1` / `uninstall.ps1` scripts, and a default `appsettings.json`. The
steps below assume you have extracted the zip, for example:
```powershell
Expand-Archive quasar-installer-windows.zip -DestinationPath C:\quasar
-cd C:\quasar\quasar-installer-windows
+cd C:\quasar\Quasar
```
### Run in the foreground
@@ -31,11 +31,11 @@ Run from an **elevated PowerShell** (Administrator):
.\install.ps1 -Start
```
-This installs Quasar to `%ProgramFiles%\Quasar` and registers a **Scheduled
-Task** named `Quasar` that starts the launcher at boot and restarts it on
-failure. The web UI is then served at . Pass
-`-User ` to run as a specific service account instead of the current
-user.
+This installs Quasar in the extracted folder, keeps Quasar state in the same
+folder by default, and registers a **Scheduled Task** named `Quasar` that starts
+the launcher at boot and restarts it on failure. The web UI is then served at
+. Pass `-InstallDir ` to copy it elsewhere, or
+`-User ` to run as a specific service account instead of the current user.
Manage the task:
@@ -48,7 +48,6 @@ Stop-ScheduledTask -TaskName Quasar
### Uninstall
```powershell
-cd "$env:ProgramFiles\Quasar"
.\uninstall.ps1 # stop and remove the Scheduled Task
.\uninstall.ps1 -Purge # also delete the install directory
```
diff --git a/scripts/uninstall.ps1 b/scripts/uninstall.ps1
index ed82aa0..f240ce5 100644
--- a/scripts/uninstall.ps1
+++ b/scripts/uninstall.ps1
@@ -2,13 +2,13 @@
# Windows analogue of uninstall.sh.
#
# Stops and removes the Quasar Scheduled Task registered by install.ps1. Runtime
-# and config data under %APPDATA%\Quasar is left untouched. Pass -Purge to also
-# delete the install directory.
+# and config data under the install directory is left untouched. Pass -Purge to
+# also delete the install directory.
[CmdletBinding()]
param(
[string]$TaskName = 'Quasar',
- [string]$InstallDir = "$env:ProgramFiles\Quasar",
+ [string]$InstallDir = $PSScriptRoot,
[switch]$Purge
)
diff --git a/uninstall.sh b/uninstall.sh
index 3dc3114..4362082 100755
--- a/uninstall.sh
+++ b/uninstall.sh
@@ -12,11 +12,12 @@ usage() {
Usage: ./uninstall.sh [options]
Stops and removes the Quasar systemd service installed by install.sh.
-Runtime/config data under the Quasar user's home directory is not removed.
+Runtime/config data under the install directory is not removed unless --purge is
+passed.
Options:
--service-name systemd service name (default: quasar)
- --install-dir Install directory (default: ~/.local/share/Quasar)
+ --install-dir Install directory (default: script directory)
--user-service Remove a user systemd service (default)
--system Remove a system service under /etc/systemd/system
--purge Also remove the install directory
diff --git a/whitelabel-theme-configurator.md b/whitelabel-theme-configurator.md
index 4071595..bdd96b2 100644
--- a/whitelabel-theme-configurator.md
+++ b/whitelabel-theme-configurator.md
@@ -9,7 +9,7 @@ Quasar currently has a static theme (`QuasarTheme.cs`) with hardcoded light/dark
```
BrandingService (singleton)
- └── loads/saves ~/.config/Quasar/branding.json
+ └── loads/saves /branding.json
└── exposes BuildMudTheme() → MudTheme
└── fires Changed event → MainLayout calls StateHasChanged