diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 03019eab..b7bd0724 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -595,30 +595,11 @@ jobs: match = matches[-1] numbers = [int(part) for part in match.group("core").split(".")] - prerelease = match.group("pre") or "" - prerelease_parts = prerelease.split(".") if prerelease else [] - prerelease_build = None - - if prerelease.isdigit(): - prerelease_build = int(prerelease) - elif prerelease_parts: - # Legacy PR/manual tags used base-pr.. and - # base-manual.. Sort them by the trailing run number so - # current review builds are not treated as older than stable - # base releases only because they carry a non-numeric label. - if prerelease_parts[0] in {"main", "manual"} and prerelease_parts[-1].isdigit(): - prerelease_build = int(prerelease_parts[-1]) - elif ( - prerelease_parts[0] == "pr" - and len(prerelease_parts) >= 3 - and prerelease_parts[-1].isdigit() - ): - prerelease_build = int(prerelease_parts[-1]) while len(numbers) < 3: numbers.append(0) if len(numbers) < 4: - numbers.append(prerelease_build or 0) + numbers.append(0) return tuple(numbers[:4]) diff --git a/.gitignore b/.gitignore index 86d7775a..5e81d912 100644 --- a/.gitignore +++ b/.gitignore @@ -9,6 +9,7 @@ *.user *.userosscache *.sln.docstates +.quasar-install-dir # User-specific files (MonoDevelop/Xamarin Studio) *.userprefs diff --git a/Docs/BuildingAndDevelopment.md b/Docs/BuildingAndDevelopment.md index 7f128f91..a89e0e98 100644 --- a/Docs/BuildingAndDevelopment.md +++ b/Docs/BuildingAndDevelopment.md @@ -22,7 +22,7 @@ utilities. For the runtime design see [Architecture](QuasarArchitecture.md). Quasar, Quasar.Agent, and companion plugins. - Quasar UI plugins Optional UI extensions are discovered through QuasarHub and installed into the - Quasar data directory at runtime. Entity Viewer now lives in the external + Quasar install directory at runtime. Entity Viewer now lives in the external `CometWorks/viewer` repository and is installed as a Quasar UI plugin instead of being staged from this repository during the core Quasar build. @@ -119,11 +119,25 @@ For local web UI development, run the worker directly: dotnet run --project Quasar/Quasar.csproj ``` -This uses the development launch profile and the normal Quasar data directory -(`~/.config/Quasar` on Linux unless `QUASAR_DATA_DIR` is set). The Bootstrap +This uses the development launch profile. Without `QUASAR_INSTALL_DIR`, the +direct worker uses its app base directory as the install root. The Bootstrap launcher and release/update cutover paths are covered by the packaged installer and release workflows rather than a local deploy helper. +To run the UI worker from Rider against an installed service/deployed tree, +write that root path into `.quasar-install-dir` at the repository root. The file +is ignored by git and is read through `QUASAR_INSTALL_DIR_FILE` from the worker +launch profile: + +```bash +printf '%s\n' "$HOME/.local/share/Quasar" > .quasar-install-dir +``` + +`QUASAR_INSTALL_DIR` still works and wins when set directly. The worker launch +profile deliberately does not set `applicationUrl`, so host/port come from that +install root's `appsettings.json`. Packaged assets and helper scripts are also +probed from the same install root. + Generate synthetic analytics data for local testing: ```bash @@ -131,8 +145,8 @@ python3 scripts/generate-analytics-data.py ``` Optional `--server ` to target one server, `--days `, `--seed `, -`--raw-hours `, `--raw-interval `. Uses `QUASAR_DATA_DIR` -automatically if set, otherwise defaults to the local Quasar data root. +`--raw-hours `, `--raw-interval `. Uses `QUASAR_INSTALL_DIR` +automatically if set, otherwise defaults to the local Quasar install root. When refreshing the local graphify graph, prune generic framework plumbing after `.graphify_extract.json` is produced and before graph build/report generation: diff --git a/Docs/Configuration.md b/Docs/Configuration.md index 80aacc93..843220e0 100644 --- a/Docs/Configuration.md +++ b/Docs/Configuration.md @@ -71,26 +71,18 @@ Magnetar log file. Auto-refresh and the Refresh button are active only for ## Where configuration is read from Both the **Bootstrap launcher** (`Quasar`/`Quasar.exe`) and the replaceable **web -worker** read JSON config from these locations, later ones overriding earlier: - -1. The install directory `appsettings.json` (next to the executable). Auto-updates - 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`. 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. +worker** read JSON config from the Quasar install root. Auto-updates preserve +`appsettings.json` during Bootstrap self-updates, and UI-worker activation +updates it from the staged, resolved `appsettings.json` so Bootstrap and the +managed worker keep the same base settings. Set `QUASAR_INSTALL_DIR`, or create +the ignored `.quasar-install-dir` file used by the development launch profile, +for direct worker/dev runs that need to target a deployed Quasar root. The shipped defaults are defined in [`Quasar/appsettings.json`](../Quasar/appsettings.json). During UI-worker staging, Quasar performs a three-way merge for `appsettings.json`: -the previous release base stored under the data directory is the merge base, the -current install-directory `appsettings.json` supplies local values, and the new +the previous release base stored under the install root is the merge base, the +current install-root `appsettings.json` supplies local values, and the new release file supplies new defaults. Clean local changes are carried into the staged version automatically. If both the local file and the release changed the same setting differently, the Updates page shows a conflict editor with @@ -130,7 +122,7 @@ mounted network share: } ``` -Relative paths are resolved under the Quasar data directory. If the folder is on +Relative paths are resolved under the Quasar install directory. If the folder is on a network share, make sure it is mounted before Quasar starts and that the Quasar service account can create, list, read, and delete files in it. Changes from the Backup page apply to new stored-backup operations immediately; direct @@ -308,9 +300,13 @@ trust broad networks unless every host in that range is under your control. ### Development port Running the worker directly with `dotnet run --project Quasar/Quasar.csproj` uses -[`Quasar/Properties/launchSettings.json`](../Quasar/Properties/launchSettings.json), -which sets `applicationUrl` to `http://0.0.0.0:8080`. Change `applicationUrl` there -to use a different port for local `dotnet run` sessions. +[`Quasar/Properties/launchSettings.json`](../Quasar/Properties/launchSettings.json) +only for development environment variables. It does not set `applicationUrl`, so +the worker still reads `Quasar:Host` / `Quasar:Port` from normal configuration. +Set those values in `QUASAR_INSTALL_DIR/appsettings.json`, or in the root named +by `.quasar-install-dir`, when running the worker from Rider against an +installed Quasar root. Use `QUASAR_WEB_HOST` / `QUASAR_WEB_PORT` for a one-off +direct-worker override. ## Browser auto-open on start diff --git a/Docs/LinuxDeploymentAndUpdates.md b/Docs/LinuxDeploymentAndUpdates.md index 06dfbcfd..9bef535a 100644 --- a/Docs/LinuxDeploymentAndUpdates.md +++ b/Docs/LinuxDeploymentAndUpdates.md @@ -73,11 +73,8 @@ review builds per PR/manual stream. ## First Start 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`. +and sets `QUASAR_INSTALL_DIR` to that same directory. 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, @@ -93,19 +90,21 @@ The downloaded archive must match the release's `SHA256SUMS` entry before it is extracted. When running as a systemd service, Bootstrap ignores a stale active-release pointer that targets a random external build directory. Only packaged -`WebService/` workers, managed web releases, staged legacy workers, or explicitly -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. +`WebService/` workers, managed web releases, or explicitly configured +`QUASAR_WEB_EXE` / `QUASAR_WEB_DLL` workers are trusted. Bootstrap always captures the managed web UI worker's stdout/stderr and mirrors it to its own console. For systemd installs, Quasar web UI warnings and errors therefore appear in the service journal as well as in the configured Quasar log files. +The UI **Shutdown Quasar** action drains the web worker, preserves managed +servers, and leaves Bootstrap running without a worker. Because Bootstrap is +still alive and exits successfully only when the service is stopped, systemd does +not restart the worker by itself. Run `systemctl --user restart quasar.service` +for the default user service, or `sudo systemctl restart quasar.service` for a +system service, to start the UI and supervisor again. + ## UI Worker Updates The running Quasar UI checks GitHub releases every 15 minutes by default. The @@ -118,9 +117,9 @@ are only queued until the operator stages the selected version. Staging requires 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 -(`` by default), and writes the resolved file into the staged worker. If the merge +the install root (`$QUASAR_INSTALL_DIR/Updates/appsettings.base.json`) 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 and save the JSON there, or choose **Force release defaults** to stage the release file without local appsettings values. @@ -224,26 +223,17 @@ tar -xzf quasar-installer-linux.tar.gz -C ~/.local/share/Quasar --strip-componen ``` 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 -stamps the launcher with `VERSION`, an exact git tag, or a short commit-derived -prerelease identity so Bootstrap update comparisons do not fall back to plain -`1.0.0`. - -If a previous `/opt/quasar` system install exists, the new user-mode installer -installs the new Bootstrap and user service first, then calls the old -`/opt/quasar/uninstall.sh --purge` through `sudo` to stop and remove the old -system service and files. +install root. Source installs keep using `~/.local/share/Quasar` as the default +install root. Use `--system` with `sudo` for a machine-wide service or +`--install-dir ` to install Quasar elsewhere. The generated service sets +`HOME` and `QUASAR_INSTALL_DIR` explicitly so Bootstrap and the worker agree on +the unified update/runtime state root. +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 stamps the launcher with +`VERSION`, an exact git tag, or a short commit-derived prerelease identity so +Bootstrap update comparisons do not fall back to plain `1.0.0`. Raising managed server priority no longer requires granting `CAP_SYS_NICE` to the whole Quasar service. The installer can build and install a narrow setuid @@ -273,11 +263,9 @@ to remove a machine-wide service. For the web UI host/port (including how to change the listening port, default `8080`) and browser auto-open behavior, see [Configuration](Configuration.md). -Update defaults live in `Quasar:Updates`. Packaged defaults come from the install -directory, and operator overrides can live in the Quasar data directory -(`/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. +Update defaults live in `Quasar:Updates`. Packaged defaults and operator +overrides live in the install root (`$QUASAR_INSTALL_DIR/appsettings.json`). +The worker and Bootstrap both read that install-root file on startup. ```json { @@ -316,7 +304,7 @@ until the operator chooses a version and presses Stage. Quasar can check GitHub releases without a token, but hosts on shared servers, NAT gateways, and public cloud IP ranges can hit GitHub's unauthenticated rate limit. The Updates page lets an admin save a GitHub token for release checks. -It is stored in `github-updates.json` under the Quasar data directory with the +It is stored in `github-updates.json` under the Quasar install directory with the same Data Protection encryption model and owner-only Unix permissions used for the Steam Workshop API key. diff --git a/Docs/QuasarArchitecture.md b/Docs/QuasarArchitecture.md index 9d440fd6..20c0d5a9 100644 --- a/Docs/QuasarArchitecture.md +++ b/Docs/QuasarArchitecture.md @@ -540,7 +540,7 @@ Practical guarantee: 2. Quasar checks GitHub releases every 15 minutes while running 3. new Linux web assets are downloaded into a staged version directory 4. staging performs a three-way `appsettings.json` rollover: previous release - base from the data directory, local install-directory values, and new release + base from the install directory, local install-directory values, and new release defaults 5. UI notifies admins that the update is queued/staged, or shows a conflict resolver when appsettings cannot be auto-merged @@ -559,10 +559,8 @@ 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. +Bootstrap sets `QUASAR_INSTALL_DIR` to the launcher install root when the +variable is not already set, so the launcher and worker share one root. The Updates page also shows installed managed-runtime versions independently of Quasar self-update state: Quasar UI/Bootstrap, Magnetar, and the Space Engineers @@ -902,7 +900,7 @@ As of this document: lines first. - first health-monitoring and auto-recovery pass exists for agent attach grace, heartbeat freshness, simulation-frame progress scoring aligned with the DS watcher, and uptime-based warning/recycle policy - initial runtime launch preparation now exists for isolated app-data roots, runtime config sync, `LastSession.sbl`, and enforced headless launch shaping -- server definitions store a saves root (`WorldPath`) plus selected save folder (`WorldSaveName`). Older definitions whose `WorldPath` pointed at a concrete save are migrated automatically by moving the final path segment into `WorldSaveName`. The server editor requires a selected save before save/start, lists existing saves from the saves root, and has an always-available Create From Template dialog that can create/import a world template before copying it into a new save. +- server definitions store a saves root (`WorldPath`) plus selected save folder (`WorldSaveName`). The server editor requires a selected save before save/start, lists existing saves from the saves root, and has an always-available Create From Template dialog that can create/import a world template before copying it into a new save. - neutral light/dark theming exists with local-storage persistence - config editing is now migrated out of Python into Quasar-managed JSON profiles and rendered runtime artifacts. Profiles cover Quasar root settings, server password (rendered to DS-compatible hash/salt), and DS-visible SE session settings including block type world limits; on server start Quasar writes session settings and mods into the world's authoritative `Sandbox_config.sbc` as well as the runtime DS config. World-template import flows can also read a template's current `Sandbox_config.sbc` and create a config profile from its session settings and mods. - file watching/reload now exists for manual edits to Quasar-managed server/profile JSON @@ -920,8 +918,8 @@ 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` 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 +- 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; 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; the UI shutdown action writes a launcher drain request, stops the worker, and leaves Bootstrap alive without respawning a worker until the service, task, or foreground launcher is restarted; 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`: 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 diff --git a/Docs/QuasarPluginSystem.md b/Docs/QuasarPluginSystem.md index d640c81e..4160cdc7 100644 --- a/Docs/QuasarPluginSystem.md +++ b/Docs/QuasarPluginSystem.md @@ -61,7 +61,7 @@ Safe-boot triggers: - command line: `--safe-mode` - environment variable: `QUASAR_SAFE_MODE=1` - environment variable: `QUASAR_DISABLE_UI_PLUGINS=1` -- data-directory marker file: `{Quasar data directory}/ui-plugins.safe-mode` +- install-root marker file: `{Quasar install directory}/ui-plugins.safe-mode` - automatic Bootstrap fallback after repeated worker startup failures or quick crashes @@ -79,7 +79,7 @@ Plugin activation should be last-known-good: 1. User installs/enables/updates a plugin. 2. Quasar writes enabled/disabled state to - `{Quasar data directory}/ui-plugins.state.json`. + `{Quasar install directory}/ui-plugins.state.json`. 3. Quasar restarts through Bootstrap. 4. The new worker loads plugins. 5. After startup and health checks pass, Quasar marks the plugin set active. @@ -141,7 +141,7 @@ repositories and pinned commits. Quasar only shows entries whose `PluginKind` is The `/settings/ui-plugins` page now manages the QuasarHub catalog: - refreshes and caches the catalog in - `{Quasar data directory}/Caches/ui-plugin-hub-catalog.json` + `{Quasar install directory}/Caches/ui-plugin-hub-catalog.json` - checks QuasarHub on Quasar startup and every 15 minutes so installed package update availability stays current - automatically installs or updates reviewed hub entries that opt into @@ -163,25 +163,25 @@ The `/settings/ui-plugins` page now manages the QuasarHub catalog: Installed UI plugin source packages live under: ```text -{Quasar data directory}/Plugins/{catalog id} +{Quasar install directory}/Plugins/{catalog id} ``` Installer staging lives under: ```text -{Quasar data directory}/Caches/ui-plugin-installer +{Quasar install directory}/Caches/ui-plugin-installer ``` Git source cache lives under: ```text -{Quasar data directory}/Caches/ui-plugin-sources +{Quasar install directory}/Caches/ui-plugin-sources ``` Enabled/disabled state lives under: ```text -{Quasar data directory}/ui-plugins.state.json +{Quasar install directory}/ui-plugins.state.json ``` The first installer supports root `quasar-plugin.json` package manifests. Build @@ -305,13 +305,13 @@ Configuration/environment knobs: Default plugin root: ```text -{Quasar data directory}/Plugins +{Quasar install directory}/Plugins ``` Shadow-copy root: ```text -{Quasar data directory}/Caches/ui-plugins/{pluginId}/{entryAssemblyHash} +{Quasar install directory}/Caches/ui-plugins/{pluginId}/{entryAssemblyHash} ``` Plugin navigation items are rendered by the main Quasar sidebar. Supported zones: @@ -570,7 +570,7 @@ The Entity Viewer can keep its JavaScript/Three.js-heavy surface in its own repository and serve it from that plugin path. Quasar core no longer copies viewer assets into `Quasar/wwwroot`; the QuasarHub installer clones the pinned viewer repository commit, builds the adapter project, and loads the package from -the Quasar data directory. +the Quasar install directory. Plugins can also ask Quasar to inject package stylesheets into the host page by declaring manifest-relative paths: diff --git a/Docs/QuickStart.md b/Docs/QuickStart.md index 8855e40b..8fd13c96 100644 --- a/Docs/QuickStart.md +++ b/Docs/QuickStart.md @@ -26,8 +26,11 @@ Quasar.exe serve ``` Quasar starts, opens `http://localhost:8080` in your browser, and prints log -output to the console. Press `Ctrl+C` to stop. The web UI port is configurable -— see [Configuration](Configuration.md). +output to the console. Press `Ctrl+C` to stop the launcher. The UI **Shutdown +Quasar** action drains the web worker and leaves the foreground launcher idle; +press `Ctrl+C`, then run `./Quasar serve` or `Quasar.exe serve` again when you +want the UI back. The web UI port is configurable — see +[Configuration](Configuration.md). ## Install as a background service @@ -56,13 +59,13 @@ tar -xzf quasar-installer-linux.tar.gz -C ~/.local/share/Quasar --strip-componen ``` 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. +folder as the install root, and writes that path to the unit as +`QUASAR_INSTALL_DIR`. Pass `--system` with `sudo` for a machine-wide service or +`--install-dir ` to install Quasar 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. +Quasar** action drains the web worker and leaves `quasar.service` running idle +without respawning it. Managed servers stay detached by default. Restart the +service to bring the UI and supervisor back. Manage the service with the usual systemd commands: @@ -98,6 +101,8 @@ 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. +The UI **Shutdown Quasar** action drains the web worker and leaves the Scheduled +Task running idle. Stop and start the task to bring the UI and supervisor back. To remove: diff --git a/Docs/StateMachines/BackupJobs.md b/Docs/StateMachines/BackupJobs.md index 648f41c0..5e3bf26c 100644 --- a/Docs/StateMachines/BackupJobs.md +++ b/Docs/StateMachines/BackupJobs.md @@ -8,8 +8,7 @@ retention policy. Relevant source: [`AutomaticBackupService.cs`](../../Quasar/Services/Backup/AutomaticBackupService.cs), [`QuasarBackupManifest.cs`](../../Quasar/Models/QuasarBackupManifest.cs), -[`BackupCompatibility.cs`](../../Quasar/Services/Backup/BackupCompatibility.cs), -[`BackupFormatMigrations.cs`](../../Quasar/Services/Backup/BackupFormatMigrations.cs). +[`BackupCompatibility.cs`](../../Quasar/Services/Backup/BackupCompatibility.cs). There are three [`QuasarBackupKind`](../../Quasar/Models/QuasarBackupManifest.cs) values — `Configuration`, `Server`, `World` — corresponding to the queued job @@ -72,8 +71,7 @@ Restore is gated by a semantic-version check ([`BackupCompatibility.Evaluate`](../../Quasar/Services/Backup/BackupCompatibility.cs)): - same `Major.Minor` → allowed; -- older backup → allowed only if [`BackupFormatMigrations.CanMigrate`](../../Quasar/Services/Backup/BackupFormatMigrations.cs) - finds a contiguous upgrade path through the registered migration steps; +- older backup than the running build → rejected; - newer backup than the running build → rejected (no downgrade). The archive's `quasar-backup.json` manifest records `FormatVersion` (archive diff --git a/Docs/StateMachines/SelfUpdateAndRelease.md b/Docs/StateMachines/SelfUpdateAndRelease.md index d65b9e70..8f4c46a4 100644 --- a/Docs/StateMachines/SelfUpdateAndRelease.md +++ b/Docs/StateMachines/SelfUpdateAndRelease.md @@ -71,6 +71,8 @@ stateDiagram-v2 ForceKilled --> WorkerLaunching: start new-release worker Running --> Restarting: worker exits unexpectedly Restarting --> WorkerLaunching: relaunch (force) + Running --> Drained: UI Shutdown Quasar / launcher drain request + Drained --> [*]: service/task/foreground launcher restarted Running --> SelfUpgrade: newer Bootstrap asset applied SelfUpgrade --> [*]: exit 75 (Linux) / detached relaunch + exit 0 (Windows) ``` @@ -85,6 +87,7 @@ stateDiagram-v2 | `Draining` | Pointer change detected; the launcher posts `/api/internal/drain` (authenticated with the per-session launcher token) and waits for graceful exit. | | `Retired` / `ForceKilled` | Old worker exited within the grace window, or was killed after timeout. | | `Restarting` | Worker exited unexpectedly (not a launcher request); relaunched with `force`. | +| `Drained` | UI **Shutdown Quasar** requested a launcher drain; Bootstrap stays alive without respawning a worker until the service, task, or foreground launcher is restarted. | | `SelfUpgrade` | A newer Bootstrap asset was applied by the periodic monitor or by a consumed `Updates/bootstrap-update-request.json` request from the Updates page; forced requests target the detected version and platform asset. Linux exits **75** so systemd restarts it; Windows spawns a detached `Quasar.exe serve --quiet` replacement and exits **0**. | The pointer is `Updates/active-release.json` diff --git a/Docs/StateMachines/diagrams/bootstrap-cutover.mmd b/Docs/StateMachines/diagrams/bootstrap-cutover.mmd index 578a0bc7..4c51f425 100644 --- a/Docs/StateMachines/diagrams/bootstrap-cutover.mmd +++ b/Docs/StateMachines/diagrams/bootstrap-cutover.mmd @@ -11,5 +11,7 @@ stateDiagram-v2 ForceKilled --> WorkerLaunching: start new-release worker Running --> Restarting: worker exits unexpectedly Restarting --> WorkerLaunching: relaunch (force) + Running --> Drained: UI Shutdown Quasar / launcher drain request + Drained --> [*]: service/task/foreground launcher restarted Running --> SelfUpgrade: newer Bootstrap asset applied SelfUpgrade --> [*]: exit 75 (Linux) / detached relaunch + exit 0 (Windows) diff --git a/Docs/StateMachines/diagrams/bootstrap-cutover.png b/Docs/StateMachines/diagrams/bootstrap-cutover.png index 49b8e43e..0ee82f07 100644 Binary files a/Docs/StateMachines/diagrams/bootstrap-cutover.png and b/Docs/StateMachines/diagrams/bootstrap-cutover.png differ diff --git a/Docs/WindowsDeploymentAndUpdates.md b/Docs/WindowsDeploymentAndUpdates.md index be35d055..54b14f90 100644 --- a/Docs/WindowsDeploymentAndUpdates.md +++ b/Docs/WindowsDeploymentAndUpdates.md @@ -74,6 +74,11 @@ Bootstrap always captures the managed web UI worker's stdout/stderr and mirrors it to the Bootstrap process console, so Quasar web UI warnings and errors are available from the launcher side in addition to the configured Quasar log files. +The UI **Shutdown Quasar** action drains the web worker, preserves managed +servers, and leaves Bootstrap running without a worker. Because the task process +is still alive, Task Scheduler does not restart the worker by itself. Stop and +start the `Quasar` Scheduled Task to start the UI and supervisor again. + 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: @@ -85,9 +90,6 @@ extracts it under: 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 @@ -105,8 +107,8 @@ browser shows a restart progress overlay, polls `/api/health` until the activated UI version is serving, then reloads the Updates page. Staging also resolves `appsettings.json`. Quasar uses the stored release base in -the data directory (`\Updates\appsettings.base.json` by default) as the -merge base, applies local values from the install directory, and writes the +the install root (`\Updates\appsettings.base.json`) 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 and save the JSON there, or choose **Force release defaults** to stage the @@ -228,7 +230,7 @@ elsewhere. Environment overrides: Quasar can check GitHub releases without a token, but hosts on shared servers, NAT gateways, and public cloud IP ranges can hit GitHub's unauthenticated rate limit. The Updates page lets an admin save a GitHub token for release checks. -It is stored in `github-updates.json` under the Quasar data directory with the +It is stored in `github-updates.json` under the Quasar install directory with the same Data Protection encryption model used for the Steam Workshop API key. Use a classic personal access token without any permissions: diff --git a/Magnetar.Protocol/Runtime/MagnetarPaths.cs b/Magnetar.Protocol/Runtime/MagnetarPaths.cs index c9e0dce5..ad5e2dbd 100644 --- a/Magnetar.Protocol/Runtime/MagnetarPaths.cs +++ b/Magnetar.Protocol/Runtime/MagnetarPaths.cs @@ -1,33 +1,47 @@ using System; +using System.Collections.Generic; using System.IO; namespace Magnetar.Protocol.Runtime; public static class MagnetarPaths { + private const string InstallDirectoryEnvironmentVariable = "QUASAR_INSTALL_DIR"; + private const string InstallDirectoryFileEnvironmentVariable = "QUASAR_INSTALL_DIR_FILE"; + private const string DevInstallDirectoryFileName = ".quasar-install-dir"; + private static string? _cachedQuasarDirectory; + // ------------------------------------------------------------------------- - // 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. + // Root - everything lives under QUASAR_INSTALL_DIR when set. Bootstrap sets + // it to the launcher install root for packaged installs. Direct worker + // sessions may read an ignored .quasar-install-dir file; otherwise they use + // the app base directory. // ------------------------------------------------------------------------- public static string GetQuasarDirectory() { - var envOverride = Environment.GetEnvironmentVariable("QUASAR_DATA_DIR"); + var cachedDirectory = _cachedQuasarDirectory; + if (cachedDirectory is not null) + return cachedDirectory; + + var resolvedDirectory = ResolveQuasarDirectory(); + _cachedQuasarDirectory = resolvedDirectory; + return resolvedDirectory; + } + + private static string ResolveQuasarDirectory() + { + var envOverride = Environment.GetEnvironmentVariable(InstallDirectoryEnvironmentVariable); if (!string.IsNullOrWhiteSpace(envOverride)) - return envOverride.Trim(); + return Path.GetFullPath(envOverride.Trim()); - var appData = Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData); - if (string.IsNullOrWhiteSpace(appData)) - appData = AppContext.BaseDirectory; + var fileOverride = TryReadInstallDirectoryOverrideFile(); + if (fileOverride is not null) + return fileOverride; - return Path.Combine(appData, "Quasar"); + return Path.GetFullPath(AppContext.BaseDirectory); } - // Kept for backward compatibility — resolves to the Quasar root. - public static string GetRuntimeDirectory() => GetQuasarDirectory(); - // ------------------------------------------------------------------------- // Bootstrap / web-service manifest // ------------------------------------------------------------------------- @@ -66,9 +80,6 @@ public static string GetQuasarBrandingPath() => public static string GetQuasarBrandingDirectory() => Path.Combine(GetQuasarDirectory(), "Branding"); - public static string GetQuasarBrandingDirectory(string webRootPath) => - GetQuasarBrandingDirectory(); - public static string GetQuasarDeathMessagesPath() => Path.Combine(GetQuasarDirectory(), "death-messages.json"); @@ -129,9 +140,6 @@ public static string GetQuasarServerAnalyticsPath(string uniqueName) => public static string GetQuasarWorldTemplatesDirectory() => Path.Combine(GetQuasarDirectory(), "WorldTemplates"); - public static string GetLegacyQuasarWorldProfilesDirectory() => - Path.Combine(GetQuasarDirectory(), "WorldProfiles"); - public static string GetQuasarWorldTemplateDirectory(string worldTemplateId) => Path.Combine(GetQuasarWorldTemplatesDirectory(), SanitizePathSegment(worldTemplateId)); @@ -207,4 +215,70 @@ private static string SanitizePathSegment(string value) return sanitized; } + + private static string? TryReadInstallDirectoryOverrideFile() + { + foreach (var filePath in EnumerateInstallDirectoryOverrideFiles()) + { + if (!File.Exists(filePath)) + continue; + + try + { + var value = File.ReadAllText(filePath).Trim(); + if (string.IsNullOrWhiteSpace(value)) + continue; + + var baseDirectory = Path.GetDirectoryName(filePath) ?? Directory.GetCurrentDirectory(); + return Path.GetFullPath(Path.IsPathRooted(value) + ? value + : Path.Combine(baseDirectory, value)); + } + catch + { + } + } + + return null; + } + + private static IEnumerable EnumerateInstallDirectoryOverrideFiles() + { + var explicitFile = Environment.GetEnvironmentVariable(InstallDirectoryFileEnvironmentVariable); + if (!string.IsNullOrWhiteSpace(explicitFile)) + { + var trimmed = explicitFile.Trim(); + if (Path.IsPathRooted(trimmed)) + { + yield return trimmed; + yield break; + } + + foreach (var directory in EnumerateProbeDirectories()) + yield return Path.Combine(directory, trimmed); + + yield break; + } + + foreach (var directory in EnumerateProbeDirectories()) + yield return Path.Combine(directory, DevInstallDirectoryFileName); + } + + private static IEnumerable EnumerateProbeDirectories() + { + var seen = new HashSet(StringComparer.OrdinalIgnoreCase); + foreach (var root in new[] { Directory.GetCurrentDirectory(), AppContext.BaseDirectory }) + { + if (string.IsNullOrWhiteSpace(root)) + continue; + + var directory = new DirectoryInfo(root); + for (var depth = 0; directory is not null && depth < 8; depth++, directory = directory.Parent) + { + var fullPath = Path.GetFullPath(directory.FullName); + if (seen.Add(fullPath)) + yield return fullPath; + } + } + } } diff --git a/Magnetar.Protocol/Runtime/QuasarReleaseVersion.cs b/Magnetar.Protocol/Runtime/QuasarReleaseVersion.cs index 32050536..7cc46372 100644 --- a/Magnetar.Protocol/Runtime/QuasarReleaseVersion.cs +++ b/Magnetar.Protocol/Runtime/QuasarReleaseVersion.cs @@ -11,10 +11,6 @@ public static class QuasarReleaseVersion @"(?\d+(?:\.\d+){1,3})(?:-(?
[0-9A-Za-z][0-9A-Za-z.-]*))?",
         RegexOptions.Compiled | RegexOptions.CultureInvariant);
 
