Files
PS_Launcher/src/PSLauncher.App/ViewModels/VersionRowViewModel.cs
j.foucher c371a79f93 v0.26.0 — Channels per-license + tag BÊTA sur versions
DEUX features liées :

1. CHANNELS : chaque license peut être attribuée à un manifest distinct
   (« channel »). Permet de servir des versions différentes selon le
   client. Le serveur lit ?channel=X et sert manifest/versions-{X}.json
   avec fallback transparent sur versions.json. NULL = default.

2. BÊTA : nouveau flag isBeta + betaNotes par version dans le manifest.
   Visible uniquement par les licenses avec can_see_betas=1. Affichage
   d'une pill orange « BÊTA » sur la row + tooltip avec les notes pour
   les testeurs. Les installations locales déjà présentes restent
   visibles même si l'accès BÊTA est retiré ensuite (on n'efface pas
   le disque du client).

DB :
  002_channel_betas.sql ajoute channel + can_see_betas sur licenses.
  Idempotent (ALTER TABLE IF NOT EXISTS), zero data migration.

Serveur PHP :
  - ValidateLicense.php signe channel + canSeeBetas dans la réponse
    (ordre des clés CRITIQUE pour matcher le canonical client).
  - Manifest.php : whitelist regex anti-traversal sur ?channel=, fallback
    silencieux sur versions.json si channel inconnu (évite leak de la
    liste de channels par probing).
  - SignManifest.php prend un channel optionnel → l'admin peut signer
    chaque manifest indépendamment.
  - admin/licenses.php : dropdown channel + checkbox bêta sur create,
    bouton détails repliable par-row pour edit.
  - admin/versions.php : channel switcher en tête, badge BÊTA sur chaque
    row, dialog repliable « Bêta » avec checkbox + notes des testeurs.

Client C# :
  - License.Channel + License.CanSeeBetas (dans le canonical signé).
  - VersionManifest.IsBeta + BetaNotes.
  - ManifestService prend un channelProvider via DI, lu depuis license
    cachée à chaque fetch (lazy, pas de circular dep).
  - MainViewModel.RebuildList filtre les versions IsBeta si !CanSeeBetas
    (mais conserve les installées locales — on ne retire pas l'accès
    rétroactivement à ce qui est déjà sur disque).
  - VersionRowViewModel : props IsBeta / BetaNotes / BetaTooltip.
  - MainWindow.xaml : pill orange à côté du n° version pour le featured
    et les rows compactes, tooltip dynamique avec les notes testeurs.

Backward compat signature :
  Anciennes licenses cachées (signées sans channel/canSeeBetas) sont
  toujours validées via un fallback canonical legacy dans VerifySignature.
  Sans ce fallback, le passage à v0.26 invaliderait toutes les caches
  hors-ligne et bloquerait les users en mobilité.

Migration côté admin : jouer 002_channel_betas.sql sur la base, déployer
les fichiers PHP, créer manifest/versions-{channel}.json pour les
nouveaux channels (l'admin versions.php propose un input « Créer/utiliser
un nouveau channel »). Les licenses existantes restent en channel=NULL
= default = comportement actuel.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 07:38:00 +02:00

199 lines
8.1 KiB
C#

