v0.28.10 — Mode auto + settings lock par license + fix post-install state
Mode auto (auto-launch d'une version désignée au démarrage et après exit) : - Master toggle + version désignée via bouton AUTO (orange) sur chaque row - Countdown 5 s configurable avec popup d'annulation (StartAutoLaunchCountdown) - Args CLI passés à PROSERVE : opérateur tape juste le nom (autoconnect, login), le launcher ajoute -/=/quotes. Format produit identique à un .bat. Fix : passage de ArgumentList à Arguments string pour éviter le quotage auto .NET sur les args contenant '=' (cassait FParse::Value). - Garde-fou anti double-lancement : refuse de lancer si _runningProserve vivant OU IProcessLauncher.IsRunning() détecte une instance externe. Silent pour les triggers auto, popup pour les clics manuels. - Option « Attendre santé système » : countdown différé tant que les health checks ne sont pas tous OK (phase 1 du dialog avec liste des pending). Settings lock par license (remplace l'ancien lock global manifest) : - Colonne settings_lock_password_hash sur table licenses (migration 003) - Backoffice : bouton 🔒 par license pour set/clear hash SHA-256 - Payload /license/validate inclut settingsLockPasswordHash (v3 canonique) - 3-tier fallback signature : v3 → v2 → legacy pour compat cache offline - SettingsLockService : in-memory unlock state, gate l'expander Avancés - SettingsLockDialog : prompt mdp, persiste déverrouillé jusqu'au restart Fix post-install : la row restait en visuel « Installing » jusqu'au prochain Check Updates. Reset explicite row.State=InstalledIdle + _activeRow=null AVANT le RebuildList post-install pour purger l'instance orpheline. Migrations : - 002_channel_betas.sql réécrit en ALTER simples (DELIMITER cassait migrate.php qui split sur ';\n') - migrate.php tolère « Duplicate column/key name » comme idempotent Strings (5 langues, ~25 nouvelles) : - Renomme « Relance automatique » → « Lancement automatique » (dialog utilisé pour les 3 entry points : clic, startup, post-exit) - Tooltips auto-mode, settings lock prompts, health wait phase, etc. Bumps : 0.27.4 → 0.28.10 (csproj + .iss). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -80,6 +80,17 @@ public sealed partial class MainViewModel : ObservableObject
|
||||
/// </summary>
|
||||
private Task? _activeInstallTask;
|
||||
|
||||
/// <summary>
|
||||
/// Référence au process PROSERVE lancé par le launcher (la PLUS RÉCENTE).
|
||||
/// Sert de garde-fou contre les double-lancements : avant chaque
|
||||
/// <see cref="LaunchVersion"/>, on vérifie que ce champ est null ou que le
|
||||
/// process a terminé. Le hook Exited remet le champ à null pour autoriser
|
||||
/// le prochain lancement (manuel ou auto). Couvre uniquement les instances
|
||||
/// LANCÉES PAR NOUS — pour détecter une instance lancée hors launcher, on
|
||||
/// délègue à <see cref="IProcessLauncher.IsRunning(InstalledVersion)"/>.
|
||||
/// </summary>
|
||||
private System.Diagnostics.Process? _runningProserve;
|
||||
|
||||
public ObservableCollection<VersionRowViewModel> OtherVersions { get; } = new();
|
||||
|
||||
/// <summary>
|
||||
@@ -430,6 +441,14 @@ public sealed partial class MainViewModel : ObservableObject
|
||||
},
|
||||
Application.Current.Dispatcher);
|
||||
_lanInfoRefreshTimer.Start();
|
||||
|
||||
// Mode auto : si une version est désignée + feature activée, on la lance
|
||||
// automatiquement après que la UI soit prête (Dispatcher.BeginInvoke différé
|
||||
// pour que MainWindow ait fini son OnLoaded). Le grace period via popup modal
|
||||
// sera affiché par-dessus la fenêtre normalement.
|
||||
Application.Current.Dispatcher.BeginInvoke(
|
||||
new Action(TryAutoLaunchAtStartup),
|
||||
System.Windows.Threading.DispatcherPriority.Background);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -607,6 +626,72 @@ public sealed partial class MainViewModel : ObservableObject
|
||||
row.ShowReleaseNotesHandler = r => _ = ShowReleaseNotesAsync(r);
|
||||
row.OpenFolderHandler = OpenVersionFolder;
|
||||
row.RestartFromZeroHandler = r => _ = RestartFromZeroAsync(r);
|
||||
row.ToggleAutoModeHandler = ToggleAutoModeForRow;
|
||||
|
||||
// Hydrate l'état mode auto depuis la config persistée (à chaque RebuildList)
|
||||
row.ShowAutoButton = _config.AutoMode.FeatureEnabled && row.IsInstalled;
|
||||
row.IsAutoModeSelected = string.Equals(
|
||||
_config.AutoMode.SelectedVersion, row.Version, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Click sur le bouton AUTO d'une row. Toggle l'état sur cette row uniquement,
|
||||
/// désélectionne toutes les autres (unicité), persiste la config. Si on toggle
|
||||
/// OFF la version actuellement sélectionnée, SelectedVersion devient null
|
||||
/// (= mode auto inactif mais feature toujours activée pour permettre une nouvelle
|
||||
/// sélection plus tard).
|
||||
/// </summary>
|
||||
private void ToggleAutoModeForRow(VersionRowViewModel row)
|
||||
{
|
||||
// Trace fichier (en plus du Serilog principal) pour pouvoir diagnostiquer le
|
||||
// path d'invocation depuis le bouton AUTO sans dépendre du logger DI.
|
||||
try
|
||||
{
|
||||
var dir = System.IO.Path.Combine(
|
||||
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
|
||||
"PSLauncher", "logs");
|
||||
System.IO.Directory.CreateDirectory(dir);
|
||||
System.IO.File.AppendAllText(
|
||||
System.IO.Path.Combine(dir, "auto-mode.log"),
|
||||
$"{DateTime.UtcNow:yyyy-MM-dd HH:mm:ss.fff}Z ToggleAutoModeForRow v{row.Version} (FeatureEnabled={_config.AutoMode.FeatureEnabled}, IsInstalled={row.IsInstalled}, current SelectedVersion={_config.AutoMode.SelectedVersion}){Environment.NewLine}");
|
||||
}
|
||||
catch { }
|
||||
|
||||
if (!_config.AutoMode.FeatureEnabled)
|
||||
{
|
||||
_logger.LogInformation("Auto-mode toggle ignored : FeatureEnabled=false");
|
||||
return;
|
||||
}
|
||||
if (!row.IsInstalled)
|
||||
{
|
||||
_logger.LogInformation("Auto-mode toggle ignored : v{Version} pas installée", row.Version);
|
||||
return;
|
||||
}
|
||||
|
||||
var newSelected = !row.IsAutoModeSelected;
|
||||
|
||||
// Désélectionne toutes les autres
|
||||
if (FeaturedVersion is not null)
|
||||
FeaturedVersion.IsAutoModeSelected = false;
|
||||
foreach (var r in OtherVersions)
|
||||
r.IsAutoModeSelected = false;
|
||||
|
||||
// Sélectionne celle-ci (ou laisse désélectionnée si on a toggle OFF)
|
||||
row.IsAutoModeSelected = newSelected;
|
||||
_config.AutoMode.SelectedVersion = newSelected ? row.Version : null;
|
||||
_configStore.Save(_config);
|
||||
_logger.LogInformation("Auto-mode selection: {Version}",
|
||||
newSelected ? row.Version : "(none)");
|
||||
|
||||
// Si on vient d'activer le mode auto sur cette version, déclenche immédiatement
|
||||
// le countdown + lancement. Différé via Dispatcher.BeginInvoke pour que le
|
||||
// bouton ait le temps de repeindre en orange AVANT que la modal apparaisse.
|
||||
if (newSelected)
|
||||
{
|
||||
Application.Current.Dispatcher.BeginInvoke(
|
||||
new Action(() => StartAutoLaunchCountdown(row)),
|
||||
System.Windows.Threading.DispatcherPriority.Background);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -897,9 +982,48 @@ public sealed partial class MainViewModel : ObservableObject
|
||||
private void LaunchVersion(VersionRowViewModel row)
|
||||
{
|
||||
if (row.Installed is null) return;
|
||||
|
||||
// ---- Garde-fou anti double-lancement ----
|
||||
// Deux niveaux de vérif pour couvrir tous les cas :
|
||||
// 1. _runningProserve : process LANCÉ PAR NOUS encore vivant ⇒ silently
|
||||
// refuse (cas typique : exit event glitché, ou double-clic du bouton
|
||||
// LANCER, ou auto-mode qui re-déclenche pendant qu'on tourne déjà).
|
||||
// 2. _processLauncher.IsRunning(row.Installed) : scan système pour
|
||||
// détecter une instance lancée HORS launcher (ex. opérateur a
|
||||
// double-cliqué le .exe dans l'explorateur).
|
||||
// En cas de match, on log + popup. La popup n'apparaît PAS pour les
|
||||
// déclenchements auto-mode (silently skip) pour ne pas spammer.
|
||||
if (IsAnyProserveAlreadyRunning(row.Installed, out var detail))
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"Refusing to launch PROSERVE v{Version} : an instance is already running ({Detail})",
|
||||
row.Version, detail);
|
||||
// Distinction manuel vs auto-mode : pour un clic LANCER manuel,
|
||||
// l'opérateur doit savoir pourquoi rien ne se passe ⇒ popup.
|
||||
// Pour un auto-trigger (countdown finish, exit retrigger…), silently
|
||||
// skip suffit — pas d'interaction utilisateur en cours.
|
||||
if (!_isAutoLaunchInFlight)
|
||||
{
|
||||
ThemedMessageBox.Show(
|
||||
Strings.MsgAlreadyRunningBody,
|
||||
Strings.MsgAlreadyRunningTitle,
|
||||
MessageBoxButton.OK, MessageBoxImage.Warning);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var proc = _processLauncher.Launch(row.Installed);
|
||||
// Si on lance la version désignée comme "auto", on passe les CLI args
|
||||
// configurés (ex: -autoconnect -login=Jerome2 -password=… -ipserver=10.0.4.100).
|
||||
// Pour les lancements manuels d'autres versions, args = null (comportement standard).
|
||||
var isAutoVersion = _config.AutoMode.FeatureEnabled
|
||||
&& !string.IsNullOrEmpty(_config.AutoMode.SelectedVersion)
|
||||
&& string.Equals(_config.AutoMode.SelectedVersion, row.Version, StringComparison.Ordinal);
|
||||
string[]? cliArgs = isAutoVersion ? BuildAutoModeCliArgs(_config.AutoMode.Args) : null;
|
||||
|
||||
var proc = _processLauncher.Launch(row.Installed, cliArgs);
|
||||
_runningProserve = proc;
|
||||
_config.LastLaunchedVersion = row.Version;
|
||||
_configStore.Save(_config);
|
||||
|
||||
@@ -917,6 +1041,13 @@ public sealed partial class MainViewModel : ObservableObject
|
||||
_logger.LogInformation("PROSERVE v{Version} a quitté — restauration du launcher", row.Version);
|
||||
Application.Current?.Dispatcher.Invoke(() =>
|
||||
{
|
||||
// Libère le slot AVANT de potentiellement relancer en mode auto
|
||||
// (sinon le garde-fou ci-dessus refuserait le nouveau launch).
|
||||
// Compare la référence pour éviter de nuller un process plus récent
|
||||
// dans un scénario rare où le user a relancé manuellement entre temps.
|
||||
if (ReferenceEquals(_runningProserve, proc))
|
||||
_runningProserve = null;
|
||||
|
||||
if (Application.Current.MainWindow is { } main)
|
||||
{
|
||||
if (main.WindowState == WindowState.Minimized)
|
||||
@@ -926,23 +1057,252 @@ public sealed partial class MainViewModel : ObservableObject
|
||||
main.Topmost = true;
|
||||
main.Topmost = false;
|
||||
}
|
||||
// Si la version qui vient de quitter est la version auto, on
|
||||
// déclenche le countdown de relance. L'opérateur peut annuler
|
||||
// (désactive le mode auto sur cette session) ou laisser le timer
|
||||
// expirer pour relancer.
|
||||
if (isAutoVersion)
|
||||
StartAutoLaunchCountdown(row);
|
||||
});
|
||||
};
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
// ShellExecute peut renvoyer un Process sans accès aux events — non bloquant
|
||||
// ShellExecute peut renvoyer un Process sans accès aux events — non bloquant.
|
||||
// Conséquence : _runningProserve ne sera jamais nullé via Exited et resterait
|
||||
// bloquant. On clear immédiatement pour ne pas verrouiller les futurs launches
|
||||
// (au pire on perd la protection sur cette instance, mais IsRunning() scanne
|
||||
// le système et catchera quand même les doublons).
|
||||
_logger.LogDebug(ex, "Could not hook Exited on launched process");
|
||||
_runningProserve = null;
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Launch failed");
|
||||
_runningProserve = null;
|
||||
ThemedMessageBox.Show(Strings.MsgLaunchFailed(ex.Message),
|
||||
Strings.MsgBoxLaunchError, MessageBoxButton.OK, MessageBoxImage.Error);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// True quand un lancement auto-mode est en cours de déclenchement (countdown
|
||||
/// en cours OU appel <see cref="LaunchVersion"/> issu du dialog). Sert à
|
||||
/// rendre le garde-fou silencieux pour les triggers auto (sinon une popup
|
||||
/// "déjà lancé" apparaîtrait à chaque tick d'un exit-event glitché en mode
|
||||
/// auto). Toggled par <see cref="StartAutoLaunchCountdown"/>.
|
||||
/// </summary>
|
||||
private bool _isAutoLaunchInFlight;
|
||||
|
||||
/// <summary>
|
||||
/// True si une instance PROSERVE tourne déjà — soit lancée par nous
|
||||
/// (<see cref="_runningProserve"/> encore vivant), soit lancée hors launcher
|
||||
/// et détectée via <see cref="IProcessLauncher.IsRunning"/>. Le paramètre
|
||||
/// <paramref name="detail"/> est rempli avec le motif de blocage pour les logs.
|
||||
/// </summary>
|
||||
private bool IsAnyProserveAlreadyRunning(InstalledVersion target, out string detail)
|
||||
{
|
||||
// 1) Notre process tracking
|
||||
if (_runningProserve is { } p)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (!p.HasExited)
|
||||
{
|
||||
detail = $"_runningProserve PID={p.Id} still alive";
|
||||
return true;
|
||||
}
|
||||
// Process a terminé mais Exited n'a pas (encore) fired ⇒ on
|
||||
// libère le slot et continue à vérifier le système.
|
||||
_runningProserve = null;
|
||||
}
|
||||
catch (InvalidOperationException)
|
||||
{
|
||||
// Process déjà disposé → considère terminé
|
||||
_runningProserve = null;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
// Sandboxing ou perte d'accès au process : on ne peut pas savoir.
|
||||
// Mieux vaut être permissif (laisser passer) que bloquer à tort.
|
||||
_logger.LogDebug(ex, "Could not poll _runningProserve.HasExited, treating as exited");
|
||||
_runningProserve = null;
|
||||
}
|
||||
}
|
||||
|
||||
// 2) Scan système pour les instances externes (ou nôtres dont on a perdu
|
||||
// le handle, ex. après un crash launcher partiel).
|
||||
try
|
||||
{
|
||||
if (_processLauncher.IsRunning(target))
|
||||
{
|
||||
detail = $"system scan detected running {Path.GetFileName(target.ExecutablePath)}";
|
||||
return true;
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogDebug(ex, "IsRunning() scan failed, treating as not running");
|
||||
}
|
||||
|
||||
detail = string.Empty;
|
||||
return false;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Convertit la liste <see cref="AutoModeConfig.Args"/> en string[] format CLI
|
||||
/// compatible Unreal Engine (FParse::Param et FParse::Value).
|
||||
///
|
||||
/// L'opérateur saisit la liste de paires {Key, Value} sans avoir à se soucier
|
||||
/// de la syntaxe : il tape <c>autoconnect</c> (sans tiret) et <c>login</c> /
|
||||
/// <c>Jerome</c>. Le launcher se charge d'ajouter les <c>-</c>, <c>=</c> et
|
||||
/// le quotage des valeurs avec espaces.
|
||||
///
|
||||
/// Règles de formatage produites :
|
||||
/// • Value vide → <c>-{key}</c> (flag : FParse::Param)
|
||||
/// • Value sans espace → <c>-{key}={value}</c> (FParse::Value classique)
|
||||
/// • Value avec espace → <c>-{key}="value spaced"</c> (quotage Win32)
|
||||
///
|
||||
/// Défensif : on strip un éventuel <c>-</c> en tête de Key et un <c>=</c> en
|
||||
/// tête de Value au cas où l'opérateur les a saisis dans l'UI ("au cas où" =
|
||||
/// retour utilisateur : certains tapent <c>-login</c> ou <c>=Jerome</c> par
|
||||
/// réflexe et obtenaient <c>--login</c> ou <c>-login==Jerome</c> à l'exécution).
|
||||
/// Les clés vides sont skippées silencieusement.
|
||||
/// </summary>
|
||||
private static string[] BuildAutoModeCliArgs(IEnumerable<AutoModeArg> args)
|
||||
{
|
||||
var list = new List<string>();
|
||||
foreach (var a in args)
|
||||
{
|
||||
// Sanitize Key : trim whitespace + leading dashes éventuels.
|
||||
var key = (a.Key ?? string.Empty).Trim().TrimStart('-').Trim();
|
||||
if (string.IsNullOrEmpty(key)) continue;
|
||||
|
||||
// Sanitize Value : trim whitespace + un éventuel `=` en tête.
|
||||
var val = (a.Value ?? string.Empty).Trim().TrimStart('=').Trim();
|
||||
|
||||
if (string.IsNullOrEmpty(val))
|
||||
{
|
||||
list.Add($"-{key}");
|
||||
}
|
||||
else if (val.IndexOfAny(new[] { ' ', '\t', '"' }) >= 0)
|
||||
{
|
||||
// Quote + escape les " internes (rare mais possible pour un mdp).
|
||||
var escaped = val.Replace("\"", "\\\"");
|
||||
list.Add($"-{key}=\"{escaped}\"");
|
||||
}
|
||||
else
|
||||
{
|
||||
list.Add($"-{key}={val}");
|
||||
}
|
||||
}
|
||||
return list.ToArray();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Point d'entrée UNIQUE pour le lancement auto avec countdown. Utilisé par :
|
||||
/// 1. Clic sur bouton AUTO → désigne la version + lance après countdown
|
||||
/// 2. Startup du launcher → si une version était désignée, idem countdown + launch
|
||||
/// 3. Exit de PROSERVE en mode auto → countdown + relance
|
||||
///
|
||||
/// Comportement :
|
||||
/// - Affiche un popup modal avec compte-à-rebours (3-5 s configurables)
|
||||
/// - Countdown expire → <see cref="LaunchVersion"/> avec args CLI mode auto
|
||||
/// - Annuler → désactive le mode auto (<c>SelectedVersion=null</c>, bouton AUTO
|
||||
/// repasse en gris sur la row CURRENT, persisté dans config.json)
|
||||
/// </summary>
|
||||
private void StartAutoLaunchCountdown(VersionRowViewModel row)
|
||||
{
|
||||
if (row.Installed is null) return;
|
||||
|
||||
// Garde-fou tôt : si PROSERVE est déjà lancé, ne pas afficher de countdown.
|
||||
// Sinon le countdown défile → LaunchVersion → refus silencieux → confusing.
|
||||
// On log simplement et on désactive le mode auto pour cette session pour
|
||||
// que l'opérateur reprenne explicitement la main (sinon un exit-event
|
||||
// glitché pourrait re-trigger ce path en boucle).
|
||||
if (IsAnyProserveAlreadyRunning(row.Installed, out var why))
|
||||
{
|
||||
_logger.LogInformation(
|
||||
"Auto-launch countdown skipped for v{Version} : already running ({Why})",
|
||||
row.Version, why);
|
||||
return;
|
||||
}
|
||||
|
||||
var grace = Math.Clamp(_config.AutoMode.GracePeriodSeconds, 1, 60);
|
||||
|
||||
// Callback santé : non-null SEULEMENT si l'option WaitForHealthChecks
|
||||
// est activée ET qu'il y a au moins un indicateur configuré. Sinon le
|
||||
// dialog saute la phase santé et démarre direct le countdown.
|
||||
Func<IReadOnlyList<string>>? healthSnapshot = null;
|
||||
if (_config.AutoMode.WaitForHealthChecks && HealthIndicators.Count > 0)
|
||||
{
|
||||
healthSnapshot = () => HealthIndicators
|
||||
.Where(h => h.Severity != Core.Health.HealthSeverity.Ok)
|
||||
.Select(h => h.Name)
|
||||
.ToList();
|
||||
}
|
||||
|
||||
_isAutoLaunchInFlight = true;
|
||||
try
|
||||
{
|
||||
var dialog = new Views.AutoRelaunchDialog(row.Version, grace, healthSnapshot)
|
||||
{
|
||||
Owner = Application.Current.MainWindow,
|
||||
};
|
||||
var result = dialog.ShowDialog();
|
||||
if (result == true)
|
||||
{
|
||||
_logger.LogInformation("Auto-launching PROSERVE v{Version}", row.Version);
|
||||
LaunchVersion(row);
|
||||
}
|
||||
else
|
||||
{
|
||||
// Cancel : désactive le mode auto sur cette version. L'opérateur peut
|
||||
// reprendre la main et re-cliquer plus tard pour réactiver.
|
||||
_logger.LogInformation("Auto-launch cancelled by user for v{Version}", row.Version);
|
||||
_config.AutoMode.SelectedVersion = null;
|
||||
_configStore.Save(_config);
|
||||
|
||||
// Trouve la row CURRENT (RebuildList a pu remplacer l'instance entre temps
|
||||
// — auquel cas `row` est orpheline et le bouton de la UI ne se mettrait
|
||||
// pas à jour). Pour le visuel, on désélectionne TOUTES les rows par sûreté.
|
||||
if (FeaturedVersion is not null)
|
||||
FeaturedVersion.IsAutoModeSelected = false;
|
||||
foreach (var r in OtherVersions)
|
||||
r.IsAutoModeSelected = false;
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
_isAutoLaunchInFlight = false;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Au démarrage du launcher, si le mode auto est configuré avec une version
|
||||
/// sélectionnée ET que cette version est bien installée, on déclenche le
|
||||
/// countdown + lancement (avec possibilité d'annuler via Cancel sur la modal).
|
||||
/// Appelé depuis le constructeur après RebuildList().
|
||||
/// </summary>
|
||||
private void TryAutoLaunchAtStartup()
|
||||
{
|
||||
if (!_config.AutoMode.FeatureEnabled) return;
|
||||
var target = _config.AutoMode.SelectedVersion;
|
||||
if (string.IsNullOrEmpty(target)) return;
|
||||
|
||||
var row = (FeaturedVersion?.Version == target ? FeaturedVersion : null)
|
||||
?? OtherVersions.FirstOrDefault(r => r.Version == target);
|
||||
if (row is null || !row.IsInstalled)
|
||||
{
|
||||
_logger.LogInformation("Auto-launch skipped: v{Version} not installed locally", target);
|
||||
return;
|
||||
}
|
||||
|
||||
_logger.LogInformation("Auto-launching v{Version} at startup (with countdown)", target);
|
||||
StartAutoLaunchCountdown(row);
|
||||
}
|
||||
|
||||
private async Task InstallVersionAsync(VersionRowViewModel row)
|
||||
{
|
||||
if (row.Remote is null) return;
|
||||
@@ -1065,6 +1425,24 @@ public sealed partial class MainViewModel : ObservableObject
|
||||
// au format progress « ⬇ v1.4.6 : X / 14 Go • Y Mo/s » dès le premier rapport.
|
||||
var zipPath = await _downloadManager.DownloadAsync(job, dlProgress, hashProgress, ct);
|
||||
|
||||
// Promote au cache LAN IMMÉDIATEMENT après le DL (avant l'extract local).
|
||||
// Pourquoi avant : sans ça, le sidecar SHA n'existe qu'après l'install complet
|
||||
// (extract + redist + deploy ~ 20-60 s), pendant lesquelles les autres PCs
|
||||
// qui probe /v1/has/{version} reçoivent 404 et tombent sur OVH inutilement.
|
||||
// En promotant ici, dès que notre DL OVH est fini, on devient seed LAN pour
|
||||
// les autres pendant que notre propre install local continue en parallèle.
|
||||
try
|
||||
{
|
||||
await _zipCacheStore.PromoteAsync(zipPath, row.Version, row.Remote.Download.Sha256, ct);
|
||||
var cachedNow = _zipCacheStore.ListCached().Select(c => c.Version).ToList();
|
||||
_logger.LogInformation("LAN cache after DL (pre-install) : {Count} version(s) [{Versions}]",
|
||||
cachedNow.Count, string.Join(", ", cachedNow));
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "ZIP cache promote failed (non-critical, LAN serving will lag until next install)");
|
||||
}
|
||||
|
||||
// 4) Install
|
||||
row.State = VersionRowState.Installing;
|
||||
StatusMessage = Strings.StatusInstallingVersion(row.Version);
|
||||
@@ -1101,22 +1479,31 @@ public sealed partial class MainViewModel : ObservableObject
|
||||
// installer. Track via metadata pour ne pas re-runner à chaque réinstall.
|
||||
// Si un installer fail (already-installed retournent souvent un exit code
|
||||
// non-zero), on log mais on continue — l'install PROSERVE ne doit pas être
|
||||
// bloquée par un redist mineur.
|
||||
await InstallRedistsAsync(target, row.Version, ct);
|
||||
// bloquée par un redist mineur. Wrappé en try/catch : si une exception
|
||||
// non gérée fuit (Process.Start qui throw bizarre, UAC denied catastrophique,
|
||||
// …), on ne doit SURTOUT PAS skip le PromoteAsync ci-dessous, sinon les
|
||||
// peers LAN ne verraient pas la version.
|
||||
try
|
||||
{
|
||||
await InstallRedistsAsync(target, row.Version, ct);
|
||||
}
|
||||
catch (OperationCanceledException) when (ct.IsCancellationRequested) { throw; }
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Redists install threw an unhandled exception (continuing to cache promote)");
|
||||
}
|
||||
|
||||
// Promote le ZIP au cache LAN (sidecar SHA écrit) puis prune les vieilles
|
||||
// versions selon MaxCachedVersions. Le ZIP est déjà au bon endroit
|
||||
// (DownloadManager.VerifyAndFinalizeAsync l'a Move'd vers `proserve-X.zip`),
|
||||
// donc PromoteAsync est essentiellement un no-op + écriture du sidecar.
|
||||
// Les versions encore installées sont protégées du pruning.
|
||||
// Prune les vieilles versions selon MaxCachedVersions (le Promote a déjà
|
||||
// eu lieu juste après le DL, voir bloc plus haut). On prune ici post-install
|
||||
// pour que les versions encore installées soient protégées (la version qu'on
|
||||
// vient d'installer compte comme "encore installée").
|
||||
try
|
||||
{
|
||||
await _zipCacheStore.PromoteAsync(zipPath, row.Version, row.Remote.Download.Sha256, ct);
|
||||
await _zipCacheStore.PruneAsync(_config.LanCache.MaxCachedVersions, ct);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "ZIP cache promote/prune failed (non-critical)");
|
||||
_logger.LogWarning(ex, "ZIP cache prune failed (non-critical)");
|
||||
}
|
||||
|
||||
// 5) Migrations DB MySQL bundled dans _migrations/. Skip si la config
|
||||
@@ -1288,6 +1675,25 @@ public sealed partial class MainViewModel : ObservableObject
|
||||
}
|
||||
}
|
||||
|
||||
// Cleanup explicite de l'ancien row instance AVANT le RebuildList.
|
||||
// Sans ça :
|
||||
// 1. `row.State` reste à `Installing` (jamais resété depuis le step 4
|
||||
// ligne ~1260) → si un binding survit sur cette instance le temps
|
||||
// que WPF rafraîchisse OtherVersions.Clear()+Add(), l'utilisateur
|
||||
// voit brièvement (ou durablement, cf. bug rapporté) une row
|
||||
// « en cours d'installation » à côté du toast "✓ installée".
|
||||
// 2. `_activeRow` pointe encore sur cette OLD instance, ce qui
|
||||
// maintient `HasActiveDownload` truthy le temps que `finally`
|
||||
// tourne — pas grave en pratique mais pas propre.
|
||||
// Le RebuildList va de toute façon créer une NOUVELLE instance
|
||||
// `ForInstalled(...)` pour cette version puisque le scan disque la
|
||||
// détecte maintenant. Cet edit purge l'ancienne instance avant que
|
||||
// les bindings puissent s'y raccrocher.
|
||||
row.State = VersionRowState.InstalledIdle;
|
||||
row.ProgressPercent = 0;
|
||||
row.ProgressDetail = null;
|
||||
_activeRow = null;
|
||||
|
||||
StatusMessage = Strings.StatusInstalledSuccess(row.Version);
|
||||
ProgressDetail = null;
|
||||
ProgressPercent = 0;
|
||||
|
||||
Reference in New Issue
Block a user