From 4b39393fa679669da8c9aa101f9ba47c0dfe5bc1 Mon Sep 17 00:00:00 2001 From: Hendrik Mennen Date: Thu, 6 Aug 2026 17:29:04 +0200 Subject: [PATCH] Apply configuration profiles from an environment variable Allow deployments to provision a configuration profile (settings, packages, package sources) without user interaction, composing with the existing OneWareStudio.defaults.json deployment defaults. ONEWARE_CONFIGURATION_PROFILE accepts a local file path or an http(s) URL, so a profile can be served centrally. It can be set through the defaults file, as a real environment variable, or via the new --configuration-profile argument. Profiles are applied once by default, tracked by a SHA-256 fingerprint of the source and content, so a deployment default does not overwrite settings the user changed afterwards. Editing the profile or repointing the variable re-applies it. ONEWARE_CONFIGURATION_PROFILE_MODE=always re-applies on every launch for locked-down deployments. A profile that cannot be downloaded or parsed is logged and skipped so it can never prevent the IDE from starting. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- README.md | 33 ++++- .../Services/IConfigurationProfileService.cs | 29 ++++ .../Services/ConfigurationProfileService.cs | 120 +++++++++++++++ .../DesktopStudioApp.cs | 4 + studio/OneWare.Studio.Desktop/Program.cs | 12 +- .../ConfigurationProfileServiceTests.cs | 137 +++++++++++++++++- 6 files changed, 332 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index f753a9ff..34873f34 100644 --- a/README.md +++ b/README.md @@ -81,13 +81,44 @@ The file maps the environment variables used by command-line arguments to values "ONEWARE_APPDATA_DIR": "/workspace/AppData", "ONEWARE_MODULES": "/plugins/ExamplePlugin.dll", "ONEWARE_AUTOLAUNCH": "example-action", - "ONEWARE_PACKAGE_REPOSITORY": "https://packages.example.com/oneware-packages.json" + "ONEWARE_PACKAGE_REPOSITORY": "https://packages.example.com/oneware-packages.json", + "ONEWARE_CONFIGURATION_PROFILE": "/workspace/team.onewareconfig" } ``` Existing environment variables and command-line arguments take precedence. Only `ONEWARE_` variables are accepted; invalid files and entries are ignored with a console warning. +### Configuration profiles + +A configuration profile (`*.onewareconfig`) captures IDE settings, installed packages, and custom package sources. +Export one from a configured installation via **Extras → Export Configuration...**, then have every deployed machine +apply it automatically by setting `ONEWARE_CONFIGURATION_PROFILE` — either in `OneWareStudio.defaults.json` above, as a +real environment variable, or via the `--configuration-profile` argument: + +```bash +OneWareStudio --configuration-profile /workspace/team.onewareconfig +OneWareStudio --configuration-profile https://config.example.com/team.onewareconfig +``` + +The value is a local file path or an `http(s)` URL, so a profile can be served centrally and updated without touching +each machine. + +By default a profile is applied only when its content differs from the last profile applied on that machine, so it will +not overwrite settings the user changed afterwards. Set `ONEWARE_CONFIGURATION_PROFILE_MODE` to `always` to re-apply on +every launch instead: + +```json +{ + "ONEWARE_CONFIGURATION_PROFILE": "https://config.example.com/team.onewareconfig", + "ONEWARE_CONFIGURATION_PROFILE_MODE": "always" +} +``` + +Packages that are already installed are left untouched, and a profile that cannot be downloaded or parsed is logged and +skipped rather than blocking startup. Settings that are only read while the application starts take effect on the next +launch. + ## Nuget | Package | Download | diff --git a/src/OneWare.Essentials/Services/IConfigurationProfileService.cs b/src/OneWare.Essentials/Services/IConfigurationProfileService.cs index 9a245220..ba684abd 100644 --- a/src/OneWare.Essentials/Services/IConfigurationProfileService.cs +++ b/src/OneWare.Essentials/Services/IConfigurationProfileService.cs @@ -7,6 +7,19 @@ namespace OneWare.Essentials.Services; /// public interface IConfigurationProfileService { + /// + /// Environment variable holding the profile to apply at startup. The value is either a local + /// file path or an http(s) URL. + /// + public const string ProfileEnvironmentVariable = "ONEWARE_CONFIGURATION_PROFILE"; + + /// + /// Environment variable controlling how often the profile from + /// is applied. once (default) applies a profile + /// only when its content has not been applied before; always re-applies on every launch. + /// + public const string ProfileModeEnvironmentVariable = "ONEWARE_CONFIGURATION_PROFILE_MODE"; + /// /// Exports the current IDE state (settings, installed packages, package sources) to a profile. /// @@ -26,4 +39,20 @@ public interface IConfigurationProfileService /// Loads a configuration profile from a file. /// Task LoadFromFileAsync(string path, CancellationToken cancellationToken = default); + + /// + /// Loads a configuration profile from a local file path or an http(s) URL. + /// + Task LoadFromSourceAsync(string source, CancellationToken cancellationToken = default); + + /// + /// Applies the profile referenced by , if set. + /// + /// + /// Intended for deployment scenarios where an installation script provisions the variable + /// (directly or through OneWareStudio.defaults.json). Failures are logged rather than + /// thrown so a bad profile can never prevent the IDE from starting. + /// + /// if a profile was applied. + Task ApplyEnvironmentProfileAsync(CancellationToken cancellationToken = default); } diff --git a/src/OneWare.PackageManager/Services/ConfigurationProfileService.cs b/src/OneWare.PackageManager/Services/ConfigurationProfileService.cs index 04fd9637..6d772727 100644 --- a/src/OneWare.PackageManager/Services/ConfigurationProfileService.cs +++ b/src/OneWare.PackageManager/Services/ConfigurationProfileService.cs @@ -1,4 +1,6 @@ using System.Collections.ObjectModel; +using System.Security.Cryptography; +using System.Text; using System.Text.Json; using System.Text.Json.Serialization; using Microsoft.Extensions.Logging; @@ -11,6 +13,8 @@ namespace OneWare.PackageManager.Services; public class ConfigurationProfileService : IConfigurationProfileService { + private const string AppliedMarkerFileName = "configuration-profile.applied"; + private static readonly JsonSerializerOptions SerializerOptions = new() { WriteIndented = true, @@ -20,17 +24,20 @@ public class ConfigurationProfileService : IConfigurationProfileService private readonly ISettingsService _settingsService; private readonly IPackageService _packageService; + private readonly IHttpService _httpService; private readonly IPaths _paths; private readonly ILogger _logger; public ConfigurationProfileService( ISettingsService settingsService, IPackageService packageService, + IHttpService httpService, IPaths paths, ILogger logger) { _settingsService = settingsService; _packageService = packageService; + _httpService = httpService; _paths = paths; _logger = logger; } @@ -82,6 +89,119 @@ public async Task LoadFromFileAsync(string path, return profile ?? throw new InvalidOperationException("Failed to deserialize configuration profile."); } + public async Task LoadFromSourceAsync(string source, + CancellationToken cancellationToken = default) + { + if (string.IsNullOrWhiteSpace(source)) + throw new ArgumentException("Profile source must not be empty.", nameof(source)); + + source = source.Trim(); + + if (!IsHttpUrl(source)) return await LoadFromFileAsync(source, cancellationToken); + + var content = await _httpService.DownloadTextAsync(source, cancellationToken: cancellationToken); + if (string.IsNullOrWhiteSpace(content)) + throw new InvalidOperationException($"Failed to download configuration profile from '{source}'."); + + return Deserialize(content); + } + + public async Task ApplyEnvironmentProfileAsync(CancellationToken cancellationToken = default) + { + var source = Environment.GetEnvironmentVariable(IConfigurationProfileService.ProfileEnvironmentVariable); + if (string.IsNullOrWhiteSpace(source)) return false; + + source = source.Trim(); + + try + { + var profile = await LoadFromSourceAsync(source, cancellationToken); + + // "once" (default) applies a given profile only when its content changed since the last + // run, so a deployment default does not overwrite the user's own settings on every + // launch. "always" re-applies unconditionally for locked-down deployments. + var alwaysApply = string.Equals( + Environment.GetEnvironmentVariable(IConfigurationProfileService.ProfileModeEnvironmentVariable)?.Trim(), + "always", StringComparison.OrdinalIgnoreCase); + + var fingerprint = ComputeFingerprint(source, profile); + + if (!alwaysApply && ReadAppliedFingerprint() == fingerprint) + { + _logger.Log($"Configuration profile '{source}' was already applied, skipping."); + return false; + } + + _logger.Log($"Applying configuration profile from '{source}'..."); + await ImportAsync(profile, cancellationToken); + + if (!cancellationToken.IsCancellationRequested) + WriteAppliedFingerprint(fingerprint); + + _logger.Log($"Configuration profile from '{source}' applied."); + return true; + } + catch (OperationCanceledException) + { + throw; + } + catch (Exception e) + { + // A broken profile must never stop the IDE from starting. + _logger.Error($"Failed to apply configuration profile from '{source}': {e.Message}", e); + return false; + } + } + + private static bool IsHttpUrl(string source) => + Uri.TryCreate(source, UriKind.Absolute, out var uri) && + (uri.Scheme == Uri.UriSchemeHttp || uri.Scheme == Uri.UriSchemeHttps); + + private static ConfigurationProfile Deserialize(string content) + { + var profile = JsonSerializer.Deserialize(content, SerializerOptions); + return profile ?? throw new InvalidOperationException("Failed to deserialize configuration profile."); + } + + private string AppliedMarkerPath => Path.Combine(_paths.AppDataDirectory, AppliedMarkerFileName); + + /// + /// Identifies a profile by source and content, so both editing the profile in place and + /// pointing the variable at a different profile trigger a re-apply. + /// + private static string ComputeFingerprint(string source, ConfigurationProfile profile) + { + var payload = source + "\n" + JsonSerializer.Serialize(profile, SerializerOptions); + return Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(payload))); + } + + private string? ReadAppliedFingerprint() + { + try + { + return File.Exists(AppliedMarkerPath) ? File.ReadAllText(AppliedMarkerPath).Trim() : null; + } + catch (Exception e) + { + _logger.Warning($"Failed to read configuration profile marker: {e.Message}"); + return null; + } + } + + private void WriteAppliedFingerprint(string fingerprint) + { + try + { + Directory.CreateDirectory(_paths.AppDataDirectory); + File.WriteAllText(AppliedMarkerPath, fingerprint); + } + catch (Exception e) + { + // Losing the marker only means the profile is applied again next launch. + _logger.Warning($"Failed to persist configuration profile marker: {e.Message}"); + } + } + private void ExportSettings(ConfigurationProfile profile) { try diff --git a/studio/OneWare.Studio.Desktop/DesktopStudioApp.cs b/studio/OneWare.Studio.Desktop/DesktopStudioApp.cs index 6a6272a6..1ec4df67 100644 --- a/studio/OneWare.Studio.Desktop/DesktopStudioApp.cs +++ b/studio/OneWare.Studio.Desktop/DesktopStudioApp.cs @@ -158,6 +158,10 @@ protected override async Task LoadContentAsync() Services.Resolve().RegisterPackageRepositoryWithFallback(repositories); + // Apply a deployment configuration profile before the rest of startup reads settings, and + // after the repositories are registered so profile packages can be resolved. + await Services.Resolve().ApplyEnvironmentProfileAsync(); + if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime) { var key = Services.Resolve() diff --git a/studio/OneWare.Studio.Desktop/Program.cs b/studio/OneWare.Studio.Desktop/Program.cs index 384e8fc0..193234d3 100644 --- a/studio/OneWare.Studio.Desktop/Program.cs +++ b/studio/OneWare.Studio.Desktop/Program.cs @@ -280,6 +280,11 @@ public static int Main(string[] args) Description = "Overrides the package repository URL(s) used by OneWare Studio. Separate multiple URLs with ';'. (optional)" }; + Option configurationProfileOption = new("--configuration-profile") + { + Description = + "Applies a configuration profile (settings, packages, package sources) at startup. Accepts a file path or an http(s) URL. (optional)" + }; Argument openArgument = new("open") { Description = "File/Folder path or oneware:// URI to open", @@ -295,7 +300,8 @@ public static int Main(string[] args) projectsDirOption, moduleOption, autoLaunchOption, - packageRepositoryOption + packageRepositoryOption, + configurationProfileOption }, Arguments = { @@ -329,6 +335,10 @@ public static int Main(string[] args) if (!string.IsNullOrEmpty(packageRepositoryValue)) Environment.SetEnvironmentVariable("ONEWARE_PACKAGE_REPOSITORY", packageRepositoryValue); + var configurationProfileValue = parseResult.GetValue(configurationProfileOption); + if (!string.IsNullOrEmpty(configurationProfileValue)) + Environment.SetEnvironmentVariable("ONEWARE_CONFIGURATION_PROFILE", configurationProfileValue); + var openValue = parseResult.GetValue(openArgument); if (!string.IsNullOrEmpty(openValue)) { diff --git a/tests/OneWare.PackageManager.UnitTests/ConfigurationProfileServiceTests.cs b/tests/OneWare.PackageManager.UnitTests/ConfigurationProfileServiceTests.cs index e7ebd4c3..ad1ffcf0 100644 --- a/tests/OneWare.PackageManager.UnitTests/ConfigurationProfileServiceTests.cs +++ b/tests/OneWare.PackageManager.UnitTests/ConfigurationProfileServiceTests.cs @@ -14,6 +14,7 @@ public class ConfigurationProfileServiceTests { private readonly ISettingsService _settingsService = Substitute.For(); private readonly IPackageService _packageService = Substitute.For(); + private readonly IHttpService _httpService = Substitute.For(); private readonly IPaths _paths = Substitute.For(); private readonly ILogger _logger = Substitute.For>(); private readonly ConfigurationProfileService _service; @@ -21,7 +22,8 @@ public class ConfigurationProfileServiceTests public ConfigurationProfileServiceTests() { _paths.SettingsPath.Returns(Path.Combine(Path.GetTempPath(), $"test-settings-{Guid.NewGuid()}.json")); - _service = new ConfigurationProfileService(_settingsService, _packageService, _paths, _logger); + _paths.AppDataDirectory.Returns(Path.Combine(Path.GetTempPath(), $"test-appdata-{Guid.NewGuid()}")); + _service = new ConfigurationProfileService(_settingsService, _packageService, _httpService, _paths, _logger); } [Fact] @@ -226,4 +228,137 @@ public async Task ExportAsync_VersionIs1() Assert.Equal(1, profile.Version); } + + [Fact] + public async Task LoadFromSourceAsync_ReadsLocalFile() + { + var tempPath = Path.Combine(Path.GetTempPath(), $"test-profile-{Guid.NewGuid()}.onewareconfig"); + try + { + await _service.SaveToFileAsync(new ConfigurationProfile { Name = "From File" }, tempPath); + + var loaded = await _service.LoadFromSourceAsync(tempPath); + + Assert.Equal("From File", loaded.Name); + } + finally + { + if (File.Exists(tempPath)) File.Delete(tempPath); + } + } + + [Fact] + public async Task LoadFromSourceAsync_DownloadsHttpUrl() + { + const string url = "https://config.example.com/team.onewareconfig"; + _httpService.DownloadTextAsync(url, Arg.Any(), Arg.Any()) + .Returns("""{"name":"From Url"}"""); + + var loaded = await _service.LoadFromSourceAsync(url); + + Assert.Equal("From Url", loaded.Name); + } + + [Fact] + public async Task LoadFromSourceAsync_ThrowsWhenDownloadFails() + { + const string url = "https://config.example.com/missing.onewareconfig"; + _httpService.DownloadTextAsync(url, Arg.Any(), Arg.Any()) + .Returns((string?)null); + + await Assert.ThrowsAsync(() => _service.LoadFromSourceAsync(url)); + } + + [Fact] + public async Task ApplyEnvironmentProfileAsync_ReturnsFalseWhenVariableNotSet() + { + using var _ = new EnvironmentVariableScope(IConfigurationProfileService.ProfileEnvironmentVariable, null); + + Assert.False(await _service.ApplyEnvironmentProfileAsync()); + } + + [Fact] + public async Task ApplyEnvironmentProfileAsync_AppliesProfileOnceThenSkips() + { + var tempPath = Path.Combine(Path.GetTempPath(), $"test-profile-{Guid.NewGuid()}.onewareconfig"); + try + { + await _service.SaveToFileAsync( + new ConfigurationProfile { Settings = { ["General_SelectedTheme"] = JsonSerializer.SerializeToElement("Dark") } }, + tempPath); + + _settingsService.HasSetting("General_SelectedTheme").Returns(true); + _settingsService.GetSetting("General_SelectedTheme").Returns(new Setting("Light")); + _packageService.Packages.Returns(new Dictionary()); + + using var _ = new EnvironmentVariableScope(IConfigurationProfileService.ProfileEnvironmentVariable, tempPath); + using var __ = new EnvironmentVariableScope(IConfigurationProfileService.ProfileModeEnvironmentVariable, null); + + Assert.True(await _service.ApplyEnvironmentProfileAsync()); + + // Unchanged profile must not be re-applied, so user edits survive the next launch. + Assert.False(await _service.ApplyEnvironmentProfileAsync()); + + _settingsService.Received(1).SetSettingValue("General_SelectedTheme", "Dark"); + } + finally + { + if (File.Exists(tempPath)) File.Delete(tempPath); + if (Directory.Exists(_paths.AppDataDirectory)) Directory.Delete(_paths.AppDataDirectory, true); + } + } + + [Fact] + public async Task ApplyEnvironmentProfileAsync_ReAppliesInAlwaysMode() + { + var tempPath = Path.Combine(Path.GetTempPath(), $"test-profile-{Guid.NewGuid()}.onewareconfig"); + try + { + await _service.SaveToFileAsync( + new ConfigurationProfile { Settings = { ["General_SelectedTheme"] = JsonSerializer.SerializeToElement("Dark") } }, + tempPath); + + _settingsService.HasSetting("General_SelectedTheme").Returns(true); + _settingsService.GetSetting("General_SelectedTheme").Returns(new Setting("Light")); + _packageService.Packages.Returns(new Dictionary()); + + using var _ = new EnvironmentVariableScope(IConfigurationProfileService.ProfileEnvironmentVariable, tempPath); + using var __ = new EnvironmentVariableScope(IConfigurationProfileService.ProfileModeEnvironmentVariable, "always"); + + Assert.True(await _service.ApplyEnvironmentProfileAsync()); + Assert.True(await _service.ApplyEnvironmentProfileAsync()); + + _settingsService.Received(2).SetSettingValue("General_SelectedTheme", "Dark"); + } + finally + { + if (File.Exists(tempPath)) File.Delete(tempPath); + if (Directory.Exists(_paths.AppDataDirectory)) Directory.Delete(_paths.AppDataDirectory, true); + } + } + + [Fact] + public async Task ApplyEnvironmentProfileAsync_ReturnsFalseWhenProfileIsMissing() + { + var missingPath = Path.Combine(Path.GetTempPath(), $"missing-{Guid.NewGuid()}.onewareconfig"); + using var _ = new EnvironmentVariableScope(IConfigurationProfileService.ProfileEnvironmentVariable, missingPath); + + // A broken profile must be logged and skipped rather than block startup. + Assert.False(await _service.ApplyEnvironmentProfileAsync()); + } + + private sealed class EnvironmentVariableScope : IDisposable + { + private readonly string _name; + private readonly string? _original; + + public EnvironmentVariableScope(string name, string? value) + { + _name = name; + _original = Environment.GetEnvironmentVariable(name); + Environment.SetEnvironmentVariable(name, value); + } + + public void Dispose() => Environment.SetEnvironmentVariable(_name, _original); + } }