From bb97168aa413cdcd90aebd8e0068b3ddec9067c8 Mon Sep 17 00:00:00 2001 From: Owen de Bree Date: Fri, 3 Jul 2026 16:09:38 +0200 Subject: [PATCH] feat(ui-plugins): install missing SDK Add SDK preflight for QuasarHub source builds and let admins run install.sh's SDK-only path from the UI when the matching SDK is missing. Document that the runtime can run packaged Quasar, but source-built UI plugin install/update needs the SDK. --- Docs/BuildingAndDevelopment.md | 3 + Docs/LinuxDeploymentAndUpdates.md | 12 + Docs/QuasarPluginSystem.md | 11 + Docs/QuickStart.md | 8 + Docs/WindowsDeploymentAndUpdates.md | 6 + Quasar/Components/Pages/UiPlugins.razor | 188 ++++++++++- .../QuasarUiPluginHubCatalogService.cs | 297 ++++++++++++++++++ install.sh | 162 +++++++++- scripts/readme-install-linux.md | 9 +- 9 files changed, 676 insertions(+), 20 deletions(-) diff --git a/Docs/BuildingAndDevelopment.md b/Docs/BuildingAndDevelopment.md index 9fccaff2..7f128f91 100644 --- a/Docs/BuildingAndDevelopment.md +++ b/Docs/BuildingAndDevelopment.md @@ -70,6 +70,9 @@ The solution file is `Quasar.sln`. generated profile beside `Quasar.Agent.dll`. Viewer scene data is requested through `IQuasarCompanionChannel` from the viewer's Magnetar companion plugin; Quasar core does not carry viewer scene DTOs or a viewer-specific HTTP API. + Runtime-only packaged installs can run Quasar, but QuasarHub source-built UI + plugin install/update requires a matching .NET SDK on `PATH`; the UI disables + those build actions when the SDK preflight fails. ## Managed runtime selection diff --git a/Docs/LinuxDeploymentAndUpdates.md b/Docs/LinuxDeploymentAndUpdates.md index 1073d507..1e0b9420 100644 --- a/Docs/LinuxDeploymentAndUpdates.md +++ b/Docs/LinuxDeploymentAndUpdates.md @@ -197,6 +197,18 @@ first adds Microsoft's Debian 13 package feed with `packages-microsoft-prod.deb`, then runs `apt-get update` and installs the selected .NET package. +Packaged installs can run Quasar with only the runtime, but QuasarHub UI plugin +install/update builds source projects with `dotnet build` and requires the .NET +10 SDK. `install.sh` offers that SDK as an optional step for package/no-build +installs; pass `--install-ui-plugin-sdk` to request it explicitly. If the SDK is +not installed, `/settings/ui-plugins` disables install/update actions and shows +`.NET SDK required to build UI plugins`. On Linux, that warning can also invoke +`install.sh --install-ui-plugin-sdk-only --yes` from the UI. The action only +installs the SDK and exits; it does not rewrite the service or reinstall Quasar. +It still uses the system package manager, so it can fail under a service account +unless `sudo -n` can install packages without a password or Quasar is already +running with sufficient privileges. + ```bash mkdir -p ~/.local/share/Quasar tar -xzf quasar-installer-linux.tar.gz -C ~/.local/share/Quasar --strip-components=1 diff --git a/Docs/QuasarPluginSystem.md b/Docs/QuasarPluginSystem.md index 9cff4759..c1935923 100644 --- a/Docs/QuasarPluginSystem.md +++ b/Docs/QuasarPluginSystem.md @@ -150,6 +150,7 @@ The `/settings/ui-plugins` page now manages the QuasarHub catalog: - shows installed, update-available, hidden, and invalid package states - enables or disables installed packages for the next restart - clones/fetches the plugin repository and checks out the pinned commit +- checks for the matching .NET SDK before building source packages - builds the declared plugin project with `dotnet build` - passes `QuasarPluginAbstractionsAssembly` to the build so the plugin compiles against the contract DLL loaded by the running Quasar worker @@ -192,6 +193,16 @@ The installer passes the running worker's physical `QuasarPluginAbstractionsAssembly`. Release packaging keeps that DLL beside the single-file worker so Bootstrap-managed installs can build UI plugins from QuasarHub without needing a NuGet package. +The web worker must still have a matching .NET SDK on `PATH`; the ASP.NET Core +runtime is enough to run Quasar, but not enough to compile source-built UI +plugins. At startup Quasar checks `dotnet --list-sdks`; if the required SDK is +missing, `/settings/ui-plugins` shows `.NET SDK required to build UI plugins` +and disables QuasarHub install/update buttons until the SDK is installed and the +check is refreshed. On Linux, the warning includes an **Install SDK** action that +runs `install.sh --install-ui-plugin-sdk-only --yes`, captures the installer +output, refreshes the SDK preflight, and leaves Quasar service files untouched. +This can still fail if package installation requires an interactive sudo +password. Owned companion build output is written under `{installed package}/.quasar/companions/{companion id}`. Quasar passes `MagnetarProtocolAssembly` from the running worker or staged `Agent/` directory diff --git a/Docs/QuickStart.md b/Docs/QuickStart.md index 556bc20b..8855e40b 100644 --- a/Docs/QuickStart.md +++ b/Docs/QuickStart.md @@ -39,12 +39,20 @@ Declining the prompt exits before files or services are changed. On Debian 13, the prompt also includes the Microsoft package feed bootstrap commands needed before installing the .NET packages. +Packaged installs need only the runtime to run Quasar. QuasarHub UI plugin +install/update compiles source with `dotnet build`, so install the .NET 10 SDK +too by accepting the optional prompt or passing `--install-ui-plugin-sdk` when +you want source-built UI plugins from QuasarHub. On Linux, the UI Plugins page +can also run the install script's SDK-only path when the SDK is missing. + **Linux — systemd** ```bash mkdir -p ~/.local/share/Quasar tar -xzf quasar-installer-linux.tar.gz -C ~/.local/share/Quasar --strip-components=1 ~/.local/share/Quasar/install.sh --start # installs in place and starts quasar.service +# Optional SDK for QuasarHub source-built UI plugin installs: +# ~/.local/share/Quasar/install.sh --start --install-ui-plugin-sdk ``` The Linux installer defaults to a user systemd service, uses the extracted diff --git a/Docs/WindowsDeploymentAndUpdates.md b/Docs/WindowsDeploymentAndUpdates.md index 3437c9ec..e895919f 100644 --- a/Docs/WindowsDeploymentAndUpdates.md +++ b/Docs/WindowsDeploymentAndUpdates.md @@ -161,6 +161,12 @@ systemd to restart it — that path is unchanged. Install the **.NET 10 runtime** before running `install.ps1`. The installer checks for `Microsoft.NETCore.App` 10.x before staging files, publishing, or registering the Scheduled Task, and exits with install instructions if it is missing. +The runtime is enough to run packaged Quasar. QuasarHub UI plugin install/update +compiles source with `dotnet build` and requires the **.NET 10 SDK**; when the SDK +is missing, `/settings/ui-plugins` shows `.NET SDK required to build UI plugins` +and disables install/update buttons. Automatic SDK install from the UI is a +Linux `install.sh` feature; Windows operators should install the .NET SDK +manually and refresh the SDK check. ```powershell # From an extracted quasar-installer-windows.zip, in an elevated PowerShell: diff --git a/Quasar/Components/Pages/UiPlugins.razor b/Quasar/Components/Pages/UiPlugins.razor index db541231..9495deff 100644 --- a/Quasar/Components/Pages/UiPlugins.razor +++ b/Quasar/Components/Pages/UiPlugins.razor @@ -26,6 +26,11 @@ Variant="Variant.Outlined"> @(PluginCatalog.SafeMode ? "Safe boot active" : "Dynamic plugins enabled") + + @GetDotNetSdkChipText() + + @if (!CanBuildUiPlugins) + { + var sdkInstallAvailability = HubCatalog.GetDotNetSdkInstallAvailability(); + + + + .NET SDK required to build UI plugins. @_dotNetSdkStatus.Message Install the .NET SDK to install or update source-built Quasar UI plugins. + + + + + @(DotNetSdkInstallerBusy ? "Installing SDK..." : "Install SDK") + + + + + Refresh SDK check + + + + @if (_lastDotNetSdkInstallResult is not null) + { + + + @_lastDotNetSdkInstallResult.Message + @if (!string.IsNullOrWhiteSpace(_lastDotNetSdkInstallResult.Output)) + { + + + @TrimInstallerOutput(_lastDotNetSdkInstallResult.Output) + + + } + + + } + + + } + @@ -189,6 +244,7 @@ @{ var installState = HubCatalog.GetInstallState(context); + var installDisabledReason = GetHubInstallDisabledReason(context); } @@ -231,14 +287,18 @@
- - @GetHubInstallButtonText(context, installState) - + + + + @GetHubInstallButtonText(context, installState) + + + @if (installState.Installed) { QuasarUiPluginCatalog.SafeModeMarkerPath; @@ -488,6 +556,10 @@ private bool HubOperationInProgress => !string.IsNullOrWhiteSpace(_hubBusyCatalogId); + private bool CanBuildUiPlugins => _dotNetSdkStatus.CanBuildUiPlugins; + + private bool DotNetSdkInstallerBusy => _dotNetSdkInstallerBusy || HubCatalog.DotNetSdkInstallInProgress; + private IReadOnlyDictionary PluginsPageParameters => new Dictionary { ["UiPlugins"] = PluginCatalog, @@ -499,6 +571,7 @@ protected override void OnInitialized() { + _dotNetSdkStatus = HubCatalog.GetDotNetSdkStatus(); HubCatalog.Changed += HandleHubCatalogChanged; PluginStates.Changed += HandlePluginStatesChanged; } @@ -591,6 +664,9 @@ if (HubOperationInProgress) return; + if (!EnsureDotNetSdkForPluginBuild()) + return; + _hubBusyCatalogId = entry.CatalogId; try { @@ -608,6 +684,50 @@ } } + private async Task InstallDotNetSdkAsync() + { + if (DotNetSdkInstallerBusy) + return; + + var availability = HubCatalog.GetDotNetSdkInstallAvailability(); + if (!availability.CanInstall) + { + Snackbar.Add(availability.Message, Severity.Warning); + return; + } + + var confirmed = await DialogService.ShowMessageBoxAsync( + "Install .NET SDK?", + "Quasar will run install.sh to install the .NET SDK required for source-built UI plugins. This may install system packages and can fail if sudo needs a password.", + yesText: "Install SDK", + cancelText: "Cancel"); + if (confirmed != true) + return; + + _dotNetSdkInstallerBusy = true; + _lastDotNetSdkInstallResult = null; + try + { + var result = await HubCatalog.InstallDotNetSdkAsync(); + _lastDotNetSdkInstallResult = result; + _dotNetSdkStatus = HubCatalog.GetDotNetSdkStatus(); + Snackbar.Add(result.Message, result.Succeeded ? Severity.Success : Severity.Error); + } + catch (Exception exception) + { + _lastDotNetSdkInstallResult = new QuasarDotNetSdkInstallResult( + Succeeded: false, + exception.Message, + string.Empty); + Snackbar.Add(exception.Message, Severity.Error); + } + finally + { + _dotNetSdkInstallerBusy = false; + await InvokeAsync(StateHasChanged); + } + } + private async Task RemoveHubPluginAsync(QuasarUiPluginHubEntry entry) { if (HubOperationInProgress) @@ -710,11 +830,32 @@ string.Equals(_hubBusyCatalogId, entry.CatalogId, StringComparison.OrdinalIgnoreCase); private bool IsHubInstallDisabled(QuasarUiPluginHubEntry entry) => + !CanBuildUiPlugins || HubOperationInProgress || _hubRefreshing || string.IsNullOrWhiteSpace(entry.RepoId) || string.IsNullOrWhiteSpace(entry.Commit); + private string GetHubInstallDisabledReason(QuasarUiPluginHubEntry entry) + { + if (!CanBuildUiPlugins) + return $".NET SDK required to build UI plugins. Install .NET {_dotNetSdkStatus.RequiredMajorVersion} SDK."; + + if (HubOperationInProgress) + return "Another QuasarHub operation is running."; + + if (_hubRefreshing) + return "QuasarHub catalog refresh is running."; + + if (string.IsNullOrWhiteSpace(entry.RepoId)) + return "QuasarHub entry has no repository."; + + if (string.IsNullOrWhiteSpace(entry.Commit)) + return "QuasarHub entry has no pinned commit."; + + return string.Empty; + } + private string GetHubInstallButtonText(QuasarUiPluginHubEntry entry, QuasarUiPluginInstallState installState) { if (IsHubBusy(entry)) @@ -786,6 +927,36 @@ private static string GetHubEnableButtonIcon(QuasarUiPluginInstallState installState) => installState.Enabled ? Icons.Material.Filled.VisibilityOff : Icons.Material.Filled.Visibility; + private Color GetDotNetSdkChipColor() => + CanBuildUiPlugins ? Color.Success : Color.Warning; + + private string GetDotNetSdkChipText() => + CanBuildUiPlugins ? $".NET {_dotNetSdkStatus.RequiredMajorVersion} SDK ready" : ".NET SDK missing"; + + private void RefreshDotNetSdkStatus() + { + _dotNetSdkStatus = HubCatalog.RefreshDotNetSdkStatus(); + Snackbar.Add(_dotNetSdkStatus.Message, CanBuildUiPlugins ? Severity.Success : Severity.Warning); + } + + private bool EnsureDotNetSdkForPluginBuild() + { + _dotNetSdkStatus = HubCatalog.RefreshDotNetSdkStatus(); + if (CanBuildUiPlugins) + return true; + + Snackbar.Add(_dotNetSdkStatus.Message, Severity.Warning); + return false; + } + + private static string TrimInstallerOutput(string output) + { + const int maxCharacters = 8000; + return output.Length <= maxCharacters + ? output + : output[^maxCharacters..]; + } + private static string GetHubDisplayName(QuasarUiPluginHubEntry entry) => string.IsNullOrWhiteSpace(entry.FriendlyName) ? entry.CatalogId : entry.FriendlyName; @@ -839,6 +1010,7 @@ private void HandleHubCatalogChanged() { + _dotNetSdkStatus = HubCatalog.GetDotNetSdkStatus(); _ = InvokeAsync(StateHasChanged); } diff --git a/Quasar/Services/Plugins/QuasarUiPluginHubCatalogService.cs b/Quasar/Services/Plugins/QuasarUiPluginHubCatalogService.cs index a8539eea..cbd5065d 100644 --- a/Quasar/Services/Plugins/QuasarUiPluginHubCatalogService.cs +++ b/Quasar/Services/Plugins/QuasarUiPluginHubCatalogService.cs @@ -18,6 +18,7 @@ public sealed class QuasarUiPluginHubCatalogService private const string PluginAbstractionsAssemblyFileName = "Quasar.Plugin.Abstractions.dll"; private const string MagnetarProtocolAssemblyFileName = "Magnetar.Protocol.dll"; private const string CompanionOutputRelativeDirectory = ".quasar/companions"; + private static readonly int RequiredDotNetSdkMajor = Environment.Version.Major; public const string DefaultHubName = "QuasarHub"; public const string DefaultHubRepo = "CometWorks/quasar-hub"; @@ -36,6 +37,9 @@ public sealed class QuasarUiPluginHubCatalogService private readonly IHostEnvironment _environment; private readonly QuasarUiPluginStateStore _pluginStates; private readonly QuasarUiPluginCatalog _uiPluginCatalog; + private readonly SemaphoreSlim _dotNetSdkInstallLock = new(1, 1); + private QuasarDotNetSdkStatus _dotNetSdkStatus; + private bool _dotNetSdkInstallInProgress; private List _entries; public QuasarUiPluginHubCatalogService( @@ -52,6 +56,7 @@ public QuasarUiPluginHubCatalogService( _environment = environment; _pluginStates = pluginStates; _uiPluginCatalog = uiPluginCatalog; + _dotNetSdkStatus = DetectDotNetSdkStatus(); _entries = LoadCache(); } @@ -61,6 +66,15 @@ public QuasarUiPluginHubCatalogService( public string LastError { get; private set; } = string.Empty; + public bool DotNetSdkInstallInProgress + { + get + { + lock (_sync) + return _dotNetSdkInstallInProgress; + } + } + public IReadOnlyList GetEntries() { lock (_sync) @@ -74,6 +88,130 @@ public IReadOnlyList GetEntries() } } + public QuasarDotNetSdkStatus GetDotNetSdkStatus() + { + lock (_sync) + return _dotNetSdkStatus; + } + + public QuasarDotNetSdkStatus RefreshDotNetSdkStatus() + { + var status = DetectDotNetSdkStatus(); + lock (_sync) + _dotNetSdkStatus = status; + + Changed?.Invoke(); + return status; + } + + public QuasarDotNetSdkInstallAvailability GetDotNetSdkInstallAvailability() + { + if (!OperatingSystem.IsLinux()) + { + return new QuasarDotNetSdkInstallAvailability( + CanInstall: false, + "Automatic SDK install is only available on Linux through install.sh.", + string.Empty); + } + + var scriptPath = GetInstallScriptPath(); + if (string.IsNullOrWhiteSpace(scriptPath)) + { + return new QuasarDotNetSdkInstallAvailability( + CanInstall: false, + "install.sh was not found beside Quasar. Install the .NET SDK manually, then refresh the SDK check.", + string.Empty); + } + + return new QuasarDotNetSdkInstallAvailability( + CanInstall: true, + "Run install.sh to install the .NET SDK required for source-built QuasarHub UI plugins.", + scriptPath); + } + + public async Task InstallDotNetSdkAsync(CancellationToken cancellationToken = default) + { + var sdkStatus = RefreshDotNetSdkStatus(); + if (sdkStatus.CanBuildUiPlugins) + { + return new QuasarDotNetSdkInstallResult( + Succeeded: true, + sdkStatus.Message, + string.Empty); + } + + var availability = GetDotNetSdkInstallAvailability(); + if (!availability.CanInstall) + { + return new QuasarDotNetSdkInstallResult( + Succeeded: false, + availability.Message, + string.Empty); + } + + if (!await _dotNetSdkInstallLock.WaitAsync(0, cancellationToken)) + { + return new QuasarDotNetSdkInstallResult( + Succeeded: false, + "SDK install is already running.", + string.Empty); + } + + SetDotNetSdkInstallInProgress(true); + try + { + _logger.LogInformation( + "Running {ScriptPath} to install the .NET SDK for QuasarHub UI plugin builds.", + availability.InstallScriptPath); + + var scriptDirectory = Path.GetDirectoryName(availability.InstallScriptPath); + var startInfo = new ProcessStartInfo("bash") + { + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false, + WorkingDirectory = string.IsNullOrWhiteSpace(scriptDirectory) ? AppContext.BaseDirectory : scriptDirectory, + }; + startInfo.ArgumentList.Add(availability.InstallScriptPath); + startInfo.ArgumentList.Add("--install-ui-plugin-sdk-only"); + startInfo.ArgumentList.Add("--yes"); + + var processResult = await RunProcessCaptureAsync( + startInfo, + "install.sh --install-ui-plugin-sdk-only", + cancellationToken); + var refreshedStatus = RefreshDotNetSdkStatus(); + if (processResult.ExitCode == 0 && refreshedStatus.CanBuildUiPlugins) + { + return new QuasarDotNetSdkInstallResult( + Succeeded: true, + refreshedStatus.Message, + processResult.Output); + } + + var message = processResult.ExitCode == 0 + ? refreshedStatus.Message + : $"SDK install script failed with exit code {processResult.ExitCode}."; + return new QuasarDotNetSdkInstallResult( + Succeeded: false, + message, + processResult.Output); + } + catch (Exception exception) + { + _logger.LogWarning(exception, "Failed to run install.sh for .NET SDK installation."); + return new QuasarDotNetSdkInstallResult( + Succeeded: false, + exception.Message, + string.Empty); + } + finally + { + SetDotNetSdkInstallInProgress(false); + _dotNetSdkInstallLock.Release(); + } + } + public async Task RefreshAsync(CancellationToken cancellationToken = default) { var entries = new Dictionary(StringComparer.OrdinalIgnoreCase); @@ -209,6 +347,10 @@ public async Task InstallOrUpdateAsync( CancellationToken cancellationToken = default, bool enableAfterInstall = true) { + var sdkStatus = RefreshDotNetSdkStatus(); + if (!sdkStatus.CanBuildUiPlugins) + throw new InvalidOperationException(sdkStatus.Message); + if (string.IsNullOrWhiteSpace(entry.RepoId)) throw new InvalidOperationException("QuasarHub entry has no repository."); @@ -571,6 +713,24 @@ private static async Task RunProcessAsync(ProcessStartInfo startInfo, string lab } } + private static async Task RunProcessCaptureAsync( + ProcessStartInfo startInfo, + string label, + CancellationToken cancellationToken) + { + using var process = Process.Start(startInfo) + ?? throw new InvalidOperationException($"Failed to start {label}."); + var stdoutTask = process.StandardOutput.ReadToEndAsync(cancellationToken); + var stderrTask = process.StandardError.ReadToEndAsync(cancellationToken); + await process.WaitForExitAsync(cancellationToken); + + var stdout = await stdoutTask; + var stderr = await stderrTask; + var output = string.Join(Environment.NewLine, [stdout.Trim(), stderr.Trim()]) + .Trim(); + return new ProcessCaptureResult(process.ExitCode, output); + } + private string GetBuildConfiguration() => Environment.GetEnvironmentVariable("QUASAR_UI_PLUGIN_BUILD_CONFIGURATION") ?? _configuration["Quasar:Plugins:BuildConfiguration"] @@ -761,6 +921,121 @@ private static void TryDeleteDirectory(string path) } } + private static QuasarDotNetSdkStatus DetectDotNetSdkStatus() + { + var startInfo = new ProcessStartInfo("dotnet") + { + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false, + }; + startInfo.ArgumentList.Add("--list-sdks"); + + try + { + using var process = Process.Start(startInfo); + if (process is null) + return CreateMissingSdkStatus("Failed to start dotnet --list-sdks."); + + if (!process.WaitForExit(5000)) + { + TryKillProcess(process); + return CreateMissingSdkStatus("dotnet --list-sdks did not finish within 5 seconds."); + } + + var stdout = process.StandardOutput.ReadToEnd(); + var stderr = process.StandardError.ReadToEnd(); + if (process.ExitCode != 0) + { + var output = string.Join(Environment.NewLine, [stdout.Trim(), stderr.Trim()]).Trim(); + return CreateMissingSdkStatus(string.IsNullOrWhiteSpace(output) + ? $"dotnet --list-sdks failed with exit code {process.ExitCode}." + : output); + } + + var sdkVersions = stdout + .Split(['\r', '\n'], StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) + .Select(GetSdkVersion) + .Where(version => !string.IsNullOrWhiteSpace(version)) + .ToArray(); + var requiredSdk = sdkVersions.FirstOrDefault(version => + version.StartsWith($"{RequiredDotNetSdkMajor}.", StringComparison.OrdinalIgnoreCase)); + + if (!string.IsNullOrWhiteSpace(requiredSdk)) + { + return new QuasarDotNetSdkStatus( + RequiredDotNetSdkMajor, + DotNetOnPath: true, + RequiredSdkAvailable: true, + sdkVersions, + $".NET {RequiredDotNetSdkMajor} SDK {requiredSdk} is available for Quasar UI plugin builds."); + } + + var installed = sdkVersions.Length == 0 ? "none" : string.Join(", ", sdkVersions); + return new QuasarDotNetSdkStatus( + RequiredDotNetSdkMajor, + DotNetOnPath: true, + RequiredSdkAvailable: false, + sdkVersions, + $".NET {RequiredDotNetSdkMajor} SDK is required to build Quasar UI plugins. Installed SDKs: {installed}."); + } + catch (Exception exception) + { + return CreateMissingSdkStatus( + $"dotnet command was not found or could not be started. .NET {RequiredDotNetSdkMajor} SDK is required to build Quasar UI plugins. {exception.Message}"); + } + } + + private static QuasarDotNetSdkStatus CreateMissingSdkStatus(string message) => + new( + RequiredDotNetSdkMajor, + DotNetOnPath: false, + RequiredSdkAvailable: false, + [], + message); + + private void SetDotNetSdkInstallInProgress(bool inProgress) + { + lock (_sync) + _dotNetSdkInstallInProgress = inProgress; + + Changed?.Invoke(); + } + + 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 + .Select(Path.GetFullPath) + .FirstOrDefault(File.Exists) + ?? string.Empty; + } + + private static string GetSdkVersion(string sdkLine) + { + var index = sdkLine.IndexOf(' ', StringComparison.Ordinal); + return index < 0 ? sdkLine : sdkLine[..index]; + } + + private static void TryKillProcess(Process process) + { + try + { + if (!process.HasExited) + process.Kill(entireProcessTree: false); + } + catch + { + } + } + private static string GetPluginRootDirectory() => Path.Combine(MagnetarPaths.GetQuasarDirectory(), "Plugins"); @@ -803,6 +1078,28 @@ private sealed class QuasarUiPluginHubCatalogCache public List Entries { get; set; } = []; } + + private sealed record ProcessCaptureResult(int ExitCode, string Output); +} + +public sealed record QuasarDotNetSdkInstallAvailability( + bool CanInstall, + string Message, + string InstallScriptPath); + +public sealed record QuasarDotNetSdkInstallResult( + bool Succeeded, + string Message, + string Output); + +public sealed record QuasarDotNetSdkStatus( + int RequiredMajorVersion, + bool DotNetOnPath, + bool RequiredSdkAvailable, + IReadOnlyList InstalledSdkVersions, + string Message) +{ + public bool CanBuildUiPlugins => DotNetOnPath && RequiredSdkAvailable; } public sealed class QuasarUiPluginHubEntry diff --git a/install.sh b/install.sh index 7307dbee..2c9ff33d 100755 --- a/install.sh +++ b/install.sh @@ -14,6 +14,9 @@ START_SERVICE=false SKIP_BUILD=false INSTALL_MODE="user" INSTALL_RENICE_HELPER=false +INSTALL_UI_PLUGIN_SDK=false +INSTALL_UI_PLUGIN_SDK_ONLY=false +ASSUME_YES=false RENICE_HELPER_PATH="/usr/local/bin/quasar-renice" RUN_USER="${USER:-}" RUN_USER_EXPLICIT=false @@ -32,7 +35,9 @@ For machine-wide service installation, pass --system and run with sudo. If the required .NET 10 SDK/runtime is missing, the installer detects apt, dnf, yum, pacman, or zypper, prints the exact install commands, and prompts before running them. On Debian 13, apt installs first add Microsoft's Debian 13 package -feed. +feed. Packaged installs need only the runtime to run Quasar; pass +--install-ui-plugin-sdk to also install the SDK used for source-built QuasarHub +UI plugin installs and updates. Options: --install-dir Install directory (default: extracted installer root; source installs use /.local/share/Quasar) @@ -47,6 +52,10 @@ Options: --no-enable Do not enable the service at boot --start Restart/start the service after installing --no-build Install from an existing publish directory + --install-ui-plugin-sdk Install .NET SDK for QuasarHub UI plugin builds + --install-ui-plugin-sdk-only + Only install .NET SDK for QuasarHub UI plugin builds, then exit + --yes Run package install commands without confirmation -h, --help Show this help EOF } @@ -61,7 +70,11 @@ privileged_prefix() { fi if have sudo; then - printf 'sudo ' + if [[ "$ASSUME_YES" == "true" ]]; then + printf 'sudo -n ' + else + printf 'sudo ' + fi return fi @@ -258,24 +271,51 @@ build_dotnet_install_commands() { prompt_and_install_dotnet() { local label="$1" local package_kind="$2" + local required="${3:-true}" + local required_message="${4:-$label is required before installing Quasar.}" local package_manager package_manager="$(detect_package_manager)" if [[ -z "$package_manager" ]]; then - echo "$label is required before installing Quasar." >&2 + if [[ "$required" == "true" ]]; then + echo "$label is required before installing Quasar." >&2 + else + echo "$label was not installed." >&2 + fi echo "No supported package manager found. Install it first: https://dotnet.microsoft.com/download/dotnet/$REQUIRED_DOTNET_MAJOR.0" >&2 - exit 1 + if [[ "$required" == "true" ]]; then + exit 1 + fi + return 1 fi local packages packages="$(dotnet_package_for_manager "$package_kind" "$package_manager")" + if [[ "$required" != "true" && "${EUID}" -ne 0 ]] && ! have sudo; then + echo "$label was not installed." >&2 + echo "sudo is required to install system packages. Install it manually: https://dotnet.microsoft.com/download/dotnet/$REQUIRED_DOTNET_MAJOR.0" >&2 + return 1 + fi + if ! build_dotnet_install_commands "$package_manager" "$packages"; then - echo "$label is required before installing Quasar." >&2 + if [[ "$required" == "true" ]]; then + echo "$label is required before installing Quasar." >&2 + else + echo "$label was not installed." >&2 + fi echo "Unsupported package manager '$package_manager'. Install it first: https://dotnet.microsoft.com/download/dotnet/$REQUIRED_DOTNET_MAJOR.0" >&2 - exit 1 + if [[ "$required" == "true" ]]; then + exit 1 + fi + return 1 fi - echo "$label is required before installing Quasar." + if [[ "$required" == "true" ]]; then + echo "$required_message" + else + echo "$label is optional for QuasarHub UI plugin builds." + echo "The runtime can run Quasar; the SDK is needed when Quasar compiles source-built UI plugins." + fi echo "Quasar can install it with the detected package manager: $package_manager" echo echo "Exact commands to run:" @@ -285,9 +325,17 @@ prompt_and_install_dotnet() { done echo echo "These commands may add or update system packages. The final command only links dotnet onto PATH if the package manager did not." + if [[ "$ASSUME_YES" == "true" ]]; then + echo "Running commands without confirmation because --yes was passed." + else if [[ ! -t 0 ]]; then - echo "Non-interactive terminal; refusing automatic .NET installation." >&2 - exit 1 + if [[ "$required" == "true" ]]; then + echo "Non-interactive terminal; refusing automatic .NET installation." >&2 + exit 1 + fi + + echo "Non-interactive terminal; skipping optional $label installation." >&2 + return 1 fi local answer @@ -296,10 +344,16 @@ prompt_and_install_dotnet() { y|Y|yes|YES|Yes) ;; *) - echo "Cancelled. Install $label manually, then rerun install.sh." >&2 - exit 1 + if [[ "$required" == "true" ]]; then + echo "Cancelled. Install $label manually, then rerun install.sh." >&2 + exit 1 + fi + + echo "Skipped optional $label installation." >&2 + return 1 ;; esac + fi for command in "${DOTNET_INSTALL_COMMANDS[@]}"; do echo "+ $command" @@ -349,6 +403,54 @@ require_dotnet_installation() { exit 1 } +ensure_optional_ui_plugin_sdk() { + if [[ "$SKIP_BUILD" == "false" ]]; then + return + fi + + if dotnet_has_sdk; then + echo ".NET $REQUIRED_DOTNET_MAJOR SDK available for QuasarHub UI plugin builds." + return + fi + + if [[ "$INSTALL_UI_PLUGIN_SDK" == "true" ]]; then + prompt_and_install_dotnet ".NET $REQUIRED_DOTNET_MAJOR SDK for QuasarHub UI plugin builds" "sdk" + if dotnet_has_sdk; then + return + fi + + echo ".NET $REQUIRED_DOTNET_MAJOR SDK is still missing after installation attempt." >&2 + print_installed_dotnet_state + exit 1 + fi + + if [[ ! -t 0 ]]; then + echo "Optional .NET $REQUIRED_DOTNET_MAJOR SDK not installed; Quasar can run, but QuasarHub UI plugin install/update is disabled until the SDK is installed." >&2 + echo "Rerun install.sh with --install-ui-plugin-sdk to install it." >&2 + return + fi + + echo + echo "Optional .NET $REQUIRED_DOTNET_MAJOR SDK for QuasarHub UI plugin builds is not installed." + echo "The ASP.NET Core runtime can run Quasar; source-built UI plugin install/update uses dotnet build." + + local answer + read -r -p "Install the SDK for UI plugin builds now? [y/N] " answer + case "$answer" in + y|Y|yes|YES|Yes) + prompt_and_install_dotnet ".NET $REQUIRED_DOTNET_MAJOR SDK for QuasarHub UI plugin builds" "sdk" false || true + if dotnet_has_sdk; then + echo ".NET $REQUIRED_DOTNET_MAJOR SDK available for QuasarHub UI plugin builds." + else + echo "Skipped optional SDK install. QuasarHub UI plugin install/update stays disabled until the SDK is installed." >&2 + fi + ;; + *) + echo "Skipping optional SDK install. QuasarHub UI plugin install/update stays disabled until the SDK is installed." + ;; + esac +} + while [[ $# -gt 0 ]]; do case "$1" in --install-dir) @@ -407,6 +509,22 @@ while [[ $# -gt 0 ]]; do SKIP_BUILD=true shift ;; + --install-ui-plugin-sdk) + INSTALL_UI_PLUGIN_SDK=true + shift + ;; + --install-ui-plugin-sdk-only) + INSTALL_UI_PLUGIN_SDK=true + INSTALL_UI_PLUGIN_SDK_ONLY=true + SKIP_BUILD=true + ENABLE_SERVICE=false + START_SERVICE=false + shift + ;; + --yes|-y) + ASSUME_YES=true + shift + ;; -h|--help) usage exit 0 @@ -432,6 +550,27 @@ if [[ "$(uname -s)" != "Linux" ]]; then exit 1 fi +if [[ "$INSTALL_UI_PLUGIN_SDK_ONLY" == "true" ]]; then + if dotnet_has_sdk; then + echo ".NET $REQUIRED_DOTNET_MAJOR SDK available for QuasarHub UI plugin builds." + exit 0 + fi + + prompt_and_install_dotnet \ + ".NET $REQUIRED_DOTNET_MAJOR SDK for QuasarHub UI plugin builds" \ + "sdk" \ + "true" \ + ".NET $REQUIRED_DOTNET_MAJOR SDK is required to install or update source-built QuasarHub UI plugins." + if dotnet_has_sdk; then + echo ".NET $REQUIRED_DOTNET_MAJOR SDK available for QuasarHub UI plugin builds." + exit 0 + fi + + echo ".NET $REQUIRED_DOTNET_MAJOR SDK is still missing after installation attempt." >&2 + print_installed_dotnet_state + exit 1 +fi + if [[ "$INSTALL_RENICE_HELPER" == "true" && "$SKIP_BUILD" == "true" && "$ENABLE_SERVICE" == "false" && "$START_SERVICE" == "false" ]]; then install_renice_helper exit 0 @@ -497,6 +636,7 @@ case "$DATA_DIR" in esac require_dotnet_installation +ensure_optional_ui_plugin_sdk write_service_unit() { if [[ "$INSTALL_MODE" == "system" ]]; then diff --git a/scripts/readme-install-linux.md b/scripts/readme-install-linux.md index fa7f7021..436d9c49 100644 --- a/scripts/readme-install-linux.md +++ b/scripts/readme-install-linux.md @@ -19,12 +19,19 @@ port is configurable — see [Configuration](Docs/Configuration.md). ### Install as a background service (systemd) -Install the **.NET 10 runtime** before running `install.sh`. +Install the **.NET 10 runtime** before running `install.sh`. Packaged Quasar can +run with the runtime alone, but QuasarHub UI plugin install/update compiles +source with `dotnet build` and needs the **.NET 10 SDK**. Accept the optional SDK +prompt or pass `--install-ui-plugin-sdk` if you plan to install source-built UI +plugins from QuasarHub. If the SDK is still missing later, the UI Plugins page +can run the install script's SDK-only path from the warning banner. ```bash mkdir -p ~/.local/share/Quasar tar -xzf quasar-installer-linux.tar.gz -C ~/.local/share/Quasar --strip-components=1 ~/.local/share/Quasar/install.sh --start +# Or install the optional UI plugin build SDK too: +# ~/.local/share/Quasar/install.sh --start --install-ui-plugin-sdk ``` This installs Quasar in the extracted folder, keeps Quasar state in the same