using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using PSLauncher.Core.Localization;
using PSLauncher.Models;
namespace PSLauncher.App.ViewModels;
public enum VersionRowState
{
InstalledIdle, // Installée localement, pas d'action en cours
AvailableIdle, // Disponible côté serveur, pas installée
Downloading,
Verifying, // SHA-256 du ZIP téléchargé en cours
Installing,
Uninstalling,
}
public sealed partial class VersionRowViewModel : ObservableObject
{
public string Version { get; }
public DateTime ReleasedAt { get; }
public string ExecutablePath { get; }
public string FolderPath { get; }
public long SizeBytes { get; }
public InstalledVersion? Installed { get; }
public VersionManifest? Remote { get; }
public bool HasReleaseNotes => Remote?.ReleaseNotesUrl is not null;
/// <summary>
/// True si la version est taggée BÊTA dans le manifest (champ
/// <see cref="VersionManifest.IsBeta"/>). Pilote l'affichage du badge
/// orange "BÊTA" + tooltip avec <see cref="BetaNotes"/>.
/// </summary>
public bool IsBeta => Remote?.IsBeta ?? false;
/// <summary>
/// Note des testeurs (texte libre). Affichée en tooltip quand on hover
/// le badge BÊTA. Null si non renseigné côté serveur.
/// </summary>
public string? BetaNotes => Remote?.BetaNotes;
/// <summary>Tooltip composé pour le hover sur le badge BÊTA.</summary>
public string BetaTooltip => string.IsNullOrEmpty(BetaNotes)
? Strings.BetaBadgeTooltipDefault
: Strings.BetaBadgeTooltip(BetaNotes!);
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(StateLabel))]
[NotifyPropertyChangedFor(nameof(IsBusy))]
[NotifyPropertyChangedFor(nameof(IsInstalled))]
[NotifyPropertyChangedFor(nameof(IsRemoteOnly))]
[NotifyPropertyChangedFor(nameof(ShowRestartFromZero))]
[NotifyCanExecuteChangedFor(nameof(LaunchCommand))]
[NotifyCanExecuteChangedFor(nameof(InstallCommand))]
[NotifyCanExecuteChangedFor(nameof(UninstallCommand))]
private VersionRowState _state;
[ObservableProperty] private double _progressPercent;
[ObservableProperty] private string? _progressDetail;
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(InstallButtonLabel))]
[NotifyPropertyChangedFor(nameof(HasResumableDownload))]
[NotifyPropertyChangedFor(nameof(ShowRestartFromZero))]
[NotifyCanExecuteChangedFor(nameof(RestartFromZeroCommand))]
private long _resumableBytes;
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(InstallButtonLabel))]
[NotifyPropertyChangedFor(nameof(LicenseAllowsInstall))]
[NotifyCanExecuteChangedFor(nameof(InstallCommand))]
private bool _licenseAllowsDownload = true;
public bool HasResumableDownload => ResumableBytes > 0;
public bool LicenseAllowsInstall => LicenseAllowsDownload;
/// <summary>
/// Visibilité du bouton rouge « Annuler » à côté de « ↻ Reprendre ».
/// Doit être vrai uniquement quand un partial existe ET qu'aucun DL n'est en
/// cours sur cette row (sinon le bouton « Annuler » se mélange visuellement
/// avec la barre de progression du DL actif, qui a son propre cancel).
/// </summary>
public bool ShowRestartFromZero => HasResumableDownload && State == VersionRowState.AvailableIdle;
public string InstallButtonLabel
{
get
{
if (!LicenseAllowsDownload) return Strings.ActionLicenseInsufficient;
if (!HasResumableDownload) return Strings.ActionInstall;
if (Remote is null || Remote.Download.SizeBytes <= 0) return Strings.ResumeButtonNoPercent;
var pct = (int)Math.Round((double)ResumableBytes / Remote.Download.SizeBytes * 100.0);
return Strings.ResumeButton(pct);
}
}
public bool IsInstalled => State is VersionRowState.InstalledIdle or VersionRowState.Uninstalling;
public bool IsRemoteOnly => State is VersionRowState.AvailableIdle;
public bool IsBusy => State is VersionRowState.Downloading
or VersionRowState.Verifying
or VersionRowState.Installing
or VersionRowState.Uninstalling;
public string StateLabel => State switch
{
VersionRowState.InstalledIdle => Strings.StatusInstalled,
VersionRowState.AvailableIdle => Strings.StatusAvailable,
VersionRowState.Downloading => Strings.StatusDownloading,
VersionRowState.Verifying => Strings.StatusVerifying,
VersionRowState.Installing => Strings.StatusInstalling,
VersionRowState.Uninstalling => Strings.StatusUninstalling,
_ => string.Empty
};
public string PrimaryDate => ReleasedAt > DateTime.MinValue
? Strings.FormatDate(ReleasedAt)
: "—";
public string SizeDisplay => FormatSize(SizeBytes);
/// <summary>Constructeur ligne « installée localement » (peut aussi être présente en remote).</summary>
public static VersionRowViewModel ForInstalled(InstalledVersion installed, VersionManifest? remote)
{
return new VersionRowViewModel(
version: installed.Version,
releasedAt: remote?.ReleasedAt ?? installed.InstalledAt,
executablePath: installed.ExecutablePath,
folderPath: installed.FolderPath,
sizeBytes: installed.SizeBytes,
installed: installed,
remote: remote,
initialState: VersionRowState.InstalledIdle);
}
/// <summary>Constructeur ligne « disponible côté serveur uniquement ».</summary>
public static VersionRowViewModel ForRemote(VersionManifest remote)
{
return new VersionRowViewModel(
version: remote.Version,
releasedAt: remote.ReleasedAt,
executablePath: string.Empty,
folderPath: string.Empty,
sizeBytes: remote.Download.SizeBytes,
installed: null,
remote: remote,
initialState: VersionRowState.AvailableIdle);
}
private VersionRowViewModel(
string version, DateTime releasedAt, string executablePath, string folderPath,
long sizeBytes, InstalledVersion? installed, VersionManifest? remote,
VersionRowState initialState)
{
Version = version;
ReleasedAt = releasedAt;
ExecutablePath = executablePath;
FolderPath = folderPath;
SizeBytes = sizeBytes;
Installed = installed;
Remote = remote;
_state = initialState;
}
// Les commandes sont câblées par le MainViewModel après instanciation
// (les actions ont besoin de services).
public Action<VersionRowViewModel>? LaunchHandler { get; set; }
public Action<VersionRowViewModel>? InstallHandler { get; set; }
public Action<VersionRowViewModel>? UninstallHandler { get; set; }
public Action<VersionRowViewModel>? ShowReleaseNotesHandler { get; set; }
public Action<VersionRowViewModel>? OpenFolderHandler { get; set; }
public Action<VersionRowViewModel>? RestartFromZeroHandler { get; set; }
[RelayCommand(CanExecute = nameof(CanLaunch))]
private void Launch() => LaunchHandler?.Invoke(this);
private bool CanLaunch() => State == VersionRowState.InstalledIdle;
[RelayCommand(CanExecute = nameof(CanInstall))]
private void Install() => InstallHandler?.Invoke(this);
private bool CanInstall() => State == VersionRowState.AvailableIdle && LicenseAllowsDownload;
[RelayCommand(CanExecute = nameof(CanUninstall))]
private void Uninstall() => UninstallHandler?.Invoke(this);
private bool CanUninstall() => State == VersionRowState.InstalledIdle;
[RelayCommand]
private void ShowReleaseNotes() => ShowReleaseNotesHandler?.Invoke(this);
[RelayCommand]
private void OpenFolder() => OpenFolderHandler?.Invoke(this);
[RelayCommand(CanExecute = nameof(CanRestartFromZero))]
private void RestartFromZero() => RestartFromZeroHandler?.Invoke(this);
private bool CanRestartFromZero() => HasResumableDownload;
private static string FormatSize(long bytes)
=> bytes <= 0 ? "—" : Strings.FormatSize(bytes);
}