yavsc/doc/architecture/postit.md
Paul Schneider c1254a522f
feat(PostIt): add dedicated blog reader and video playback
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-10-16 16:21:57 +01:00

18 KiB

PostIt — Topologie, navigation, DI

Récapitulatif : PostIt est le client Avalonia du projet Yavsc. C'est un code-base unique (src/PostIt/PostIt/PostIt.csproj) multi-cible vers trois front-ends distincts (PostIt.Desktop, Postit.Android, PostIt.Browser). Cette fiche couvre la topologie des projets, le DI, le ViewLocator custo et la navigation — c'est-à-dire tout ce que la fiche postit-oidc.md ne détaille pas déjà (l'OIDC, le flow d'auth, la persistance des tokens). Détail dans cette page, racine de l'architecture : Architecture.md.

Surface : un code-base, trois front-ends

                       ┌────────────────────────┐
                       │ PostIt (lib)           │
                       │ src/PostIt/PostIt/     │
                       │ Pages, ViewModels,     │
                       │ Services, ViewLocator  │
                       │ (aucun rendu natif)    │
                       └──────┬───┬─────┬───────┘
                              │   │     │
              ┌───────────────┘   │     └────────────────┐
              │                   │                      │
   ┌──────────▼────────┐ ┌────────▼─────────┐ ┌──────────▼────────┐
   │ PostIt.Desktop    │ │ PostIt.Android   │ │ PostIt.Browser    │
   │ Avalonia.Desktop  │ │ Avalonia.Android │ │ Avalonia.Browser  │
   │ Linux/Windows     │ │ APK              │ │ WASM              │
   │ + custom scheme   │ │ + Chrome Custom  │ │ (no native proc)  │
   │   postit://       │ │   Tabs           │ │                   │
   │ + IBrowser custo  │ │ + IBrowser custo │ │                   │
   └───────────────────┘ └──────────────────┘ └───────────────────┘

Le code partagé vit dans PostIt/. Chaque front-end est un projet Satellite SDK Avalonia qui ne contient que le Program.Main, le Platform.CreateBrowser, et les manifestes spécifiques (IntentFilter Android, app.manifest Desktop). Toute la logique (VM, services, navigation, settings, OIDC) est dans le code-base partagé.

Lecture des billets et fichiers utilisateur

La page des blogs affiche par défaut une vue de lecture : titre, login de l'auteur, date de modification et contenu Markdown dans une zone défilante de largeur limitée. Modifier ouvre l'éditeur existant et Nouveau billet initialise explicitement un brouillon. Lire affiche le contenu enregistré sans effacer les modifications non enregistrées ; un nouveau billet ne peut pas remplacer un brouillon modifié non sauvé.

La base des ressources est dérivée de Settings.Authentication.Authority (le déploiement Yavsc.Org), jamais de BlogsApiUrl : https://<hôte Org>/files/<Author.UserName>/. Le repli sur AuthorId conserve la compatibilité avec les anciens DTO dépourvus d'auteur détaillé. Un lien photo.jpg ou blogs/42/video.mp4 est relatif à ce répertoire ; un lien /files/paul/photo.jpg conserve son chemin depuis la racine Org. Les liens absolus ne sont pas réécrits. Les images Markdown ![texte](url) sont affichées dans le billet et leur résolveur n'accepte que HTTP(S), sans accès aux fichiers locaux du poste.

Le lecteur utilise Markdown.Avalonia.Tight 12.0.0-a3 (version compatible Avalonia 12) pour déléguer les hyperliens à une commande PostIt. L'éditeur conserve MarkdownViewer.Core : son gestionnaire intégré ouvre le navigateur avant d'émettre l'événement de lien et ne permet donc pas ce routage.

Au clic sur un fichier sous /files/<login>/... de la même origine Org (schéma, hôte et port), BlogResourceService sonde Content-Type avec HEAD, puis un GET limité par Range: bytes=0-0 si le serveur refuse HEAD (405/501). Le corps n'est pas téléchargé pour la détection. Une extension connue sert de repli uniquement en l'absence de type exploitable (type absent ou application/octet-stream). Les erreurs HTTP/réseau sont affichées dans la barre de statut, sans les masquer par une déduction MIME.

