diff --git a/installer/PSLauncher.iss b/installer/PSLauncher.iss
index b9ec615..4954334 100644
--- a/installer/PSLauncher.iss
+++ b/installer/PSLauncher.iss
@@ -11,7 +11,7 @@
#define MyAppName "PROSERVE Launcher"
#define MyAppShortName "PS_Launcher"
-#define MyAppVersion "1.0.4"
+#define MyAppVersion "1.0.5"
#define MyAppPublisher "ASTERION VR"
#define MyAppURL "https://asterionvr.com"
#define MyAppExeName "PS_Launcher.exe"
diff --git a/server/api/routes/DownloadUrl.php b/server/api/routes/DownloadUrl.php
index 39f1014..7a4babe 100644
--- a/server/api/routes/DownloadUrl.php
+++ b/server/api/routes/DownloadUrl.php
@@ -75,12 +75,36 @@ final class DownloadUrl
Response::error('manifest_missing', 'Manifest absent côté serveur', 500);
}
$manifest = json_decode(file_get_contents($manifestPath), true);
+
+ // Filename attendu, envoyé par le client (extrait de son propre manifest signé).
+ // Sert à disambiguer les manifestes multi-channels où plusieurs entrées
+ // partagent le même numéro de version (ex : proserve-firefighter-1.5.4.32 et
+ // proserve-full-1.5.4.32 sur v1.5.4.32). Sans ce filtre, le foreach ci-dessous
+ // retournait la 1re entrée matchant le numéro → l'URL signée pointait vers le
+ // MAUVAIS ZIP, et le client détectait le mismatch filename manifest vs signé
+ // et abortait (garde-fou côté MainViewModel.InstallVersionAsync). Optionnel
+ // pour rétro-compat avec les vieux clients (v1.0.4-) qui ne l'envoient pas ;
+ // dans ce cas la 1re entrée gagne, comme avant.
+ $expectedFilename = trim((string)($_GET['filename'] ?? ''));
+ // Whitelist défensive — même règle que la validation du filename lu du manifest
+ // plus bas dans cette route. Bloque path traversal via query param.
+ if ($expectedFilename !== '' && !preg_match('/^[a-zA-Z0-9._-]+\.zip$/', $expectedFilename)) {
+ Response::error('invalid_filename', "Query filename invalide : '{$expectedFilename}'", 400);
+ }
+
$entry = null;
foreach ($manifest['versions'] ?? [] as $v) {
- if (($v['version'] ?? '') === $version) { $entry = $v; break; }
+ if (($v['version'] ?? '') !== $version) continue;
+ if ($expectedFilename !== '') {
+ $entryFilename = basename(parse_url((string)($v['download']['url'] ?? ''), PHP_URL_PATH) ?: '');
+ if ($entryFilename !== $expectedFilename) continue;
+ }
+ $entry = $v;
+ break;
}
if (!$entry) {
- Response::error('version_not_found', "Version {$version} absente du manifest", 404);
+ $suffix = $expectedFilename !== '' ? " (filename attendu : {$expectedFilename})" : '';
+ Response::error('version_not_found', "Version {$version} absente du manifest{$suffix}", 404);
}
// Vérif droits téléchargement
diff --git a/src/PSLauncher.App/PSLauncher.App.csproj b/src/PSLauncher.App/PSLauncher.App.csproj
index bec7227..748edba 100644
--- a/src/PSLauncher.App/PSLauncher.App.csproj
+++ b/src/PSLauncher.App/PSLauncher.App.csproj
@@ -18,9 +18,9 @@
PROSERVE Launcher
© 2026 ASTERION VR — All rights reserved
PSLauncher.App
- 1.0.4
- 1.0.4.0
- 1.0.4.0
+ 1.0.5
+ 1.0.5.0
+ 1.0.5.0
true
diff --git a/src/PSLauncher.App/ViewModels/MainViewModel.cs b/src/PSLauncher.App/ViewModels/MainViewModel.cs
index 09cfd49..135a612 100644
--- a/src/PSLauncher.App/ViewModels/MainViewModel.cs
+++ b/src/PSLauncher.App/ViewModels/MainViewModel.cs
@@ -1531,7 +1531,15 @@ public sealed partial class MainViewModel : ObservableObject
else
{
_currentPeerHost = null;
- var signed = await _licenseService.GetSignedDownloadUrlAsync(row.Version, ct);
+ // Extrait le filename attendu depuis l'URL du manifest signé pour le
+ // passer à /download-url. Nécessaire sur les manifestes multi-channels
+ // où plusieurs entrées partagent un numéro de version (firefighter vs
+ // full sur v1.5.4.32) : sans ça le serveur retourne l'URL du 1er match
+ // par numéro → mismatch filename → abort avec l'erreur InvalidOperation
+ // ci-dessous. Rétro-compat : un serveur qui ignore ?filename= retombe
+ // sur son comportement historique.
+ var expectedFilename = Path.GetFileName(new Uri(row.Remote.Download.Url).AbsolutePath);
+ var signed = await _licenseService.GetSignedDownloadUrlAsync(row.Version, expectedFilename, ct);
var urlString = signed ?? row.Remote.Download.Url;
url = new Uri(urlString);
@@ -1573,7 +1581,10 @@ public sealed partial class MainViewModel : ObservableObject
async Task RefreshUrlAsync(CancellationToken c)
{
if (peerSrc is not null) return peerSrc.ZipUrl;
- var fresh = await _licenseService.GetSignedDownloadUrlAsync(row.Version, c);
+ // Passe le filename attendu — le refresh doit re-signer la même
+ // ligne channel qu'à l'origine, pas la 1re entrée par version.
+ var refreshFilename = Path.GetFileName(new Uri(row.Remote.Download.Url).AbsolutePath);
+ var fresh = await _licenseService.GetSignedDownloadUrlAsync(row.Version, refreshFilename, c);
return fresh is null ? null : new Uri(fresh);
}
var job = new DownloadJob(row.Version, url, row.Remote.Download.SizeBytes, row.Remote.Download.Sha256)
diff --git a/src/PSLauncher.Core/Licensing/ILicenseService.cs b/src/PSLauncher.Core/Licensing/ILicenseService.cs
index 4ce4725..c247dbe 100644
--- a/src/PSLauncher.Core/Licensing/ILicenseService.cs
+++ b/src/PSLauncher.Core/Licensing/ILicenseService.cs
@@ -16,8 +16,14 @@ public interface ILicenseService
/// Demande au serveur une URL HMAC-signée valide 1 h pour le ZIP de cette version.
/// Retourne null si l'endpoint n'est pas dispo / pas configuré (ancien serveur),
/// auquel cas l'appelant retombe sur l'URL publique du manifest.
+ /// est le nom du fichier attendu, extrait du
+ /// download.url côté manifest client. Sert au serveur à disambiguer les
+ /// entrées quand plusieurs channels partagent le même numéro de version
+ /// (proserve-firefighter-1.5.4.32 vs proserve-full-1.5.4.32 sur v1.5.4.32).
+ /// Si null ou vide, le serveur retombe sur "1re entrée matchant le numéro"
+ /// (rétro-compat avec les vieux serveurs qui ignorent ce paramètre).
///
- Task GetSignedDownloadUrlAsync(string version, CancellationToken ct);
+ Task GetSignedDownloadUrlAsync(string version, string? expectedFilename, CancellationToken ct);
///
/// Décrypte la clé de license stockée en cache (DPAPI CurrentUser). Retourne null
diff --git a/src/PSLauncher.Core/Licensing/LicenseService.cs b/src/PSLauncher.Core/Licensing/LicenseService.cs
index 4fe2e96..6b072a2 100644
--- a/src/PSLauncher.Core/Licensing/LicenseService.cs
+++ b/src/PSLauncher.Core/Licensing/LicenseService.cs
@@ -266,7 +266,7 @@ public sealed class LicenseService : ILicenseService
return license.CanDownload(version);
}
- public async Task GetSignedDownloadUrlAsync(string version, CancellationToken ct)
+ public async Task GetSignedDownloadUrlAsync(string version, string? expectedFilename, CancellationToken ct)
{
var key = GetDecryptedKey();
if (string.IsNullOrEmpty(key)) return null;
@@ -276,6 +276,13 @@ public sealed class LicenseService : ILicenseService
// PHP. La requête reste en HTTPS donc la clé n'est pas exposée sur le câble.
var encodedKey = Uri.EscapeDataString(key);
var url = TrimSlash(_serverBaseUrlProvider()) + "/download-url/" + version + "?key=" + encodedKey;
+ // Passe le filename attendu pour disambiguer les channels sur les manifests
+ // multi-entrée par version. Si null/empty, le serveur retombe sur "1re entrée
+ // matchant le numéro" (rétro-compat avec les vieux serveurs).
+ if (!string.IsNullOrWhiteSpace(expectedFilename))
+ {
+ url += "&filename=" + Uri.EscapeDataString(expectedFilename);
+ }
try
{
// Timeout court : on est dans le hot path d'Install. Si OVH ne répond pas