451 lines
18 KiB
C#
451 lines
18 KiB
C#
using System.Runtime.CompilerServices;
|
|
using CommunityToolkit.Mvvm.ComponentModel;
|
|
using CommunityToolkit.Mvvm.Input;
|
|
using IdentityModel.OidcClient;
|
|
using System;
|
|
using System.Collections.Generic;
|
|
using System.IO;
|
|
using System.Net.Http;
|
|
using System.Text.Json;
|
|
|
|
[assembly: InternalsVisibleTo("PostIt.Tests")]
|
|
|
|
namespace PostIt.ViewModels;
|
|
|
|
public partial class Settings : ViewModelBase
|
|
{
|
|
const string SettingsFileName = "postit-settings.json";
|
|
|
|
[ObservableProperty]
|
|
public partial AuthenticationSettings Authentication { get; set; } = new();
|
|
|
|
[ObservableProperty]
|
|
public partial bool DarkMode { get; set; } = false;
|
|
|
|
[ObservableProperty]
|
|
public partial string BlogsApiUrl { get; set; } = "https://blogs.pschneider.fr/api/v1/";
|
|
|
|
[ObservableProperty]
|
|
public partial string ApiUrl { get; set; } = "https://api.pschneider.fr/api/v1/";
|
|
|
|
[ObservableProperty]
|
|
public partial string SearchText { get; set; } = string.Empty;
|
|
|
|
/// <summary>
|
|
/// Catch top-level mutations: the four ObservableProperty
|
|
/// setters above all funnel through here, and we flip
|
|
/// <see cref="IsDirty"/> in lock-step. Sub-property mutations
|
|
/// (e.g. <c>Authentication.Authority</c>) are caught by the
|
|
/// subscription wired up in
|
|
/// below. <see cref="ApplyJson"/> disables the flag during bulk
|
|
/// hydration so the disk load itself does not count as a user
|
|
/// edit.
|
|
/// </summary>
|
|
private void MarkDirty() => IsDirty = true;
|
|
|
|
partial void OnDarkModeChanged(bool value) => MarkDirty();
|
|
partial void OnBlogsApiUrlChanged(string value) => MarkDirty();
|
|
partial void OnApiUrlChanged(string value) => MarkDirty();
|
|
partial void OnSearchTextChanged(string value) => MarkDirty();
|
|
|
|
/// <summary>
|
|
/// Authentication can be reassigned wholesale by
|
|
/// <see cref="ApplyJson"/>; on each reassignment we (re)wire a
|
|
/// <c>PropertyChanged</c> listener so sub-property edits
|
|
/// (Authority, ClientId, RedirectUri, Scopes) are picked up
|
|
/// by the dirty tracker. We don't filter on PropertyName: any
|
|
/// nested setter is treated as a user edit, which matches the
|
|
/// user's mental model ("I typed in a field, the page is now
|
|
/// dirty").
|
|
/// </summary>
|
|
partial void OnAuthenticationChanged(AuthenticationSettings value)
|
|
{
|
|
if (value is not null)
|
|
{
|
|
value.PropertyChanged += (_, _) => MarkDirty();
|
|
}
|
|
MarkDirty();
|
|
}
|
|
|
|
public bool Loaded { get; private set; } = false;
|
|
|
|
/// <summary>
|
|
/// True when the in-memory state has drifted from the last
|
|
/// <see cref="Load"/> or <see cref="Save"/> snapshot. The
|
|
/// Settings page binds the Sauver button's <c>IsEnabled</c> to
|
|
/// this flag, so it only enables when the user has actually
|
|
/// touched something since the last load / save. Cleared by
|
|
/// <see cref="Load"/> (and by <see cref="ApplyJson"/>), set by
|
|
/// every successful setter on the four top-level mutable
|
|
/// properties and on the sub-properties of
|
|
/// <see cref="Authentication"/>.
|
|
/// </summary>
|
|
[ObservableProperty]
|
|
public partial bool IsDirty { get; private set; } = false;
|
|
|
|
/// <summary>
|
|
/// Guards every mutation of the observable state. <c>[ObservableProperty]</c>
|
|
/// generates setters that call <c>SetProperty(...)</c> which fires
|
|
/// <c>PropertyChanged</c>. Avalonia bindings consume that event on
|
|
/// the UI thread, and a stray background-thread update is exactly
|
|
/// what crashed <c>DataValidationErrors.SetErrors</c> on
|
|
/// <c>postit://callback</c> re-launches. The lock makes mutations
|
|
/// atomic; <see cref="OnPropertyChanged(PropertyChangedEventArgs)"/>
|
|
/// then marshals the notification onto the UI thread so bindings
|
|
/// observe the change on the right thread.
|
|
/// </summary>
|
|
private readonly object _mutationGate = new();
|
|
|
|
/// <summary>
|
|
/// Build OidcClient options configured for Authorization Code + PKCE
|
|
/// (no client secret). The browser implementation should be supplied
|
|
/// per-platform by the caller.
|
|
/// </summary>
|
|
internal OidcClientOptions GetOidcClientOptions(IdentityModel.OidcClient.Browser.IBrowser? browser = null)
|
|
{
|
|
if (!Loaded) Load();
|
|
// Snapshot under the gate so the caller observes a consistent
|
|
// view of all six properties; without this, a concurrent
|
|
// Load() could swap Authentication mid-method and we would
|
|
// build options from a torn read.
|
|
lock (_mutationGate)
|
|
{
|
|
var options = new OidcClientOptions
|
|
{
|
|
Authority = Authentication.Authority,
|
|
ClientId = Authentication.ClientId,
|
|
RedirectUri = Authentication.RedirectUri,
|
|
Scope = string.Join(' ', MergeScopes(this.Authentication.Scopes)),
|
|
TokenClientCredentialStyle = IdentityModel.Client.ClientCredentialStyle.PostBody,
|
|
PostLogoutRedirectUri = Authentication.Authority,
|
|
// PKCE is enabled by default when no client_secret is provided.
|
|
};
|
|
|
|
if (IsDevelopmentEnvironment())
|
|
{
|
|
// Dev only: allow local/self-signed TLS for discovery/token
|
|
// endpoints when the machine does not trust a custom root.
|
|
options.BackchannelHandler = new HttpClientHandler
|
|
{
|
|
ServerCertificateCustomValidationCallback = (_, _, _, _) => true
|
|
};
|
|
}
|
|
|
|
if (browser is not null)
|
|
options.Browser = browser;
|
|
|
|
return options;
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Scopes the PostIt client always requires from the OIDC provider,
|
|
/// regardless of what the user has in their settings file.
|
|
///
|
|
/// <para>PostIt calls into the Blog API (and any other Yavsc API
|
|
/// gated by an <c>[Authorize("…Scope")]</c> policy) and is silent
|
|
/// about the contract: a missing scope here surfaces as a 401
|
|
/// on the very first API call after login, with no obvious link
|
|
/// to the settings. The "feature" scopes the user must opt into
|
|
/// (e.g. <c>blogs</c>) are still their choice — we only force the
|
|
/// structural ones that OIDC itself needs.</para>
|
|
/// </summary>
|
|
private static readonly string[] BuiltInScopes = new[]
|
|
{
|
|
"openid", // OIDC: required for the id_token
|
|
"profile", // OIDC: standard profile claims
|
|
"offline_access", // OIDC: required to receive a refresh_token
|
|
"blogs",
|
|
"api"
|
|
|
|
};
|
|
|
|
/// <summary>
|
|
/// Merge user-configured scopes with the built-in ones. User scopes
|
|
/// come first (preserves author intent), then the built-ins, with
|
|
/// duplicates removed case-sensitively. <c>null</c> or empty input
|
|
/// is fine — we still emit the built-ins.
|
|
/// </summary>
|
|
internal static IEnumerable<string> MergeScopes(string[]? userScopes)
|
|
{
|
|
var seen = new HashSet<string>(StringComparer.Ordinal);
|
|
if (userScopes is not null)
|
|
{
|
|
foreach (var s in userScopes)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(s)) continue;
|
|
if (seen.Add(s)) yield return s;
|
|
}
|
|
}
|
|
foreach (var s in BuiltInScopes)
|
|
{
|
|
if (seen.Add(s)) yield return s;
|
|
}
|
|
}
|
|
|
|
private static bool IsDevelopmentEnvironment()
|
|
{
|
|
return string.Equals(
|
|
Environment.GetEnvironmentVariable("ASPNETCORE_ENVIRONMENT"),
|
|
"Development",
|
|
StringComparison.OrdinalIgnoreCase);
|
|
}
|
|
|
|
internal void Load()
|
|
{
|
|
if (Loaded) return;
|
|
|
|
// Trust an already-populated Authority: tests pre-fill Settings
|
|
// with the OIDC stub's random loopback port, and programmatic
|
|
// callers (CLI flags, integration tests) wire their own. If we
|
|
// fall through to the disk / embedded read here we'd silently
|
|
// overwrite their value with the bundled default
|
|
// (yavsc.pschneider.fr), break the stubbed discovery URL, and
|
|
// turn a passing login into an invalid_grant.
|
|
lock (_mutationGate)
|
|
{
|
|
if (Loaded) return; // double-check after taking the gate
|
|
if (!string.IsNullOrWhiteSpace(Authentication?.Authority))
|
|
{
|
|
Loaded = true;
|
|
return;
|
|
}
|
|
}
|
|
|
|
string configDir = Path.Combine(
|
|
Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData),
|
|
"PostIt"
|
|
);
|
|
Directory.CreateDirectory(configDir);
|
|
|
|
string configPath = Path.Combine(configDir, SettingsFileName);
|
|
|
|
FileInfo configFileInfo = new FileInfo(configPath);
|
|
|
|
if (!configFileInfo.Exists)
|
|
{
|
|
Console.Error.WriteLine($"🩎 Settings file not found at {configFileInfo.FullName}");
|
|
// No user-level config: fall back to the embedded default.
|
|
// We only get here when Authentication.Authority is empty
|
|
// (the early-return above) so the redundant guard is gone.
|
|
if (!TryLoadEmbeddedFallback())
|
|
{
|
|
Console.Error.WriteLine("🩎 No embedded default settings; running with empty configuration.");
|
|
}
|
|
return;
|
|
}
|
|
|
|
Console.WriteLine($"🔎 Loading settings from {configFileInfo.FullName}");
|
|
|
|
try
|
|
{
|
|
// Synchronous read on purpose: Settings.Load() is called from
|
|
// synchronous startup paths (App.axaml.cs, ViewModel ctors,
|
|
// tests) and bridging to async here with .Wait() / .GetAwaiter()
|
|
// .GetResult() deadlocks the Avalonia UI thread because the
|
|
// continuation can't resume on the same thread. The settings
|
|
// file is a few KiB at most; async I/O gains nothing here.
|
|
using var stream = configFileInfo.OpenRead();
|
|
using var reader = new StreamReader(stream);
|
|
var json = reader.ReadToEnd();
|
|
ApplyJson(json, $"user file {configFileInfo.FullName}");
|
|
Loaded = true;
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
Console.Error.WriteLine($"🩎 Error loading settings: {ex.Message}");
|
|
}
|
|
}
|
|
|
|
private bool TryLoadEmbeddedFallback()
|
|
{
|
|
const string ResourceName = "PostIt.postit-settings.json";
|
|
var assembly = typeof(Settings).Assembly;
|
|
using var stream = assembly.GetManifestResourceStream(ResourceName);
|
|
if (stream is null)
|
|
{
|
|
Console.Error.WriteLine($"🩎 Embedded resource {ResourceName} not found.");
|
|
return false;
|
|
}
|
|
using var reader = new StreamReader(stream);
|
|
var json = reader.ReadToEnd();
|
|
if (string.IsNullOrWhiteSpace(json))
|
|
{
|
|
Console.Error.WriteLine("🩎 Embedded settings resource is empty.");
|
|
return false;
|
|
}
|
|
Console.WriteLine($"🔎 Loading embedded default settings ({ResourceName}).");
|
|
ApplyJson(json, $"embedded resource {ResourceName}");
|
|
return true;
|
|
}
|
|
|
|
private void ApplyJson(string json, string source)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(json))
|
|
{
|
|
Console.Error.WriteLine($"🩎 Settings payload is empty (source: {source}).");
|
|
return;
|
|
}
|
|
try
|
|
{
|
|
var settings = JsonSerializer.Deserialize<Settings>(json);
|
|
if (settings is null)
|
|
{
|
|
UseDefaultSettings();
|
|
}
|
|
// Apply under the gate so concurrent Load() callers cannot
|
|
// see half the new values / half the old ones. The actual
|
|
// PropertyChanged fan-out is handled by [ObservableProperty]'s
|
|
// setters which we route through SetProperty → OnPropertyChanged
|
|
// → our overridden dispatcher-safe marshaller below.
|
|
else lock (_mutationGate)
|
|
{
|
|
var legacyApiUrl = TryReadLegacyApiUrl(json);
|
|
this.Authentication = settings.Authentication;
|
|
this.DarkMode = settings.DarkMode;
|
|
this.BlogsApiUrl = !string.IsNullOrWhiteSpace(settings.BlogsApiUrl)
|
|
? settings.BlogsApiUrl
|
|
: legacyApiUrl ?? this.BlogsApiUrl;
|
|
this.ApiUrl = !string.IsNullOrWhiteSpace(settings.ApiUrl)
|
|
? settings.ApiUrl
|
|
: this.ApiUrl;
|
|
this.SearchText = settings.SearchText ?? string.Empty;
|
|
if (!(settings.Authentication is null))
|
|
{
|
|
this.Authentication = new AuthenticationSettings();
|
|
this.Authentication.Authority = string.IsNullOrWhiteSpace(settings.Authentication.Authority) ?
|
|
AuthenticationSettings.DefaultAuthority : settings.Authentication.Authority;
|
|
this.Authentication.ClientId = string.IsNullOrWhiteSpace(settings.Authentication.ClientId) ?
|
|
AuthenticationSettings.DefaultClientId : settings.Authentication.ClientId;
|
|
this.Authentication.RedirectUri = string.IsNullOrWhiteSpace(settings.Authentication.RedirectUri) ?
|
|
AuthenticationSettings.DesktopRedirectUri : settings.Authentication.RedirectUri;
|
|
if (settings.Authentication.Scopes is null || settings.Authentication.Scopes.Length == 0)
|
|
{
|
|
settings.Authentication.Scopes = AuthenticationSettings.DefaultScopes;
|
|
}
|
|
else
|
|
this.Authentication.Scopes = settings.Authentication.Scopes;
|
|
}
|
|
}
|
|
// A disk load (or an embedded-resource fallback) is the
|
|
// baseline, not a user edit. Clear the dirty flag last
|
|
// so the OnAuthenticationChanged / sub-property fan-out
|
|
// triggered by the assignments above doesn't leave it
|
|
// stuck at true.
|
|
IsDirty = false;
|
|
// Refresh the space-separated ScopeListText view after
|
|
// hydration so the SettingsPage TextBox reflects the
|
|
// loaded scopes (and not the default empty string the
|
|
// ObservableProperty was constructed with). OnScopesChanged
|
|
// already tries to do this, but it skips when the new
|
|
// array parses to the same text — calling explicitly
|
|
// forces a re-sync and normalises any whitespace the
|
|
// JSON might have introduced.
|
|
this.Authentication?.RefreshScopeListText();
|
|
// Re-notify the command in case the button was bound
|
|
// before Load finished and the CanExecute cache is
|
|
// stale.
|
|
SaveCommand.NotifyCanExecuteChanged();
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
Console.Error.WriteLine($"🩎 Error applying settings from {source}: {ex.Message}");
|
|
}
|
|
}
|
|
|
|
private static string? TryReadLegacyApiUrl(string json)
|
|
{
|
|
try
|
|
{
|
|
using var doc = JsonDocument.Parse(json);
|
|
if (doc.RootElement.TryGetProperty("ApiUrl", out var apiUrl)
|
|
&& apiUrl.ValueKind == JsonValueKind.String)
|
|
{
|
|
return apiUrl.GetString();
|
|
}
|
|
}
|
|
catch
|
|
{
|
|
// Ignore legacy payload parse errors: normal deserialization
|
|
// already reports actionable diagnostics to the caller.
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
private void UseDefaultSettings()
|
|
{
|
|
this.Authentication = new AuthenticationSettings
|
|
{
|
|
Authority = AuthenticationSettings.DefaultAuthority,
|
|
ClientId = AuthenticationSettings.DefaultClientId,
|
|
RedirectUri = AuthenticationSettings.DesktopRedirectUri,
|
|
Scopes = AuthenticationSettings.DefaultScopes
|
|
};
|
|
this.DarkMode = false;
|
|
this.BlogsApiUrl = "https://blogs.pschneider.fr/api/v1/";
|
|
this.ApiUrl = "https://api.pschneider.fr/api/v1/";
|
|
this.SearchText = string.Empty;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Persist the current in-memory state to
|
|
/// <c>~/.config/PostIt/postit-settings.json</c> (Linux) /
|
|
/// equivalent <c>%APPDATA%\PostIt\postit-settings.json</c>
|
|
/// (Windows). Symmetrical to <see cref="Load"/>: same path,
|
|
/// same directory creation, same <c>0600</c> file mode (POSIX)
|
|
/// as <c>TokenStore.Save</c>. Clears <see cref="IsDirty"/>
|
|
/// on success.
|
|
///
|
|
/// <para>Synchronous on purpose: matches <see cref="Load"/>'s
|
|
/// contract (the file is a few KiB at most, and the Avalonia
|
|
/// UI thread cannot await here without risking the same
|
|
/// deadlock <see cref="Load"/>'s docstring describes).
|
|
/// </para>
|
|
/// </summary>
|
|
[RelayCommand(CanExecute = nameof(CanSave))]
|
|
public void Save()
|
|
{
|
|
var configDir = Path.Combine(
|
|
Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData),
|
|
"PostIt");
|
|
Directory.CreateDirectory(configDir);
|
|
var configPath = Path.Combine(configDir, SettingsFileName);
|
|
|
|
lock (_mutationGate)
|
|
{
|
|
try
|
|
{
|
|
var json = JsonSerializer.Serialize(this, new JsonSerializerOptions
|
|
{
|
|
WriteIndented = true,
|
|
});
|
|
File.WriteAllText(configPath, json);
|
|
if (OperatingSystem.IsLinux() || OperatingSystem.IsMacOS())
|
|
File.SetUnixFileMode(configPath,
|
|
UnixFileMode.UserRead | UnixFileMode.UserWrite);
|
|
IsDirty = false;
|
|
Console.WriteLine($"💾 Settings saved to {configPath}");
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
Console.Error.WriteLine($"🩎 Error saving settings to {configPath}: {ex.Message}");
|
|
throw;
|
|
}
|
|
}
|
|
}
|
|
|
|
private bool CanSave() => IsDirty;
|
|
|
|
/// <summary>
|
|
/// Re-notify the <c>SaveCommand</c> (generated by
|
|
/// <c>[RelayCommand]</c> on <see cref="Save"/>) so XAML
|
|
/// re-evaluates <c>CanExecute</c> when the dirty flag flips
|
|
/// outside the scope of a direct save (e.g. on <see cref="Load"/>
|
|
/// / <see cref="ApplyJson"/>).
|
|
/// </summary>
|
|
partial void OnIsDirtyChanged(bool value) => SaveCommand.NotifyCanExecuteChanged();
|
|
|
|
public override bool CanNavigateNext { get => false; protected set => throw new System.NotImplementedException(); }
|
|
public override bool CanNavigatePrevious { get => true; protected set => throw new System.NotImplementedException(); }
|
|
}
|