SteamVR + Vive Business Streaming settings merge :
- Nouveau ISteamVrSettingsDeployer : si _steamvr/steamvr.vrsettings est
dans le ZIP, deep-merge récursif des blocs racine dans le fichier user
(clé absente → ajout, deux objets → recurse, sinon → replace ; les
sous-clés cible non touchées sont préservées — crucial pour "trackers")
- Auto-localisation via registre Steam (HKCU/HKLM) + fallback Program Files
- Phase pré-check (CheckMergeNeededAsync) : skip silencieux si la cible
est déjà à jour (deep equality) → pas de kill SteamVR/VBS inutile, pas
de popup à l'opérateur. Si changements nécessaires → popup OK/Annuler
avec nombre de blocs qui changeront + liste des process à fermer.
- Liste de kill construite depuis les health checks de type "Process"
(l'opérateur connaît déjà sa stack VR) + guardians (Vive Business
Streaming en tête car il relance SteamVR auto)
- Settings UI : section dédiée avec opt-in, override path, liste process
éditable + affichage du path canonique attendu
Résilience téléchargements :
- 404 mid-DL traité comme 403/410 (URL refresh trigger) au niveau segment :
on extrait le nouveau filename via /download-url server-side, retry transparent
- Auto-retry install une fois après refresh manifest sur 404 : l'opérateur
ne voit rien si la cause était un manifest local stale
- Si retry échoue aussi en 404 → message ciblé "manifest serveur stale"
(problème côté serveur, pas client)
- Sur SHA-256 mismatch : auto-purge du cache LAN local (.zip + .sha256)
+ message d'erreur dédié avec source du DL (peer ou OVH) + nouveau menu
"↻ Forcer re-téléchargement" pour purger état + cache manuellement
- Détection client-side de l'incohérence "signed URL filename != manifest
URL filename" avant DL : abort immédiat plutôt que 14 Go pour rien
- IZipCacheStore.InvalidateAsync : nouvelle API pour purger un cache par
version (utilisée par SHA mismatch handler + Force Fresh menu)
Bug serveur (DownloadUrl.php) :
- L'endpoint construisait l'URL signée avec un template hardcodé
`/builds/proserve-{version}.zip`, ignorant complètement
download.url du manifest. Conséquence : un opérateur qui rename
son ZIP pour buster le cache CDN OVH (ex. proserve-full-1.5.4.zip)
voyait toutes ses releases retournent du 404 silencieux côté client.
- Fix : on lit basename(parse_url(entry.download.url).path), whitelist
sur le filename, vérif is_file() avant de signer, erreur 500 explicite
si manifest et filesystem désynchros.
Strings (5 langues) :
- ~20 nouvelles : SteamVR popups + status, SHA mismatch dialog + source
labels, force fresh confirm + menu, manifest stale + auto-retry status
Bumps : 0.29.6 (déployé hors-commit pendant la session) → 0.29.7.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
223 lines
8.6 KiB
C#
223 lines
8.6 KiB
C#
using System.Globalization;
|
|
using Microsoft.Extensions.Logging;
|
|
using PSLauncher.Core.ReportTool;
|
|
using PSLauncher.Models;
|
|
|
|
namespace PSLauncher.Core.DocTool;
|
|
|
|
/// <summary>
|
|
/// Implémentation : copy récursif + atomic rename + backups horodatés.
|
|
/// Strictement parallèle à <see cref="ReportToolDeployer"/> mais avec
|
|
/// <c>_doc/</c> comme source au lieu de <c>_report/</c>. Voir le commentaire
|
|
/// de cette dernière classe pour les détails du mécanisme.
|
|
/// </summary>
|
|
public sealed class DocToolDeployer : IDocToolDeployer
|
|
{
|
|
private const string SourceSubdir = "_doc";
|
|
private const string BackupSuffix = ".backup-";
|
|
private const string TimestampFormat = "yyyyMMdd-HHmmss";
|
|
|
|
private readonly Func<DocToolConfig> _configProvider;
|
|
private readonly ILogger<DocToolDeployer> _logger;
|
|
|
|
public DocToolDeployer(
|
|
Func<DocToolConfig> configProvider,
|
|
ILogger<DocToolDeployer> logger)
|
|
{
|
|
_configProvider = configProvider;
|
|
_logger = logger;
|
|
}
|
|
|
|
public async Task<DeployResult> DeployAsync(
|
|
string installFolder,
|
|
IProgress<DeployProgress>? progress,
|
|
CancellationToken ct)
|
|
{
|
|
var cfg = _configProvider();
|
|
var source = Path.Combine(installFolder, SourceSubdir);
|
|
var target = Path.Combine(cfg.HtdocsRoot, cfg.FolderName);
|
|
|
|
if (!Directory.Exists(source))
|
|
{
|
|
_logger.LogInformation("No {Sub}/ directory in {Folder}, skipping doc deploy", SourceSubdir, installFolder);
|
|
return new DeployResult(DeployStatus.SkippedNoSourceFolder, 0, 0, target);
|
|
}
|
|
|
|
if (!Directory.Exists(cfg.HtdocsRoot))
|
|
{
|
|
// Crée le dossier htdocs si absent (cf. ReportToolDeployer pour le rationale).
|
|
try
|
|
{
|
|
Directory.CreateDirectory(cfg.HtdocsRoot);
|
|
_logger.LogWarning(
|
|
"HtdocsRoot {Root} did not exist, created it. Verify that XAMPP/Apache is configured for this path.",
|
|
cfg.HtdocsRoot);
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logger.LogError(ex, "Could not create HtdocsRoot {Root}", cfg.HtdocsRoot);
|
|
return new DeployResult(DeployStatus.XamppNotFound, 0, 0, target,
|
|
$"Impossible de créer {cfg.HtdocsRoot} : {ex.Message}");
|
|
}
|
|
}
|
|
|
|
var stagingTarget = target + ".new";
|
|
var backupTarget = target + BackupSuffix + DateTime.UtcNow.ToString(TimestampFormat, CultureInfo.InvariantCulture);
|
|
|
|
try
|
|
{
|
|
if (Directory.Exists(stagingTarget)) Directory.Delete(stagingTarget, recursive: true);
|
|
|
|
var allFiles = Directory.EnumerateFiles(source, "*", SearchOption.AllDirectories).ToList();
|
|
int total = allFiles.Count;
|
|
long bytesTotal = 0;
|
|
|
|
await Task.Run(() =>
|
|
{
|
|
Directory.CreateDirectory(stagingTarget);
|
|
int done = 0;
|
|
foreach (var srcFile in allFiles)
|
|
{
|
|
ct.ThrowIfCancellationRequested();
|
|
var rel = Path.GetRelativePath(source, srcFile);
|
|
var dstFile = Path.Combine(stagingTarget, rel);
|
|
var dstDir = Path.GetDirectoryName(dstFile);
|
|
if (!string.IsNullOrEmpty(dstDir) && !Directory.Exists(dstDir))
|
|
Directory.CreateDirectory(dstDir);
|
|
File.Copy(srcFile, dstFile, overwrite: true);
|
|
bytesTotal += new FileInfo(srcFile).Length;
|
|
done++;
|
|
progress?.Report(new DeployProgress(done, total, rel));
|
|
}
|
|
}, ct).ConfigureAwait(false);
|
|
|
|
if (Directory.Exists(target))
|
|
Directory.Move(target, backupTarget);
|
|
Directory.Move(stagingTarget, target);
|
|
|
|
PruneOldBackups(cfg);
|
|
|
|
_logger.LogInformation("Doc tool deployed to {Target} ({Files} files, {Bytes} bytes), backup at {Backup}",
|
|
target, total, bytesTotal, backupTarget);
|
|
return new DeployResult(DeployStatus.Deployed, total, bytesTotal, target);
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logger.LogError(ex, "Failed to deploy doc tool to {Target}", target);
|
|
try { if (Directory.Exists(stagingTarget)) Directory.Delete(stagingTarget, recursive: true); }
|
|
catch { /* ignore */ }
|
|
return new DeployResult(DeployStatus.Failed, 0, 0, target, ex.Message);
|
|
}
|
|
}
|
|
|
|
public Task<IReadOnlyList<BackupInfo>> ListBackupsAsync(CancellationToken ct)
|
|
{
|
|
var cfg = _configProvider();
|
|
var prefix = cfg.FolderName + BackupSuffix;
|
|
|
|
if (!Directory.Exists(cfg.HtdocsRoot))
|
|
return Task.FromResult<IReadOnlyList<BackupInfo>>(Array.Empty<BackupInfo>());
|
|
|
|
var list = new List<BackupInfo>();
|
|
foreach (var dir in Directory.EnumerateDirectories(cfg.HtdocsRoot, prefix + "*", SearchOption.TopDirectoryOnly))
|
|
{
|
|
ct.ThrowIfCancellationRequested();
|
|
var name = Path.GetFileName(dir);
|
|
var tsPart = name.Substring(prefix.Length);
|
|
if (!DateTime.TryParseExact(tsPart, TimestampFormat, CultureInfo.InvariantCulture,
|
|
DateTimeStyles.AssumeUniversal | DateTimeStyles.AdjustToUniversal, out var ts))
|
|
{
|
|
continue;
|
|
}
|
|
long size = 0;
|
|
try
|
|
{
|
|
foreach (var f in Directory.EnumerateFiles(dir, "*", SearchOption.AllDirectories))
|
|
size += new FileInfo(f).Length;
|
|
}
|
|
catch { /* size best-effort */ }
|
|
list.Add(new BackupInfo(name, ts, size));
|
|
}
|
|
list.Sort((a, b) => b.TimestampUtc.CompareTo(a.TimestampUtc));
|
|
return Task.FromResult<IReadOnlyList<BackupInfo>>(list);
|
|
}
|
|
|
|
public async Task<DeployResult> RevertAsync(string backupFolderName, CancellationToken ct)
|
|
{
|
|
var cfg = _configProvider();
|
|
var backupPath = Path.Combine(cfg.HtdocsRoot, backupFolderName);
|
|
var target = Path.Combine(cfg.HtdocsRoot, cfg.FolderName);
|
|
|
|
if (!Directory.Exists(backupPath))
|
|
{
|
|
return new DeployResult(DeployStatus.BackupNotFound, 0, 0, target,
|
|
$"Le backup {backupFolderName} n'existe plus.");
|
|
}
|
|
|
|
var newBackupForCurrent = target + BackupSuffix + DateTime.UtcNow.ToString(TimestampFormat, CultureInfo.InvariantCulture);
|
|
|
|
try
|
|
{
|
|
await Task.Run(() =>
|
|
{
|
|
ct.ThrowIfCancellationRequested();
|
|
if (Directory.Exists(target))
|
|
Directory.Move(target, newBackupForCurrent);
|
|
Directory.Move(backupPath, target);
|
|
}, ct).ConfigureAwait(false);
|
|
|
|
int files = 0;
|
|
long bytes = 0;
|
|
try
|
|
{
|
|
foreach (var f in Directory.EnumerateFiles(target, "*", SearchOption.AllDirectories))
|
|
{
|
|
files++;
|
|
bytes += new FileInfo(f).Length;
|
|
}
|
|
}
|
|
catch { /* best-effort */ }
|
|
|
|
PruneOldBackups(cfg);
|
|
_logger.LogInformation("Reverted doc to backup {Backup} ({Files} files), previous current saved as {New}",
|
|
backupFolderName, files, newBackupForCurrent);
|
|
return new DeployResult(DeployStatus.Deployed, files, bytes, target);
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logger.LogError(ex, "Doc revert to {Backup} failed", backupFolderName);
|
|
return new DeployResult(DeployStatus.Failed, 0, 0, target, ex.Message);
|
|
}
|
|
}
|
|
|
|
private void PruneOldBackups(DocToolConfig cfg)
|
|
{
|
|
try
|
|
{
|
|
var prefix = cfg.FolderName + BackupSuffix;
|
|
var dirs = Directory.EnumerateDirectories(cfg.HtdocsRoot, prefix + "*", SearchOption.TopDirectoryOnly)
|
|
.Select(d => new { Path = d, Name = Path.GetFileName(d) })
|
|
.OrderByDescending(d => d.Name, StringComparer.Ordinal)
|
|
.ToList();
|
|
|
|
int keep = Math.Max(0, cfg.MaxBackups);
|
|
for (int i = keep; i < dirs.Count; i++)
|
|
{
|
|
try
|
|
{
|
|
Directory.Delete(dirs[i].Path, recursive: true);
|
|
_logger.LogDebug("Pruned old doc backup {Dir}", dirs[i].Name);
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logger.LogDebug(ex, "Could not prune doc backup {Dir} (will retry next deploy)", dirs[i].Name);
|
|
}
|
|
}
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logger.LogDebug(ex, "Doc backup pruning skipped");
|
|
}
|
|
}
|
|
}
|