WIP PostIt login

This commit is contained in:
Paul Schneider 2026-06-25 00:08:25 +01:00
commit f3a3b63595
13 changed files with 103 additions and 160 deletions

View file

@ -1,4 +1,5 @@
using System;
using System.Threading.Tasks;
using Avalonia;
using Avalonia.Controls.ApplicationLifetimes;
using Avalonia.Markup.Xaml;
@ -31,33 +32,41 @@ public partial class App : Application
{
return;
}
var settings = new Settings();
// Synchronous: Settings.Load is intentionally non-async so we
// don't deadlock the Avalonia UI thread. .Wait() on an async
// method would block here forever on the await inside the
// file read.
settings.Load();
var tokenStore = new TokenStore(System.IO.Path.Combine(
System.Environment.GetFolderPath(System.Environment.SpecialFolder.ApplicationData),
"PostIt", "tokens.json"));
var client = new BlogApiClient(new YavscApiClient(settings, tokenStore));
if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop)
{
var blog = BuildBlogClient(out var settings);
desktop.MainWindow = new MainWindow
{
DataContext = new MainPageViewModel(blog, settings)
DataContext = new MainPageViewModel(client, settings)
};
}
else if (ApplicationLifetime is IActivityApplicationLifetime singleViewFactoryApplicationLifetime)
{
singleViewFactoryApplicationLifetime.MainViewFactory = () =>
{
var blog = BuildBlogClient(out var settings);
return new MainPage { DataContext = new MainPageViewModel(blog, settings) };
return new MainPage { DataContext = new MainPageViewModel(client, settings) };
};
}
else if (ApplicationLifetime is ISingleViewApplicationLifetime singleViewPlatform)
{
var blog = BuildBlogClient(out var settings);
singleViewPlatform.MainView = new MainPage
{
DataContext = new MainPageViewModel(blog, settings)
DataContext = new MainPageViewModel(client, settings)
};
}
else
throw new NotSupportedException("ApplicationLifetime not supported.");
base.OnFrameworkInitializationCompleted();
}
private bool TryHandOffCustomSchemeUrl()
@ -90,19 +99,4 @@ public partial class App : Application
return false;
}
/// <summary>
/// Build the (Settings, BlogApiClient) pair used by all UI
/// lifetimes. A single TokenStore is shared so a login performed
/// by the LoginPage is observable to the MainPage (and vice-versa)
/// without going through disk on every API call.
/// </summary>
private static BlogApiClient BuildBlogClient(out Settings settings)
{
settings = new Settings();
try { settings.Load().GetAwaiter().GetResult(); } catch { /* fall back to embedded defaults */ }
var tokenStore = new TokenStore(System.IO.Path.Combine(
System.Environment.GetFolderPath(System.Environment.SpecialFolder.ApplicationData),
"PostIt", "tokens.json"));
return new BlogApiClient(new YavscApiClient(settings, tokenStore));
}
}

View file

@ -1,5 +1,4 @@
using System.Runtime.CompilerServices;
using System.Runtime.CompilerServices;
using Avalonia;
using Avalonia.Controls;
using Avalonia.Platform.Storage;
@ -9,7 +8,6 @@ using PostIt.Services;
using System;
using System.IO;
using System.Text.Json;
using System.Threading.Tasks;
[assembly: InternalsVisibleTo("PostIt.Tests")]
@ -69,6 +67,7 @@ public partial class Settings : ObservableObject
[ObservableProperty]
public partial string[] Scopes { get; set; }
public bool Loaded { get; private set; } = false;
/// <summary>
/// Build OidcClient options configured for Authorization Code + PKCE
@ -77,12 +76,14 @@ public partial class Settings : ObservableObject
/// </summary>
internal OidcClientOptions GetOidcClientOptions(IdentityModel.OidcClient.Browser.IBrowser? browser = null)
{
if (!Loaded) Load();
var options = new OidcClientOptions
{
Authority = Authentication.Authority,
ClientId = Authentication.ClientId,
RedirectUri = RedirectUri,
Scope = string.Join(' ', this.Scopes),
TokenClientCredentialStyle = IdentityModel.Client.ClientCredentialStyle.PostBody
// PKCE is enabled by default when no client_secret is provided.
};
@ -92,8 +93,9 @@ public partial class Settings : ObservableObject
return options;
}
internal async Task Load()
internal void Load()
{
if (Loaded) return;
string configDir = Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData),
"PostIt"
@ -123,10 +125,17 @@ public partial class Settings : ObservableObject
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 = await reader.ReadToEndAsync();
var json = reader.ReadToEnd();
ApplyJson(json, $"user file {configFileInfo.FullName}");
Loaded = true;
}
catch (Exception ex)
{
@ -182,4 +191,4 @@ public partial class Settings : ObservableObject
Console.Error.WriteLine($"🩎 Error applying settings from {source}: {ex.Message}");
}
}
}
}

View file

@ -139,8 +139,10 @@ public partial class LoginPageViewModel : ViewModelBase
{
// Load settings eagerly so RegisterUrl / ForgotPasswordUrl are
// populated as soon as the page renders (XAML bindings fire
// before the user clicks Login).
try { Settings.Load().GetAwaiter().GetResult(); }
// before the user clicks Login). Settings.Load is synchronous
// on purpose; calling .GetAwaiter().GetResult() on it would
// deadlock the UI thread on the await inside the file read.
try { Settings.Load(); }
catch { /* settings may be missing in tests/dev; LoginAsync will surface real errors */ }
}
@ -173,7 +175,7 @@ public partial class LoginPageViewModel : ViewModelBase
if (SettingsLoadOverride is not null)
await SettingsLoadOverride().ConfigureAwait(false);
else
await Settings.Load().ConfigureAwait(false);
Settings.Load();
// Guard: refuse to call OidcClient when the authority is
// empty. IdentityModel would otherwise build a bogus

View file

@ -12,4 +12,4 @@
"offline_access",
"blogs"
]
}
}

View file

@ -3,12 +3,12 @@
"ClientId": "postit",
"Authority": "https://yavsc.pschneider.fr/"
},
"RedirectUri": "postit://callback",
"DarkMode": true,
"ApiUrl": "https://blogs.pschneider.fr/api/v1/",
"Scopes": [
"openid",
"profile",
"email",
"offline_access",
"blogs"
]