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>
This commit is contained in:
2026-05-05 07:38:00 +02:00
parent 3d691c3e38
commit c371a79f93
17 changed files with 510 additions and 32 deletions

View File

@@ -179,6 +179,11 @@ public partial class App : Application
new ManifestService(
sp.GetRequiredService<HttpClient>(),
() => sp.GetRequiredService<LocalConfig>().ServerBaseUrl,
// Channel provider : lit le channel depuis la license cachée à
// chaque fetch (sp.GetRequiredService est lazy, donc pas de
// dépendance circulaire avec ILicenseService au démarrage).
// Si pas de license cachée → null → manifest default.
() => sp.GetRequiredService<ILicenseService>().GetCached()?.Channel,
sp.GetRequiredService<IManifestCache>(),
sp.GetRequiredService<IPeerManifestFetcher>(),
sp.GetRequiredService<ILogger<ManifestService>>()));

View File

@@ -18,9 +18,9 @@
<Product>PROSERVE Launcher</Product>
<Copyright>© 2026 ASTERION VR — All rights reserved</Copyright>
<RootNamespace>PSLauncher.App</RootNamespace>
<Version>0.25.10</Version>
<AssemblyVersion>0.25.10.0</AssemblyVersion>
<FileVersion>0.25.10.0</FileVersion>
<Version>0.26.0</Version>
<AssemblyVersion>0.26.0.0</AssemblyVersion>
<FileVersion>0.26.0.0</FileVersion>
<!-- Single-file self-contained publish profile (used by `dotnet publish`) -->
<PublishSingleFile>true</PublishSingleFile>

View File

@@ -513,7 +513,17 @@ public sealed partial class MainViewModel : ObservableObject
&& oldActive.State is VersionRowState.Downloading or VersionRowState.Verifying;
var installed = _registry.Scan().ToDictionary(v => v.Version);
var remote = _lastManifest?.Versions ?? new List<VersionManifest>();
// Filtrage BÊTA : si la license n'a pas le flag canSeeBetas, on cache
// les versions taggées isBeta=true. Les installations locales déjà
// présentes ne sont pas filtrées (un client qui avait installé une
// bêta puis perd l'accès continue de la voir et de pouvoir la lancer
// — on n'efface pas son disque).
var canSeeBetas = _license?.CanSeeBetas ?? false;
var rawRemote = _lastManifest?.Versions ?? new List<VersionManifest>();
var remote = canSeeBetas
? rawRemote
: rawRemote.Where(v => !v.IsBeta).ToList();
var remoteByVer = remote.ToDictionary(v => v.Version);
var allVersions = installed.Keys.Union(remoteByVer.Keys)

View File

@@ -26,6 +26,24 @@ public sealed partial class VersionRowViewModel : ObservableObject
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))]

View File

@@ -85,6 +85,17 @@
FontSize="15" FontWeight="SemiBold"
VerticalAlignment="Center"
Foreground="{StaticResource Brush.Text.Primary}" />
<!-- Badge BÊTA pour les rows secondaires (compact). Même look
que celui du featured mais en plus petit pour matcher la row. -->
<Border CornerRadius="8" Padding="6,2" Margin="10,0,0,0"
VerticalAlignment="Center"
Background="#F59E0B"
ToolTip="{Binding BetaTooltip}"
Visibility="{Binding IsBeta, Converter={StaticResource BoolToVisibility}}">
<TextBlock Text="{x:Static loc:Strings.BetaBadgeLabel}"
FontSize="10" FontWeight="Bold"
Foreground="#1A0A00" />
</Border>
<Border CornerRadius="10" Padding="8,3" Margin="12,0,0,0"
VerticalAlignment="Center">
<Border.Style>
@@ -472,6 +483,18 @@
<TextBlock Text="{Binding FeaturedVersion.Version, StringFormat='PROSERVE v{0}'}"
FontSize="32" FontWeight="Bold"
Foreground="{StaticResource Brush.Text.Primary}" />
<!-- Badge BÊTA : pill orange à côté de la version. Visible
uniquement quand isBeta=true dans le manifest. Tooltip
dynamique avec les notes des testeurs s'il y en a. -->
<Border CornerRadius="10" Padding="8,3" Margin="12,8,0,0"
VerticalAlignment="Center"
Background="#F59E0B"
ToolTip="{Binding FeaturedVersion.BetaTooltip}"
Visibility="{Binding FeaturedVersion.IsBeta, Converter={StaticResource BoolToVisibility}}">
<TextBlock Text="{x:Static loc:Strings.BetaBadgeLabel}"
FontSize="11" FontWeight="Bold"
Foreground="#1A0A00" />
</Border>
<Border CornerRadius="12" Padding="10,4" Margin="16,8,0,0"
VerticalAlignment="Center">
<Border.Style>