-    private static readonly Regex LegacyChannelPrereleasePattern = new(
-        @"^(?:(?main|manual)\.(?\d+)|pr\.(?\d+)\.(?\d+))$",
-        RegexOptions.Compiled | RegexOptions.CultureInvariant | RegexOptions.IgnoreCase);
-
     public static string GetEntryAssemblyVersion()
     {
         var assembly = Assembly.GetEntryAssembly();
@@ -55,11 +51,6 @@ public static string Normalize(string value)
 
         var core = NormalizeCore(match.Groups["core"].Value);
         var prerelease = match.Groups["pre"].Success ? match.Groups["pre"].Value : string.Empty;
-        if (TryNormalizeLegacyChannelPrerelease(core, prerelease, out var channelVersion))
-            return channelVersion;
-
-        if (int.TryParse(prerelease, out _))
-            return $"{core}.{prerelease}";
 
         return string.IsNullOrWhiteSpace(prerelease) ? core : $"{core}-{prerelease}";
     }
@@ -73,28 +64,6 @@ private static string NormalizeCore(string core)
         return string.Join(".", parts);
     }
 
-    private static bool TryNormalizeLegacyChannelPrerelease(string core, string prerelease, out string version)
-    {
-        version = string.Empty;
-        if (string.IsNullOrWhiteSpace(prerelease))
-            return false;
-
-        var match = LegacyChannelPrereleasePattern.Match(prerelease);
-        if (!match.Success)
-            return false;
-
-        var build = match.Groups["build"].Value;
-        var normalizedCore = $"{core}.{build}";
-        if (match.Groups["pr"].Success)
-        {
-            version = $"{normalizedCore}-pr.{match.Groups["pr"].Value}";
-            return true;
-        }
-
-        version = $"{normalizedCore}-{match.Groups["channel"].Value.ToLowerInvariant()}";
-        return true;
-    }
-
     private static bool TryParse(string value, out ReleaseVersion version)
     {
         version = default!;
diff --git a/Quasar.Bootstrap/Program.cs b/Quasar.Bootstrap/Program.cs
index 954af280..c593a22b 100644
--- a/Quasar.Bootstrap/Program.cs
+++ b/Quasar.Bootstrap/Program.cs
@@ -30,7 +30,7 @@ internal static class Program
 
     public static async Task Main(string[] args)
     {
-        BootstrapDataDirectoryMigration.ApplyInstallRootDefault();
+        BootstrapInstallDirectoryDefaults.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));
@@ -560,244 +560,23 @@ private static bool IsDotNetHost(string processPath)
     }
 }
 
