yavsc/src/PostIt/PostIt/ViewModels/Settings/Settings.cs
2026-09-04 20:25:06 +01:00

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(); }
}