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;
|
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-05-29 01:29:36 +01:00
|
|
|
using PostIt.ViewModels;
|
|
|
|
|
using PostIt.Views;
|
|
|
|
|
|
|
|
|
|
namespace PostIt;
|
|
|
|
|
|
|
|
|
|
public partial class App : Application
|
|
|
|
|
{
|
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>
|
|
|
|
|
public IServiceProvider? Services { get; private set; }
|
|
|
|
|
|
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);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public override void OnFrameworkInitializationCompleted()
|
|
|
|
|
{
|
2026-06-27 12:47:52 +01:00
|
|
|
// 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;
|
|
|
|
|
|
2026-06-25 00:08:25 +01:00
|
|
|
var settings = new Settings();
|
|
|
|
|
settings.Load();
|
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-06-25 00:08:25 +01:00
|
|
|
var tokenStore = new TokenStore(System.IO.Path.Combine(
|
|
|
|
|
System.Environment.GetFolderPath(System.Environment.SpecialFolder.ApplicationData),
|
|
|
|
|
"PostIt", "tokens.json"));
|
2026-06-26 01:45:21 +01:00
|
|
|
|
|
|
|
|
var api = new YavscApiClient(settings, tokenStore);
|
|
|
|
|
var client = new BlogApiClient(api);
|
|
|
|
|
|
|
|
|
|
var services = new ServiceCollection();
|
|
|
|
|
|
|
|
|
|
// Vues
|
|
|
|
|
services.AddTransient<MainPage>();
|
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
|
|
|
// 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) the OpenSettingsRequested handler is a
|
|
|
|
|
// pure push with a no-op-if-already-on-top guard, never a
|
|
|
|
|
// re-resolution from DI. 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.
|
|
|
|
|
services.AddSingleton<SettingsPage>();
|
2026-06-26 01:45:21 +01:00
|
|
|
services.AddTransient<HomePage>();
|
feat(postit): signature capture page (dev entry, file persistence)
Builds on f9cfd560 (SignaturePadControl + SignaturePadData) with a
full Avalonia page that captures signatures, renders them as Polylines,
and persists the wire-format payload to ~/.local/share/PostIt/signatures
as JSON v1.
Scope
- New SignaturePage (axaml + code-behind) hosts the render-agnostic
control: a fixed-size Border is the hit-test surface, an overlaid
Canvas is rebuilt on every RedrawRequested from the Strokes buffer.
- SignaturePageViewModel wraps the control: exposes StrokeCount /
PointCount / StatusMessage, Clear and CaptureAsync commands, and
Attach/Detach for view-lifetime ownership.
- CaptureAsync writes a JSON envelope { format, coordinateMax,
capturedAtUtc, strokes, strokeCount } to
LocalApplicationData/PostIt/signatures/signature-yyyyMMdd-HHmmssfff.json.
This is a stop-gap; the production transport will be
POST /api/signature/{devisId} on Yavsc.Api (commit 3+).
- Entry point is a [DEV] button on MainPage that pushes the page
onto the NavigationPage. The production trigger is a SignalR push
from Yavsc.Org ("devis received, sign here") landing on a hub
handler — the button and its Click handler are explicitly marked
dev-only and tracked for removal in the same commit that wires
the SignalR handler.
Plumbing
- App.axaml.cs: SignaturePage and SignaturePageViewModel registered
as Transient in the DI container.
- ViewLocator: routes SignaturePageViewModel to SignaturePage.
- SignaturePadData: adds PointCount (sum of pairs across strokes),
used by the VM status bar and the test surface.
Tests (57/57 green, 9 new in this commit)
- SignaturePageViewModelTests: constructors and dimension validation,
Attach/Detach idempotence, StrokeCompleted and Clear propagate to
the VM, CaptureAsync on empty buffer is a no-op, CaptureAsync on a
non-empty buffer writes a v1 envelope with the expected
structure (parsed back via JsonDocument, not text matching), and
creates the destination directory if missing.
- All previously-green tests (48) remain green.
Out of scope
- POST /api/signature endpoint on Yavsc.Api (commit 3).
- SignalR handler that opens the page on a "devis received" push.
- Rasterization: this commit only proves capture and persistence;
the visible ink is a Polyline reconstruction, not a PNG, by
design (per the wire-format decision in commit 1).
Note on SignaturePadData
- The PointCount property was added after f9cfd560 landed. It is
folded into this commit rather than amending f9cfd560 to keep
the existing history readable; the change is mechanical and
tested by the new SignaturePageViewModelTests.
2026-07-04 15:11:22 +01:00
|
|
|
services.AddTransient<SignaturePage>();
|
2026-06-26 01:45:21 +01:00
|
|
|
|
|
|
|
|
// ViewModels
|
|
|
|
|
services.AddSingleton(settings);
|
|
|
|
|
services.AddSingleton(api);
|
|
|
|
|
services.AddSingleton(client);
|
|
|
|
|
services.AddTransient<MainPageViewModel>();
|
|
|
|
|
services.AddTransient<HomePageViewModel>();
|
feat(postit): signature capture page (dev entry, file persistence)
Builds on f9cfd560 (SignaturePadControl + SignaturePadData) with a
full Avalonia page that captures signatures, renders them as Polylines,
and persists the wire-format payload to ~/.local/share/PostIt/signatures
as JSON v1.
Scope
- New SignaturePage (axaml + code-behind) hosts the render-agnostic
control: a fixed-size Border is the hit-test surface, an overlaid
Canvas is rebuilt on every RedrawRequested from the Strokes buffer.
- SignaturePageViewModel wraps the control: exposes StrokeCount /
PointCount / StatusMessage, Clear and CaptureAsync commands, and
Attach/Detach for view-lifetime ownership.
- CaptureAsync writes a JSON envelope { format, coordinateMax,
capturedAtUtc, strokes, strokeCount } to
LocalApplicationData/PostIt/signatures/signature-yyyyMMdd-HHmmssfff.json.
This is a stop-gap; the production transport will be
POST /api/signature/{devisId} on Yavsc.Api (commit 3+).
- Entry point is a [DEV] button on MainPage that pushes the page
onto the NavigationPage. The production trigger is a SignalR push
from Yavsc.Org ("devis received, sign here") landing on a hub
handler — the button and its Click handler are explicitly marked
dev-only and tracked for removal in the same commit that wires
the SignalR handler.
Plumbing
- App.axaml.cs: SignaturePage and SignaturePageViewModel registered
as Transient in the DI container.
- ViewLocator: routes SignaturePageViewModel to SignaturePage.
- SignaturePadData: adds PointCount (sum of pairs across strokes),
used by the VM status bar and the test surface.
Tests (57/57 green, 9 new in this commit)
- SignaturePageViewModelTests: constructors and dimension validation,
Attach/Detach idempotence, StrokeCompleted and Clear propagate to
the VM, CaptureAsync on empty buffer is a no-op, CaptureAsync on a
non-empty buffer writes a v1 envelope with the expected
structure (parsed back via JsonDocument, not text matching), and
creates the destination directory if missing.
- All previously-green tests (48) remain green.
Out of scope
- POST /api/signature endpoint on Yavsc.Api (commit 3).
- SignalR handler that opens the page on a "devis received" push.
- Rasterization: this commit only proves capture and persistence;
the visible ink is a Polyline reconstruction, not a PNG, by
design (per the wire-format decision in commit 1).
Note on SignaturePadData
- The PointCount property was added after f9cfd560 landed. It is
folded into this commit rather than amending f9cfd560 to keep
the existing history readable; the change is mechanical and
tested by the new SignaturePageViewModelTests.
2026-07-04 15:11:22 +01:00
|
|
|
services.AddTransient<SignaturePageViewModel>();
|
2026-06-27 12:47:52 +01:00
|
|
|
|
|
|
|
|
// 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>();
|
|
|
|
|
|
2026-06-26 01:45:21 +01:00
|
|
|
var provider = services.BuildServiceProvider();
|
|
|
|
|
|
2026-06-28 13:33:06 +01:00
|
|
|
// Bind the canonical Settings to the static accessor so any
|
|
|
|
|
// code path that can't easily take a constructor parameter
|
PostIt: drop LoginPage, hoist Login into session banner, drop AuthorId field
Login flow no longer needs a dedicated page. The OIDC interactive
login now lives on the persistent SessionStatusBanner, alongside
'Se déconnecter', driven by a new SessionStatusViewModel.LoginAsync
command. On success the VM raises LoginSucceeded and App.axaml.cs
pushes MainPage on top of HomePage — same path BootAsync already
takes when the silent refresh succeeds at boot, so the two flows
can't drift apart (PushMainPageAsync helper, single source of
truth).
MainPage no longer shows an editable AuthorId field: the server
infers the author from the bearer token, so the client-side
control was misleading at best. The detail grid drops from 5 rows
to 4.
Removed:
- Views/LoginPage.axaml + .axaml.cs
- ViewModels/LoginPageViewModel.cs
- HomePage Login button + OnLoginClick code-behind
- DI registrations for LoginPage / LoginPageViewModel
- ViewLocator mapping
- dangling <c>LoginPage*</c> cref / comments in Platform.cs,
PlatformBootstrap.cs (Desktop + Android), MainWindow.axaml,
YavscApiClient.cs
2026-07-07 20:47:00 +01:00
|
|
|
// (designer surfaces, Avalonia data templates) still gets
|
|
|
|
|
// the same instance the rest of the app is using. Idempotent:
|
|
|
|
|
// re-binding from a second App boot (tests) is a no-op.
|
2026-06-28 13:33:06 +01:00
|
|
|
Settings.BindToServiceProvider(provider);
|
|
|
|
|
|
|
|
|
|
Services = provider;
|
|
|
|
|
|
2026-06-26 01:45:21 +01:00
|
|
|
DataTemplates.Clear();
|
|
|
|
|
DataTemplates.Add(new ViewLocator(provider));
|
|
|
|
|
|
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.).
|
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
|
|
|
provider.GetRequiredService<SettingsPage>().DataContext = settings;
|
|
|
|
|
|
|
|
|
|
// 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-05-29 01:29:36 +01:00
|
|
|
if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop)
|
|
|
|
|
{
|
2026-06-27 12:47:52 +01:00
|
|
|
var homePage = provider.GetRequiredService<HomePage>();
|
|
|
|
|
homePage.DataContext = provider.GetRequiredService<HomePageViewModel>();
|
|
|
|
|
|
|
|
|
|
var window = new MainWindow();
|
|
|
|
|
window.SessionBanner.DataContext = sessionStatus;
|
|
|
|
|
|
|
|
|
|
// Build the navigation stack from scratch: HomePage is the
|
|
|
|
|
// root in both cases. App.BootAsync will push MainPage on
|
|
|
|
|
// top if the silent refresh succeeds.
|
|
|
|
|
window.DataContext = homePage.DataContext;
|
|
|
|
|
desktop.MainWindow = window;
|
|
|
|
|
_ = window.NavRoot.PushAsync(homePage);
|
|
|
|
|
|
|
|
|
|
// When the user logs out, route back to HomePage. We
|
|
|
|
|
// ReplaceAsync the current top so we don't grow the stack
|
|
|
|
|
// on every logout — otherwise repeated login/logout would
|
|
|
|
|
// eventually balloon the back history.
|
|
|
|
|
sessionStatus.LogoutCompleted += () =>
|
|
|
|
|
{
|
|
|
|
|
var w = (MainWindow)((IClassicDesktopStyleApplicationLifetime)ApplicationLifetime!).MainWindow!;
|
|
|
|
|
var nav = w.NavRoot;
|
|
|
|
|
var hp = provider.GetRequiredService<HomePage>();
|
|
|
|
|
hp.DataContext = provider.GetRequiredService<HomePageViewModel>();
|
|
|
|
|
_ = nav.PopToRootAsync();
|
|
|
|
|
};
|
|
|
|
|
|
PostIt: drop LoginPage, hoist Login into session banner, drop AuthorId field
Login flow no longer needs a dedicated page. The OIDC interactive
login now lives on the persistent SessionStatusBanner, alongside
'Se déconnecter', driven by a new SessionStatusViewModel.LoginAsync
command. On success the VM raises LoginSucceeded and App.axaml.cs
pushes MainPage on top of HomePage — same path BootAsync already
takes when the silent refresh succeeds at boot, so the two flows
can't drift apart (PushMainPageAsync helper, single source of
truth).
MainPage no longer shows an editable AuthorId field: the server
infers the author from the bearer token, so the client-side
control was misleading at best. The detail grid drops from 5 rows
to 4.
Removed:
- Views/LoginPage.axaml + .axaml.cs
- ViewModels/LoginPageViewModel.cs
- HomePage Login button + OnLoginClick code-behind
- DI registrations for LoginPage / LoginPageViewModel
- ViewLocator mapping
- dangling <c>LoginPage*</c> cref / comments in Platform.cs,
PlatformBootstrap.cs (Desktop + Android), MainWindow.axaml,
YavscApiClient.cs
2026-07-07 20:47:00 +01:00
|
|
|
// When the user signs in interactively (Login button on
|
|
|
|
|
// the session banner), push MainPage on top of HomePage.
|
|
|
|
|
sessionStatus.LoginSucceeded += () =>
|
|
|
|
|
{
|
|
|
|
|
var w = (MainWindow)((IClassicDesktopStyleApplicationLifetime)ApplicationLifetime!).MainWindow!;
|
|
|
|
|
_ = PushMainPageAsync(provider, w);
|
|
|
|
|
};
|
|
|
|
|
|
2026-07-08 19:03:54 +01:00
|
|
|
// When the user clicks the "Paramètres" button on the
|
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
|
|
|
// session banner, push the SettingsPage singleton on top
|
|
|
|
|
// of the current navigation stack. The DataContext is
|
|
|
|
|
// already wired at composition time (see the
|
|
|
|
|
// provider.GetRequiredService<SettingsPage>().DataContext
|
|
|
|
|
// assignment above), so this handler is a pure
|
|
|
|
|
// navigation concern.
|
|
|
|
|
//
|
|
|
|
|
// Anti-empilement guard: if the SettingsPage is already
|
|
|
|
|
// at the top of the stack, do nothing. NavigationPage's
|
|
|
|
|
// PushAsync does not deduplicate; calling it twice with
|
|
|
|
|
// the same instance would push it a second time and the
|
|
|
|
|
// user would have to tap Back twice to leave. Reference
|
|
|
|
|
// comparison is correct here because SettingsPage is a
|
|
|
|
|
// singleton — there is exactly one instance to compare
|
|
|
|
|
// against.
|
2026-07-08 19:03:54 +01:00
|
|
|
sessionStatus.OpenSettingsRequested += () =>
|
|
|
|
|
{
|
|
|
|
|
var w = (MainWindow)((IClassicDesktopStyleApplicationLifetime)ApplicationLifetime!).MainWindow!;
|
|
|
|
|
var settingsPage = provider.GetRequiredService<SettingsPage>();
|
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
|
|
|
var stack = w.NavRoot.NavigationStack;
|
|
|
|
|
if (stack.Count > 0 && ReferenceEquals(stack[stack.Count - 1], settingsPage))
|
|
|
|
|
{
|
|
|
|
|
return;
|
|
|
|
|
}
|
2026-07-08 19:03:54 +01:00
|
|
|
_ = w.NavRoot.PushAsync(settingsPage);
|
|
|
|
|
};
|
|
|
|
|
|
2026-06-27 12:47:52 +01:00
|
|
|
window.Opened += async (_, _) => await BootAsync(provider, api, window);
|
2026-05-29 01:29:36 +01:00
|
|
|
}
|
2026-06-26 01:45:21 +01:00
|
|
|
else if (ApplicationLifetime is ISingleViewApplicationLifetime singleView)
|
2026-05-29 01:29:36 +01:00
|
|
|
{
|
2026-06-27 12:47:52 +01:00
|
|
|
singleView.MainView = new MainWindow
|
|
|
|
|
{
|
|
|
|
|
DataContext = provider.GetRequiredService<HomePageViewModel>()
|
|
|
|
|
};
|
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
|
|
|
|
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;
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-27 12:47:52 +01:00
|
|
|
/// <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,
|
|
|
|
|
YavscApiClient api,
|
|
|
|
|
MainWindow window)
|
|
|
|
|
{
|
|
|
|
|
var refreshed = await api.TrySilentLoginAsync().ConfigureAwait(true);
|
|
|
|
|
var sessionStatus = provider.GetRequiredService<SessionStatusViewModel>();
|
|
|
|
|
sessionStatus.Refresh();
|
|
|
|
|
if (!refreshed) return;
|
|
|
|
|
|
PostIt: drop LoginPage, hoist Login into session banner, drop AuthorId field
Login flow no longer needs a dedicated page. The OIDC interactive
login now lives on the persistent SessionStatusBanner, alongside
'Se déconnecter', driven by a new SessionStatusViewModel.LoginAsync
command. On success the VM raises LoginSucceeded and App.axaml.cs
pushes MainPage on top of HomePage — same path BootAsync already
takes when the silent refresh succeeds at boot, so the two flows
can't drift apart (PushMainPageAsync helper, single source of
truth).
MainPage no longer shows an editable AuthorId field: the server
infers the author from the bearer token, so the client-side
control was misleading at best. The detail grid drops from 5 rows
to 4.
Removed:
- Views/LoginPage.axaml + .axaml.cs
- ViewModels/LoginPageViewModel.cs
- HomePage Login button + OnLoginClick code-behind
- DI registrations for LoginPage / LoginPageViewModel
- ViewLocator mapping
- dangling <c>LoginPage*</c> cref / comments in Platform.cs,
PlatformBootstrap.cs (Desktop + Android), MainWindow.axaml,
YavscApiClient.cs
2026-07-07 20:47:00 +01:00
|
|
|
await PushMainPageAsync(provider, window).ConfigureAwait(true);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// <summary>
|
|
|
|
|
/// Resolve a fresh <c>MainPage</c> + VM from DI and push it 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>
|
|
|
|
|
private static async Task PushMainPageAsync(IServiceProvider provider, MainWindow window)
|
|
|
|
|
{
|
2026-06-27 12:47:52 +01:00
|
|
|
var mainVm = provider.GetRequiredService<MainPageViewModel>();
|
|
|
|
|
var mainPage = provider.GetRequiredService<MainPage>();
|
|
|
|
|
mainPage.DataContext = mainVm;
|
PostIt: drop LoginPage, hoist Login into session banner, drop AuthorId field
Login flow no longer needs a dedicated page. The OIDC interactive
login now lives on the persistent SessionStatusBanner, alongside
'Se déconnecter', driven by a new SessionStatusViewModel.LoginAsync
command. On success the VM raises LoginSucceeded and App.axaml.cs
pushes MainPage on top of HomePage — same path BootAsync already
takes when the silent refresh succeeds at boot, so the two flows
can't drift apart (PushMainPageAsync helper, single source of
truth).
MainPage no longer shows an editable AuthorId field: the server
infers the author from the bearer token, so the client-side
control was misleading at best. The detail grid drops from 5 rows
to 4.
Removed:
- Views/LoginPage.axaml + .axaml.cs
- ViewModels/LoginPageViewModel.cs
- HomePage Login button + OnLoginClick code-behind
- DI registrations for LoginPage / LoginPageViewModel
- ViewLocator mapping
- dangling <c>LoginPage*</c> cref / comments in Platform.cs,
PlatformBootstrap.cs (Desktop + Android), MainWindow.axaml,
YavscApiClient.cs
2026-07-07 20:47:00 +01:00
|
|
|
await window.NavRoot.PushAsync(mainPage).ConfigureAwait(true);
|
2026-06-27 12:47:52 +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
|
|
|
private bool TryHandOffCustomSchemeUrl()
|
|
|
|
|
{
|
2026-06-27 12:47:52 +01:00
|
|
|
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
|
|
|
{
|
2026-06-27 12:47:52 +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
|
|
|
}
|
2026-06-27 12:47:52 +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
|
|
|
|
2026-06-10 16:59:23 +01:00
|
|
|
}
|