-internal static class BootstrapDataDirectoryMigration
+internal static class BootstrapInstallDirectoryDefaults
 {
-    private const string DataDirectoryEnvironmentVariable = "QUASAR_DATA_DIR";
-
-    public static BootstrapDataDirectoryMigrationResult LastResult { get; private set; } = BootstrapDataDirectoryMigrationResult.Empty;
+    private const string InstallDirectoryEnvironmentVariable = "QUASAR_INSTALL_DIR";
 
     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))
+        if (!string.IsNullOrWhiteSpace(Environment.GetEnvironmentVariable(InstallDirectoryEnvironmentVariable)))
             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))
+        var installRoot = AppContext.BaseDirectory;
+        if (string.IsNullOrWhiteSpace(installRoot))
             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);
-            var arguments = RewriteMigratedArguments(pointer.Arguments, legacyRoot, targetRoot);
-            if (string.Equals(fileName, pointer.FileName, StringComparison.Ordinal) &&
-                string.Equals(workingDirectory, pointer.WorkingDirectory, StringComparison.Ordinal) &&
-                string.Equals(arguments, pointer.Arguments, StringComparison.Ordinal))
-            {
-                return;
-            }
-
-            var rewritten = new QuasarActiveReleasePointer
-            {
-                Version = pointer.Version,
-                FileName = fileName,
-                Arguments = 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 string RewriteMigratedArguments(string value, string legacyRoot, string targetRoot)
-    {
-        if (string.IsNullOrWhiteSpace(value) || string.IsNullOrWhiteSpace(legacyRoot))
-            return value;
-
-        var normalizedLegacyRoot = NormalizeDirectory(legacyRoot);
-        var normalizedTargetRoot = NormalizeDirectory(targetRoot);
-        var comparison = OperatingSystem.IsWindows() ? StringComparison.OrdinalIgnoreCase : StringComparison.Ordinal;
-        var rewritten = value.Replace(normalizedLegacyRoot, normalizedTargetRoot, comparison);
-
-        if (OperatingSystem.IsWindows())
-        {
-            rewritten = rewritten.Replace(
-                normalizedLegacyRoot.Replace('\\', '/'),
-                normalizedTargetRoot.Replace('\\', '/'),
-                StringComparison.OrdinalIgnoreCase);
-        }
-
-        return rewritten;
-    }
-
-    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);
+        Environment.SetEnvironmentVariable(InstallDirectoryEnvironmentVariable, Path.GetFullPath(installRoot));
     }
 }
 
-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";
@@ -975,16 +754,14 @@ public static string ReadToken(ILogger logger)
             if (persisted is null)
                 return string.Empty;
 
-            if (!string.IsNullOrWhiteSpace(persisted.ProtectedToken))
-            {
-                var protector = DataProtectionProvider
-                    .Create(new DirectoryInfo(MagnetarPaths.GetQuasarDataProtectionKeyringDirectory()), options => options.SetApplicationName("Quasar"))
-                    .CreateProtector(DataProtectionPurpose);
+            if (string.IsNullOrWhiteSpace(persisted.ProtectedToken))
+                return string.Empty;
 
-                return protector.Unprotect(persisted.ProtectedToken).Trim();
-            }
+            var protector = DataProtectionProvider
+                .Create(new DirectoryInfo(MagnetarPaths.GetQuasarDataProtectionKeyringDirectory()), options => options.SetApplicationName("Quasar"))
+                .CreateProtector(DataProtectionPurpose);
 
-            return persisted.Token?.Trim() ?? string.Empty;
+            return protector.Unprotect(persisted.ProtectedToken).Trim();
         }
         catch (Exception exception)
         {
@@ -996,8 +773,6 @@ public static string ReadToken(ILogger logger)
     private sealed class PersistedCredentials
     {
         public string? ProtectedToken { get; set; }
-
-        public string? Token { get; set; }
     }
 }
 
@@ -1031,6 +806,7 @@ internal sealed class LauncherCoordinator : IHostedService, IDisposable
     private WorkerProcessHandle? _currentWorker;
     private bool _isStopping;
     private bool _isRestartingForBootstrapUpdate;
+    private bool _isDrained;
 
     public LauncherCoordinator(BootstrapOptions options, LauncherForegroundOptions foregroundOptions, ILogger logger)
     {
@@ -1054,7 +830,7 @@ public bool IsReady
         {
             lock (_sync)
             {
-                return _currentWorker is not null && !_currentWorker.Process.HasExited;
+                return !_isDrained && _currentWorker is not null && !_currentWorker.Process.HasExited;
             }
         }
     }
