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:
2026-05-12 16:56:55 +02:00
parent 9b17dfa84c
commit efb53f079e
31 changed files with 1994 additions and 77 deletions

View File

@@ -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;