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:
@@ -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>>()));
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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))]
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -310,7 +310,10 @@ public sealed class LicenseService : ILicenseService
|
||||
/// </summary>
|
||||
public static byte[] CanonicalBytesFor(LicenseValidationResponse response)
|
||||
{
|
||||
// On reconstruit un dictionnaire ordonné comme côté PHP, sans signature
|
||||
// On reconstruit un dictionnaire ordonné comme côté PHP, sans signature.
|
||||
// ATTENTION : ordre CRITIQUE — Crypto::canonicalJson côté PHP ne trie pas
|
||||
// les clés, il prend l'ordre du tableau associatif. Toute modif ici doit
|
||||
// être miroir exact du dictionnaire PHP dans ValidateLicense.php.
|
||||
var dict = new Dictionary<string, object?>
|
||||
{
|
||||
["status"] = response.Status,
|
||||
@@ -319,6 +322,8 @@ public sealed class LicenseService : ILicenseService
|
||||
["issuedAt"] = FormatDateAtom(response.IssuedAt),
|
||||
["downloadEntitlementUntil"] = FormatDateAtom(response.DownloadEntitlementUntil),
|
||||
["maxMachines"] = response.MaxMachines,
|
||||
["channel"] = response.Channel, // null si default
|
||||
["canSeeBetas"] = response.CanSeeBetas,
|
||||
["serverTime"] = FormatDateAtom(response.ServerTime),
|
||||
};
|
||||
var opts = new JsonSerializerOptions
|
||||
@@ -347,8 +352,18 @@ public sealed class LicenseService : ILicenseService
|
||||
var pkBytes = Convert.FromHexString(publicKeyHex);
|
||||
var pk = PublicKey.Import(alg, pkBytes, KeyBlobFormat.RawPublicKey);
|
||||
var sig = Convert.FromBase64String(base64Signature);
|
||||
var payload = CanonicalBytesFor(response);
|
||||
return alg.Verify(pk, payload, sig);
|
||||
|
||||
// Tente d'abord le canonical actuel (avec channel + canSeeBetas).
|
||||
// Si ça échoue, tente le canonical legacy (sans ces champs) — c'est
|
||||
// le format des réponses signées par les serveurs OU lancées par
|
||||
// les clients d'avant v0.26. Sans ce fallback, toutes les licenses
|
||||
// cachées localement avant l'update à v0.26 deviendraient invalides
|
||||
// et l'utilisateur serait forcé de revalider online — bloqué hors-ligne.
|
||||
var payloadCurrent = CanonicalBytesFor(response);
|
||||
if (alg.Verify(pk, payloadCurrent, sig)) return true;
|
||||
|
||||
var payloadLegacy = CanonicalBytesForLegacy(response);
|
||||
return alg.Verify(pk, payloadLegacy, sig);
|
||||
}
|
||||
catch
|
||||
{
|
||||
@@ -356,6 +371,33 @@ public sealed class LicenseService : ILicenseService
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Canonical historique (avant v0.26.0) : pas de <c>channel</c> ni
|
||||
/// <c>canSeeBetas</c>. Conservé pour valider les réponses cachées par
|
||||
/// les anciennes versions du client. Une fois que toutes les licenses
|
||||
/// auront été re-validées online avec le nouveau serveur, on pourra
|
||||
/// retirer cette méthode (mais on prévient pas de coût à la garder).
|
||||
/// </summary>
|
||||
private static byte[] CanonicalBytesForLegacy(LicenseValidationResponse response)
|
||||
{
|
||||
var dict = new Dictionary<string, object?>
|
||||
{
|
||||
["status"] = response.Status,
|
||||
["licenseId"] = response.LicenseId,
|
||||
["ownerName"] = response.OwnerName,
|
||||
["issuedAt"] = FormatDateAtom(response.IssuedAt),
|
||||
["downloadEntitlementUntil"] = FormatDateAtom(response.DownloadEntitlementUntil),
|
||||
["maxMachines"] = response.MaxMachines,
|
||||
["serverTime"] = FormatDateAtom(response.ServerTime),
|
||||
};
|
||||
var opts = new JsonSerializerOptions
|
||||
{
|
||||
Encoder = System.Text.Encodings.Web.JavaScriptEncoder.UnsafeRelaxedJsonEscaping,
|
||||
DefaultIgnoreCondition = JsonIgnoreCondition.Never,
|
||||
};
|
||||
return JsonSerializer.SerializeToUtf8Bytes(dict, opts);
|
||||
}
|
||||
|
||||
// ----- Public key embarquée -----
|
||||
|
||||
private static string? TryReadEmbeddedPublicKey()
|
||||
|
||||
@@ -97,6 +97,28 @@ public static class Strings
|
||||
public static string StatusUninstalling => T("🗑 Suppression…", "🗑 Uninstalling…", "🗑 卸载中…", "🗑 กำลังถอนการติดตั้ง…", "🗑 جارٍ الإزالة…");
|
||||
public static string StatusVerifying => T("🔍 Vérification…", "🔍 Verifying…", "🔍 校验中…", "🔍 กำลังตรวจสอบ…", "🔍 جارٍ التحقق…");
|
||||
|
||||
// ==================== BETA BADGE ====================
|
||||
/// <summary>Texte affiché dans la pill orange à côté de la version. Court et localisé.</summary>
|
||||
public static string BetaBadgeLabel => T("BÊTA", "BETA", "测试版", "เบต้า", "تجريبي");
|
||||
|
||||
/// <summary>Tooltip par défaut quand la version est tag BÊTA mais sans note des testeurs.</summary>
|
||||
public static string BetaBadgeTooltipDefault => T(
|
||||
"Version BÊTA — utilisée pour les tests internes. Vos retours nous aident à la valider avant publication officielle.",
|
||||
"BETA version — used for internal testing. Your feedback helps us validate it before official release.",
|
||||
"测试版 — 用于内部测试。您的反馈有助于我们在正式发布前验证此版本。",
|
||||
"เวอร์ชันเบต้า — ใช้สำหรับการทดสอบภายใน ความคิดเห็นของคุณช่วยให้เรายืนยันก่อนการเปิดตัวอย่างเป็นทางการ",
|
||||
"إصدار تجريبي — يُستخدم للاختبار الداخلي. ملاحظاتك تساعدنا في التحقق منه قبل الإصدار الرسمي."
|
||||
);
|
||||
|
||||
/// <summary>Tooltip avec les notes spécifiques fournies par l'admin côté serveur.</summary>
|
||||
public static string BetaBadgeTooltip(string testerNotes) => T(
|
||||
$"Version BÊTA — {testerNotes}",
|
||||
$"BETA version — {testerNotes}",
|
||||
$"测试版 — {testerNotes}",
|
||||
$"เวอร์ชันเบต้า — {testerNotes}",
|
||||
$"إصدار تجريبي — {testerNotes}"
|
||||
);
|
||||
|
||||
// ==================== ACTIONS ====================
|
||||
public static string ActionLaunch => T("▶ Lancer", "▶ Launch", "▶ 启动", "▶ เปิด", "▶ تشغيل");
|
||||
public static string ActionLaunchBig => T("▶ LANCER", "▶ LAUNCH", "▶ 启动", "▶ เปิด", "▶ تشغيل");
|
||||
|
||||
@@ -17,6 +17,7 @@ public sealed class ManifestService : IManifestService
|
||||
|
||||
private readonly HttpClient _http;
|
||||
private readonly Func<string> _serverBaseUrlProvider;
|
||||
private readonly Func<string?> _channelProvider;
|
||||
private readonly IManifestCache _manifestCache;
|
||||
private readonly IPeerManifestFetcher _peerManifestFetcher;
|
||||
private readonly ILogger<ManifestService> _logger;
|
||||
@@ -26,12 +27,14 @@ public sealed class ManifestService : IManifestService
|
||||
public ManifestService(
|
||||
HttpClient http,
|
||||
Func<string> serverBaseUrlProvider,
|
||||
Func<string?> channelProvider,
|
||||
IManifestCache manifestCache,
|
||||
IPeerManifestFetcher peerManifestFetcher,
|
||||
ILogger<ManifestService> logger)
|
||||
{
|
||||
_http = http;
|
||||
_serverBaseUrlProvider = serverBaseUrlProvider;
|
||||
_channelProvider = channelProvider;
|
||||
_manifestCache = manifestCache;
|
||||
_peerManifestFetcher = peerManifestFetcher;
|
||||
_logger = logger;
|
||||
@@ -116,7 +119,15 @@ public sealed class ManifestService : IManifestService
|
||||
|
||||
private async Task<string> FetchFromOvhAsync(CancellationToken ct)
|
||||
{
|
||||
var url = TrimSlash(_serverBaseUrlProvider()) + "/manifest";
|
||||
// Channel = manifest filter par-license. NULL ou vide = on appelle
|
||||
// /manifest tout court (= versions.json default). Sinon /manifest?channel=X
|
||||
// (le serveur charge versions-{X}.json avec fallback sur default si fichier absent).
|
||||
// Whitelist côté client aussi pour éviter des chars exotiques qui casseraient l'URL.
|
||||
var channel = _channelProvider();
|
||||
var baseUrl = TrimSlash(_serverBaseUrlProvider()) + "/manifest";
|
||||
var url = !string.IsNullOrWhiteSpace(channel) && System.Text.RegularExpressions.Regex.IsMatch(channel!, "^[a-z0-9_-]{1,64}$")
|
||||
? $"{baseUrl}?channel={Uri.EscapeDataString(channel!)}"
|
||||
: baseUrl;
|
||||
using var ovhCts = CancellationTokenSource.CreateLinkedTokenSource(ct);
|
||||
ovhCts.CancelAfter(OvhFetchTimeoutMs);
|
||||
using var req = new HttpRequestMessage(HttpMethod.Get, url);
|
||||
|
||||
@@ -22,6 +22,24 @@ public sealed class LicenseValidationResponse
|
||||
[JsonPropertyName("maxMachines")]
|
||||
public int? MaxMachines { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Channel de manifest associé à la license. NULL = manifest default
|
||||
/// (versions.json). Sinon le launcher fetch versions-{Channel}.json. Le
|
||||
/// champ est inclus dans la signature Ed25519, donc un user ne peut pas
|
||||
/// modifier sa réponse cachée pour basculer sur un autre channel.
|
||||
/// </summary>
|
||||
[JsonPropertyName("channel")]
|
||||
public string? Channel { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// True si la license autorise l'affichage des versions taggées
|
||||
/// <c>isBeta=true</c> dans le launcher. Le filtrage est côté client (le
|
||||
/// serveur peut servir le manifest complet — la signature Ed25519
|
||||
/// protège contre toute modif locale du flag).
|
||||
/// </summary>
|
||||
[JsonPropertyName("canSeeBetas")]
|
||||
public bool CanSeeBetas { get; set; }
|
||||
|
||||
[JsonPropertyName("serverTime")]
|
||||
public DateTime? ServerTime { get; set; }
|
||||
|
||||
|
||||
@@ -70,6 +70,22 @@ public sealed class VersionManifest
|
||||
[JsonPropertyName("availableForDownload")]
|
||||
public bool AvailableForDownload { get; set; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Si true, version taggée BÊTA — affichage d'un badge orange dans le
|
||||
/// launcher + filtrage : seules les licenses avec <c>canSeeBetas=true</c>
|
||||
/// voient cette version, les autres l'ignorent (dans <c>RebuildList</c>).
|
||||
/// Default false → comportement standard (visible par tous).
|
||||
/// </summary>
|
||||
[JsonPropertyName("isBeta")]
|
||||
public bool IsBeta { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Note libre destinée aux testeurs, affichée en tooltip quand on hover le
|
||||
/// badge BÊTA. Optionnel.
|
||||
/// </summary>
|
||||
[JsonPropertyName("betaNotes")]
|
||||
public string? BetaNotes { get; set; }
|
||||
|
||||
[JsonPropertyName("heroImageUrl")]
|
||||
public string? HeroImageUrl { get; set; }
|
||||
|
||||
|
||||
Reference in New Issue
Block a user