@@ -1063,19 +839,23 @@ public object GetHealthPayload()
     {
         var workerVersion = string.Empty;
         var workerBaseUrl = string.Empty;
+        var isDrained = false;
+        var isReady = false;
 
         lock (_sync)
         {
+            isDrained = _isDrained;
             if (_currentWorker is not null)
             {
                 workerVersion = _currentWorker.Release.Version;
                 workerBaseUrl = _currentWorker.BaseUri.AbsoluteUri.TrimEnd('/');
+                isReady = !_isDrained && !_currentWorker.Process.HasExited;
             }
         }
 
         return new
         {
-            status = IsReady ? "ok" : "starting",
+            status = isDrained ? "drained" : isReady ? "ok" : "starting",
             workerId = _workerId,
             hostId = Environment.MachineName.ToLowerInvariant(),
             hostName = Environment.MachineName,
@@ -1101,25 +881,11 @@ 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());
+        if (TryConsumeLauncherShutdownRequest())
+            _logger.LogInformation("Cleared stale Quasar launcher drain request on startup.");
+
         await EnsureInitialWebReleaseAvailableAsync(cancellationToken).ConfigureAwait(false);
         EnsureActiveReleasePointerExists();
         await ActivateCurrentReleaseAsync(force: false, cancellationToken).ConfigureAwait(false);
@@ -1293,13 +1059,13 @@ private async Task RunBootstrapUpdateMonitorAsync(CancellationToken cancellation
 
     private async Task TryUpgradeBootstrapAsync(CancellationToken cancellationToken, BootstrapUpdateRequest? request = null)
     {
-        if (_isStopping || _isRestartingForBootstrapUpdate)
+        if (_isStopping || _isRestartingForBootstrapUpdate || IsDrained())
             return;
 
         await _bootstrapUpdateLock.WaitAsync(cancellationToken).ConfigureAwait(false);
         try
         {
-            if (_isStopping || _isRestartingForBootstrapUpdate)
+            if (_isStopping || _isRestartingForBootstrapUpdate || IsDrained())
                 return;
 
             var requestedVersion = QuasarReleaseVersion.Normalize(request?.Version ?? string.Empty);
@@ -1506,10 +1272,7 @@ private async Task EnsureInitialWebReleaseAvailableAsync(CancellationToken cance
     {
         var existing = ReadActiveReleasePointer();
         if (existing is not null && IsReleasePointerUsable(existing, allowExternalPointer: !IsServiceMode()))
-        {
-            TryMigrateStagedActiveRelease(existing);
             return;
-        }
 
         if (FindPackagedWorkerCandidate() is not null)
             return;
@@ -1798,6 +1561,9 @@ private void HandleReleasePointerChanged(object sender, FileSystemEventArgs args
 
     private async Task ActivateCurrentReleaseAsync(bool force, CancellationToken cancellationToken)
     {
+        if (IsDrained())
+            return;
+
         var pointer = ReadActiveReleasePointer();
         if (pointer is null)
             return;
@@ -1810,6 +1576,9 @@ private async Task ActivateCurrentReleaseAsync(bool force, CancellationToken can
             WorkerProcessHandle? current;
             lock (_sync)
             {
+                if (_isDrained)
+                    return;
+
                 current = _currentWorker;
                 if (!force &&
                     current is not null &&
@@ -1835,6 +1604,9 @@ await DrainAndRetireWorkerAsync(
                     cancellationToken).ConfigureAwait(false);
             }
 
+            if (IsDrained())
+                return;
+
             var nextWorker = await StartWorkerAsync(pointer, cancellationToken).ConfigureAwait(false);
             if (nextWorker is null)
             {
@@ -1860,9 +1632,15 @@ await DrainAndRetireWorkerAsync(
 
     private async Task ActivateSpecificReleaseAsync(QuasarActiveReleasePointer pointer, CancellationToken cancellationToken)
     {
+        if (IsDrained())
+            return;
+
         await _activationLock.WaitAsync(cancellationToken).ConfigureAwait(false);
         try
         {
+            if (IsDrained())
+                return;
+
             var worker = await StartWorkerAsync(pointer, cancellationToken).ConfigureAwait(false);
             if (worker is null)
                 return;
@@ -1886,6 +1664,9 @@ private async Task ActivateSpecificReleaseAsync(QuasarActiveReleasePointer point
         CancellationToken cancellationToken,
         bool allowSafeModeRetry = true)
     {
+        if (IsDrained())
+            return null;
+
         if (string.IsNullOrWhiteSpace(pointer.FileName))
             return null;
 
@@ -1918,8 +1699,7 @@ private async Task ActivateSpecificReleaseAsync(QuasarActiveReleasePointer point
         startInfo.Environment["QUASAR_MODE"] = "service";
         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_INSTALL_DIR"] = ResolveInstallDirectoryForWorker();
         startInfo.Environment["QUASAR_PRESERVE_SERVERS_ON_SHUTDOWN"] = _options.PreserveServersOnShutdown ? "true" : "false";
         startInfo.Environment["QUASAR_CONSOLE_LOGGING"] = "true";
 
@@ -1973,7 +1753,10 @@ private static void SyncInstallAppSettingsToWorker(string workerDirectory)
         if (string.IsNullOrWhiteSpace(workerDirectory))
             return;
 
-        var sourcePath = Path.Combine(AppContext.BaseDirectory, "appsettings.json");
+        var sourcePath = ResolveInstallAppSettingsPath();
+        if (string.IsNullOrWhiteSpace(sourcePath))
+            return;
+
         var destinationPath = Path.Combine(workerDirectory, "appsettings.json");
         if (!File.Exists(sourcePath) || IsSamePath(sourcePath, destinationPath))
             return;
@@ -2075,14 +1858,22 @@ private void HandleWorkerExited(WorkerProcessHandle worker)
 
         if (TryConsumeLauncherShutdownRequest())
         {
-            _logger.LogInformation("Quasar worker requested launcher shutdown; exiting without restarting worker.");
-            Environment.Exit(0);
+            lock (_sync)
+            {
+                if (ReferenceEquals(_currentWorker, worker))
+                    _currentWorker = null;
+
+                _isDrained = true;
+            }
+
+            _logger.LogInformation("Quasar worker requested launcher drain; Bootstrap remains running without a worker until the service or task is restarted.");
+            TryDisposeProcess(worker.Process);
             return;
         }
 
         lock (_sync)
         {
-            if (!_isStopping && ReferenceEquals(_currentWorker, worker))
+            if (!_isStopping && !_isDrained && ReferenceEquals(_currentWorker, worker))
             {
                 _currentWorker = null;
                 shouldRestart = true;
@@ -2108,6 +1899,14 @@ private void HandleWorkerExited(WorkerProcessHandle worker)
             _ = Task.Run(() => ActivateCurrentReleaseAsync(force: true, CancellationToken.None), CancellationToken.None);
     }
 
+    private bool IsDrained()
+    {
+        lock (_sync)
+        {
+            return _isDrained;
+        }
+    }
+
     private bool RegisterWorkerStartupFailure(string reason)
     {
         var now = DateTimeOffset.UtcNow;
@@ -2189,6 +1988,17 @@ private static bool TryConsumeLauncherShutdownRequest()
         return true;
     }
 
+    private static void TryDisposeProcess(Process process)
+    {
+        try
+        {
+            process.Dispose();
+        }
+        catch
+        {
+        }
+    }
+
     private static bool TryConsumeBootstrapUpdateRequest(out BootstrapUpdateRequest request)
     {
         request = new BootstrapUpdateRequest();
@@ -2264,52 +2074,6 @@ private static void WriteActiveReleasePointer(QuasarActiveReleasePointer pointer
         File.WriteAllText(path, JsonSerializer.Serialize(pointer, JsonOptions));
     }
 
-    private static bool TryMigrateStagedActiveRelease(QuasarActiveReleasePointer pointer)
-    {
-        pointer = Normalize(pointer);
-        var sourceDirectory = string.IsNullOrWhiteSpace(pointer.WorkingDirectory)
-            ? Path.GetDirectoryName(pointer.FileName) ?? string.Empty
-            : pointer.WorkingDirectory;
-        if (string.IsNullOrWhiteSpace(sourceDirectory) ||
-            !IsPathUnder(sourceDirectory, MagnetarPaths.GetQuasarStagingDirectory()))
-        {
-            return false;
-        }
-
-        var version = QuasarReleaseVersion.Normalize(string.IsNullOrWhiteSpace(pointer.Version)
-            ? Path.GetFileName(sourceDirectory)
-            : pointer.Version);
-        var activeDirectory = MagnetarPaths.GetQuasarManagedWebReleaseDirectory(version);
-        if (IsSamePath(sourceDirectory, activeDirectory))
-            return false;
-
-        try
-        {
-            if (Directory.Exists(activeDirectory))
-                Directory.Delete(activeDirectory, recursive: true);
-
-            CopyDirectory(sourceDirectory, activeDirectory);
-            QuasarWebReleaseLayout.ValidateDirectory(activeDirectory);
-            var workerPath = Path.Combine(activeDirectory, QuasarWebReleaseLayout.WorkerExecutableFileName);
-            EnsureExecutableBit(workerPath);
-
-            WriteActiveReleasePointer(new QuasarActiveReleasePointer
-            {
-                Version = version,
-                FileName = workerPath,
-                Arguments = string.Empty,
-                WorkingDirectory = activeDirectory,
-                ActivatedAtUtc = pointer.ActivatedAtUtc == default ? DateTimeOffset.UtcNow : pointer.ActivatedAtUtc,
-            });
-            CleanupStagedUpdates(activeDirectory);
-            return true;
-        }
-        catch
-        {
-            return false;
-        }
-    }
-
     private static void CleanupStagedUpdates(string activeWorkingDirectory)
     {
         var stagingDirectory = MagnetarPaths.GetQuasarStagingDirectory();
@@ -2340,17 +2104,6 @@ private static void CleanupInactiveManagedWebReleases(string activeWorkingDirect
         }
     }
 
-    private static void CopyDirectory(string sourceDirectory, string destinationDirectory)
-    {
-        foreach (var sourcePath in Directory.EnumerateFiles(sourceDirectory, "*", SearchOption.AllDirectories))
-        {
-            var relativePath = Path.GetRelativePath(sourceDirectory, sourcePath);
-            var destinationPath = Path.Combine(destinationDirectory, relativePath);
-            Directory.CreateDirectory(Path.GetDirectoryName(destinationPath)!);
-            File.Copy(sourcePath, destinationPath, overwrite: true);
-        }
-    }
-
     private static bool IsSamePath(string left, string right)
     {
         if (string.IsNullOrWhiteSpace(left) || string.IsNullOrWhiteSpace(right))
@@ -2541,9 +2294,13 @@ private static bool IsKnownReleasePath(string path)
         if (string.IsNullOrWhiteSpace(path))
             return false;
 
-        return IsPathUnder(path, Path.Combine(AppContext.BaseDirectory, "WebService")) ||
-               IsPathUnder(path, MagnetarPaths.GetQuasarManagedWebServiceDirectory()) ||
-               IsPathUnder(path, MagnetarPaths.GetQuasarStagingDirectory());
+        foreach (var directory in EnumerateInstallCandidateDirectories())
+        {
+            if (IsPathUnder(path, Path.Combine(directory, "WebService")))
+                return true;
+        }
+
+        return IsPathUnder(path, MagnetarPaths.GetQuasarManagedWebServiceDirectory());
     }
 
     private static bool IsPathUnder(string path, string root)
@@ -2558,9 +2315,24 @@ private static bool IsPathUnder(string path, string root)
 
     private static string? FindPackagedWorkerCandidate()
     {
-        foreach (var fileName in GetQuasarExecutableFileNames())
+        foreach (var directory in EnumerateInstallCandidateDirectories())
         {
-            var candidate = Path.Combine(AppContext.BaseDirectory, "WebService", fileName);
+            foreach (var fileName in GetQuasarExecutableFileNames())
+            {
+                var candidate = Path.Combine(directory, "WebService", fileName);
+                if (File.Exists(candidate))
+                    return candidate;
+            }
+        }
+
+        return null;
+    }
+
+    private static string? ResolveInstallAppSettingsPath()
+    {
+        foreach (var directory in EnumerateInstallCandidateDirectories())
+        {
+            var candidate = Path.Combine(directory, "appsettings.json");
             if (File.Exists(candidate))
                 return candidate;
         }
@@ -2568,6 +2340,49 @@ private static bool IsPathUnder(string path, string root)
         return null;
     }
 
+    private static string ResolveInstallDirectoryForWorker()
+    {
+        var explicitInstallDirectory = Environment.GetEnvironmentVariable("QUASAR_INSTALL_DIR");
+        if (!string.IsNullOrWhiteSpace(explicitInstallDirectory))
+            return Path.GetFullPath(explicitInstallDirectory.Trim());
+
+        foreach (var directory in EnumerateInstallCandidateDirectories())
+        {
+            if (ContainsInstallAsset(directory))
+                return directory;
+        }
+
+        return Path.GetFullPath(AppContext.BaseDirectory);
+    }
+
+    private static bool ContainsInstallAsset(string directory) =>
+        File.Exists(Path.Combine(directory, LauncherExecutableFileName)) ||
+        File.Exists(Path.Combine(directory, "install.sh")) ||
+        File.Exists(Path.Combine(directory, "install.ps1")) ||
+        Directory.Exists(Path.Combine(directory, "WebService"));
+
+    private static IEnumerable EnumerateInstallCandidateDirectories()
+    {
+        var seen = new HashSet(StringComparer.OrdinalIgnoreCase);
+        foreach (var directory in EnumerateRawInstallCandidateDirectories())
+        {
+            if (string.IsNullOrWhiteSpace(directory))
+                continue;
+
+            var fullPath = Path.GetFullPath(directory.Trim());
+            if (seen.Add(fullPath))
+                yield return fullPath;
+        }
+    }
+
+    private static IEnumerable EnumerateRawInstallCandidateDirectories()
+    {
+        yield return Environment.GetEnvironmentVariable("QUASAR_INSTALL_DIR");
+        yield return MagnetarPaths.GetQuasarDirectory();
+        yield return AppContext.BaseDirectory;
+        yield return Directory.GetCurrentDirectory();
+    }
+
     private static IEnumerable GetQuasarExecutableFileNames()
     {
         yield return OperatingSystem.IsWindows() ? "Quasar.exe" : "Quasar";
diff --git a/Quasar/Components/Layout/MainLayout.razor b/Quasar/Components/Layout/MainLayout.razor
index 5e1ccf60..454e927f 100644
--- a/Quasar/Components/Layout/MainLayout.razor
+++ b/Quasar/Components/Layout/MainLayout.razor
@@ -337,7 +337,7 @@
     private async Task ShutdownQuasarAsync()
     {
         _isShuttingDown = true;
-        _shutdownStatus = "Shutting down Quasar… running servers continue.";
+        _shutdownStatus = "Draining Quasar… restart the service or task to bring the UI back.";
         await InvokeAsync(StateHasChanged);
         ShutdownService.ShutdownQuasarPreservingServers();
     }
diff --git a/Quasar/Components/Layout/QuasarControlDialog.razor b/Quasar/Components/Layout/QuasarControlDialog.razor
index 48d9678e..8a2f5ce6 100644
--- a/Quasar/Components/Layout/QuasarControlDialog.razor
+++ b/Quasar/Components/Layout/QuasarControlDialog.razor
@@ -31,7 +31,7 @@
                            Class="quasar-control-action">
                     
                         Shutdown Quasar
-                        Servers continue to run without Quasar supervision.
+                        Drain the web UI; servers continue without supervision.
                     
                 
 
@@ -117,7 +117,7 @@
     {
         QuasarControlAction.RestartQuasar => "Running servers continue to run.",
         QuasarControlAction.ShutdownAllServers => "Every running server will save and stop.",
-        _ => "Quasar will stop, but running servers continue.",
+        _ => "Quasar will drain, but running servers continue.",
     };
 
     private string ConfirmationBody => _pendingAction switch
@@ -127,7 +127,7 @@
         QuasarControlAction.ShutdownAllServers =>
             "Quasar stays online. Each running server receives a normal save-and-stop request, and its goal state is set to Off so Quasar does not restart it.",
         _ =>
-            $"The Quasar web UI and supervisor stop. Running servers are left detached and can continue for {FormatOfflineGrace()} without Quasar before their agent offline policy takes over. Start Quasar again to re-adopt them.",
+            $"The web UI and supervisor stop, and the launcher stays drained so it will not restart them until the service, task, or foreground launcher is restarted. Running servers are left detached and can continue for {FormatOfflineGrace()} without Quasar before their agent offline policy takes over.",
     };
 
     private void Select(QuasarControlAction action)
diff --git a/Quasar/Components/Pages/Backup.razor b/Quasar/Components/Pages/Backup.razor
index 2e2b90e2..45e119fe 100644
--- a/Quasar/Components/Pages/Backup.razor
+++ b/Quasar/Components/Pages/Backup.razor
@@ -84,8 +84,8 @@
                     
                         Same major.minor version restores in full; patch versions are compatible both ways.
                     
-                    
-                        Older backups are upgraded through a forward migration path when one is available.
+                    
+                        Restoring a backup from an older major.minor version is not supported.
                     
                     
                         Restoring a backup from a newer major.minor version is not supported (no downgrade).
@@ -341,7 +341,7 @@
                                       Label="Backup folder"
                                       Variant="Variant.Outlined"
                                       Disabled="@BackupDirectoryEditorDisabled"
-                                      HelperText="Leave empty for the default data-dir Backups folder. Relative paths resolve under the Quasar data directory." />
+                                      HelperText="Leave empty for the default install-root Backups folder. Relative paths resolve under the Quasar install directory." />
                     
                     
                         
diff --git a/Quasar/Components/Pages/CreateWorldSaveFromTemplateDialog.razor b/Quasar/Components/Pages/CreateWorldSaveFromTemplateDialog.razor
index ab3fc8b7..8d062ff6 100644
--- a/Quasar/Components/Pages/CreateWorldSaveFromTemplateDialog.razor
+++ b/Quasar/Components/Pages/CreateWorldSaveFromTemplateDialog.razor
@@ -113,9 +113,9 @@
             template = importResult.Template;
             configProfileId = importResult.ConfigProfileId;
         }
-        else if (result.Data is QuasarWorldTemplate legacyTemplate)
+        else if (result.Data is QuasarWorldTemplate selectedTemplate)
         {
-            template = legacyTemplate;
+            template = selectedTemplate;
         }
 
         if (template is null)
diff --git a/Quasar/Components/Pages/SteamWorkshopApiKeyDialog.razor b/Quasar/Components/Pages/SteamWorkshopApiKeyDialog.razor
index 4a4edb94..2e8f4012 100644
--- a/Quasar/Components/Pages/SteamWorkshopApiKeyDialog.razor
+++ b/Quasar/Components/Pages/SteamWorkshopApiKeyDialog.razor
@@ -75,12 +75,12 @@
     private static string GetPlatformStorageLocation()
     {
         if (OperatingSystem.IsWindows())
-            return "the Quasar install directory, or QUASAR_DATA_DIR when that override is set";
+            return "the Quasar install directory";
 
         if (OperatingSystem.IsLinux() || OperatingSystem.IsMacOS())
-            return "the Quasar install directory, or QUASAR_DATA_DIR when that override is set";
+            return "the Quasar install directory";
 
-        return "the Quasar data directory, or QUASAR_DATA_DIR when that override is set";
+        return "the Quasar install directory";
     }
 
     private static string GetPlatformFileProtectionDescription()
@@ -91,6 +91,6 @@
         if (OperatingSystem.IsLinux() || OperatingSystem.IsMacOS())
             return "On this platform Quasar also restricts the credentials file to owner read/write permissions (0600) when the filesystem supports it.";
 
-        return "Restrict access to the Quasar data directory with the host operating system's file permissions.";
+        return "Restrict access to the Quasar install directory with the host operating system's file permissions.";
     }
 }
diff --git a/Quasar/Models/QuasarBackupSettings.cs b/Quasar/Models/QuasarBackupSettings.cs
index 8c6ef3c0..65ba9608 100644
--- a/Quasar/Models/QuasarBackupSettings.cs
+++ b/Quasar/Models/QuasarBackupSettings.cs
@@ -72,7 +72,7 @@ public static QuasarBackupRuleSettings Normalize(QuasarBackupRuleSettings? setti
 
 /// 
 /// Persistent configuration for the automatic backup scheduler, serialized to
-/// backup-settings.json in the Quasar data directory. Each backup scope
+/// backup-settings.json in the Quasar install directory. Each backup scope
 /// has its own rule.
 /// 
 public sealed class QuasarBackupSettings
@@ -87,19 +87,6 @@ public sealed class QuasarBackupSettings
 
     public QuasarBackupRuleSettings World { get; set; } = new();
 
-    // Legacy flat settings kept for reading old backup-settings.json files.
-    public bool? Enabled { get; set; }
-
-    public BackupFrequency? Frequency { get; set; }
-
-    public TimeOnly? TimeOfDay { get; set; }
-
-    public DayOfWeek? DayOfWeek { get; set; }
-
-    public int? RetentionCount { get; set; }
-
-    public DateTimeOffset? LastBackupUtc { get; set; }
-
     public QuasarBackupSettings Clone()
     {
         return new QuasarBackupSettings
@@ -115,30 +102,9 @@ public static QuasarBackupSettings Normalize(QuasarBackupSettings? settings)
     {
         settings ??= new QuasarBackupSettings();
 
-        var useLegacyConfiguration =
-            settings.Configuration is null ||
-            settings.Enabled.HasValue ||
-            settings.LastBackupUtc is not null ||
-            settings.Frequency.HasValue ||
-            settings.TimeOfDay.HasValue ||
-            settings.DayOfWeek.HasValue ||
-            settings.RetentionCount.HasValue;
-
-        var configuration = useLegacyConfiguration
-            ? QuasarBackupRuleSettings.Normalize(new QuasarBackupRuleSettings
-            {
-                Enabled = settings.Enabled ?? false,
-                Frequency = settings.Frequency ?? BackupFrequency.Daily,
-                TimeOfDay = settings.TimeOfDay ?? new TimeOnly(3, 0),
-                DayOfWeek = settings.DayOfWeek ?? System.DayOfWeek.Sunday,
-                RetentionCount = settings.RetentionCount ?? DefaultRetentionCount,
-                LastBackupUtc = settings.LastBackupUtc,
-            })
-            : QuasarBackupRuleSettings.Normalize(settings.Configuration);
-
         return new QuasarBackupSettings
         {
-            Configuration = configuration,
+            Configuration = QuasarBackupRuleSettings.Normalize(settings.Configuration),
             Server = QuasarBackupRuleSettings.Normalize(settings.Server),
             World = QuasarBackupRuleSettings.Normalize(settings.World),
         };
diff --git a/Quasar/Program.cs b/Quasar/Program.cs
index d473eb21..ebe29b28 100644
--- a/Quasar/Program.cs
+++ b/Quasar/Program.cs
@@ -413,7 +413,7 @@ or DedicatedServerProcessState.Restarting
 
             app.MapStaticAssets();
 
-            // Runtime-uploaded branding assets live in the Quasar data directory
+            // Runtime-uploaded branding assets live in the Quasar install root
             // so web-service updates do not replace custom logos or favicons.
             var brandingAssetsDirectory = MagnetarPaths.GetQuasarBrandingDirectory();
             Directory.CreateDirectory(brandingAssetsDirectory);
@@ -449,7 +449,7 @@ or DedicatedServerProcessState.Restarting
                 startupLogger.LogWarning("Quasar UI plugin load failed: {Error}", loadError);
 
             startupLogger.LogInformation(
-                "Quasar {Version} starting. BootstrapVersion={BootstrapVersion}; HostId={HostId}; DataDirectory={DataDirectory}.",
+                "Quasar {Version} starting. BootstrapVersion={BootstrapVersion}; HostId={HostId}; InstallRoot={InstallRoot}.",
                 webServiceOptions.Version,
                 string.IsNullOrWhiteSpace(webServiceOptions.BootstrapVersion) ? "none" : webServiceOptions.BootstrapVersion,
                 webServiceOptions.HostId,
diff --git a/Quasar/Properties/launchSettings.json b/Quasar/Properties/launchSettings.json
index 96691fb5..15a953e3 100644
--- a/Quasar/Properties/launchSettings.json
+++ b/Quasar/Properties/launchSettings.json
@@ -5,9 +5,9 @@
       "commandName": "Project",
       "dotnetRunMessages": false,
       "launchBrowser": false,
-      "applicationUrl": "http://0.0.0.0:8080",
       "environmentVariables": {
-        "ASPNETCORE_ENVIRONMENT": "Development"
+        "ASPNETCORE_ENVIRONMENT": "Development",
+        "QUASAR_INSTALL_DIR_FILE": ".quasar-install-dir"
       }
     }
   }
diff --git a/Quasar/Services/Backup/BackupCompatibility.cs b/Quasar/Services/Backup/BackupCompatibility.cs
index 8a2f47c2..65ffec84 100644
--- a/Quasar/Services/Backup/BackupCompatibility.cs
+++ b/Quasar/Services/Backup/BackupCompatibility.cs
@@ -1,15 +1,14 @@
 namespace Quasar.Services.Backup;
 
 /// Result of checking a backup's version against the running Quasar.
-public readonly record struct BackupCompatibilityResult(bool Allowed, bool MigrationRequired, string Reason);
+public readonly record struct BackupCompatibilityResult(bool Allowed, string Reason);
 
 /// 
 /// Applies the semantic-versioning rules that govern whether a backup may be
 /// restored into the running Quasar:
 /// 
 ///   Same Major.Minor — always allowed; patch may differ either direction.
-///   Older Major.Minor — allowed only if a forward migration path exists
-///         (see ); otherwise rejected.
+///   Older Major.Minor — rejected.
 ///   Newer Major.Minor — rejected (no cross-Major.Minor downgrade).
 /// 
 /// 
@@ -18,29 +17,25 @@ public static class BackupCompatibility
     public static BackupCompatibilityResult Evaluate(string? backupVersion, string? runningVersion)
     {
         if (!TryParse(backupVersion, out var backup))
-            return new BackupCompatibilityResult(false, false, $"The backup version '{backupVersion}' is not recognized.");
+            return new BackupCompatibilityResult(false, $"The backup version '{backupVersion}' is not recognized.");
 
         if (!TryParse(runningVersion, out var running))
-            return new BackupCompatibilityResult(false, false, $"The running Quasar version '{runningVersion}' is not recognized.");
+            return new BackupCompatibilityResult(false, $"The running Quasar version '{runningVersion}' is not recognized.");
 
         var comparison = CompareMajorMinor(backup, running);
         if (comparison == 0)
-            return new BackupCompatibilityResult(true, false, "Same major.minor version — fully compatible.");
+            return new BackupCompatibilityResult(true, "Same major.minor version — fully compatible.");
 
         if (comparison > 0)
         {
-            return new BackupCompatibilityResult(false, false,
+            return new BackupCompatibilityResult(false,
                 $"Cannot restore a backup from a newer Quasar ({backup.Major}.{backup.Minor}) into this older one " +
                 $"({running.Major}.{running.Minor}). Downgrading across major.minor versions is not supported.");
         }
 
-        // Backup is from an older major.minor — a forward migration path is required.
-        if (BackupFormatMigrations.CanMigrate(backup, running))
-            return new BackupCompatibilityResult(true, true, "An upgrade path is available for this older backup.");
-
-        return new BackupCompatibilityResult(false, false,
-            $"Restoring a backup from {backup.Major}.{backup.Minor} into {running.Major}.{running.Minor} requires a " +
-            "settings upgrade that is not available yet.");
+        return new BackupCompatibilityResult(false,
+            $"Restoring a backup from older Quasar {backup.Major}.{backup.Minor} into {running.Major}.{running.Minor} " +
+            "is not supported.");
     }
 
     /// Compares two versions by Major then Minor only (patch is ignored).
diff --git a/Quasar/Services/Backup/BackupFormatMigrations.cs b/Quasar/Services/Backup/BackupFormatMigrations.cs
deleted file mode 100644
index 62df15a1..00000000
--- a/Quasar/Services/Backup/BackupFormatMigrations.cs
+++ /dev/null
@@ -1,52 +0,0 @@
-namespace Quasar.Services.Backup;
-
-/// 
-/// Registry of forward upgrade steps that migrate backup contents from one
-/// major.minor release to the next. Restores across an incompatible
-/// major.minor boundary chain these steps from the backup's version up to the
-/// running version.
-/// 
-/// No migrations exist yet — today's backups carry the same data structures as
-/// the code that reads them, so only same-major.minor restores are accepted.
-/// When a future release changes a persisted structure, add a
-///  from the last minor release to the new one
-/// here, and teach  to apply the chain while
-/// extracting.
-/// 
-/// 
-public static class BackupFormatMigrations
-{
-    /// A single hop that upgrades backup data from  to  (major.minor).
-    public sealed record BackupMigrationStep(Version From, Version To);
-
-    /// Ordered, contiguous upgrade steps. Empty until the first breaking change ships.
-    public static IReadOnlyList Steps { get; } = Array.Empty();
-
-    /// 
-    /// True when a contiguous chain of  upgrades a backup taken at
-    ///  up to  (compared by major.minor).
-    /// 
-    public static bool CanMigrate(Version backupVersion, Version runningVersion)
-    {
-        var current = new Version(backupVersion.Major, backupVersion.Minor);
-        var target = new Version(runningVersion.Major, runningVersion.Minor);
-
-        // Walk the registered steps, advancing as long as one continues the chain.
-        var advanced = true;
-        while (advanced && BackupCompatibility.CompareMajorMinor(current, target) < 0)
-        {
-            advanced = false;
-            foreach (var step in Steps)
-            {
-                if (BackupCompatibility.CompareMajorMinor(step.From, current) == 0)
-                {
-                    current = new Version(step.To.Major, step.To.Minor);
-                    advanced = true;
-                    break;
-                }
-            }
-        }
-
-        return BackupCompatibility.CompareMajorMinor(current, target) == 0;
-    }
-}
diff --git a/Quasar/Services/Backup/QuasarBackupSettingsService.cs b/Quasar/Services/Backup/QuasarBackupSettingsService.cs
index 219a3282..6bafdfa5 100644
--- a/Quasar/Services/Backup/QuasarBackupSettingsService.cs
+++ b/Quasar/Services/Backup/QuasarBackupSettingsService.cs
@@ -8,7 +8,7 @@ namespace Quasar.Services.Backup;
 
 /// 
 /// Singleton store for the automatic-backup schedule settings. Persists to
-/// backup-settings.json in the Quasar data directory and picks up external
+/// backup-settings.json in the Quasar install directory and picks up external
 /// edits via a debounced file watch, mirroring .
 /// 
 public sealed class QuasarBackupSettingsService : IDisposable
diff --git a/Quasar/Services/BrandingService.cs b/Quasar/Services/BrandingService.cs
index 8d2b133f..d59f3980 100644
--- a/Quasar/Services/BrandingService.cs
+++ b/Quasar/Services/BrandingService.cs
@@ -8,8 +8,8 @@ namespace Quasar.Services;
 
 /// 
 /// Singleton store for branding and theme configuration. Persists to
-/// branding.json in the Quasar data directory and writes uploaded logo /
-/// favicon assets into the Quasar data directory. Mirrors the file-watch
+/// branding.json in the Quasar install directory and writes uploaded logo /
+/// favicon assets into the Quasar install directory. Mirrors the file-watch
 /// debounce pattern used by  so
 /// external edits are picked up live.
 /// 
@@ -29,15 +29,10 @@ public sealed class BrandingService : IDisposable
     private FileSystemWatcher? _watcher;
     private CancellationTokenSource? _reloadDebounce;
 
-    public BrandingService(ILogger logger, IWebHostEnvironment environment)
+    public BrandingService(ILogger logger)
     {
         _logger = logger;
-
-        var legacyWebRootPath = string.IsNullOrWhiteSpace(environment.WebRootPath)
-            ? Path.Combine(environment.ContentRootPath, "wwwroot")
-            : environment.WebRootPath;
         _brandingAssetsDirectory = MagnetarPaths.GetQuasarBrandingDirectory();
-        MigrateLegacyBrandingAssets(Path.Combine(legacyWebRootPath, "branding"));
 
         _settings = LoadSettings();
         _snapshot = CreateSnapshot(_settings);
@@ -179,34 +174,6 @@ private void RemoveExistingAssets(string baseName)
         }
     }
 
-    private void MigrateLegacyBrandingAssets(string legacyDirectory)
-    {
-        try
-        {
-            if (string.IsNullOrWhiteSpace(legacyDirectory) ||
-                !Directory.Exists(legacyDirectory) ||
-                string.Equals(
-                    Path.GetFullPath(legacyDirectory),
-                    Path.GetFullPath(_brandingAssetsDirectory),
-                    StringComparison.OrdinalIgnoreCase))
-            {
-                return;
-            }
-
-            Directory.CreateDirectory(_brandingAssetsDirectory);
-            foreach (var legacyPath in Directory.EnumerateFiles(legacyDirectory, "*", SearchOption.TopDirectoryOnly))
-            {
-                var destination = Path.Combine(_brandingAssetsDirectory, Path.GetFileName(legacyPath));
-                if (!File.Exists(destination))
-                    File.Copy(legacyPath, destination);
-            }
-        }
-        catch (Exception exception)
-        {
-            _logger.LogWarning(exception, "Failed migrating legacy branding assets from {Path}.", legacyDirectory);
-        }
-    }
-
     private static string NormalizeExtension(string extension)
     {
         if (string.IsNullOrWhiteSpace(extension))
diff --git a/Quasar/Services/DedicatedServerCatalog.cs b/Quasar/Services/DedicatedServerCatalog.cs
index 2ffa6775..73c7ccdc 100644
--- a/Quasar/Services/DedicatedServerCatalog.cs
+++ b/Quasar/Services/DedicatedServerCatalog.cs
@@ -181,26 +181,6 @@ private List LoadServers()
             if (definition is null)
                 return null;
 
-            using var document = JsonDocument.Parse(json);
-            var root = document.RootElement;
-            var rewriteDefinition = false;
-            var hasWorldSaveName = root.TryGetProperty("worldSaveName", out var worldSaveName) &&
-                                   worldSaveName.ValueKind == JsonValueKind.String;
-            if (!hasWorldSaveName)
-                rewriteDefinition = TryMigrateLegacyWorldPath(definition);
-
-            var hasLegacyWorldTemplateId = root.TryGetProperty("worldProfileId", out var legacyWorldProfileId);
-            if (string.IsNullOrWhiteSpace(definition.WorldTemplateId) &&
-                hasLegacyWorldTemplateId &&
-                legacyWorldProfileId.ValueKind == JsonValueKind.String)
-            {
-                definition.WorldTemplateId = legacyWorldProfileId.GetString() ?? string.Empty;
-                rewriteDefinition = true;
-            }
-
-            if (rewriteDefinition)
-                RewriteLegacyServerDefinition(path, definition);
-
             return definition;
         }
         catch (Exception exception)
@@ -210,12 +190,6 @@ private List LoadServers()
         }
     }
 
-    private static void RewriteLegacyServerDefinition(string path, DedicatedServerDefinition definition)
-    {
-        var json = JsonSerializer.Serialize(definition, JsonOptions);
-        WriteTextReplacing(path, json);
-    }
-
     private async Task SaveServerAsync(DedicatedServerDefinition definition, CancellationToken cancellationToken)
     {
         definition.UpdatedAtUtc = DateTimeOffset.UtcNow;
@@ -272,13 +246,6 @@ private static DedicatedServerDefinition Normalize(DedicatedServerDefinition ser
         server.WorldPath = string.IsNullOrWhiteSpace(server.WorldPath)
             ? Path.Combine(server.DedicatedServerAppDataPath, "Saves")
             : server.WorldPath.Trim();
-        if (string.IsNullOrWhiteSpace(server.WorldSaveName) &&
-            LooksLikeWorldSavePath(server.WorldPath) &&
-            TrySplitWorldSavePath(server.WorldPath, out var savesPath, out var saveName))
-        {
-            server.WorldPath = savesPath;
-            server.WorldSaveName = saveName;
-        }
         server.WorldSaveName = NormalizeWorldSaveName(server.WorldSaveName);
         server.ConfigFilePath = string.IsNullOrWhiteSpace(server.ConfigFilePath)
             ? Path.Combine(server.DedicatedServerAppDataPath, "SpaceEngineers-Dedicated.cfg")
@@ -423,75 +390,6 @@ private static bool IsValidWorldSaveName(string value)
         return value.IndexOfAny(Path.GetInvalidFileNameChars()) < 0;
     }
 
-    private static bool TryMigrateLegacyWorldPath(DedicatedServerDefinition definition)
-    {
-        if (string.IsNullOrWhiteSpace(definition.WorldPath))
-            return false;
-
-        return TrySplitWorldSavePath(definition.WorldPath, out var savesPath, out var saveName) &&
-               ApplyWorldPathSplit(definition, savesPath, saveName);
-    }
-
-    private static bool LooksLikeWorldSavePath(string path)
-    {
-        if (string.IsNullOrWhiteSpace(path))
-            return false;
-
-        var candidate = path.Trim();
-        if (File.Exists(candidate))
-        {
-            return string.Equals(Path.GetFileName(candidate), "Sandbox.sbc", StringComparison.OrdinalIgnoreCase);
-        }
-
-        return File.Exists(Path.Combine(candidate, "Sandbox.sbc"));
-    }
-
-    private static bool TrySplitWorldSavePath(string path, out string savesPath, out string saveName)
-    {
-        savesPath = string.Empty;
-        saveName = string.Empty;
-
-        var candidate = path.Trim();
-        if (string.IsNullOrWhiteSpace(candidate))
-            return false;
-
-        if (File.Exists(candidate) &&
-            string.Equals(Path.GetFileName(candidate), "Sandbox.sbc", StringComparison.OrdinalIgnoreCase))
-        {
-            candidate = Path.GetDirectoryName(candidate) ?? string.Empty;
-        }
-
-        candidate = candidate.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar);
-        if (string.IsNullOrWhiteSpace(candidate))
-            return false;
-
-        var parent = Path.GetDirectoryName(candidate);
-        var name = Path.GetFileName(candidate);
-        if (string.IsNullOrWhiteSpace(parent) ||
-            string.IsNullOrWhiteSpace(name) ||
-            !IsValidWorldSaveName(name))
-        {
-            return false;
-        }
-
-        savesPath = parent;
-        saveName = name;
-        return true;
-    }
-
-    private static bool ApplyWorldPathSplit(DedicatedServerDefinition definition, string savesPath, string saveName)
-    {
-        if (string.Equals(definition.WorldPath?.Trim(), savesPath, StringComparison.Ordinal) &&
-            string.Equals(definition.WorldSaveName?.Trim(), saveName, StringComparison.Ordinal))
-        {
-            return false;
-        }
-
-        definition.WorldPath = savesPath;
-        definition.WorldSaveName = saveName;
-        return true;
-    }
-
     /// 
     /// Derives a unique-name slug from the supplied text, appending a "-N" suffix when
     /// needed so it does not collide with an existing server (in memory or on disk).
@@ -675,11 +573,4 @@ private static string CreateSnapshot(IEnumerable serv
         return JsonSerializer.Serialize(normalized, JsonOptions);
     }
 
-    private static void WriteTextReplacing(string path, string content)
-    {
-        Directory.CreateDirectory(Path.GetDirectoryName(path)!);
-        var tempPath = $"{path}.{Guid.NewGuid():N}.tmp";
-        File.WriteAllText(tempPath, content);
-        File.Move(tempPath, path, overwrite: true);
-    }
 }
diff --git a/Quasar/Services/DedicatedServerRuntimePreparer.cs b/Quasar/Services/DedicatedServerRuntimePreparer.cs
index e5835f9d..cf788bee 100644
--- a/Quasar/Services/DedicatedServerRuntimePreparer.cs
+++ b/Quasar/Services/DedicatedServerRuntimePreparer.cs
@@ -4,6 +4,7 @@
 using System.Text.RegularExpressions;
 using System.Xml;
 using System.Xml.Linq;
+using Magnetar.Protocol.Runtime;
 using Quasar.Plugin.Abstractions.Manifests;
 using Quasar.Models;
 using Quasar.Services.Plugins;
@@ -597,9 +598,11 @@ private static string TryComputeSha256Hex(string path)
 
     private static string? LocateAgentSourceDirectory()
     {
-        var stagedDirectory = Path.Combine(AppContext.BaseDirectory, "Agent");
-        if (File.Exists(Path.Combine(stagedDirectory, AgentPluginFileName)))
-            return stagedDirectory;
+        foreach (var stagedDirectory in EnumerateAgentSourceCandidates())
+        {
+            if (File.Exists(Path.Combine(stagedDirectory, AgentPluginFileName)))
+                return stagedDirectory;
+        }
 
         var directory = new DirectoryInfo(AppContext.BaseDirectory);
         for (var depth = 0; directory is not null && depth < 8; depth++, directory = directory.Parent)
@@ -620,6 +623,28 @@ private static string TryComputeSha256Hex(string path)
         return null;
     }
 
+    private static IEnumerable EnumerateAgentSourceCandidates()
+    {
+        var seen = new HashSet(StringComparer.OrdinalIgnoreCase);
+        foreach (var directory in EnumerateInstallCandidateDirectories())
+        {
+            var candidate = Path.Combine(directory, "Agent");
+            if (seen.Add(Path.GetFullPath(candidate)))
+                yield return candidate;
+        }
+    }
+
+    private static IEnumerable EnumerateInstallCandidateDirectories()
+    {
+        var installDirectory = Environment.GetEnvironmentVariable("QUASAR_INSTALL_DIR");
+        if (!string.IsNullOrWhiteSpace(installDirectory))
+            yield return installDirectory.Trim();
+
+        yield return MagnetarPaths.GetQuasarDirectory();
+        yield return AppContext.BaseDirectory;
+        yield return Directory.GetCurrentDirectory();
+    }
+
     private static string SanitizePathSegment(string value)
     {
         var invalid = Path.GetInvalidFileNameChars();
diff --git a/Quasar/Services/GitHubUpdateCredentialsCatalog.cs b/Quasar/Services/GitHubUpdateCredentialsCatalog.cs
index eea6acbc..c4b2ecfd 100644
--- a/Quasar/Services/GitHubUpdateCredentialsCatalog.cs
+++ b/Quasar/Services/GitHubUpdateCredentialsCatalog.cs
@@ -32,12 +32,9 @@ public GitHubUpdateCredentialsCatalog(
     {
         _logger = logger;
         _protector = dataProtectionProvider.CreateProtector(DataProtectionPurpose);
-        _credentials = LoadCredentials(out var requiresMigration);
+        _credentials = LoadCredentials();
         _snapshot = CreateSnapshot(_credentials);
 
-        if (requiresMigration)
-            _ = MigrateLegacyPlaintextAsync();
-
         StartWatching();
     }
 
@@ -92,9 +89,8 @@ public void Dispose()
         _reloadDebounce?.Dispose();
     }
 
-    private GitHubUpdateCredentials LoadCredentials(out bool requiresMigration)
+    private GitHubUpdateCredentials LoadCredentials()
     {
-        requiresMigration = false;
         var path = MagnetarPaths.GetQuasarGitHubUpdateCredentialsPath();
 
         try
@@ -107,8 +103,7 @@ private GitHubUpdateCredentials LoadCredentials(out bool requiresMigration)
             if (persisted is null)
                 return GitHubUpdateCredentials.Normalize(null);
 
-            var credentials = persisted.ToCredentials(_protector, _logger, out var migrated);
-            requiresMigration = migrated;
+            var credentials = persisted.ToCredentials(_protector, _logger);
             return GitHubUpdateCredentials.Normalize(credentials);
         }
         catch (Exception exception)
@@ -118,25 +113,6 @@ private GitHubUpdateCredentials LoadCredentials(out bool requiresMigration)
         }
     }
 
-    private async Task MigrateLegacyPlaintextAsync()
-    {
-        try
-        {
-            GitHubUpdateCredentials snapshot;
-            lock (_sync)
-            {
-                snapshot = _credentials.Clone();
-            }
-
-            await SaveAsync(snapshot).ConfigureAwait(false);
-            _logger.LogInformation("Migrated legacy plaintext GitHub update credentials to protected storage.");
-        }
-        catch (Exception exception)
-        {
-            _logger.LogWarning(exception, "Failed migrating legacy plaintext GitHub update credentials.");
-        }
-    }
-
     private void StartWatching()
     {
         var path = MagnetarPaths.GetQuasarGitHubUpdateCredentialsPath();
@@ -197,7 +173,7 @@ private void ScheduleReload()
 
     private void ReloadFromDisk()
     {
-        var reloaded = LoadCredentials(out var requiresMigration);
+        var reloaded = LoadCredentials();
         var snapshot = CreateSnapshot(reloaded);
         var changed = false;
 
@@ -211,9 +187,6 @@ private void ReloadFromDisk()
             }
         }
 
-        if (requiresMigration)
-            _ = MigrateLegacyPlaintextAsync();
-
         if (!changed)
             return;
 
@@ -246,9 +219,6 @@ private sealed class PersistedCredentials
     {
         public string? ProtectedToken { get; set; }
 
-        // Legacy plaintext field, read-only for migration if early builds wrote it.
-        public string? Token { get; set; }
-
         public static PersistedCredentials FromCredentials(GitHubUpdateCredentials credentials, IDataProtector protector)
         {
             var token = credentials.Token;
@@ -260,11 +230,8 @@ public static PersistedCredentials FromCredentials(GitHubUpdateCredentials crede
 
         public GitHubUpdateCredentials ToCredentials(
             IDataProtector protector,
-            ILogger logger,
-            out bool migratedFromPlaintext)
+            ILogger logger)
         {
-            migratedFromPlaintext = false;
-
             if (!string.IsNullOrWhiteSpace(ProtectedToken))
             {
                 try
@@ -283,15 +250,6 @@ public GitHubUpdateCredentials ToCredentials(
                 }
             }
 
-            if (!string.IsNullOrWhiteSpace(Token))
-            {
-                migratedFromPlaintext = true;
-                return new GitHubUpdateCredentials
-                {
-                    Token = Token,
-                };
-            }
-
             return new GitHubUpdateCredentials();
         }
     }
diff --git a/Quasar/Services/ManagedDedicatedServerRuntimeResolver.cs b/Quasar/Services/ManagedDedicatedServerRuntimeResolver.cs
index 8bb46ff2..387c3664 100644
--- a/Quasar/Services/ManagedDedicatedServerRuntimeResolver.cs
+++ b/Quasar/Services/ManagedDedicatedServerRuntimeResolver.cs
@@ -760,8 +760,8 @@ private string ResolveInstalledMagnetarPath()
             if (File.Exists(interimPath))
                 return interimPath;
 
-            var legacyPath = Path.Combine(_options.MagnetarInstallDirectory, GetWindowsMagnetarLauncherFileName(ManagedServerRuntime.NetFramework48));
-            return File.Exists(legacyPath) ? legacyPath : string.Empty;
+            var netFrameworkPath = Path.Combine(_options.MagnetarInstallDirectory, GetWindowsMagnetarLauncherFileName(ManagedServerRuntime.NetFramework48));
+            return File.Exists(netFrameworkPath) ? netFrameworkPath : string.Empty;
         }
 
         return FindImmediateFile(Path.Combine(_options.MagnetarInstallDirectory, "Bin"), MagnetarLauncherFileNames) ?? string.Empty;
diff --git a/Quasar/Services/Plugins/QuasarUiPluginHubCatalogService.cs b/Quasar/Services/Plugins/QuasarUiPluginHubCatalogService.cs
index cbd5065d..539c3a64 100644
--- a/Quasar/Services/Plugins/QuasarUiPluginHubCatalogService.cs
+++ b/Quasar/Services/Plugins/QuasarUiPluginHubCatalogService.cs
@@ -679,18 +679,26 @@ private async Task RunCompanionPluginBuildAsync(
 
     private static string GetPluginAbstractionsAssemblyPath()
     {
-        var baseDirectoryPath = Path.Combine(AppContext.BaseDirectory, PluginAbstractionsAssemblyFileName);
-        return File.Exists(baseDirectoryPath) ? baseDirectoryPath : string.Empty;
+        return EnumerateInstallCandidateDirectories()
+            .Select(directory => Path.Combine(directory, PluginAbstractionsAssemblyFileName))
+            .FirstOrDefault(File.Exists)
+            ?? string.Empty;
     }
 
     private static string GetMagnetarProtocolAssemblyPath()
     {
-        var baseDirectoryPath = Path.Combine(AppContext.BaseDirectory, MagnetarProtocolAssemblyFileName);
-        if (File.Exists(baseDirectoryPath))
-            return baseDirectoryPath;
+        foreach (var directory in EnumerateInstallCandidateDirectories())
+        {
+            var baseDirectoryPath = Path.Combine(directory, MagnetarProtocolAssemblyFileName);
+            if (File.Exists(baseDirectoryPath))
+                return baseDirectoryPath;
+
+            var agentDirectoryPath = Path.Combine(directory, "Agent", MagnetarProtocolAssemblyFileName);
+            if (File.Exists(agentDirectoryPath))
+                return agentDirectoryPath;
+        }
 
-        var agentDirectoryPath = Path.Combine(AppContext.BaseDirectory, "Agent", MagnetarProtocolAssemblyFileName);
-        return File.Exists(agentDirectoryPath) ? agentDirectoryPath : string.Empty;
+        return string.Empty;
     }
 
     private static async Task RunProcessAsync(ProcessStartInfo startInfo, string label, CancellationToken cancellationToken)
@@ -1004,20 +1012,36 @@ private void SetDotNetSdkInstallInProgress(bool inProgress)
 
     private string GetInstallScriptPath()
     {
-        var candidates = new[]
-        {
-            Path.Combine(AppContext.BaseDirectory, "install.sh"),
-            Path.Combine(_environment.ContentRootPath, "install.sh"),
-            Path.Combine(_environment.ContentRootPath, "..", "install.sh"),
-            Path.Combine(Directory.GetCurrentDirectory(), "install.sh"),
-        };
-
-        return candidates
+        return EnumerateInstallCandidateDirectories()
+            .Concat([_environment.ContentRootPath, Path.Combine(_environment.ContentRootPath, "..")])
+            .Select(directory => Path.Combine(directory, "install.sh"))
             .Select(Path.GetFullPath)
             .FirstOrDefault(File.Exists)
             ?? string.Empty;
     }
 
+    private static IEnumerable EnumerateInstallCandidateDirectories()
+    {
+        var seen = new HashSet(StringComparer.OrdinalIgnoreCase);
+        foreach (var directory in EnumerateRawInstallCandidateDirectories())
+        {
+            if (string.IsNullOrWhiteSpace(directory))
+                continue;
+
+            var fullPath = Path.GetFullPath(directory.Trim());
+            if (seen.Add(fullPath))
+                yield return fullPath;
+        }
+    }
+
+    private static IEnumerable EnumerateRawInstallCandidateDirectories()
+    {
+        yield return Environment.GetEnvironmentVariable("QUASAR_INSTALL_DIR");
+        yield return MagnetarPaths.GetQuasarDirectory();
+        yield return AppContext.BaseDirectory;
+        yield return Directory.GetCurrentDirectory();
+    }
+
     private static string GetSdkVersion(string sdkLine)
     {
         var index = sdkLine.IndexOf(' ', StringComparison.Ordinal);
diff --git a/Quasar/Services/QuasarShutdownService.cs b/Quasar/Services/QuasarShutdownService.cs
index e9e8fd5b..343ddecb 100644
--- a/Quasar/Services/QuasarShutdownService.cs
+++ b/Quasar/Services/QuasarShutdownService.cs
@@ -1,6 +1,5 @@
 using Magnetar.Protocol.Runtime;
 using Quasar.Models;
-using System.Diagnostics;
 
 namespace Quasar.Services;
 
@@ -104,179 +103,17 @@ public void RestartWorker(IProgress? progress = null)
 
     /// 
     /// Stops Quasar while preserving managed servers. When launched by Bootstrap,
-    /// the worker first asks the launcher process to exit so it does not
-    /// respawn the worker.
+    /// the worker asks the launcher to enter a drained state so it does not
+    /// respawn the worker until the external service/task is restarted.
     /// 
     public void ShutdownQuasarPreservingServers(IProgress? progress = null)
     {
         progress?.Report("Shutting down Quasar…");
         _supervisor.BeginLauncherDrain();
-        if (TryRequestSystemdServiceStop())
-            return;
-
         RequestLauncherShutdown();
         _lifetime.StopApplication();
     }
 
-    private bool TryRequestSystemdServiceStop()
-    {
-        if (!OperatingSystem.IsLinux())
-            return false;
-
-        var target = ResolveSystemdServiceTarget();
-        if (target is null)
-            return false;
-
-        try
-        {
-            var startInfo = new ProcessStartInfo
-            {
-                FileName = "systemctl",
-                UseShellExecute = false,
-                CreateNoWindow = true,
-            };
-            AddSystemctlStopArguments(startInfo, target.Value);
-
-            using var process = Process.Start(startInfo);
-
-            if (process is null)
-                return false;
-
-            if (!process.WaitForExit(3000))
-            {
-                TryKillProcess(process);
-                _logger.LogWarning(
-                    "systemctl stop {ServiceName} did not return before timeout; falling back to launcher shutdown request.",
-                    target.Value.ServiceName);
-                return false;
-            }
-
-            if (process.ExitCode == 0)
-            {
-                _logger.LogInformation("Requested systemd stop for {ServiceName}.", target.Value.ServiceName);
-                return true;
-            }
-
-            _logger.LogWarning(
-                "systemctl stop {ServiceName} failed with exit code {ExitCode}; falling back to launcher shutdown request.",
-                target.Value.ServiceName,
-                process.ExitCode);
-        }
-        catch (Exception exception)
-        {
-            _logger.LogWarning(exception, "Failed requesting systemd stop; falling back to launcher shutdown request.");
-        }
-
-        return false;
-    }
-
-    private static void TryKillProcess(Process process)
-    {
-        try
-        {
-            if (!process.HasExited)
-                process.Kill(entireProcessTree: false);
-        }
-        catch
-        {
-        }
-    }
-
-    private static void AddSystemctlStopArguments(ProcessStartInfo startInfo, SystemdServiceTarget target)
-    {
-        if (target.Scope == SystemdServiceScope.User)
-            startInfo.ArgumentList.Add("--user");
-
-        startInfo.ArgumentList.Add("--no-block");
-        startInfo.ArgumentList.Add("stop");
-        startInfo.ArgumentList.Add(target.ServiceName);
-    }
-
-    private static SystemdServiceTarget? ResolveSystemdServiceTarget()
-    {
-        var serviceName = NormalizeServiceName(Environment.GetEnvironmentVariable("QUASAR_SYSTEMD_SERVICE"));
-        var scope = ParseSystemdScope(Environment.GetEnvironmentVariable("QUASAR_SYSTEMD_SCOPE"));
-        if (!string.IsNullOrWhiteSpace(serviceName) && scope is not null)
-            return new SystemdServiceTarget(serviceName, scope.Value);
-
-        var detected = DetectSystemdServiceTargetFromCgroup();
-        if (detected is null)
-            return null;
-
-        return new SystemdServiceTarget(serviceName ?? detected.Value.ServiceName, scope ?? detected.Value.Scope);
-    }
-
-    private static SystemdServiceTarget? DetectSystemdServiceTargetFromCgroup()
-    {
-        const string cgroupPath = "/proc/self/cgroup";
-        if (!File.Exists(cgroupPath))
-            return null;
-
-        try
-        {
-            foreach (var line in File.ReadLines(cgroupPath))
-            {
-                var pathStart = line.IndexOf(":/", StringComparison.Ordinal);
-                if (pathStart < 0)
-                    continue;
-
-                var path = line[(pathStart + 1)..];
-                var serviceName = ExtractServiceName(path);
-                if (serviceName is null)
-                    continue;
-
-                if (path.Contains("/user.slice/", StringComparison.Ordinal) ||
-                    path.Contains("/user@", StringComparison.Ordinal))
-                {
-                    return new SystemdServiceTarget(serviceName, SystemdServiceScope.User);
-                }
-
-                if (path.Contains("/system.slice/", StringComparison.Ordinal))
-                    return new SystemdServiceTarget(serviceName, SystemdServiceScope.System);
-            }
-        }
-        catch
-        {
-        }
-
-        return null;
-    }
-
-    private static string? ExtractServiceName(string cgroupPath)
-    {
-        foreach (var segment in cgroupPath.Split('/', StringSplitOptions.RemoveEmptyEntries).Reverse())
-        {
-            if (segment.EndsWith(".service", StringComparison.Ordinal))
-                return segment;
-        }
-
-        return null;
-    }
-
-    private static string? NormalizeServiceName(string? value)
-    {
-        if (string.IsNullOrWhiteSpace(value))
-            return null;
-
-        var serviceName = value.Trim();
-        return serviceName.EndsWith(".service", StringComparison.Ordinal)
-            ? serviceName
-            : $"{serviceName}.service";
-    }
-
-    private static SystemdServiceScope? ParseSystemdScope(string? value)
-    {
-        if (string.IsNullOrWhiteSpace(value))
-            return null;
-
-        return value.Trim().ToLowerInvariant() switch
-        {
-            "user" => SystemdServiceScope.User,
-            "system" => SystemdServiceScope.System,
-            _ => null,
-        };
-    }
-
     private void RequestLauncherShutdown()
     {
         if (string.IsNullOrWhiteSpace(_options.LauncherToken))
@@ -288,18 +125,10 @@ private void RequestLauncherShutdown()
         }
         catch (Exception exception)
         {
-            _logger.LogWarning(exception, "Failed writing Quasar launcher shutdown request.");
+            _logger.LogWarning(exception, "Failed writing Quasar launcher drain request.");
         }
     }
 
     private static string GetLauncherShutdownRequestPath() =>
         Path.Combine(MagnetarPaths.GetQuasarDirectory(), "launcher-shutdown-request");
-
-    private readonly record struct SystemdServiceTarget(string ServiceName, SystemdServiceScope Scope);
-
-    private enum SystemdServiceScope
-    {
-        User,
-        System,
-    }
 }
diff --git a/Quasar/Services/QuasarWorldTemplateCatalog.cs b/Quasar/Services/QuasarWorldTemplateCatalog.cs
index 32b6bb71..7f810735 100644
--- a/Quasar/Services/QuasarWorldTemplateCatalog.cs
+++ b/Quasar/Services/QuasarWorldTemplateCatalog.cs
@@ -23,7 +23,6 @@ public sealed class QuasarWorldTemplateCatalog : IDisposable
     public QuasarWorldTemplateCatalog(ILogger logger)
     {
         _logger = logger;
-        MigrateLegacyStorage();
         _templates = LoadTemplates();
         _snapshot = CreateSnapshot(_templates);
         StartWatching();
@@ -163,7 +162,6 @@ private List LoadTemplates()
 
             return Directory
                 .GetFiles(directory, "template.json", SearchOption.AllDirectories)
-                .Concat(Directory.GetFiles(directory, "profile.json", SearchOption.AllDirectories))
                 .Distinct(StringComparer.OrdinalIgnoreCase)
                 .OrderBy(path => path, StringComparer.OrdinalIgnoreCase)
                 .Select(LoadTemplate)
@@ -184,19 +182,8 @@ private List LoadTemplates()
         {
             var json = File.ReadAllText(path);
             var template = JsonSerializer.Deserialize(json, JsonOptions) ?? new QuasarWorldTemplate();
-            using var document = JsonDocument.Parse(json);
-            var root = document.RootElement;
             var pathTemplateId = GetTemplateIdFromDefinitionPath(path);
 
-            if (TryGetString(root, "worldTemplateId", out var storedId))
-            {
-                template.WorldTemplateId = storedId;
-            }
-            else if (TryGetString(root, "worldProfileId", out var legacyId))
-            {
-                template.WorldTemplateId = legacyId;
-            }
-
             if (!string.IsNullOrWhiteSpace(pathTemplateId))
                 template.WorldTemplateId = pathTemplateId;
 
@@ -379,104 +366,6 @@ private static string CreateSnapshot(IEnumerable templates)
         return JsonSerializer.Serialize(normalized, JsonOptions);
     }
 
-    private void MigrateLegacyStorage()
-    {
-        var currentDirectory = MagnetarPaths.GetQuasarWorldTemplatesDirectory();
-        var legacyDirectory = MagnetarPaths.GetLegacyQuasarWorldProfilesDirectory();
-
-        try
-        {
-            if (Directory.Exists(legacyDirectory) && !Directory.Exists(currentDirectory))
-            {
-                Directory.CreateDirectory(Path.GetDirectoryName(currentDirectory)!);
-                Directory.Move(legacyDirectory, currentDirectory);
-                _logger.LogInformation("Migrated Quasar world templates from {LegacyPath} to {Path}.", legacyDirectory, currentDirectory);
-            }
-            else if (Directory.Exists(legacyDirectory) && Directory.Exists(currentDirectory))
-            {
-                foreach (var legacyTemplateDirectory in Directory.GetDirectories(legacyDirectory))
-                {
-                    var targetDirectory = Path.Combine(currentDirectory, Path.GetFileName(legacyTemplateDirectory));
-                    if (!Directory.Exists(targetDirectory))
-                        Directory.Move(legacyTemplateDirectory, targetDirectory);
-                }
-            }
-
-            if (Directory.Exists(currentDirectory))
-            {
-                RenameLegacyTemplateDefinitions(currentDirectory);
-                RewriteLegacyTemplateDefinitions(currentDirectory);
-            }
-        }
-        catch (Exception exception)
-        {
-            _logger.LogWarning(exception, "Failed migrating legacy Quasar world template storage.");
-        }
-    }
-
-    private static void RenameLegacyTemplateDefinitions(string directory)
-    {
-        foreach (var legacyDefinitionPath in Directory.GetFiles(directory, "profile.json", SearchOption.AllDirectories))
-        {
-            var newDefinitionPath = Path.Combine(Path.GetDirectoryName(legacyDefinitionPath)!, "template.json");
-            if (File.Exists(newDefinitionPath))
-                continue;
-
-            File.Move(legacyDefinitionPath, newDefinitionPath);
-        }
-    }
-
-    private static void RewriteLegacyTemplateDefinitions(string directory)
-    {
-        foreach (var definitionPath in Directory.GetFiles(directory, "template.json", SearchOption.AllDirectories))
-        {
-            var json = File.ReadAllText(definitionPath);
-            using var document = JsonDocument.Parse(json);
-            var root = document.RootElement;
-            var pathTemplateId = GetTemplateIdFromDefinitionPath(definitionPath);
-            var hasWorldTemplateId = TryGetString(root, "worldTemplateId", out var storedId);
-            var hasLegacyWorldTemplateId = TryGetString(root, "worldProfileId", out var legacyId);
-
-            if (hasWorldTemplateId &&
-                !hasLegacyWorldTemplateId &&
-                string.Equals(storedId, pathTemplateId, StringComparison.OrdinalIgnoreCase))
-            {
-                continue;
-            }
-
-            var template = JsonSerializer.Deserialize(json, JsonOptions) ?? new QuasarWorldTemplate();
-            template.WorldTemplateId = !string.IsNullOrWhiteSpace(pathTemplateId)
-                ? pathTemplateId
-                : hasLegacyWorldTemplateId
-                    ? legacyId
-                    : template.WorldTemplateId;
-
-            var rewrittenJson = JsonSerializer.Serialize(Normalize(template), JsonOptions);
-            WriteTextReplacing(definitionPath, rewrittenJson);
-        }
-    }
-
     private static string GetTemplateIdFromDefinitionPath(string definitionPath) =>
         Path.GetFileName(Path.GetDirectoryName(definitionPath)) ?? string.Empty;
-
-    private static void WriteTextReplacing(string path, string content)
-    {
-        Directory.CreateDirectory(Path.GetDirectoryName(path)!);
-        var tempPath = $"{path}.{Guid.NewGuid():N}.tmp";
-        File.WriteAllText(tempPath, content);
-        File.Move(tempPath, path, overwrite: true);
-    }
-
-    private static bool TryGetString(JsonElement element, string propertyName, out string value)
-    {
-        value = string.Empty;
-        if (!element.TryGetProperty(propertyName, out var property) ||
-            property.ValueKind != JsonValueKind.String)
-        {
-            return false;
-        }
-
-        value = property.GetString() ?? string.Empty;
-        return !string.IsNullOrWhiteSpace(value);
-    }
 }
diff --git a/Quasar/Services/SteamWorkshopCredentialsCatalog.cs b/Quasar/Services/SteamWorkshopCredentialsCatalog.cs
index 01fe678f..c4da915e 100644
--- a/Quasar/Services/SteamWorkshopCredentialsCatalog.cs
+++ b/Quasar/Services/SteamWorkshopCredentialsCatalog.cs
@@ -32,12 +32,9 @@ public SteamWorkshopCredentialsCatalog(
     {
         _logger = logger;
         _protector = dataProtectionProvider.CreateProtector(DataProtectionPurpose);
-        _credentials = LoadCredentials(out var requiresMigration);
+        _credentials = LoadCredentials();
         _snapshot = CreateSnapshot(_credentials);
 
-        if (requiresMigration)
-            _ = MigrateLegacyPlaintextAsync();
-
         StartWatching();
     }
 
@@ -89,9 +86,8 @@ public void Dispose()
         _reloadDebounce?.Dispose();
     }
 
-    private SteamWorkshopCredentials LoadCredentials(out bool requiresMigration)
+    private SteamWorkshopCredentials LoadCredentials()
     {
-        requiresMigration = false;
         var path = MagnetarPaths.GetQuasarWorkshopOptionsPath();
 
         try
@@ -104,8 +100,7 @@ private SteamWorkshopCredentials LoadCredentials(out bool requiresMigration)
             if (persisted is null)
                 return SteamWorkshopCredentials.Normalize(null);
 
-            var credentials = persisted.ToCredentials(_protector, _logger, out var migrated);
-            requiresMigration = migrated;
+            var credentials = persisted.ToCredentials(_protector, _logger);
             return SteamWorkshopCredentials.Normalize(credentials);
         }
         catch (Exception exception)
@@ -115,25 +110,6 @@ private SteamWorkshopCredentials LoadCredentials(out bool requiresMigration)
         }
     }
 
-    private async Task MigrateLegacyPlaintextAsync()
-    {
-        try
-        {
-            SteamWorkshopCredentials snapshot;
-            lock (_sync)
-            {
-                snapshot = _credentials.Clone();
-            }
-
-            await SaveAsync(snapshot).ConfigureAwait(false);
-            _logger.LogInformation("Migrated legacy plaintext Steam Workshop credentials to protected storage.");
-        }
-        catch (Exception exception)
-        {
-            _logger.LogWarning(exception, "Failed migrating legacy plaintext Steam Workshop credentials.");
-        }
-    }
-
     private void StartWatching()
     {
         var path = MagnetarPaths.GetQuasarWorkshopOptionsPath();
@@ -194,7 +170,7 @@ private void ScheduleReload()
 
     private void ReloadFromDisk()
     {
-        var reloaded = LoadCredentials(out var requiresMigration);
+        var reloaded = LoadCredentials();
         var snapshot = CreateSnapshot(reloaded);
         var changed = false;
 
@@ -208,9 +184,6 @@ private void ReloadFromDisk()
             }
         }
 
-        if (requiresMigration)
-            _ = MigrateLegacyPlaintextAsync();
-
         if (!changed)
             return;
 
@@ -243,10 +216,6 @@ private sealed class PersistedCredentials
     {
         public string? ProtectedWebApiKey { get; set; }
 
-        // Legacy plaintext field — read-only for backward compatibility.
-        // New writes only emit ProtectedWebApiKey.
-        public string? WebApiKey { get; set; }
-
         public static PersistedCredentials FromCredentials(SteamWorkshopCredentials credentials, IDataProtector protector)
         {
             var key = credentials.WebApiKey;
@@ -258,11 +227,8 @@ public static PersistedCredentials FromCredentials(SteamWorkshopCredentials cred
 
         public SteamWorkshopCredentials ToCredentials(
             IDataProtector protector,
-            ILogger logger,
-            out bool migratedFromPlaintext)
+            ILogger logger)
         {
-            migratedFromPlaintext = false;
-
             if (!string.IsNullOrWhiteSpace(ProtectedWebApiKey))
             {
                 try
@@ -281,15 +247,6 @@ public SteamWorkshopCredentials ToCredentials(
                 }
             }
 
-            if (!string.IsNullOrWhiteSpace(WebApiKey))
-            {
-                migratedFromPlaintext = true;
-                return new SteamWorkshopCredentials
-                {
-                    WebApiKey = WebApiKey,
-                };
-            }
-
             return new SteamWorkshopCredentials();
         }
     }
diff --git a/Quasar/Services/Updates/QuasarUpdateService.cs b/Quasar/Services/Updates/QuasarUpdateService.cs
index da18fa5a..f6e66b27 100644
--- a/Quasar/Services/Updates/QuasarUpdateService.cs
+++ b/Quasar/Services/Updates/QuasarUpdateService.cs
@@ -888,13 +888,16 @@ private static bool HasUnresolvedAppSettingsConflict(string stageDirectory)
 
     private static string? ResolveCurrentAppSettingsPath(string stageDirectory)
     {
-        var candidates = new[]
+        var candidates = new List
         {
-            Path.Combine(Environment.GetEnvironmentVariable("QUASAR_INSTALL_DIR") ?? string.Empty, AppSettingsFileName),
+            Path.Combine(MagnetarPaths.GetQuasarDirectory(), AppSettingsFileName),
+        };
+
+        candidates.AddRange([
             Path.Combine(AppContext.BaseDirectory, AppSettingsFileName),
             Path.Combine(Directory.GetCurrentDirectory(), AppSettingsFileName),
             Path.Combine(AppContext.BaseDirectory, "WebService", AppSettingsFileName),
-        };
+        ]);
 
         foreach (var candidate in candidates)
         {
@@ -919,9 +922,7 @@ private static async Task PersistAppSettingsBaseAsync(JsonObject release, Cancel
 
     private static void TrySyncActiveAppSettingsToInstallDirectory(string activeDirectory)
     {
-        var installDirectory = Environment.GetEnvironmentVariable("QUASAR_INSTALL_DIR");
-        if (string.IsNullOrWhiteSpace(installDirectory))
-            return;
+        var installDirectory = MagnetarPaths.GetQuasarDirectory();
 
         var sourcePath = Path.Combine(activeDirectory, AppSettingsFileName);
         var destinationPath = Path.Combine(installDirectory, AppSettingsFileName);
diff --git a/Quasar/wwwroot/quasar-configs.js b/Quasar/wwwroot/quasar-configs.js
index f63ed1bb..f0c6d90b 100644
--- a/Quasar/wwwroot/quasar-configs.js
+++ b/Quasar/wwwroot/quasar-configs.js
@@ -566,7 +566,7 @@ window.quasarConfigs = window.quasarConfigs || {
                 await navigator.clipboard.writeText(text);
                 return true;
             }
-        } catch (e) { /* fall through to legacy path */ }
+        } catch (e) { /* fall through to fallback path */ }
         // Fallback for non-secure contexts (HTTP LAN access)
         try {
             const ta = document.createElement("textarea");
diff --git a/install.sh b/install.sh
index 2c9ff33d..b38b8831 100755
--- a/install.sh
+++ b/install.sh
@@ -5,7 +5,6 @@ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
 
 SERVICE_NAME="quasar"
 INSTALL_DIR=""
-DATA_DIR=""
 PACKAGED_INSTALL=false
 CONFIGURATION="Release"
 RUNTIME="linux-x64"
@@ -41,7 +40,6 @@ UI plugin installs and updates.
 
 Options:
   --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)
@@ -457,10 +455,6 @@ while [[ $# -gt 0 ]]; do
             INSTALL_DIR="${2:?Missing value for --install-dir}"
             shift 2
             ;;
-        --data-dir)
-            DATA_DIR="${2:?Missing value for --data-dir}"
-            shift 2
-            ;;
         --service-name)
             SERVICE_NAME="${2:?Missing value for --service-name}"
             shift 2
@@ -615,11 +609,7 @@ if [[ -z "$INSTALL_DIR" ]]; then
         INSTALL_DIR="$RUN_HOME/.local/share/Quasar"
     fi
 fi
-if [[ -z "$DATA_DIR" ]]; then
-    DATA_DIR="$INSTALL_DIR"
-fi
 INSTALL_DIR="$(realpath -m "$INSTALL_DIR")"
-DATA_DIR="$(realpath -m "$DATA_DIR")"
 
 case "$INSTALL_DIR" in
     ""|"/"|"/bin"|"/boot"|"/dev"|"/etc"|"/home"|"/lib"|"/lib64"|"/proc"|"/root"|"/run"|"/sbin"|"/sys"|"/tmp"|"/usr"|"/var")
@@ -628,13 +618,6 @@ case "$INSTALL_DIR" in
         ;;
 esac
 
-case "$DATA_DIR" in
-    ""|"/"|"/bin"|"/boot"|"/dev"|"/etc"|"/home"|"/lib"|"/lib64"|"/proc"|"/root"|"/run"|"/sbin"|"/sys"|"/tmp"|"/usr"|"/var")
-        echo "Refusing unsafe data directory: $DATA_DIR" >&2
-        exit 1
-        ;;
-esac
-
 require_dotnet_installation
 ensure_optional_ui_plugin_sdk
 
@@ -663,9 +646,7 @@ SuccessExitStatus=130 143
 Environment=QUASAR_MODE=Service
 Environment=QUASAR_OPEN_BROWSER_ON_START=false
 Environment=HOME=$RUN_HOME
-Environment=QUASAR_DATA_DIR=$DATA_DIR
-Environment=QUASAR_SYSTEMD_SERVICE=$SERVICE_NAME.service
-Environment=QUASAR_SYSTEMD_SCOPE=system
+Environment=QUASAR_INSTALL_DIR=$INSTALL_DIR
 
 [Install]
 WantedBy=multi-user.target
@@ -697,9 +678,7 @@ SuccessExitStatus=130 143
 Environment=QUASAR_MODE=Service
 Environment=QUASAR_OPEN_BROWSER_ON_START=false
 Environment=HOME=$RUN_HOME
-Environment=QUASAR_DATA_DIR=$DATA_DIR
-Environment=QUASAR_SYSTEMD_SERVICE=$SERVICE_NAME.service
-Environment=QUASAR_SYSTEMD_SCOPE=user
+Environment=QUASAR_INSTALL_DIR=$INSTALL_DIR
 
 [Install]
 WantedBy=default.target
@@ -737,28 +716,6 @@ copy_if_different() {
     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
-        return
-    fi
-
-    echo "Old system install found at $old_install_dir."
-    local uninstall_args=(--install-dir "$old_install_dir" --purge)
-    if grep -q -- "--system" "$old_install_dir/uninstall.sh"; then
-        uninstall_args=(--system "${uninstall_args[@]}")
-    fi
-
-    if [[ "${EUID}" -eq 0 ]]; then
-        "$old_install_dir/uninstall.sh" "${uninstall_args[@]}" || true
-    elif have sudo; then
-        echo "Removing old system install with sudo..."
-        sudo "$old_install_dir/uninstall.sh" "${uninstall_args[@]}" || true
-    else
-        echo "sudo not found; old system install remains at $old_install_dir." >&2
-    fi
-}
-
 publish_quasar() {
     local build_version="$1"
     local nuget_version="$2"
@@ -847,13 +804,8 @@ fi
 echo "Installing Quasar to $INSTALL_DIR..."
 if [[ "${EUID}" -eq 0 ]]; then
     install -d -m 0755 -o "$RUN_USER" -g "$RUN_GROUP" "$INSTALL_DIR"
-    install -d -m 0755 -o "$RUN_USER" -g "$RUN_GROUP" "$DATA_DIR"
 else
     install -d -m 0755 "$INSTALL_DIR"
-    install -d -m 0755 "$DATA_DIR"
-fi
-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"
@@ -887,8 +839,6 @@ if [[ "$ENABLE_SERVICE" == "true" ]]; then
     enable_service
 fi
 
-cleanup_old_opt_install
-
 if [[ "$START_SERVICE" == "true" ]]; then
     restart_service
 fi
@@ -898,7 +848,6 @@ Installed Quasar.
 
 Service:     $SERVICE_NAME.service ($INSTALL_MODE)
 Install dir: $INSTALL_DIR
-Data dir:    $DATA_DIR
 Run user:    $RUN_USER
 
 Renice helper:
@@ -915,3 +864,5 @@ else
     echo "To run the user service before login, enable linger once:"
     echo "  sudo loginctl enable-linger $RUN_USER"
 fi
+echo
+echo "UI Shutdown Quasar drains the worker; run the restart command above to bring it back."
diff --git a/scripts/generate-analytics-data.py b/scripts/generate-analytics-data.py
index e76fe6e7..769a755b 100755
--- a/scripts/generate-analytics-data.py
+++ b/scripts/generate-analytics-data.py
@@ -22,20 +22,15 @@ def clamp(value: float, min_value: float, max_value: float) -> float:
     return max(min_value, min(max_value, value))
 
 
-def get_quasar_root(data_dir: str | None) -> Path:
-    if data_dir:
-        return Path(data_dir).expanduser().resolve()
+def get_quasar_root(install_dir: str | None) -> Path:
+    if install_dir:
+        return Path(install_dir).expanduser().resolve()
 
-    env_override = os.getenv("QUASAR_DATA_DIR")
+    env_override = os.getenv("QUASAR_INSTALL_DIR")
     if env_override:
         return Path(env_override).expanduser().resolve()
 
-    if os.name == "nt":
-        appdata = os.getenv("APPDATA")
-        if appdata:
-            return Path(appdata) / "Quasar"
-
-    return Path.home() / ".config" / "Quasar"
+    return Path.cwd().resolve()
 
 
 def list_servers(quasar_root: Path) -> list[str]:
@@ -140,7 +135,7 @@ def generate_series(
 
 def build_args():
     parser = argparse.ArgumentParser(description="Generate Quasar analytics.jsonl test data.")
-    parser.add_argument("--data-dir", default=None, help="Override Quasar root directory")
+    parser.add_argument("--install-dir", default=None, help="Override Quasar install root directory")
     parser.add_argument("--days", type=int, default=30, help="How many days to generate (default 30)")
     parser.add_argument("--server", action="append", help="Server uniqueName; repeatable")
     parser.add_argument("--seed", type=int, default=1337, help="Random seed for repeatable output")
@@ -158,7 +153,7 @@ def main():
     if args.raw_hours <= 0:
         raise ValueError("--raw-hours must be > 0")
 
-    quasar_root = get_quasar_root(args.data_dir)
+    quasar_root = get_quasar_root(args.install_dir)
     servers_dir = quasar_root / "Magnetars"
 
     if args.server:
diff --git a/scripts/install.ps1 b/scripts/install.ps1
index 09fab034..76d90b78 100644
--- a/scripts/install.ps1
+++ b/scripts/install.ps1
@@ -296,7 +296,6 @@ Installed Quasar.
 
 Scheduled task: $TaskName
 Install dir:    $InstallDir
-Data dir:       $InstallDir
 Run as:         $runAs
 Web UI:         $uiUrl
 
@@ -304,6 +303,7 @@ $startHint
 The task starts at boot and restarts the launcher on failure (keep-alive). On
 first start the launcher downloads the Quasar web UI from GitHub and then serves
 it at $uiUrl.
+UI Shutdown Quasar drains the worker; stop and start the task to bring it back.
 
 Manage the task:
   Get-ScheduledTask -TaskName '$TaskName'
diff --git a/scripts/readme-install-linux.md b/scripts/readme-install-linux.md
index 436d9c49..a266d819 100644
--- a/scripts/readme-install-linux.md
+++ b/scripts/readme-install-linux.md
@@ -13,9 +13,11 @@ cd Quasar
 ```
 
 Quasar starts, opens `http://localhost:8080` in your browser, and prints log
-output to the console. Press `Ctrl+C` to stop. On first start the launcher
-downloads the Quasar web UI from GitHub and caches it locally. The listening
-port is configurable — see [Configuration](Docs/Configuration.md).
+output to the console. Press `Ctrl+C` to stop the launcher. The UI **Shutdown
+Quasar** action drains the web worker and leaves the foreground launcher idle;
+press `Ctrl+C`, then run `./Quasar serve` again when you want the UI back. On
+first start the launcher downloads the Quasar web UI from GitHub and caches it
+locally. The listening port is configurable — see [Configuration](Docs/Configuration.md).
 
 ### Install as a background service (systemd)
 
@@ -34,13 +36,12 @@ tar -xzf quasar-installer-linux.tar.gz -C ~/.local/share/Quasar --strip-componen
 # ~/.local/share/Quasar/install.sh --start --install-ui-plugin-sdk
 ```
 
-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
+This installs Quasar in the extracted folder and starts the user
+`quasar.service`. Pass `--system` with `sudo` for a machine-wide service or
+`--install-dir ` to install Quasar 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:
+Quasar** action drains the web worker and leaves `quasar.service` running without
+respawning it. Restart the service to bring the UI and supervisor back:
 
 ```bash
 systemctl --user status  quasar.service
diff --git a/scripts/readme-install-windows.md b/scripts/readme-install-windows.md
index 0816f4f8..d7572031 100644
--- a/scripts/readme-install-windows.md
+++ b/scripts/readme-install-windows.md
@@ -17,9 +17,11 @@ cd C:\quasar\Quasar
 ```
 
 Quasar starts, opens  in your browser, and prints log
-output to the console. Press `Ctrl+C` to stop. On first start the launcher
-downloads the Quasar web UI from GitHub and caches it locally. The listening
-port is configurable — see [Configuration](Docs/Configuration.md).
+output to the console. Press `Ctrl+C` to stop the launcher. The UI **Shutdown
+Quasar** action drains the web worker and leaves the foreground launcher idle;
+press `Ctrl+C`, then run `.\Quasar.exe serve` again when you want the UI back.
+On first start the launcher downloads the Quasar web UI from GitHub and caches
+it locally. The listening port is configurable — see [Configuration](Docs/Configuration.md).
 
 ### Install as a background service (Scheduled Task)
 
@@ -36,6 +38,8 @@ 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.
+The UI **Shutdown Quasar** action drains the web worker and leaves the task
+running idle. Stop and start the task to bring the UI and supervisor back.
 
 Manage the task:
 
diff --git a/whitelabel-theme-configurator.md b/whitelabel-theme-configurator.md
index bdd96b20..6bca9830 100644
--- a/whitelabel-theme-configurator.md
+++ b/whitelabel-theme-configurator.md
@@ -175,8 +175,8 @@ Add two methods after `GetQuasarDiscordOptionsPath()`:
 public static string GetQuasarBrandingPath() =>
     Path.Combine(GetQuasarDirectory(), "branding.json");
 
-public static string GetQuasarBrandingDirectory(string webRootPath) =>
-    Path.Combine(webRootPath, "branding");
+public static string GetQuasarBrandingDirectory() =>
+    Path.Combine(GetQuasarDirectory(), "Branding");
 ```
 
 ### 7. `Quasar/Services/ThemePreferenceService.cs`