postit: traceable OIDC login UX + session persistence + 2nd-instance early exit

- OidcLoginPhase enum + IProgress<OidcLoginPhase> on
  YavscApiClient.LoginInteractiveAsync, surfaced in the UI as
  PhaseLabel (FR). Lets operators see where the flow actually
  stalls, in particular whether the postit://callback ever arrives
  on the running instance.
- YavscApiClient.TrySilentLoginAsync: silent refresh at boot.
  Returns false (and purges the store) when the refresh token is
  rejected by the OP.
- App.OnFrameworkInitializationCompleted auto-routes: HomePage is
  the navigation root; on Opened the app calls TrySilentLoginAsync
  and pushes MainPage if a session is restored. Logout pops back
  to HomePage via the new persistent SessionStatusBanner (Connecté
  / Déconnecté + Logout button).
- PostIt.Desktop.Program.Main now detects the postit://callback
  URL BEFORE Avalonia boots, hands it off via SingleInstance, and
  exits. Stops the 2nd PostIt instance from flashing its own
  MainWindow while the 1st instance is still waiting on the named
  pipe. The check in App.OnFrameworkInitializationCompleted is
  kept as belt-and-braces defence-in-depth.
- SchemeUrlDetector: pure platform-independent detector extracted
  for unit testing.
- Tests: 7 new SchemeUrlDetectorTests + 5 new phase / silent
  refresh tests in YavscApiClientTests.
This commit is contained in:
Paul Schneider 2026-06-27 12:47:52 +01:00
commit 514549c5f9
14 changed files with 804 additions and 42 deletions

View file

@ -1,5 +1,7 @@
using System;
using System.Threading;
using Avalonia;
using PostIt.Services;
namespace PostIt.Desktop;
@ -12,10 +14,57 @@ sealed class Program
public static void Main(string[] args)
{
PlatformBootstrap.EnsureInitialized();
// Short-circuit 2nd-instance launches (OS handing us the
// postit://callback URL) BEFORE Avalonia spins up a window.
// If we let Avalonia initialise, the new MainWindow flashes
// open for a frame before OnFrameworkInitializationCompleted
// detects the scheme and shuts down — visible to the user as
// a second window with "Déconnecté" while the original
// instance is still waiting on the named pipe.
//
// We only need the scheme prefix and the OS-supplied URL,
// both of which are plain System.* / PostIt.Services —
// nothing Avalonia-specific is touched here, so the rule
// above is not violated.
if (TryHandOffCustomSchemeUrl(args)) return;
BuildAvaloniaApp()
.StartWithClassicDesktopLifetime(args);
}
/// <summary>
/// Detect a 2nd-instance launch (OS dispatching the postit:// URL
/// after the user completed login in the system browser), forward
/// the URL to the running instance over the named pipe, and exit
/// before Avalonia can open a window. Returns true when the
/// process should terminate without booting Avalonia.
/// </summary>
private static bool TryHandOffCustomSchemeUrl(string[] args)
{
var url = SchemeUrlDetector.FindCallbackUrl(args);
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 (user double-clicked the link after closing
// PostIt), there's no 1st instance to forward to — we
// exit cleanly anyway rather than booting a stray
// PostIt window that would just confuse the user.
try
{
SingleInstance.TryHandOffAsync(url).GetAwaiter().GetResult();
}
catch
{
// Pipe errors are non-fatal for the 2nd-instance
// hand-off — we still want to exit cleanly.
}
Environment.Exit(0);
return true;
}
// Avalonia configuration, don't remove; also used by visual designer.
public static AppBuilder BuildAvaloniaApp()
=> AppBuilder.Configure<App>()