Les types video/* ouvrent VideoPage via App.PushPageAsync sur desktop. Cette page contient un LibVLCSharp.Avalonia.VideoView, lecture/pause et une action d'ouverture externe. Le lecteur et ses ressources natives sont arrêtés et libérés à la sortie de la page. LibVLC doit être installé sur le poste (par exemple libvlc5 et les plugins VLC sur Linux ; les bibliothèques natives adaptées sont nécessaires sous Windows/macOS). Une absence de LibVLC ou une erreur de décodage est signalée dans la page. Sur Android et Browser, où ce contrôle natif desktop n'est pas disponible, les vidéos sont ouvertes dans le navigateur, comme les autres fichiers (images liées, PDF, téléchargements) et les liens externes.

ViewLocator custo

Le ViewLocator (cf. src/PostIt/PostIt/ViewLocator.cs) est un IDataTemplate Avalonia explicitement câblé sur le IServiceProvider :

public Control Build(object? data) => data switch
{
    MainPageViewModel   => _services.GetRequiredService<MainPage>(),
    Settings            => _services.GetRequiredService<SettingsPage>(),
    HomePageViewModel   => _services.GetRequiredService<HomePage>(),
    SignaturePageViewModel => _services.GetRequiredService<SignaturePage>(),
    null                => new TextBlock { Text = "No view for <null>" },
    _                   => new TextBlock { Text = $"No view for {data.GetType().Name}" }
};
public bool Match(object? data) => data is ViewModelBase;

Pourquoi un custo, et pas le ViewLocatorBase par défaut d'Avalonia.Mvvm ? Pour deux raisons :

  1. Sortie du Activator.CreateInstance — les pages PostIt sont enregistrées dans le DI et peuvent avoir des dépendances (par construction, aujourd'hui aucune, mais l'extension future est ouverte). Le ViewLocatorBase historique fait new View(), ce qui rend impossible l'injection et complique les tests.
  2. Filtrage par ViewModelBase — Match n'accepte que les types dérivés de ViewModelBase. Toute tentative d'afficher un objet métier (par ex. un DTO de l'API Yavsc) tombe sur le TextBlock "No view for X", pas sur un crash Avalonia.

Le ViewLocator est ajouté aux DataTemplates de l'app dans App.OnFrameworkInitializationCompleted :

DataTemplates.Clear();
DataTemplates.Add(new ViewLocator(provider));

Conséquence pratique : pour qu'une nouvelle page soit affichée par un ContentControl qui binde un ViewModel, il faut deux enregistrements : la page en AddTransient (ou AddSingleton) dans le DI, et une case dans le switch de ViewLocator.Build. Si l'un manque, l'app affiche "No view for X" sans crash.

Composition root (App.axaml.cs)

App.OnFrameworkInitializationCompleted est le seul endroit où le DI est construit. Ordre, dans cet ordre :

  1. new Settings() + settings.Load() — lit ~/.config/PostIt/postit-settings.json (ou le fallback embarqué dans PostIt.dll).
  2. new TokenStore(...) + new YavscApiClient(settings, tokenStore).
  3. new ServiceCollection() + enregistrements en bloc.
  4. services.BuildServiceProvider().
  5. Settings.BindToServiceProvider(provider) — pose le singleton statique pour les helpers hors-DI (Settings.GetCurrent(), Settings.RequireCurrent()).
  6. DataTemplates.Add(new ViewLocator(provider)).
  7. Branche IClassicDesktopStyleApplicationLifetime / ISingleViewApplicationLifetime (Browser/Android).

Enregistrements DI

Service Lifetime Pourquoi
Settings Singleton État partagé (Loaded, IsDirty, Authentication) — doit être unique.
YavscApiClient Singleton Porte le TokenStore et le cache de tokens ; un seul par process.
BlogApiClient Singleton Mapper stateless, partagé.
SettingsPage Singleton Une seule instance pour la vie de l'app : le DataContext est câblé une fois au boot, le push est idempotent (cf. section Garde anti-empilement ci-dessous).
MainPage / HomePage / SignaturePage Transient Résolution à la demande par le ViewLocator.
MainPageViewModel / HomePageViewModel / SignaturePageViewModel Transient VM reconstruites à chaque navigation ; pas d'état partagé à conserver.
SessionStatusViewModel + SessionStatusBanner Singleton + Transient Le VM est un singleton (survit à la navigation), le bandeau est transient (réinstancié quand la fenêtre le recrée).

Invariant : Settings est uniquement un singleton. Un AddTransient<Settings>() supplémentaire (qui réécrase le singleton dans le container) ferait que chaque push de SettingsPage crée une instance vide, casse les bindings Authority/ClientId, et perd toute édition. Si tu dois toucher à cette table, ne pas ajouter de registration pour Settings ailleurs que la ligne AddSingleton(settings).

Défilement de la page des billets

La page BlogsPage utilise une grille à hauteur contrainte : l'en-tête et les commandes restent visibles, tandis que la liste des billets et l'éditeur se partagent l'espace disponible. Un GridSplitter permet de modifier cette répartition. La liste et l'article défilent séparément.

Dans MarkdownEditorControl, AvaloniaEdit gère son propre défilement ; l'aperçu Markdown est placé dans un ScrollViewer. Ne pas limiter la hauteur du MarkdownViewer lui-même : son contenu doit pouvoir dépasser le viewport pour que les barres automatiques s'activent. Ne pas placer cette grille dans un StackPanel vertical ou un ScrollViewer de page, qui lui donnerait une hauteur de mesure illimitée.

Navigation

Le host de navigation est un NavigationPage x:Name="NavRoot" posé sur MainWindow.axaml. La pile est gérée par deux mécanismes distincts :

  1. Nav utilisateur (VM-first) : un ViewModel (souvent dans une commande [RelayCommand]) appelle await ((App)App.Current!).PushPageAsync(targetVm).ConfigureAwait(true);. App.PushPageAsync (src/PostIt/PostIt/App.axaml.cs) résout la Control correspondante via le ViewLocator enregistré dans Application.DataTemplates, l'identifie comme Page, lui assigne le VM comme DataContext, et appelle NavRoot.PushAsync(page). C'est le seul chemin pour les boutons de la toolbar, les OpenSettings / OpenCircles / ManageAcl / OpenSignatureDev, et toute autre nav déclenchée par un ViewModel.

  2. Signaux de cycle de vie : le SessionStatusViewModel lève des événements consommés dans App.OnFrameworkInitializationCompleted pour orchestrer la nav de boot :

    Événement Effet
    LoginSucceeded PushAsync(MainPage) au-dessus de HomePage (post-login).
    LogoutCompleted PopToRootAsync() (revient à HomePage).

    Ces events ne sont pas un canal de nav utilisateur ; ils portent une transition d'état applicatif (authentification établie / perdue) et c'est App qui choisit d'en faire une transition de pile.

Garde anti-empilement

NavigationPage.PushAsync n'est pas idempotent : pousser deux fois la même instance l'empile deux fois, et l'utilisateur doit taper Retour N fois pour sortir. La garde est implémentée dans App.PushPageAsync (et consommée par tous les chemins de nav utilisateur) :

var stack = window.NavRoot.NavigationStack;
if (stack.Count > 0 && ReferenceEquals(stack[stack.Count - 1], page))
{
    return Task.CompletedTask;  // déjà au sommet, no-op silencieux
}
return window.NavRoot.PushAsync(page);

La comparaison est par référence, pas par type : on ne veut empêcher qu'un push de cette instance particulière, pas celui d'une éventuelle autre SettingsPage (il n'en existe qu'une, mais l'invariant est plus clair comme ça). La garde repose sur le fait que SettingsPage est un singleton ; si on repassait en Transient, ReferenceEquals resterait correct mais la pertinence de la garde s'évaporerait (chaque push apporterait une nouvelle instance et l'anti-empilement reposerait sur l'invariant « la même est déjà au sommet », qui ne tiendrait plus).

ViewModels et invariants d'état

  • Settings est un objet-modèle exposé comme DataContext des pages. Il n'hérite pas de ViewModelBase (c'est un POCO [ObservableProperty]-généré par CommunityToolkit.Mvvm). Le fait qu'il soit utilisé comme DataContext est un raccourci de composition acceptable ici, pas un pattern à généraliser.

  • SessionStatusViewModel est le seul VM avec une durée de vie process-entière (singleton). Il survit à toutes les navigations, expose HasValidSession en continu, et porte les événements de cycle de vie consommés par App pour orchestrer la nav de boot (LoginSucceeded, LogoutCompleted). La nav utilisateur déclenchée par l'utilisateur passe par App.PushPageAsync(vm), pas par un événement du SessionStatusViewModel.

  • MainPageViewModel / HomePageViewModel / SignaturePageViewModel sont Transient — une nouvelle instance est créée à chaque push, l'ancienne est libérée quand la page est dépilée. Pas d'état partagé entre occurrences ; pour passer une donnée d'une page à l'autre, on passe par un singleton (souvent YavscApiClient ou Settings).

Bindings XAML : conventions de nommage

Pour les [RelayCommand] (cf. CommunityToolkit.Mvvm), le binding XAML reprend le nom exact de la méthode, sans suffixe :

Méthode C# Binding XAML
Save() {Binding Save}
SaveAsync() {Binding SaveAsync}
LoginCommand() {Binding LoginCommand} (nom littéral, pas de suffixe ajouté)
Clear() {Binding Clear}
CaptureAsync() {Binding CaptureAsync}

JAMAIS SaveCommand, SaveCmd, DoSave, etc. Le source generator [RelayCommand] émet une propriété ICommand du même nom que la méthode. Un binding qui pointe vers une propriété inexistante casse l'app au moment du câblage (le bouton ne se câble pas, et selon la version ça peut faire planter l'init de la page).

Référence canonique : AGENTS.md, section "Avalonia + CommunityToolkit.Mvvm : conventions de binding pour [RelayCommand]".

Pages et leurs rôles

Page DataContext Rôle
MainWindow HomePageViewModel (initial) Host de la NavigationPage.
SessionStatusBanner SessionStatusViewModel Bandeau persistant en haut de la fenêtre, visible sur toutes les pages. Boutons Login / Logout / Paramètres.
HomePage HomePageViewModel Page d'accueil publique.
MainPage MainPageViewModel Éditeur de post de blog (après login).
SignaturePage SignaturePageViewModel Capture de signature (estimateur).
SettingsPage Settings Édition de Authority / ClientId / Scopes / URLs API / Dark mode. Sauver via Save (RelayCommand).

Conséquences pratiques

  • Ajouter une page : créer la View + le ViewModel + enregistrer les deux dans le DI et dans le switch de ViewLocator.Build. Oublier le ViewLocator est silencieux (juste un TextBlock "No view for X"), pas une exception.
  • Ajouter un événement global de navigation (par ex. "Push après payment success") : ne pas capturer MainWindow ni NavigationPage depuis le VM. La nav passe par App.PushPageAsync(vm) dans tous les cas : soit le VM appelle la méthode directement depuis une commande ([RelayCommand]), soit un handler abonné à un événement d'un singleton (cf. SessionStatusViewModel) l'appelle. Garder les VMs découplés du IClassicDesktopStyleApplicationLifetime.
  • Modifier l'OIDC : la fiche à lire est postit-oidc.md, pas celle-ci. Cette fiche ne ré-explique ni le flow, ni le pipe, ni le custom scheme.
  • Modifier les Settings : ne pas casser le singleton (cf. invariant ci-dessus). Toute propriété présentationnelle ajoutée (par ex. ScopeListText) doit porter [JsonIgnore] pour ne pas polluer le format sur disque.

Voir aussi