yavsc/src/PostIt/PostIt/App.axaml.cs

354 lines
14 KiB
C#
Raw Normal View History

postit: replace loopback HTTP listener with custom URI scheme OAuth2 redirect handling for the desktop PostIt app now follows RFC 8252 §7.1: the redirect URI is a custom scheme (postit://callback) that the OS routes back to PostIt instead of a 127.0.0.1 HTTP listener. The browser hits the scheme, the OS launches a fresh PostIt process, that process hands the URL to the running instance over a named pipe, then exits. Architecture: - SingleInstance: cross-platform named-pipe helper. TryHandOffAsync is what the 2nd instance calls to forward its command-line URL; StartServerAsync runs on the 1st instance and pumps URLs into a callback (the running CustomSchemeBrowser). - CustomSchemeBrowser: IBrowser that opens the system browser on the authorize URL and blocks until the named pipe yields the callback URL. No HTTP listener, no port to bind or release, no HttpListener lifecycle to babysit. - Platform.DefaultRedirectUri is now postit://callback. The CustomScheme property exposes the prefix for the redirect validator. - App.OnFrameworkInitializationCompleted detects a 2nd-instance launch by scanning command-line args for the scheme prefix, hands the URL off, and exits before opening a window. The first instance starts normally and only the browser is replaced. What still has to happen on the user's machine: - Registering the postit:// scheme with the OS (a one-time setup step: .desktop file on Linux, registry key on Windows, Info.plist / LSSetDefaultHandlerForURLScheme on macOS). The code already validates EndUrl starts with the configured scheme, so a missing registration surfaces as a clear error from CustomSchemeBrowser rather than a silent hang. Removed: - LoopbackBrowser and LoopbackBrowserTests — the listener, the timeout, the double Stop/Close dance. The whole class of 'port already bound' / 'next launch fails' issues goes away. - The /tests/LoopbackBrowserTests.cs regression coverage is obsolete: there is no listener to release anymore. The single- instance hand-off is covered by the existing tests on the OidcClient flow path. Tests: PostIt.Tests 17/17 pass, Yavsc.Org.Tests 13/14 (the one remaining failure is SendEMailSynchrone, pre-existing and unrelated to this change).
2026-06-22 00:53:01 +01:00
using System;
using System.Linq;
2026-06-25 00:08:25 +01:00
using System.Threading.Tasks;
2026-06-26 01:45:21 +01:00
using Microsoft.Extensions.DependencyInjection;
2026-05-29 01:29:36 +01:00
using Avalonia;
2026-06-26 01:45:21 +01:00
using Avalonia.Controls;
2026-05-29 01:29:36 +01:00
using Avalonia.Controls.ApplicationLifetimes;
using Avalonia.Markup.Xaml;
postIt: wire DarkMode, drop dead themeVariant, add UI tests for the banner Three related changes that close the loop on the DarkMode field and lay the first stone of a UI test scaffold for PostIt. 1. Settings.DarkMode was previously a dead field. It round- tripped through postit-settings.json and the SettingsPage CheckBox, OnDarkModeChanged flipped IsDirty, and that was it — no consumer ever read the value, so toggling the CheckBox had no visible effect. The fix is in App.OnFrameworkInitializationCompleted: read the value Load() just populated and set Application.Current.RequestedThemeVariant accordingly (so a dark-mode user lands on a dark window on first launch, not on a default-light window that flips after the user touches the toggle), then subscribe to settings.PropertyChanged and update the theme on every DarkMode change. The consumer lives in App.axaml.cs, not in Settings, so the Settings model stays free of any Avalonia.Application dependency and the SettingsLoadTests (which construct Settings outside an Avalonia host) still pass unchanged. 2. MainPageViewModel had a vestigial [ObservableProperty] ThemeVariant themeVariant = ThemeVariant.Default that no XAML, no code, and no test ever read. It was the start of a half-finished attempt to expose the theme variant on the page VM. The dark-mode wiring above makes it irrelevant: the theme is now driven by Application, not by a VM property. The field is removed, along with the using Avalonia.Styling; it pulled in (now unused). 3. SessionStatusBannerTests adds the first set of UI tests for PostIt. They mount a real MainWindow via the headless Avalonia host declared in TestApp.cs, attach a SessionStatusViewModel as the banner's DataContext, and assert the actual visual tree contents: three buttons render (Se déconnecter, Se connecter, Paramètres), the Login button is visible when logged out, the Logout button is hidden when logged out, the Paramètres button is visible regardless of session, and the session label text reflects the VM. The pattern follows what UnitTest1.MainPage_Should_Load already established: [AvaloniaFact] (from Avalonia.Headless.XUnit) plus new MainWindow() / window.Show(). A plain [Fact] cannot drive Window..ctor() because the headless platform's PlatformManager.CreateWindow() has no service registered outside a dispatcher-aware test context; the AvaloniaFact attribute provides that context. The DataContext is set on the banner directly because App.OnFrameworkInitializationCompleted is not called in a unit test (production wiring is exercised by the manual launch, not here). Build: 0 errors. Tests: 5/5 SessionStatusBannerTests, 3/3 SettingsLoadTests, 1/1 MainPageTests (the existing scaffold test, unchanged). The other PostIt.Tests suites depend on the OIDC stub WebApplicationFactory and time out on this network-restricted host.
2026-07-09 22:17:56 +01:00
using Avalonia.Styling;
postit: replace loopback HTTP listener with custom URI scheme OAuth2 redirect handling for the desktop PostIt app now follows RFC 8252 §7.1: the redirect URI is a custom scheme (postit://callback) that the OS routes back to PostIt instead of a 127.0.0.1 HTTP listener. The browser hits the scheme, the OS launches a fresh PostIt process, that process hands the URL to the running instance over a named pipe, then exits. Architecture: - SingleInstance: cross-platform named-pipe helper. TryHandOffAsync is what the 2nd instance calls to forward its command-line URL; StartServerAsync runs on the 1st instance and pumps URLs into a callback (the running CustomSchemeBrowser). - CustomSchemeBrowser: IBrowser that opens the system browser on the authorize URL and blocks until the named pipe yields the callback URL. No HTTP listener, no port to bind or release, no HttpListener lifecycle to babysit. - Platform.DefaultRedirectUri is now postit://callback. The CustomScheme property exposes the prefix for the redirect validator. - App.OnFrameworkInitializationCompleted detects a 2nd-instance launch by scanning command-line args for the scheme prefix, hands the URL off, and exits before opening a window. The first instance starts normally and only the browser is replaced. What still has to happen on the user's machine: - Registering the postit:// scheme with the OS (a one-time setup step: .desktop file on Linux, registry key on Windows, Info.plist / LSSetDefaultHandlerForURLScheme on macOS). The code already validates EndUrl starts with the configured scheme, so a missing registration surfaces as a clear error from CustomSchemeBrowser rather than a silent hang. Removed: - LoopbackBrowser and LoopbackBrowserTests — the listener, the timeout, the double Stop/Close dance. The whole class of 'port already bound' / 'next launch fails' issues goes away. - The /tests/LoopbackBrowserTests.cs regression coverage is obsolete: there is no listener to release anymore. The single- instance hand-off is covered by the existing tests on the OidcClient flow path. Tests: PostIt.Tests 17/17 pass, Yavsc.Org.Tests 13/14 (the one remaining failure is SendEMailSynchrone, pre-existing and unrelated to this change).
2026-06-22 00:53:01 +01:00
using PostIt.Services;
2026-08-17 23:50:35 +01:00
using Yavsc.Api.Client;
2026-05-29 01:29:36 +01:00
using PostIt.ViewModels;
using PostIt.Views;
namespace PostIt;
public partial class App : Application
{
postit: make Settings thread-safe and route PropertyChanged through UI dispatcher The postit://callback re-launch crashed Avalonia inside DataValidationErrors.SetErrors with 'The calling thread cannot access this object because a different thread owns it'. Two Settings instances raced on PropertyChanged: one was the DI singleton registered by App.OnFrameworkInitializationCompleted, the other was a freshly-constructed fallback in LoginPageViewModel() and LoginPage.axaml.cs's DataContext-null branch. Avalonia's binding sink caught the cross-thread notification and crashed before the LoginPage could render. Fix at three layers: 1. Settings: lock the mutation gate so concurrent Load() / ApplyJson() callers cannot tear reads; override OnPropertyChanged to marshal every notification onto the Avalonia UI thread via a new UiDispatcher helper (no more cross-thread SetErrors). Add BindToServiceProvider / RequireCurrent so production code paths cannot silently allocate a second instance. 2. LoginPageViewModel(): resolve the canonical Settings from the DI container (Settings.RequireCurrent) instead of new Settings(). The cross-thread crash is now caught loudly with a clear 'Settings.Current is not bound' error if something instantiates the VM outside a bound App. 3. HomePage.axaml.cs and LoginPage.axaml.cs: resolve the next view-model and BlogApiClient through App.Services instead of constructing them with 'new'. Same instance tree as the rest of the app; the postit://callback race disappears by construction. MainPageViewModel and HomePageViewModel keep their existing '?? new Settings()' fallback for test friendliness, but the fallback is now harmless because Settings itself is thread-safe. Adds two regression tests in SettingsLoadTests covering concurrent Load+mutate and concurrent idempotent Load.
2026-06-28 13:33:06 +01:00
/// <summary>
/// DI container the platform entry points hand to ViewModels so
/// they can resolve the canonical <see cref="Settings"/> singleton
/// (and any other shared service) instead of falling back to a
/// freshly-constructed <c>new Settings()</c>. The earlier fallback
/// path is what created two Settings instances on
/// <c>postit://callback</c> re-launches and crashed Avalonia's
/// binding sink with a cross-thread exception inside
/// <c>DataValidationErrors.SetErrors</c>.
/// </summary>
2026-08-03 01:32:59 +01:00
public IServiceProvider? ServiceProvider { get; private set; }
2026-08-23 18:34:34 +01:00
2026-06-10 16:59:23 +01:00
public App()
{
}
postit: replace loopback HTTP listener with custom URI scheme OAuth2 redirect handling for the desktop PostIt app now follows RFC 8252 §7.1: the redirect URI is a custom scheme (postit://callback) that the OS routes back to PostIt instead of a 127.0.0.1 HTTP listener. The browser hits the scheme, the OS launches a fresh PostIt process, that process hands the URL to the running instance over a named pipe, then exits. Architecture: - SingleInstance: cross-platform named-pipe helper. TryHandOffAsync is what the 2nd instance calls to forward its command-line URL; StartServerAsync runs on the 1st instance and pumps URLs into a callback (the running CustomSchemeBrowser). - CustomSchemeBrowser: IBrowser that opens the system browser on the authorize URL and blocks until the named pipe yields the callback URL. No HTTP listener, no port to bind or release, no HttpListener lifecycle to babysit. - Platform.DefaultRedirectUri is now postit://callback. The CustomScheme property exposes the prefix for the redirect validator. - App.OnFrameworkInitializationCompleted detects a 2nd-instance launch by scanning command-line args for the scheme prefix, hands the URL off, and exits before opening a window. The first instance starts normally and only the browser is replaced. What still has to happen on the user's machine: - Registering the postit:// scheme with the OS (a one-time setup step: .desktop file on Linux, registry key on Windows, Info.plist / LSSetDefaultHandlerForURLScheme on macOS). The code already validates EndUrl starts with the configured scheme, so a missing registration surfaces as a clear error from CustomSchemeBrowser rather than a silent hang. Removed: - LoopbackBrowser and LoopbackBrowserTests — the listener, the timeout, the double Stop/Close dance. The whole class of 'port already bound' / 'next launch fails' issues goes away. - The /tests/LoopbackBrowserTests.cs regression coverage is obsolete: there is no listener to release anymore. The single- instance hand-off is covered by the existing tests on the OidcClient flow path. Tests: PostIt.Tests 17/17 pass, Yavsc.Org.Tests 13/14 (the one remaining failure is SendEMailSynchrone, pre-existing and unrelated to this change).
2026-06-22 00:53:01 +01:00
2026-05-29 01:29:36 +01:00
public override void Initialize()
{
AvaloniaXamlLoader.Load(this);
2026-08-23 18:34:34 +01:00
#if DEBUG
this.AttachDeveloperTools();
#endif
2026-05-29 01:29:36 +01:00
}
public override void OnFrameworkInitializationCompleted()
{
// Belt-and-braces 2nd-instance guard. The primary check now
// lives in PostIt.Desktop.Program.Main and exits before
// Avalonia boots — preventing a flash of the MainWindow on
// every postit://callback launch. This block is kept for any
// entry point that bypasses Program.Main (PostIt.Browser,
// PostIt.Android's process lifecycle, ad-hoc tests that build
// App directly) and as defence-in-depth in case the Desktop
// build is ever reconfigured to skip the early check.
2026-06-26 01:45:21 +01:00
if (TryHandOffCustomSchemeUrl()) return;
feat(postit): circles+ACL UI, blog fixture→SQLite, seed default user Bundled end-of-branch commit on feat/postit-acl-members. PostIt UI for circles + per-post ACL - Reorganise PostIt.Tests into Auth/ and Blogs/ subfolders (Bearer/OIDC scope tests vs. blog API fakes live where they belong) and introduces PostItHeadlessCollection so the Avalonia.Headless tests share a single xUnit collection instead of contending with the EF-Core test host. - Adds BlogAclApiTests (a brand-new behavioural layer over POST /api/v1/blogacl) and the fakes it relies on (BlogApiTestFakes, BlogPostAuthorDtoTests, AddCircleMember DialogTests); pulls UserId-through-OIDC-sub path into BearerScopeTests / FakeAuthorizingBrowser / OidcStubAuthority. - App.axaml.cs gets a small PushPageAsync touch-up the new tests rely on. - Drops UnitTest1.cs (xUnit scaffold, never used). Yavsc.Blogs.Tests — SQLite instead of InMemory - Bumps Yavsc.Blogs.Tests.csproj on Microsoft.EntityFrameworkCore.Sqlite and rewrites BlogsWebServerFixture to hold a single shared SqliteConnection (Cache=Shared) for the fixture lifetime, with a sync Dispose close to dodge async teardown hangs. Reason: the EF Core InMemory provider silently ignores FKs, which masked the kind of bug we are about to pin in the ACL tests. SQLite enforces them, so any future INSERT that forgets to seed its parent rows fails loudly here instead of passing the test and breaking prod. - PublishEndpointTests and BlogApiSmokeTests get a one-line tweak to follow the new connection lifecycle. Foreign-key fallout: seed the default user in the fixture - Adds BlogsWebServerFixture.SeedUser(userName). Now that SQLite enforces BlogPost.AuthorId → AspNetUsers.Id, every test that POST/PUT/DELETE a BlogPost and sends AuthorId= 'tester' in the payload needs an AspNetUsers row to satisfy the FK or it returns 500 with SQLite Error 19. - BlogApiTests wraps the existing ResetDatabase with a ResetAndSeedDefaultUser helper for the six mutating tests; the four GET-only and ModelState-only tests keep the bare ResetDatabase. - Side benefit: every test in Yavsc.Blogs.Tests now finishes cleanly instead of hanging at teardown — previously a stuck test held the shared SqliteConnection open and the next tests waited indefinitely. Verified: dotnet test src/Yavsc.Blogs.Tests passes 25/25 green from a clean run, no fixture teardown hang.
2026-08-20 23:59:21 +01:00
this.ServiceProvider = BuildServices(new ServiceCollection());
AttachServiceProvider(ServiceProvider);
var settings = ServiceProvider.GetRequiredService<Settings>();
postit: make Settings thread-safe and route PropertyChanged through UI dispatcher The postit://callback re-launch crashed Avalonia inside DataValidationErrors.SetErrors with 'The calling thread cannot access this object because a different thread owns it'. Two Settings instances raced on PropertyChanged: one was the DI singleton registered by App.OnFrameworkInitializationCompleted, the other was a freshly-constructed fallback in LoginPageViewModel() and LoginPage.axaml.cs's DataContext-null branch. Avalonia's binding sink caught the cross-thread notification and crashed before the LoginPage could render. Fix at three layers: 1. Settings: lock the mutation gate so concurrent Load() / ApplyJson() callers cannot tear reads; override OnPropertyChanged to marshal every notification onto the Avalonia UI thread via a new UiDispatcher helper (no more cross-thread SetErrors). Add BindToServiceProvider / RequireCurrent so production code paths cannot silently allocate a second instance. 2. LoginPageViewModel(): resolve the canonical Settings from the DI container (Settings.RequireCurrent) instead of new Settings(). The cross-thread crash is now caught loudly with a clear 'Settings.Current is not bound' error if something instantiates the VM outside a bound App. 3. HomePage.axaml.cs and LoginPage.axaml.cs: resolve the next view-model and BlogApiClient through App.Services instead of constructing them with 'new'. Same instance tree as the rest of the app; the postit://callback race disappears by construction. MainPageViewModel and HomePageViewModel keep their existing '?? new Settings()' fallback for test friendliness, but the fallback is now harmless because Settings itself is thread-safe. Adds two regression tests in SettingsLoadTests covering concurrent Load+mutate and concurrent idempotent Load.
2026-06-28 13:33:06 +01:00
2026-06-26 01:45:21 +01:00
DataTemplates.Clear();
2026-08-03 01:32:59 +01:00
DataTemplates.Add(new ViewLocator(ServiceProvider));
2026-06-26 01:45:21 +01:00
postIt: SettingsPage is a singleton, navigation is idempotent Two related changes that close the loop on the SettingsPage push semantics. 1. The SettingsPage used to be registered as Transient. Each click on the Paramètres button resolved a fresh instance, re-bound it to the Settings singleton, and pushed it onto the navigation stack. Repeated clicks accumulated stacked instances, each fully bound, and the user had to tap Back N times to leave. The fix is to register the page as a Singleton in the DI container. There is now one and only one SettingsPage ContentPage for the lifetime of the app: - its DataContext is wired once, at composition time (just after the ViewLocator is added to DataTemplates), not on every push; - the OpenSettingsRequested handler is a pure navigation concern, with no DI resolution and no rebinding; - the in-memory Settings state is preserved across visits (any in-flight edit stays in the same instance). 2. The OpenSettingsRequested handler is guarded so that if the SettingsPage is already at the top of NavigationStack, the push is a no-op. NavigationPage.PushAsync does not deduplicate; without the guard, calling it twice with the same instance pushes it a second time, and the user has to tap Back twice to leave. The guard is a reference comparison on NavigationStack[Count - 1] against the singleton instance, which is correct precisely because the page is a singleton. doc/architecture/postit.md is updated to match: the DI table reflects the new lifetime, and the 'Garde anti-empilement' section is rewritten from 'to be implemented' to the actual implementation, including the rationale for reference comparison and the cross-dependency between the singleton lifetime and the guard. The Settings-singleton invariant (in the same doc) is unchanged: Settings is still a singleton, and adding a transient override would still be the bug it always was. The new SettingsPage singleton sits alongside it cleanly. Build: 0 errors. Tests: 3/3 SettingsLoadTests green.
2026-07-09 22:00:00 +01:00
// Wire the Settings singleton onto the SettingsPage singleton
// once, at composition time. The page is registered as a
// singleton (see above) precisely so this binding is stable
// for the lifetime of the app: every push to / pop from the
// navigation stack finds the same ContentPage with the same
// DataContext, and the TwoWay bindings inside the page keep
// mutating the same in-memory Settings instance that the rest
// of the app reads (OidcClientOptions construction, etc.).
2026-08-03 01:32:59 +01:00
ServiceProvider.GetRequiredService<SettingsPage>().DataContext = settings;
postIt: wire DarkMode, drop dead themeVariant, add UI tests for the banner Three related changes that close the loop on the DarkMode field and lay the first stone of a UI test scaffold for PostIt. 1. Settings.DarkMode was previously a dead field. It round- tripped through postit-settings.json and the SettingsPage CheckBox, OnDarkModeChanged flipped IsDirty, and that was it — no consumer ever read the value, so toggling the CheckBox had no visible effect. The fix is in App.OnFrameworkInitializationCompleted: read the value Load() just populated and set Application.Current.RequestedThemeVariant accordingly (so a dark-mode user lands on a dark window on first launch, not on a default-light window that flips after the user touches the toggle), then subscribe to settings.PropertyChanged and update the theme on every DarkMode change. The consumer lives in App.axaml.cs, not in Settings, so the Settings model stays free of any Avalonia.Application dependency and the SettingsLoadTests (which construct Settings outside an Avalonia host) still pass unchanged. 2. MainPageViewModel had a vestigial [ObservableProperty] ThemeVariant themeVariant = ThemeVariant.Default that no XAML, no code, and no test ever read. It was the start of a half-finished attempt to expose the theme variant on the page VM. The dark-mode wiring above makes it irrelevant: the theme is now driven by Application, not by a VM property. The field is removed, along with the using Avalonia.Styling; it pulled in (now unused). 3. SessionStatusBannerTests adds the first set of UI tests for PostIt. They mount a real MainWindow via the headless Avalonia host declared in TestApp.cs, attach a SessionStatusViewModel as the banner's DataContext, and assert the actual visual tree contents: three buttons render (Se déconnecter, Se connecter, Paramètres), the Login button is visible when logged out, the Logout button is hidden when logged out, the Paramètres button is visible regardless of session, and the session label text reflects the VM. The pattern follows what UnitTest1.MainPage_Should_Load already established: [AvaloniaFact] (from Avalonia.Headless.XUnit) plus new MainWindow() / window.Show(). A plain [Fact] cannot drive Window..ctor() because the headless platform's PlatformManager.CreateWindow() has no service registered outside a dispatcher-aware test context; the AvaloniaFact attribute provides that context. The DataContext is set on the banner directly because App.OnFrameworkInitializationCompleted is not called in a unit test (production wiring is exercised by the manual launch, not here). Build: 0 errors. Tests: 5/5 SessionStatusBannerTests, 3/3 SettingsLoadTests, 1/1 MainPageTests (the existing scaffold test, unchanged). The other PostIt.Tests suites depend on the OIDC stub WebApplicationFactory and time out on this network-restricted host.
2026-07-09 22:17:56 +01:00
// Settings.DarkMode was previously a dead field: it round-
// tripped through the settings file and the SettingsPage
// CheckBox, but no consumer ever read it. Wire it here to
// Application.RequestedThemeVariant so the toggle takes
// effect immediately, and seed the initial theme from the
// value Load() just populated (so a dark-mode user lands on
// a dark window on first launch, not on a default-light
// window that flips after the user touches the toggle).
ApplyDarkMode(settings);
settings.PropertyChanged += (_, e) =>
{
if (e.PropertyName == nameof(Settings.DarkMode))
{
ApplyDarkMode(settings);
}
};
postIt: SettingsPage is a singleton, navigation is idempotent Two related changes that close the loop on the SettingsPage push semantics. 1. The SettingsPage used to be registered as Transient. Each click on the Paramètres button resolved a fresh instance, re-bound it to the Settings singleton, and pushed it onto the navigation stack. Repeated clicks accumulated stacked instances, each fully bound, and the user had to tap Back N times to leave. The fix is to register the page as a Singleton in the DI container. There is now one and only one SettingsPage ContentPage for the lifetime of the app: - its DataContext is wired once, at composition time (just after the ViewLocator is added to DataTemplates), not on every push; - the OpenSettingsRequested handler is a pure navigation concern, with no DI resolution and no rebinding; - the in-memory Settings state is preserved across visits (any in-flight edit stays in the same instance). 2. The OpenSettingsRequested handler is guarded so that if the SettingsPage is already at the top of NavigationStack, the push is a no-op. NavigationPage.PushAsync does not deduplicate; without the guard, calling it twice with the same instance pushes it a second time, and the user has to tap Back twice to leave. The guard is a reference comparison on NavigationStack[Count - 1] against the singleton instance, which is correct precisely because the page is a singleton. doc/architecture/postit.md is updated to match: the DI table reflects the new lifetime, and the 'Garde anti-empilement' section is rewritten from 'to be implemented' to the actual implementation, including the rationale for reference comparison and the cross-dependency between the singleton lifetime and the guard. The Settings-singleton invariant (in the same doc) is unchanged: Settings is still a singleton, and adding a transient override would still be the bug it always was. The new SettingsPage singleton sits alongside it cleanly. Build: 0 errors. Tests: 3/3 SettingsLoadTests green.
2026-07-09 22:00:00 +01:00
2026-08-23 18:34:34 +01:00
2026-05-29 01:29:36 +01:00
if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop)
{
2026-08-23 18:34:34 +01:00
desktop.MainWindow = CreateMainWindow();
2026-05-29 01:29:36 +01:00
}
2026-08-23 18:34:34 +01:00
else if (ApplicationLifetime is IActivityApplicationLifetime singleViewFactoryApplicationLifetime)
2026-05-29 01:29:36 +01:00
{
2026-08-23 18:34:34 +01:00
singleViewFactoryApplicationLifetime.MainViewFactory = () => CreateMainWindow();
}
else if (ApplicationLifetime is ISingleViewApplicationLifetime singleViewPlatform)
{
singleViewPlatform.MainView = CreateMainWindow();
2026-05-29 01:29:36 +01:00
}
2026-08-23 18:34:34 +01:00
base.OnFrameworkInitializationCompleted();
}
MainWindow window;
private MainWindow CreateMainWindow()
{
window = new MainWindow();
var api = ServiceProvider!.GetRequiredService<YavscApiClient>();
window.Opened += async (_, _) => await BootAsync(this.ServiceProvider!, api);
var sessionStatus = ServiceProvider!.GetRequiredService<SessionStatusViewModel>();
sessionStatus.LogoutCompleted += () =>
{
window.NavRoot.PopToRootAsync();
};
sessionStatus.LoginSucceeded += () =>
{
PushMainPageAsync();
};
var homeVm = ServiceProvider!.GetRequiredService<HomePageViewModel>();
this.PushPageAsync(homeVm).Wait();
window.SessionBanner.DataContext = sessionStatus;
return window;
2026-05-29 01:29:36 +01:00
}
postit: replace loopback HTTP listener with custom URI scheme OAuth2 redirect handling for the desktop PostIt app now follows RFC 8252 §7.1: the redirect URI is a custom scheme (postit://callback) that the OS routes back to PostIt instead of a 127.0.0.1 HTTP listener. The browser hits the scheme, the OS launches a fresh PostIt process, that process hands the URL to the running instance over a named pipe, then exits. Architecture: - SingleInstance: cross-platform named-pipe helper. TryHandOffAsync is what the 2nd instance calls to forward its command-line URL; StartServerAsync runs on the 1st instance and pumps URLs into a callback (the running CustomSchemeBrowser). - CustomSchemeBrowser: IBrowser that opens the system browser on the authorize URL and blocks until the named pipe yields the callback URL. No HTTP listener, no port to bind or release, no HttpListener lifecycle to babysit. - Platform.DefaultRedirectUri is now postit://callback. The CustomScheme property exposes the prefix for the redirect validator. - App.OnFrameworkInitializationCompleted detects a 2nd-instance launch by scanning command-line args for the scheme prefix, hands the URL off, and exits before opening a window. The first instance starts normally and only the browser is replaced. What still has to happen on the user's machine: - Registering the postit:// scheme with the OS (a one-time setup step: .desktop file on Linux, registry key on Windows, Info.plist / LSSetDefaultHandlerForURLScheme on macOS). The code already validates EndUrl starts with the configured scheme, so a missing registration surfaces as a clear error from CustomSchemeBrowser rather than a silent hang. Removed: - LoopbackBrowser and LoopbackBrowserTests — the listener, the timeout, the double Stop/Close dance. The whole class of 'port already bound' / 'next launch fails' issues goes away. - The /tests/LoopbackBrowserTests.cs regression coverage is obsolete: there is no listener to release anymore. The single- instance hand-off is covered by the existing tests on the OidcClient flow path. Tests: PostIt.Tests 17/17 pass, Yavsc.Org.Tests 13/14 (the one remaining failure is SendEMailSynchrone, pre-existing and unrelated to this change).
2026-06-22 00:53:01 +01:00
2026-08-19 14:09:31 +01:00
/// <summary>
/// Build the DI container the app uses. Pulled out of
/// <see cref="OnFrameworkInitializationCompleted"/> so headless
/// tests can construct the same container at <c>TestApp</c> boot
/// without going through the full Avalonia desktop lifetime
/// (which never runs in a unit test). The container returned is
/// the exact one production uses — no test-only fakes, no
/// trimmed service list — so a test that exercises a VM, page,
/// or service resolves through the same wiring the real app
/// does, and a green test is a green contract for prod.
/// </summary>
feat(postit): circles+ACL UI, blog fixture→SQLite, seed default user Bundled end-of-branch commit on feat/postit-acl-members. PostIt UI for circles + per-post ACL - Reorganise PostIt.Tests into Auth/ and Blogs/ subfolders (Bearer/OIDC scope tests vs. blog API fakes live where they belong) and introduces PostItHeadlessCollection so the Avalonia.Headless tests share a single xUnit collection instead of contending with the EF-Core test host. - Adds BlogAclApiTests (a brand-new behavioural layer over POST /api/v1/blogacl) and the fakes it relies on (BlogApiTestFakes, BlogPostAuthorDtoTests, AddCircleMember DialogTests); pulls UserId-through-OIDC-sub path into BearerScopeTests / FakeAuthorizingBrowser / OidcStubAuthority. - App.axaml.cs gets a small PushPageAsync touch-up the new tests rely on. - Drops UnitTest1.cs (xUnit scaffold, never used). Yavsc.Blogs.Tests — SQLite instead of InMemory - Bumps Yavsc.Blogs.Tests.csproj on Microsoft.EntityFrameworkCore.Sqlite and rewrites BlogsWebServerFixture to hold a single shared SqliteConnection (Cache=Shared) for the fixture lifetime, with a sync Dispose close to dodge async teardown hangs. Reason: the EF Core InMemory provider silently ignores FKs, which masked the kind of bug we are about to pin in the ACL tests. SQLite enforces them, so any future INSERT that forgets to seed its parent rows fails loudly here instead of passing the test and breaking prod. - PublishEndpointTests and BlogApiSmokeTests get a one-line tweak to follow the new connection lifecycle. Foreign-key fallout: seed the default user in the fixture - Adds BlogsWebServerFixture.SeedUser(userName). Now that SQLite enforces BlogPost.AuthorId → AspNetUsers.Id, every test that POST/PUT/DELETE a BlogPost and sends AuthorId= 'tester' in the payload needs an AspNetUsers row to satisfy the FK or it returns 500 with SQLite Error 19. - BlogApiTests wraps the existing ResetDatabase with a ResetAndSeedDefaultUser helper for the six mutating tests; the four GET-only and ModelState-only tests keep the bare ResetDatabase. - Side benefit: every test in Yavsc.Blogs.Tests now finishes cleanly instead of hanging at teardown — previously a stuck test held the shared SqliteConnection open and the next tests waited indefinitely. Verified: dotnet test src/Yavsc.Blogs.Tests passes 25/25 green from a clean run, no fixture teardown hang.
2026-08-20 23:59:21 +01:00
internal static IServiceProvider BuildServices(ServiceCollection services)
2026-08-19 14:09:31 +01:00
{
var settings = new Settings();
settings.Load();
var tokenStore = new TokenStore(System.IO.Path.Combine(
System.Environment.GetFolderPath(System.Environment.SpecialFolder.ApplicationData),
"PostIt", "tokens.json"));
var api = new YavscApiClient(settings, tokenStore);
var client = new BlogApiClient(api, settings.BlogsApiUrl);
var circleClient = new CircleApiClient(api, settings.BlogsApiUrl);
var blogAclClient = new BlogAclApiClient(api, settings.BlogsApiUrl);
var userSearchClient = new UserSearchClient(api, settings.BlogsApiUrl);
var contactService = new ContactService();
var userDirectory = new UserDirectory(userSearchClient);
// Vues
services.AddTransient<MainPage>();
// SettingsPage is a singleton: there must be one and only one
// instance of the settings UI for the lifetime of the app.
// This guarantees that (a) the bindings always reflect the
// current in-memory Settings state, (b) the page already has
// its DataContext wired up at composition-root time (see
// below), and (c) PushPageAsync's anti-empilement guard sees
// the same instance across pushes, so a second Settings tap
// is a no-op rather than re-pushing the page. Transient would
// let the user accumulate stale SettingsPage instances on
// the navigation stack, each bound to a fresh
// SettingsViewModel and missing any in-flight edits.
2026-08-19 14:09:31 +01:00
services.AddSingleton<SettingsPage>();
services.AddTransient<HomePage>();
services.AddTransient<SignaturePage>();
services.AddTransient<CirclesPage>();
// Dialogs (modal-light pages): the ViewLocator resolves
// them when a caller pushes a PostAclDialogViewModel or
// AddCircleMemberDialogViewModel via App.PushPageAsync.
// App.PushPageAsync overwrites the page's DataContext with
// the caller-built VM, so the parameterless ctor is enough
// here — the parametrised ctors stay for direct test wiring.
services.AddTransient<PostAclDialog>();
services.AddTransient<AddCircleMemberDialog>();
2026-08-19 14:09:31 +01:00
// ViewModels
services.AddSingleton(settings);
services.AddSingleton<YavscApiClient>(api);
services.AddSingleton<IYavscApiClient>(api);
services.AddSingleton(client);
services.AddSingleton(circleClient);
services.AddSingleton(blogAclClient);
services.AddSingleton(userSearchClient);
services.AddSingleton<IContactService>(contactService);
services.AddSingleton<IUserDirectory>(userDirectory);
services.AddTransient<MainPageViewModel>();
services.AddTransient<HomePageViewModel>();
services.AddTransient<SignaturePageViewModel>();
services.AddTransient<CirclesPageViewModel>();
// Persistent session banner: one instance for the lifetime of
// the app so the same VM survives page navigation.
var sessionStatus = new SessionStatusViewModel { Api = api };
sessionStatus.Refresh();
services.AddSingleton(sessionStatus);
services.AddTransient<SessionStatusBanner>();
return services.BuildServiceProvider();
}
/// <summary>
/// Attach a pre-built DI container to this <see cref="App"/>
/// instance. Used by headless tests after
/// <see cref="BuildServices"/>; in production this happens
/// implicitly via <see cref="OnFrameworkInitializationCompleted"/>.
/// Idempotent w.r.t. <see cref="Settings.BindToServiceProvider"/>:
/// re-binding from a second App boot is a no-op.
/// </summary>
internal void AttachServiceProvider(IServiceProvider sp)
{
ServiceProvider = sp;
Settings.BindToServiceProvider(sp);
}
/// <summary>
/// Test-only hook: bind a concrete <see cref="MainWindow"/> so
/// command-driven navigation paths (<see cref="PushPage"/>) can
/// push onto a real <see cref="NavigationPage"/> in headless
/// fixtures that do not run the full desktop lifetime bootstrap.
/// </summary>
internal void AttachMainWindow(MainWindow mainWindow)
{
window = mainWindow ?? throw new ArgumentNullException(nameof(mainWindow));
}
postIt: wire DarkMode, drop dead themeVariant, add UI tests for the banner Three related changes that close the loop on the DarkMode field and lay the first stone of a UI test scaffold for PostIt. 1. Settings.DarkMode was previously a dead field. It round- tripped through postit-settings.json and the SettingsPage CheckBox, OnDarkModeChanged flipped IsDirty, and that was it — no consumer ever read the value, so toggling the CheckBox had no visible effect. The fix is in App.OnFrameworkInitializationCompleted: read the value Load() just populated and set Application.Current.RequestedThemeVariant accordingly (so a dark-mode user lands on a dark window on first launch, not on a default-light window that flips after the user touches the toggle), then subscribe to settings.PropertyChanged and update the theme on every DarkMode change. The consumer lives in App.axaml.cs, not in Settings, so the Settings model stays free of any Avalonia.Application dependency and the SettingsLoadTests (which construct Settings outside an Avalonia host) still pass unchanged. 2. MainPageViewModel had a vestigial [ObservableProperty] ThemeVariant themeVariant = ThemeVariant.Default that no XAML, no code, and no test ever read. It was the start of a half-finished attempt to expose the theme variant on the page VM. The dark-mode wiring above makes it irrelevant: the theme is now driven by Application, not by a VM property. The field is removed, along with the using Avalonia.Styling; it pulled in (now unused). 3. SessionStatusBannerTests adds the first set of UI tests for PostIt. They mount a real MainWindow via the headless Avalonia host declared in TestApp.cs, attach a SessionStatusViewModel as the banner's DataContext, and assert the actual visual tree contents: three buttons render (Se déconnecter, Se connecter, Paramètres), the Login button is visible when logged out, the Logout button is hidden when logged out, the Paramètres button is visible regardless of session, and the session label text reflects the VM. The pattern follows what UnitTest1.MainPage_Should_Load already established: [AvaloniaFact] (from Avalonia.Headless.XUnit) plus new MainWindow() / window.Show(). A plain [Fact] cannot drive Window..ctor() because the headless platform's PlatformManager.CreateWindow() has no service registered outside a dispatcher-aware test context; the AvaloniaFact attribute provides that context. The DataContext is set on the banner directly because App.OnFrameworkInitializationCompleted is not called in a unit test (production wiring is exercised by the manual launch, not here). Build: 0 errors. Tests: 5/5 SessionStatusBannerTests, 3/3 SettingsLoadTests, 1/1 MainPageTests (the existing scaffold test, unchanged). The other PostIt.Tests suites depend on the OIDC stub WebApplicationFactory and time out on this network-restricted host.
2026-07-09 22:17:56 +01:00
private static void ApplyDarkMode(Settings settings)
{
Application.Current!.RequestedThemeVariant =
settings.DarkMode ? ThemeVariant.Dark : ThemeVariant.Light;
}
/// <summary>
/// Run once after the main window is shown: try to refresh the
/// cached OIDC tokens silently; on success, push MainPage on top
/// of HomePage so the user lands on the blog editor already
/// authenticated. On failure (refresh token rejected, no bundle
/// on disk), leave them on HomePage and the Login button is the
/// next step.
/// </summary>
private static async Task BootAsync(
IServiceProvider provider,
2026-08-02 21:08:25 +01:00
YavscApiClient api)
{
var refreshed = await api.TrySilentLoginAsync().ConfigureAwait(true);
var sessionStatus = provider.GetRequiredService<SessionStatusViewModel>();
sessionStatus.Refresh();
if (!refreshed) return;
2026-08-02 21:08:25 +01:00
await PushMainPageAsync().ConfigureAwait(true);
}
/// <summary>
/// Resolve a fresh <c>MainPageViewModel</c> from DI and push its
/// mapped page (via <see cref="ViewLocator"/>) on top
/// of the current navigation stack. Used both by <see cref="BootAsync"/>
/// (silent refresh at boot) and by <c>SessionStatusViewModel.LoginSucceeded</c>
/// (interactive login from the banner). Pulled out as a helper so
/// the two callers can't drift apart.
/// </summary>
public static Task PushMainPageAsync()
{
2026-08-23 18:34:34 +01:00
var app = (App)Current!;
var mainVm = app.ServiceProvider!.GetRequiredService<MainPageViewModel>();
return app.PushPageAsync(mainVm);
}
postit: replace loopback HTTP listener with custom URI scheme OAuth2 redirect handling for the desktop PostIt app now follows RFC 8252 §7.1: the redirect URI is a custom scheme (postit://callback) that the OS routes back to PostIt instead of a 127.0.0.1 HTTP listener. The browser hits the scheme, the OS launches a fresh PostIt process, that process hands the URL to the running instance over a named pipe, then exits. Architecture: - SingleInstance: cross-platform named-pipe helper. TryHandOffAsync is what the 2nd instance calls to forward its command-line URL; StartServerAsync runs on the 1st instance and pumps URLs into a callback (the running CustomSchemeBrowser). - CustomSchemeBrowser: IBrowser that opens the system browser on the authorize URL and blocks until the named pipe yields the callback URL. No HTTP listener, no port to bind or release, no HttpListener lifecycle to babysit. - Platform.DefaultRedirectUri is now postit://callback. The CustomScheme property exposes the prefix for the redirect validator. - App.OnFrameworkInitializationCompleted detects a 2nd-instance launch by scanning command-line args for the scheme prefix, hands the URL off, and exits before opening a window. The first instance starts normally and only the browser is replaced. What still has to happen on the user's machine: - Registering the postit:// scheme with the OS (a one-time setup step: .desktop file on Linux, registry key on Windows, Info.plist / LSSetDefaultHandlerForURLScheme on macOS). The code already validates EndUrl starts with the configured scheme, so a missing registration surfaces as a clear error from CustomSchemeBrowser rather than a silent hang. Removed: - LoopbackBrowser and LoopbackBrowserTests — the listener, the timeout, the double Stop/Close dance. The whole class of 'port already bound' / 'next launch fails' issues goes away. - The /tests/LoopbackBrowserTests.cs regression coverage is obsolete: there is no listener to release anymore. The single- instance hand-off is covered by the existing tests on the OidcClient flow path. Tests: PostIt.Tests 17/17 pass, Yavsc.Org.Tests 13/14 (the one remaining failure is SendEMailSynchrone, pre-existing and unrelated to this change).
2026-06-22 00:53:01 +01:00
private bool TryHandOffCustomSchemeUrl()
{
var url = SchemeUrlDetector.FindCallbackUrl(Environment.GetCommandLineArgs());
if (url is null) return false;
// Best-effort: try to send the URL to the running
// instance via the named pipe. If the pipe isn't
// answering, just exit — there's no 1st instance
// to forward to (e.g. user double-clicked the link
// after closing PostIt). Falling through with a
// normal startup would be confusing.
try
postit: replace loopback HTTP listener with custom URI scheme OAuth2 redirect handling for the desktop PostIt app now follows RFC 8252 §7.1: the redirect URI is a custom scheme (postit://callback) that the OS routes back to PostIt instead of a 127.0.0.1 HTTP listener. The browser hits the scheme, the OS launches a fresh PostIt process, that process hands the URL to the running instance over a named pipe, then exits. Architecture: - SingleInstance: cross-platform named-pipe helper. TryHandOffAsync is what the 2nd instance calls to forward its command-line URL; StartServerAsync runs on the 1st instance and pumps URLs into a callback (the running CustomSchemeBrowser). - CustomSchemeBrowser: IBrowser that opens the system browser on the authorize URL and blocks until the named pipe yields the callback URL. No HTTP listener, no port to bind or release, no HttpListener lifecycle to babysit. - Platform.DefaultRedirectUri is now postit://callback. The CustomScheme property exposes the prefix for the redirect validator. - App.OnFrameworkInitializationCompleted detects a 2nd-instance launch by scanning command-line args for the scheme prefix, hands the URL off, and exits before opening a window. The first instance starts normally and only the browser is replaced. What still has to happen on the user's machine: - Registering the postit:// scheme with the OS (a one-time setup step: .desktop file on Linux, registry key on Windows, Info.plist / LSSetDefaultHandlerForURLScheme on macOS). The code already validates EndUrl starts with the configured scheme, so a missing registration surfaces as a clear error from CustomSchemeBrowser rather than a silent hang. Removed: - LoopbackBrowser and LoopbackBrowserTests — the listener, the timeout, the double Stop/Close dance. The whole class of 'port already bound' / 'next launch fails' issues goes away. - The /tests/LoopbackBrowserTests.cs regression coverage is obsolete: there is no listener to release anymore. The single- instance hand-off is covered by the existing tests on the OidcClient flow path. Tests: PostIt.Tests 17/17 pass, Yavsc.Org.Tests 13/14 (the one remaining failure is SendEMailSynchrone, pre-existing and unrelated to this change).
2026-06-22 00:53:01 +01:00
{
SingleInstance.TryHandOffAsync(url).GetAwaiter().GetResult();
}
catch
{
// Pipe errors are non-fatal for the 2nd-instance
// hand-off.
}
if (ApplicationLifetime is IControlledApplicationLifetime lifetime)
{
lifetime.Shutdown(0);
}
else
{
Environment.Exit(0);
postit: replace loopback HTTP listener with custom URI scheme OAuth2 redirect handling for the desktop PostIt app now follows RFC 8252 §7.1: the redirect URI is a custom scheme (postit://callback) that the OS routes back to PostIt instead of a 127.0.0.1 HTTP listener. The browser hits the scheme, the OS launches a fresh PostIt process, that process hands the URL to the running instance over a named pipe, then exits. Architecture: - SingleInstance: cross-platform named-pipe helper. TryHandOffAsync is what the 2nd instance calls to forward its command-line URL; StartServerAsync runs on the 1st instance and pumps URLs into a callback (the running CustomSchemeBrowser). - CustomSchemeBrowser: IBrowser that opens the system browser on the authorize URL and blocks until the named pipe yields the callback URL. No HTTP listener, no port to bind or release, no HttpListener lifecycle to babysit. - Platform.DefaultRedirectUri is now postit://callback. The CustomScheme property exposes the prefix for the redirect validator. - App.OnFrameworkInitializationCompleted detects a 2nd-instance launch by scanning command-line args for the scheme prefix, hands the URL off, and exits before opening a window. The first instance starts normally and only the browser is replaced. What still has to happen on the user's machine: - Registering the postit:// scheme with the OS (a one-time setup step: .desktop file on Linux, registry key on Windows, Info.plist / LSSetDefaultHandlerForURLScheme on macOS). The code already validates EndUrl starts with the configured scheme, so a missing registration surfaces as a clear error from CustomSchemeBrowser rather than a silent hang. Removed: - LoopbackBrowser and LoopbackBrowserTests — the listener, the timeout, the double Stop/Close dance. The whole class of 'port already bound' / 'next launch fails' issues goes away. - The /tests/LoopbackBrowserTests.cs regression coverage is obsolete: there is no listener to release anymore. The single- instance hand-off is covered by the existing tests on the OidcClient flow path. Tests: PostIt.Tests 17/17 pass, Yavsc.Org.Tests 13/14 (the one remaining failure is SendEMailSynchrone, pre-existing and unrelated to this change).
2026-06-22 00:53:01 +01:00
}
return true;
postit: replace loopback HTTP listener with custom URI scheme OAuth2 redirect handling for the desktop PostIt app now follows RFC 8252 §7.1: the redirect URI is a custom scheme (postit://callback) that the OS routes back to PostIt instead of a 127.0.0.1 HTTP listener. The browser hits the scheme, the OS launches a fresh PostIt process, that process hands the URL to the running instance over a named pipe, then exits. Architecture: - SingleInstance: cross-platform named-pipe helper. TryHandOffAsync is what the 2nd instance calls to forward its command-line URL; StartServerAsync runs on the 1st instance and pumps URLs into a callback (the running CustomSchemeBrowser). - CustomSchemeBrowser: IBrowser that opens the system browser on the authorize URL and blocks until the named pipe yields the callback URL. No HTTP listener, no port to bind or release, no HttpListener lifecycle to babysit. - Platform.DefaultRedirectUri is now postit://callback. The CustomScheme property exposes the prefix for the redirect validator. - App.OnFrameworkInitializationCompleted detects a 2nd-instance launch by scanning command-line args for the scheme prefix, hands the URL off, and exits before opening a window. The first instance starts normally and only the browser is replaced. What still has to happen on the user's machine: - Registering the postit:// scheme with the OS (a one-time setup step: .desktop file on Linux, registry key on Windows, Info.plist / LSSetDefaultHandlerForURLScheme on macOS). The code already validates EndUrl starts with the configured scheme, so a missing registration surfaces as a clear error from CustomSchemeBrowser rather than a silent hang. Removed: - LoopbackBrowser and LoopbackBrowserTests — the listener, the timeout, the double Stop/Close dance. The whole class of 'port already bound' / 'next launch fails' issues goes away. - The /tests/LoopbackBrowserTests.cs regression coverage is obsolete: there is no listener to release anymore. The single- instance hand-off is covered by the existing tests on the OidcClient flow path. Tests: PostIt.Tests 17/17 pass, Yavsc.Org.Tests 13/14 (the one remaining failure is SendEMailSynchrone, pre-existing and unrelated to this change).
2026-06-22 00:53:01 +01:00
}
postit: wire BlogApiClient through YavscApiClient and unify auth BlogApiClient is a thin DTO↔path mapper on top of YavscApiClient: - No more HttpClient, no more accessToken constructor argument. - Single responsibility: turn Yavsc.Blogs endpoint paths into typed BlogPost payloads and back, while YavscApiClient owns auth + refresh + JSON shape. - Default path prefix is 'api/blog', overridable for tests. MainPageViewModel and App.axaml.cs are now free of any direct OidcClient / IBrowser / BearerToken plumbing. The MainPage receives a fully-configured BlogApiClient (which holds a YavscApiClient, which holds a TokenStore) at construction. The duplicate LoginAsync method on MainPageViewModel is gone; the LoginPage is the single entry point for the interactive PKCE flow. PlatformBootstrap.Desktop no longer overrides the redirect URI to loopback. PostIt runs the postit://callback custom scheme (SingleInstance hand-off) as the production path on desktop; the loopback constant stays for tests and for platforms that cannot register a custom scheme. Settings.cs: DefaultLoopbackRedirectUri is now documented as a fallback; DefaultDesktopRedirectUri ('postit://callback') is introduced as the canonical desktop default. TokenStore.Load() tolerates an empty file (returns null) so first-launch races and stubbed test fixtures don't blow up the constructor. YavscApiClient: - HasValidSession is exposed for warm-start UI logic. - CurrentAccessToken / CurrentIdToken are exposed so the LoginPage ViewModel can mirror the result onto its observable properties. - CallAsync<T> is now virtual (and the class is no longer sealed) to allow stubbing in PostItViewModelTests. Tests (PostIt.Tests): - YavscApiClientTests covers the silent refresh path (cache the token, mark it expired, observe a new Bearer in the API server), the 401 → refresh → retry path (forceFirstRequest on the stub), HasValidSession after login, and the throw-when-no-token guard. - OidcStubAuthority now mints a refresh_token in the token response so YavscApiClient.RefreshTokenAsync can hit /connect/token in tests. - PostItViewModelTests uses a ThrowingYavscApiClient / StubYavscApiClient pair instead of the old HttpClient injection point, matching the new constructor shape. dotnet test: 21/21 green. dotnet build: 0 errors.
2026-06-23 21:25:17 +01:00
internal void PushPage(ViewModelBase vm)
{
_ = PushPageAsync(vm);
}
2026-08-23 18:34:34 +01:00
internal async Task PushPageAsync(ViewModelBase vm)
{
if (window is null)
{
throw new InvalidOperationException("MainWindow is not initialized yet.");
}
var template = DataTemplates.FirstOrDefault(t => t.Match(vm));
if (template is null)
{
throw new InvalidOperationException($"No IDataTemplate found for {vm.GetType().Name}.");
}
var view = template.Build(vm);
if (view is null)
{
throw new InvalidOperationException(
$"Template for {vm.GetType().Name} returned <null>.");
}
var page = view as Page;
if (page is null)
{
// NavigationPage expects Page instances. Wrap any fallback control
// (e.g. ViewLocator error TextBlock) into a ContentPage so it can render.
page = new ContentPage { Content = view };
}
page.DataContext = vm;
// Avoid stacking the same singleton page twice (e.g. SettingsPage).
var stack = window.NavRoot.NavigationStack;
if (stack.Count > 0 && ReferenceEquals(stack[stack.Count - 1], page))
{
2026-08-23 18:34:34 +01:00
return;
}
2026-08-23 18:34:34 +01:00
await window.NavRoot.PushAsync(page);
}
2026-08-20 20:50:52 +01:00
internal async Task GoBackAsync()
{
await window.NavRoot.PopAsync();
}
2026-06-10 16:59:23 +01:00
}