Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 32 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down
29 changes: 29 additions & 0 deletions src/OneWare.Essentials/Services/IConfigurationProfileService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,19 @@ namespace OneWare.Essentials.Services;
/// </summary>
public interface IConfigurationProfileService
{
/// <summary>
/// Environment variable holding the profile to apply at startup. The value is either a local
/// file path or an <c>http(s)</c> URL.
/// </summary>
public const string ProfileEnvironmentVariable = "ONEWARE_CONFIGURATION_PROFILE";

/// <summary>
/// Environment variable controlling how often the profile from
/// <see cref="ProfileEnvironmentVariable"/> is applied. <c>once</c> (default) applies a profile
/// only when its content has not been applied before; <c>always</c> re-applies on every launch.
/// </summary>
public const string ProfileModeEnvironmentVariable = "ONEWARE_CONFIGURATION_PROFILE_MODE";

/// <summary>
/// Exports the current IDE state (settings, installed packages, package sources) to a profile.
/// </summary>
Expand All @@ -26,4 +39,20 @@ public interface IConfigurationProfileService
/// Loads a configuration profile from a file.
/// </summary>
Task<ConfigurationProfile> LoadFromFileAsync(string path, CancellationToken cancellationToken = default);

/// <summary>
/// Loads a configuration profile from a local file path or an <c>http(s)</c> URL.
/// </summary>
Task<ConfigurationProfile> LoadFromSourceAsync(string source, CancellationToken cancellationToken = default);

/// <summary>
/// Applies the profile referenced by <see cref="ProfileEnvironmentVariable"/>, if set.
/// </summary>
/// <remarks>
/// Intended for deployment scenarios where an installation script provisions the variable
/// (directly or through <c>OneWareStudio.defaults.json</c>). Failures are logged rather than
/// thrown so a bad profile can never prevent the IDE from starting.
/// </remarks>
/// <returns><see langword="true"/> if a profile was applied.</returns>
Task<bool> ApplyEnvironmentProfileAsync(CancellationToken cancellationToken = default);
}
120 changes: 120 additions & 0 deletions src/OneWare.PackageManager/Services/ConfigurationProfileService.cs
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -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,
Expand All @@ -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<ConfigurationProfileService> logger)
{
_settingsService = settingsService;
_packageService = packageService;
_httpService = httpService;
_paths = paths;
_logger = logger;
}
Expand Down Expand Up @@ -82,6 +89,119 @@ public async Task<ConfigurationProfile> LoadFromFileAsync(string path,
return profile ?? throw new InvalidOperationException("Failed to deserialize configuration profile.");
}

public async Task<ConfigurationProfile> 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<bool> 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<ConfigurationProfile>(content, SerializerOptions);
return profile ?? throw new InvalidOperationException("Failed to deserialize configuration profile.");
}

private string AppliedMarkerPath => Path.Combine(_paths.AppDataDirectory, AppliedMarkerFileName);

/// <summary>
/// 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.
/// </summary>
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
Expand Down
4 changes: 4 additions & 0 deletions studio/OneWare.Studio.Desktop/DesktopStudioApp.cs
Original file line number Diff line number Diff line change
Expand Up @@ -158,6 +158,10 @@ protected override async Task LoadContentAsync()

Services.Resolve<IPackageService>().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<IConfigurationProfileService>().ApplyEnvironmentProfileAsync();

if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime)
{
var key = Services.Resolve<IApplicationStateService>()
Expand Down
12 changes: 11 additions & 1 deletion studio/OneWare.Studio.Desktop/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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<string> 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<string?> openArgument = new("open")
{
Description = "File/Folder path or oneware:// URI to open",
Expand All @@ -295,7 +300,8 @@ public static int Main(string[] args)
projectsDirOption,
moduleOption,
autoLaunchOption,
packageRepositoryOption
packageRepositoryOption,
configurationProfileOption
},
Arguments =
{
Expand Down Expand Up @@ -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))
{
Expand Down
Loading
Loading