Initial scaffolding: PS_Launcher v0.2
Client (C# / .NET 8 / WPF, MVVM via CommunityToolkit.Mvvm):
- PSLauncher.App: WPF UI dark theme (Epic-style sidebar + hero + big play button)
with UpdateAvailableDialog rendering Markdown release notes via Markdig.Wpf.
- PSLauncher.Core: services for installation registry (scans Proserve v{X.Y.Z}/),
process launcher, manifest fetch, SHA-256 integrity, HTTP download with
progress, ZIP install via .tmp + atomic rename, update orchestrator.
- PSLauncher.Models: RemoteManifest, InstalledVersion, LocalConfig DTOs.
Server (PHP 8 for OVH mutualisé, deployed under www/PS_Launcher/):
- Front controller + routes /manifest and /releasenotes/{version}.
- Static signed-manifest workflow with tools/sign-manifest.php CLI to
recompute SHA-256 and sizeBytes after each ZIP upload.
- .htaccess: HTTPS redirect, rewrite, security headers.
- config.example.php template; real config.php is gitignored.
Cohabiting versions: each release lives in its own Proserve v{version}/ folder
under installRoot. Old versions are never deleted automatically.
Roadmap: v0.3 = HTTP Range resume + Polly retry + state.json,
v0.4 = MySQL license + Ed25519 signatures + DPAPI, v0.5 = settings/UX polish.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
12
src/PSLauncher.App/App.xaml
Normal file
12
src/PSLauncher.App/App.xaml
Normal file
@@ -0,0 +1,12 @@
|
||||
<Application x:Class="PSLauncher.App.App"
|
||||
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
ShutdownMode="OnMainWindowClose">
|
||||
<Application.Resources>
|
||||
<ResourceDictionary>
|
||||
<ResourceDictionary.MergedDictionaries>
|
||||
<ResourceDictionary Source="Resources/Theme.xaml" />
|
||||
</ResourceDictionary.MergedDictionaries>
|
||||
</ResourceDictionary>
|
||||
</Application.Resources>
|
||||
</Application>
|
||||
84
src/PSLauncher.App/App.xaml.cs
Normal file
84
src/PSLauncher.App/App.xaml.cs
Normal file
@@ -0,0 +1,84 @@
|
||||
using System.Net.Http;
|
||||
using System.Net.Http.Headers;
|
||||
using System.Windows;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using PSLauncher.App.ViewModels;
|
||||
using PSLauncher.App.Views;
|
||||
using PSLauncher.Core.Configuration;
|
||||
using PSLauncher.Core.Downloads;
|
||||
using PSLauncher.Core.Installations;
|
||||
using PSLauncher.Core.Integrity;
|
||||
using PSLauncher.Core.Manifests;
|
||||
using PSLauncher.Core.Process;
|
||||
using PSLauncher.Core.Updates;
|
||||
using PSLauncher.Models;
|
||||
|
||||
namespace PSLauncher.App;
|
||||
|
||||
public partial class App : Application
|
||||
{
|
||||
private IHost? _host;
|
||||
|
||||
protected override void OnStartup(StartupEventArgs e)
|
||||
{
|
||||
base.OnStartup(e);
|
||||
|
||||
_host = Host.CreateDefaultBuilder()
|
||||
.ConfigureLogging(b => { b.ClearProviders(); b.AddDebug(); })
|
||||
.ConfigureServices((_, services) =>
|
||||
{
|
||||
services.AddSingleton<IConfigStore, ConfigStore>();
|
||||
services.AddSingleton<LocalConfig>(sp => sp.GetRequiredService<IConfigStore>().Load());
|
||||
|
||||
// HttpClient partagé (timeout long pour les gros téléchargements)
|
||||
services.AddSingleton(sp =>
|
||||
{
|
||||
var http = new HttpClient(new SocketsHttpHandler
|
||||
{
|
||||
PooledConnectionLifetime = TimeSpan.FromMinutes(5),
|
||||
AutomaticDecompression = System.Net.DecompressionMethods.All
|
||||
})
|
||||
{
|
||||
Timeout = Timeout.InfiniteTimeSpan
|
||||
};
|
||||
http.DefaultRequestHeaders.UserAgent.Add(new ProductInfoHeaderValue("PSLauncher", "0.2"));
|
||||
return http;
|
||||
});
|
||||
|
||||
services.AddSingleton<IInstallationRegistry>(sp =>
|
||||
new InstallationRegistry(
|
||||
sp.GetRequiredService<ILogger<InstallationRegistry>>(),
|
||||
() => sp.GetRequiredService<LocalConfig>().InstallRoot));
|
||||
|
||||
services.AddSingleton<IProcessLauncher, ProcessLauncher>();
|
||||
services.AddSingleton<IIntegrityService, IntegrityService>();
|
||||
services.AddSingleton<IZipInstaller, ZipInstaller>();
|
||||
|
||||
services.AddSingleton<IManifestService>(sp =>
|
||||
new ManifestService(
|
||||
sp.GetRequiredService<HttpClient>(),
|
||||
() => sp.GetRequiredService<LocalConfig>().ServerBaseUrl,
|
||||
sp.GetRequiredService<ILogger<ManifestService>>()));
|
||||
|
||||
services.AddSingleton<IDownloadManager, DownloadManager>();
|
||||
services.AddSingleton<IUpdateChecker, UpdateChecker>();
|
||||
|
||||
services.AddSingleton<MainViewModel>();
|
||||
services.AddSingleton<MainWindow>();
|
||||
})
|
||||
.Build();
|
||||
|
||||
var window = _host.Services.GetRequiredService<MainWindow>();
|
||||
window.DataContext = _host.Services.GetRequiredService<MainViewModel>();
|
||||
MainWindow = window;
|
||||
window.Show();
|
||||
}
|
||||
|
||||
protected override void OnExit(ExitEventArgs e)
|
||||
{
|
||||
_host?.Dispose();
|
||||
base.OnExit(e);
|
||||
}
|
||||
}
|
||||
37
src/PSLauncher.App/PSLauncher.App.csproj
Normal file
37
src/PSLauncher.App/PSLauncher.App.csproj
Normal file
@@ -0,0 +1,37 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<OutputType>WinExe</OutputType>
|
||||
<TargetFramework>net8.0-windows</TargetFramework>
|
||||
<UseWPF>true</UseWPF>
|
||||
<Nullable>enable</Nullable>
|
||||
<LangVersion>latest</LangVersion>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<ApplicationIcon></ApplicationIcon>
|
||||
<AssemblyName>PSLauncher</AssemblyName>
|
||||
<RootNamespace>PSLauncher.App</RootNamespace>
|
||||
|
||||
<!-- Single-file self-contained publish profile (used by `dotnet publish`) -->
|
||||
<PublishSingleFile>true</PublishSingleFile>
|
||||
<SelfContained>true</SelfContained>
|
||||
<RuntimeIdentifier>win-x64</RuntimeIdentifier>
|
||||
<IncludeNativeLibrariesForSelfExtract>true</IncludeNativeLibrariesForSelfExtract>
|
||||
<EnableCompressionInSingleFile>true</EnableCompressionInSingleFile>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="CommunityToolkit.Mvvm" Version="8.3.2" />
|
||||
<PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="8.0.1" />
|
||||
<PackageReference Include="Microsoft.Extensions.Hosting" Version="8.0.1" />
|
||||
<PackageReference Include="Microsoft.Extensions.Logging" Version="8.0.1" />
|
||||
<PackageReference Include="Microsoft.Extensions.Logging.Debug" Version="8.0.1" />
|
||||
<PackageReference Include="Markdig" Version="0.37.0" />
|
||||
<PackageReference Include="Markdig.Wpf" Version="0.5.0.1" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\PSLauncher.Core\PSLauncher.Core.csproj" />
|
||||
<ProjectReference Include="..\PSLauncher.Models\PSLauncher.Models.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
103
src/PSLauncher.App/Resources/Theme.xaml
Normal file
103
src/PSLauncher.App/Resources/Theme.xaml
Normal file
@@ -0,0 +1,103 @@
|
||||
<ResourceDictionary xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
|
||||
|
||||
<BooleanToVisibilityConverter x:Key="BoolToVisibility" />
|
||||
|
||||
<!-- Epic Games inspired dark palette -->
|
||||
<SolidColorBrush x:Key="Brush.Bg.Window" Color="#1B1B1F" />
|
||||
<SolidColorBrush x:Key="Brush.Bg.Sidebar" Color="#202024" />
|
||||
<SolidColorBrush x:Key="Brush.Bg.Card" Color="#26262B" />
|
||||
<SolidColorBrush x:Key="Brush.Bg.Footer" Color="#0F0F12" />
|
||||
<SolidColorBrush x:Key="Brush.Border" Color="#37373D" />
|
||||
<SolidColorBrush x:Key="Brush.Text.Primary" Color="#F2F2F2" />
|
||||
<SolidColorBrush x:Key="Brush.Text.Secondary" Color="#A0A0A8" />
|
||||
<SolidColorBrush x:Key="Brush.Accent" Color="#0078D4" />
|
||||
<SolidColorBrush x:Key="Brush.AccentHover" Color="#1A8CE0" />
|
||||
<SolidColorBrush x:Key="Brush.Play" Color="#0CA84B" />
|
||||
<SolidColorBrush x:Key="Brush.PlayHover" Color="#10C257" />
|
||||
|
||||
<Style TargetType="Window">
|
||||
<Setter Property="Background" Value="{StaticResource Brush.Bg.Window}" />
|
||||
<Setter Property="Foreground" Value="{StaticResource Brush.Text.Primary}" />
|
||||
<Setter Property="FontFamily" Value="Segoe UI" />
|
||||
<Setter Property="FontSize" Value="13" />
|
||||
</Style>
|
||||
|
||||
<Style TargetType="TextBlock">
|
||||
<Setter Property="Foreground" Value="{StaticResource Brush.Text.Primary}" />
|
||||
</Style>
|
||||
|
||||
<Style x:Key="SidebarItem" TargetType="RadioButton">
|
||||
<Setter Property="Foreground" Value="{StaticResource Brush.Text.Secondary}" />
|
||||
<Setter Property="Background" Value="Transparent" />
|
||||
<Setter Property="Cursor" Value="Hand" />
|
||||
<Setter Property="FontSize" Value="14" />
|
||||
<Setter Property="HorizontalContentAlignment" Value="Left" />
|
||||
<Setter Property="Padding" Value="20,10" />
|
||||
<Setter Property="Template">
|
||||
<Setter.Value>
|
||||
<ControlTemplate TargetType="RadioButton">
|
||||
<Border x:Name="Bd"
|
||||
Background="{TemplateBinding Background}"
|
||||
Padding="{TemplateBinding Padding}">
|
||||
<ContentPresenter VerticalAlignment="Center" />
|
||||
</Border>
|
||||
<ControlTemplate.Triggers>
|
||||
<Trigger Property="IsMouseOver" Value="True">
|
||||
<Setter TargetName="Bd" Property="Background" Value="#2C2C32" />
|
||||
</Trigger>
|
||||
<Trigger Property="IsChecked" Value="True">
|
||||
<Setter TargetName="Bd" Property="Background" Value="#2C2C32" />
|
||||
<Setter Property="Foreground" Value="{StaticResource Brush.Text.Primary}" />
|
||||
</Trigger>
|
||||
</ControlTemplate.Triggers>
|
||||
</ControlTemplate>
|
||||
</Setter.Value>
|
||||
</Setter>
|
||||
</Style>
|
||||
|
||||
<Style x:Key="PrimaryButton" TargetType="Button">
|
||||
<Setter Property="Background" Value="{StaticResource Brush.Play}" />
|
||||
<Setter Property="Foreground" Value="White" />
|
||||
<Setter Property="FontSize" Value="18" />
|
||||
<Setter Property="FontWeight" Value="SemiBold" />
|
||||
<Setter Property="Padding" Value="48,14" />
|
||||
<Setter Property="BorderThickness" Value="0" />
|
||||
<Setter Property="Cursor" Value="Hand" />
|
||||
<Setter Property="Template">
|
||||
<Setter.Value>
|
||||
<ControlTemplate TargetType="Button">
|
||||
<Border x:Name="Bd"
|
||||
Background="{TemplateBinding Background}"
|
||||
CornerRadius="4"
|
||||
Padding="{TemplateBinding Padding}">
|
||||
<ContentPresenter HorizontalAlignment="Center" VerticalAlignment="Center" />
|
||||
</Border>
|
||||
<ControlTemplate.Triggers>
|
||||
<Trigger Property="IsMouseOver" Value="True">
|
||||
<Setter TargetName="Bd" Property="Background" Value="{StaticResource Brush.PlayHover}" />
|
||||
</Trigger>
|
||||
<Trigger Property="IsEnabled" Value="False">
|
||||
<Setter TargetName="Bd" Property="Background" Value="#444" />
|
||||
<Setter Property="Foreground" Value="#888" />
|
||||
</Trigger>
|
||||
</ControlTemplate.Triggers>
|
||||
</ControlTemplate>
|
||||
</Setter.Value>
|
||||
</Setter>
|
||||
</Style>
|
||||
|
||||
<Style x:Key="SecondaryButton" TargetType="Button" BasedOn="{StaticResource PrimaryButton}">
|
||||
<Setter Property="Background" Value="#3A3A40" />
|
||||
<Setter Property="FontSize" Value="13" />
|
||||
<Setter Property="FontWeight" Value="Normal" />
|
||||
<Setter Property="Padding" Value="14,8" />
|
||||
</Style>
|
||||
|
||||
<Style x:Key="VersionPill" TargetType="Border">
|
||||
<Setter Property="Background" Value="#2C2C32" />
|
||||
<Setter Property="CornerRadius" Value="12" />
|
||||
<Setter Property="Padding" Value="10,4" />
|
||||
</Style>
|
||||
|
||||
</ResourceDictionary>
|
||||
27
src/PSLauncher.App/ViewModels/InstalledVersionViewModel.cs
Normal file
27
src/PSLauncher.App/ViewModels/InstalledVersionViewModel.cs
Normal file
@@ -0,0 +1,27 @@
|
||||
using PSLauncher.Models;
|
||||
|
||||
namespace PSLauncher.App.ViewModels;
|
||||
|
||||
public sealed class InstalledVersionViewModel
|
||||
{
|
||||
public InstalledVersion Model { get; }
|
||||
|
||||
public InstalledVersionViewModel(InstalledVersion model) => Model = model;
|
||||
|
||||
public string Display => $"v{Model.Version}";
|
||||
|
||||
public string Subtitle =>
|
||||
$"{FormatSize(Model.SizeBytes)} • Installée le {Model.InstalledAt.ToLocalTime():dd/MM/yyyy}";
|
||||
|
||||
private static string FormatSize(long bytes)
|
||||
{
|
||||
if (bytes <= 0) return "—";
|
||||
string[] units = { "o", "Ko", "Mo", "Go", "To" };
|
||||
double v = bytes;
|
||||
int u = 0;
|
||||
while (v >= 1024 && u < units.Length - 1) { v /= 1024; u++; }
|
||||
return $"{v:0.#} {units[u]}";
|
||||
}
|
||||
|
||||
public override string ToString() => Display;
|
||||
}
|
||||
359
src/PSLauncher.App/ViewModels/MainViewModel.cs
Normal file
359
src/PSLauncher.App/ViewModels/MainViewModel.cs
Normal file
@@ -0,0 +1,359 @@
|
||||
using System.Collections.ObjectModel;
|
||||
using System.Diagnostics;
|
||||
using System.IO;
|
||||
using System.Windows;
|
||||
using CommunityToolkit.Mvvm.ComponentModel;
|
||||
using CommunityToolkit.Mvvm.Input;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using PSLauncher.App.Views;
|
||||
using PSLauncher.Core.Configuration;
|
||||
using PSLauncher.Core.Downloads;
|
||||
using PSLauncher.Core.Installations;
|
||||
using PSLauncher.Core.Manifests;
|
||||
using PSLauncher.Core.Process;
|
||||
using PSLauncher.Core.Updates;
|
||||
using PSLauncher.Models;
|
||||
|
||||
namespace PSLauncher.App.ViewModels;
|
||||
|
||||
public sealed partial class MainViewModel : ObservableObject
|
||||
{
|
||||
private readonly IInstallationRegistry _registry;
|
||||
private readonly IProcessLauncher _processLauncher;
|
||||
private readonly IConfigStore _configStore;
|
||||
private readonly LocalConfig _config;
|
||||
private readonly IManifestService _manifestService;
|
||||
private readonly IUpdateChecker _updateChecker;
|
||||
private readonly IDownloadManager _downloadManager;
|
||||
private readonly IZipInstaller _zipInstaller;
|
||||
private readonly ILogger<MainViewModel> _logger;
|
||||
|
||||
private CancellationTokenSource? _downloadCts;
|
||||
|
||||
public ObservableCollection<InstalledVersionViewModel> InstalledVersions { get; } = new();
|
||||
|
||||
[ObservableProperty]
|
||||
[NotifyPropertyChangedFor(nameof(StatusBadge))]
|
||||
[NotifyPropertyChangedFor(nameof(PrimaryActionLabel))]
|
||||
[NotifyPropertyChangedFor(nameof(PrimaryActionEnabled))]
|
||||
[NotifyPropertyChangedFor(nameof(HeroTitle))]
|
||||
[NotifyCanExecuteChangedFor(nameof(PrimaryActionCommand))]
|
||||
private InstalledVersionViewModel? _selectedVersion;
|
||||
|
||||
[ObservableProperty]
|
||||
[NotifyPropertyChangedFor(nameof(StatusBadge))]
|
||||
[NotifyPropertyChangedFor(nameof(PrimaryActionLabel))]
|
||||
[NotifyPropertyChangedFor(nameof(PrimaryActionEnabled))]
|
||||
private VersionManifest? _availableUpdate;
|
||||
|
||||
[ObservableProperty]
|
||||
[NotifyPropertyChangedFor(nameof(FooterText))]
|
||||
[NotifyPropertyChangedFor(nameof(FooterVisibility))]
|
||||
private string? _statusMessage;
|
||||
|
||||
[ObservableProperty]
|
||||
[NotifyPropertyChangedFor(nameof(FooterText))]
|
||||
[NotifyPropertyChangedFor(nameof(FooterVisibility))]
|
||||
[NotifyPropertyChangedFor(nameof(StatusBadge))]
|
||||
[NotifyPropertyChangedFor(nameof(PrimaryActionLabel))]
|
||||
[NotifyPropertyChangedFor(nameof(PrimaryActionEnabled))]
|
||||
[NotifyCanExecuteChangedFor(nameof(PrimaryActionCommand))]
|
||||
[NotifyCanExecuteChangedFor(nameof(CheckForUpdatesCommand))]
|
||||
private bool _isBusy;
|
||||
|
||||
[ObservableProperty]
|
||||
[NotifyPropertyChangedFor(nameof(FooterText))]
|
||||
[NotifyPropertyChangedFor(nameof(FooterVisibility))]
|
||||
private double _progressPercent;
|
||||
|
||||
[ObservableProperty]
|
||||
[NotifyPropertyChangedFor(nameof(FooterText))]
|
||||
private string? _progressDetail;
|
||||
|
||||
public string StatusBadge
|
||||
{
|
||||
get
|
||||
{
|
||||
if (IsBusy) return "● En cours";
|
||||
if (AvailableUpdate is not null) return "🔔 MAJ disponible";
|
||||
if (SelectedVersion is null) return "Aucune version";
|
||||
return "● Installée";
|
||||
}
|
||||
}
|
||||
|
||||
public string PrimaryActionLabel
|
||||
{
|
||||
get
|
||||
{
|
||||
if (IsBusy) return "Veuillez patienter…";
|
||||
if (AvailableUpdate is not null && SelectedVersion is null)
|
||||
return $"⬇ INSTALLER v{AvailableUpdate.Version}";
|
||||
if (AvailableUpdate is not null)
|
||||
return $"⬇ TÉLÉCHARGER v{AvailableUpdate.Version}";
|
||||
return SelectedVersion is null ? "Aucune version disponible" : "▶ LANCER";
|
||||
}
|
||||
}
|
||||
|
||||
public bool PrimaryActionEnabled => !IsBusy && (SelectedVersion is not null || AvailableUpdate is not null);
|
||||
|
||||
public string HeroTitle
|
||||
{
|
||||
get
|
||||
{
|
||||
if (AvailableUpdate is not null) return $"PROSERVE — v{AvailableUpdate.Version}";
|
||||
return SelectedVersion is null ? "PROSERVE" : $"PROSERVE — {SelectedVersion.Display}";
|
||||
}
|
||||
}
|
||||
|
||||
public string LicenseSummary => "License : non configurée (v0.4)";
|
||||
|
||||
public string EmptyHint =>
|
||||
$"Aucune version trouvée dans :\n{_config.InstallRoot}\n\n" +
|
||||
"Configure l'URL serveur dans config.json puis clique « Vérifier les mises à jour », " +
|
||||
"ou place un dossier « Proserve v{X.Y.Z} » contenant PROSERVE_UE_5_5.exe à cet emplacement.";
|
||||
|
||||
public Visibility EmptyHintVisibility => InstalledVersions.Count == 0 ? Visibility.Visible : Visibility.Collapsed;
|
||||
|
||||
public Visibility FooterVisibility => IsBusy || !string.IsNullOrEmpty(StatusMessage) ? Visibility.Visible : Visibility.Collapsed;
|
||||
|
||||
public string FooterText
|
||||
{
|
||||
get
|
||||
{
|
||||
if (!string.IsNullOrEmpty(ProgressDetail)) return ProgressDetail;
|
||||
return StatusMessage ?? string.Empty;
|
||||
}
|
||||
}
|
||||
|
||||
public MainViewModel(
|
||||
IInstallationRegistry registry,
|
||||
IProcessLauncher processLauncher,
|
||||
IConfigStore configStore,
|
||||
LocalConfig config,
|
||||
IManifestService manifestService,
|
||||
IUpdateChecker updateChecker,
|
||||
IDownloadManager downloadManager,
|
||||
IZipInstaller zipInstaller,
|
||||
ILogger<MainViewModel> logger)
|
||||
{
|
||||
_registry = registry;
|
||||
_processLauncher = processLauncher;
|
||||
_configStore = configStore;
|
||||
_config = config;
|
||||
_manifestService = manifestService;
|
||||
_updateChecker = updateChecker;
|
||||
_downloadManager = downloadManager;
|
||||
_zipInstaller = zipInstaller;
|
||||
_logger = logger;
|
||||
Refresh();
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private void Refresh()
|
||||
{
|
||||
InstalledVersions.Clear();
|
||||
foreach (var v in _registry.Scan())
|
||||
InstalledVersions.Add(new InstalledVersionViewModel(v));
|
||||
|
||||
SelectedVersion = !string.IsNullOrEmpty(_config.LastLaunchedVersion)
|
||||
? InstalledVersions.FirstOrDefault(v => v.Model.Version == _config.LastLaunchedVersion)
|
||||
?? InstalledVersions.FirstOrDefault()
|
||||
: InstalledVersions.FirstOrDefault();
|
||||
|
||||
OnPropertyChanged(nameof(EmptyHint));
|
||||
OnPropertyChanged(nameof(EmptyHintVisibility));
|
||||
}
|
||||
|
||||
[RelayCommand(CanExecute = nameof(CanCheckUpdates))]
|
||||
private async Task CheckForUpdatesAsync()
|
||||
{
|
||||
IsBusy = true;
|
||||
StatusMessage = "Vérification des mises à jour…";
|
||||
try
|
||||
{
|
||||
var result = await _updateChecker.CheckAsync(CancellationToken.None);
|
||||
if (result.Error is not null)
|
||||
{
|
||||
StatusMessage = $"Erreur : {result.Error}";
|
||||
AvailableUpdate = null;
|
||||
return;
|
||||
}
|
||||
|
||||
if (result.IsLatestNewerThanInstalled && result.LatestRemote is not null)
|
||||
{
|
||||
AvailableUpdate = result.LatestRemote;
|
||||
StatusMessage = $"Nouvelle version disponible : v{result.LatestRemote.Version}";
|
||||
}
|
||||
else
|
||||
{
|
||||
AvailableUpdate = null;
|
||||
StatusMessage = result.LatestRemote is not null
|
||||
? $"À jour (v{result.LatestRemote.Version})"
|
||||
: "Aucune version distante trouvée";
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Update check failed");
|
||||
StatusMessage = $"Erreur : {ex.Message}";
|
||||
}
|
||||
finally
|
||||
{
|
||||
IsBusy = false;
|
||||
}
|
||||
}
|
||||
|
||||
private bool CanCheckUpdates() => !IsBusy;
|
||||
|
||||
[RelayCommand(CanExecute = nameof(PrimaryActionEnabled))]
|
||||
private async Task PrimaryActionAsync()
|
||||
{
|
||||
if (AvailableUpdate is not null)
|
||||
{
|
||||
await DownloadAndInstallAsync(AvailableUpdate);
|
||||
return;
|
||||
}
|
||||
|
||||
if (SelectedVersion is null) return;
|
||||
try
|
||||
{
|
||||
_processLauncher.Launch(SelectedVersion.Model);
|
||||
_config.LastLaunchedVersion = SelectedVersion.Model.Version;
|
||||
_configStore.Save(_config);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to launch");
|
||||
MessageBox.Show(
|
||||
$"Impossible de lancer la version :\n\n{ex.Message}",
|
||||
"Erreur de lancement",
|
||||
MessageBoxButton.OK, MessageBoxImage.Error);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task DownloadAndInstallAsync(VersionManifest version)
|
||||
{
|
||||
// 1) Récupère release notes et propose dialog
|
||||
string notes;
|
||||
try
|
||||
{
|
||||
notes = !string.IsNullOrEmpty(version.ReleaseNotesUrl)
|
||||
? await _manifestService.FetchReleaseNotesAsync(version.ReleaseNotesUrl, CancellationToken.None)
|
||||
: "_Aucune release note fournie._";
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Failed to fetch release notes");
|
||||
notes = "_Release notes indisponibles._";
|
||||
}
|
||||
|
||||
var dialog = new UpdateAvailableDialog(version, notes)
|
||||
{
|
||||
Owner = Application.Current.MainWindow
|
||||
};
|
||||
dialog.ShowDialog();
|
||||
if (!dialog.DownloadRequested) return;
|
||||
|
||||
IsBusy = true;
|
||||
_downloadCts = new CancellationTokenSource();
|
||||
try
|
||||
{
|
||||
var url = new Uri(version.Download.Url);
|
||||
var job = new DownloadJob(version.Version, url, version.Download.SizeBytes, version.Download.Sha256);
|
||||
|
||||
var progress = new Progress<DownloadProgress>(p => UpdateDlProgress(version.Version, p));
|
||||
StatusMessage = $"Téléchargement v{version.Version}…";
|
||||
var zipPath = await _downloadManager.DownloadAsync(job, progress, _downloadCts.Token);
|
||||
|
||||
// 2) Extraction
|
||||
StatusMessage = $"Installation v{version.Version}…";
|
||||
var folderName = version.GetInstallFolderName();
|
||||
var target = Path.Combine(_config.InstallRoot, folderName);
|
||||
var installProgress = new Progress<InstallProgress>(ip =>
|
||||
{
|
||||
if (ip.BytesTotal <= 0) return;
|
||||
ProgressPercent = (double)ip.BytesDone / ip.BytesTotal * 100.0;
|
||||
ProgressDetail = $"Extraction : {ip.EntriesDone}/{ip.EntriesTotal} fichiers ({FormatSize(ip.BytesDone)} / {FormatSize(ip.BytesTotal)})";
|
||||
});
|
||||
await _zipInstaller.InstallAsync(zipPath, target, installProgress, _downloadCts.Token);
|
||||
|
||||
// 3) Nettoyage du ZIP téléchargé (le user peut le rétablir depuis le serveur s'il en a besoin)
|
||||
try { File.Delete(zipPath); } catch { /* non critique */ }
|
||||
|
||||
StatusMessage = $"v{version.Version} installée avec succès";
|
||||
ProgressDetail = null;
|
||||
ProgressPercent = 0;
|
||||
AvailableUpdate = null;
|
||||
Refresh();
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
StatusMessage = "Téléchargement annulé";
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Download/install failed");
|
||||
MessageBox.Show(
|
||||
$"Le téléchargement ou l'installation a échoué :\n\n{ex.Message}",
|
||||
"Erreur",
|
||||
MessageBoxButton.OK, MessageBoxImage.Error);
|
||||
StatusMessage = $"Erreur : {ex.Message}";
|
||||
}
|
||||
finally
|
||||
{
|
||||
_downloadCts?.Dispose();
|
||||
_downloadCts = null;
|
||||
IsBusy = false;
|
||||
}
|
||||
}
|
||||
|
||||
private void UpdateDlProgress(string version, DownloadProgress p)
|
||||
{
|
||||
if (p.TotalBytes > 0)
|
||||
ProgressPercent = (double)p.BytesDownloaded / p.TotalBytes * 100.0;
|
||||
|
||||
var sb = new System.Text.StringBuilder();
|
||||
sb.Append("⬇ v").Append(version).Append(" : ");
|
||||
sb.Append(FormatSize(p.BytesDownloaded));
|
||||
if (p.TotalBytes > 0) sb.Append(" / ").Append(FormatSize(p.TotalBytes));
|
||||
if (p.BytesPerSecond > 0) sb.Append(" • ").Append(FormatSize((long)p.BytesPerSecond)).Append("/s");
|
||||
if (p.Eta is { } eta && eta.TotalSeconds > 1) sb.Append(" • ETA ").Append(FormatEta(eta));
|
||||
ProgressDetail = sb.ToString();
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private void OpenFolder()
|
||||
{
|
||||
var path = SelectedVersion?.Model.FolderPath ?? _config.InstallRoot;
|
||||
if (string.IsNullOrEmpty(path) || !Directory.Exists(path)) return;
|
||||
Process.Start(new ProcessStartInfo
|
||||
{
|
||||
FileName = path,
|
||||
UseShellExecute = true,
|
||||
Verb = "open"
|
||||
});
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private void CancelDownload()
|
||||
{
|
||||
_downloadCts?.Cancel();
|
||||
}
|
||||
|
||||
private static string FormatSize(long bytes)
|
||||
{
|
||||
if (bytes <= 0) return "0 o";
|
||||
string[] units = { "o", "Ko", "Mo", "Go", "To" };
|
||||
double v = bytes;
|
||||
int u = 0;
|
||||
while (v >= 1024 && u < units.Length - 1) { v /= 1024; u++; }
|
||||
return $"{v:0.#} {units[u]}";
|
||||
}
|
||||
|
||||
private static string FormatEta(TimeSpan eta)
|
||||
{
|
||||
if (eta.TotalHours >= 1) return $"{(int)eta.TotalHours}h{eta.Minutes:D2}";
|
||||
if (eta.TotalMinutes >= 1) return $"{(int)eta.TotalMinutes}m{eta.Seconds:D2}";
|
||||
return $"{(int)eta.TotalSeconds}s";
|
||||
}
|
||||
}
|
||||
194
src/PSLauncher.App/Views/MainWindow.xaml
Normal file
194
src/PSLauncher.App/Views/MainWindow.xaml
Normal file
@@ -0,0 +1,194 @@
|
||||
<Window x:Class="PSLauncher.App.Views.MainWindow"
|
||||
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:vm="clr-namespace:PSLauncher.App.ViewModels"
|
||||
xmlns:views="clr-namespace:PSLauncher.App.Views"
|
||||
Title="PROSERVE Launcher"
|
||||
Width="1280" Height="800"
|
||||
MinWidth="900" MinHeight="600"
|
||||
WindowStartupLocation="CenterScreen"
|
||||
d:DataContext="{d:DesignInstance Type=vm:MainViewModel}"
|
||||
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
|
||||
mc:Ignorable="d"
|
||||
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006">
|
||||
|
||||
<Grid>
|
||||
<Grid.RowDefinitions>
|
||||
<RowDefinition Height="Auto" /> <!-- Top bar -->
|
||||
<RowDefinition Height="*" /> <!-- Body -->
|
||||
<RowDefinition Height="Auto" /> <!-- Footer (download) -->
|
||||
</Grid.RowDefinitions>
|
||||
|
||||
<!-- Top bar -->
|
||||
<Border Grid.Row="0" Background="{StaticResource Brush.Bg.Sidebar}"
|
||||
BorderBrush="{StaticResource Brush.Border}" BorderThickness="0,0,0,1"
|
||||
Padding="20,12">
|
||||
<Grid>
|
||||
<TextBlock Text="PROSERVE" FontSize="18" FontWeight="Bold"
|
||||
Foreground="{StaticResource Brush.Text.Primary}"
|
||||
VerticalAlignment="Center" />
|
||||
<StackPanel HorizontalAlignment="Right" Orientation="Horizontal" VerticalAlignment="Center">
|
||||
<TextBlock Text="{Binding LicenseSummary}"
|
||||
Foreground="{StaticResource Brush.Text.Secondary}"
|
||||
VerticalAlignment="Center" Margin="0,0,12,0" />
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
</Border>
|
||||
|
||||
<!-- Body : sidebar + content -->
|
||||
<Grid Grid.Row="1">
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="200" />
|
||||
<ColumnDefinition Width="*" />
|
||||
</Grid.ColumnDefinitions>
|
||||
|
||||
<!-- Sidebar -->
|
||||
<Border Grid.Column="0" Background="{StaticResource Brush.Bg.Sidebar}"
|
||||
BorderBrush="{StaticResource Brush.Border}" BorderThickness="0,0,1,0">
|
||||
<StackPanel>
|
||||
<RadioButton Style="{StaticResource SidebarItem}" GroupName="Nav"
|
||||
Content="📚 Bibliothèque" IsChecked="True" />
|
||||
<RadioButton Style="{StaticResource SidebarItem}" GroupName="Nav"
|
||||
Content="📰 Nouveautés" IsEnabled="False" />
|
||||
<RadioButton Style="{StaticResource SidebarItem}" GroupName="Nav"
|
||||
Content="⚙️ Paramètres" IsEnabled="False" />
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<!-- Library content -->
|
||||
<ScrollViewer Grid.Column="1" VerticalScrollBarVisibility="Auto">
|
||||
<StackPanel Margin="40,30">
|
||||
|
||||
<!-- Hero -->
|
||||
<Border Background="#2A2A30" CornerRadius="8" Height="280"
|
||||
BorderBrush="{StaticResource Brush.Border}" BorderThickness="1">
|
||||
<Grid>
|
||||
<TextBlock Text="PROSERVE"
|
||||
FontSize="64" FontWeight="Bold"
|
||||
Foreground="#3D3D45"
|
||||
HorizontalAlignment="Center" VerticalAlignment="Center" />
|
||||
<Border VerticalAlignment="Bottom" HorizontalAlignment="Left"
|
||||
Margin="24" Background="#000000B0"
|
||||
CornerRadius="4" Padding="12,6">
|
||||
<TextBlock Text="{Binding HeroTitle}"
|
||||
FontSize="22" FontWeight="SemiBold"
|
||||
Foreground="White" />
|
||||
</Border>
|
||||
</Grid>
|
||||
</Border>
|
||||
|
||||
<!-- Action row -->
|
||||
<Grid Margin="0,24,0,0">
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="Auto" />
|
||||
<ColumnDefinition Width="*" />
|
||||
<ColumnDefinition Width="Auto" />
|
||||
</Grid.ColumnDefinitions>
|
||||
|
||||
<!-- Version selector -->
|
||||
<StackPanel Grid.Column="0" Orientation="Horizontal" VerticalAlignment="Center">
|
||||
<TextBlock Text="Version :" Foreground="{StaticResource Brush.Text.Secondary}"
|
||||
VerticalAlignment="Center" Margin="0,0,10,0" />
|
||||
<ComboBox Width="160"
|
||||
ItemsSource="{Binding InstalledVersions}"
|
||||
DisplayMemberPath="Display"
|
||||
SelectedItem="{Binding SelectedVersion, Mode=TwoWay}" />
|
||||
<Border Style="{StaticResource VersionPill}" Margin="12,0,0,0">
|
||||
<TextBlock Text="{Binding StatusBadge}" FontSize="12"
|
||||
Foreground="{StaticResource Brush.Text.Primary}" />
|
||||
</Border>
|
||||
</StackPanel>
|
||||
|
||||
<!-- Primary action -->
|
||||
<Button Grid.Column="2"
|
||||
Style="{StaticResource PrimaryButton}"
|
||||
Content="{Binding PrimaryActionLabel}"
|
||||
Command="{Binding PrimaryActionCommand}"
|
||||
IsEnabled="{Binding PrimaryActionEnabled}" />
|
||||
</Grid>
|
||||
|
||||
<!-- Secondary actions -->
|
||||
<StackPanel Orientation="Horizontal" Margin="0,16,0,0">
|
||||
<Button Style="{StaticResource SecondaryButton}"
|
||||
Content="🔄 Vérifier les mises à jour"
|
||||
Command="{Binding CheckForUpdatesCommand}"
|
||||
Margin="0,0,10,0" />
|
||||
<Button Style="{StaticResource SecondaryButton}"
|
||||
Content="📁 Ouvrir le dossier"
|
||||
Command="{Binding OpenFolderCommand}"
|
||||
Margin="0,0,10,0" />
|
||||
<Button Style="{StaticResource SecondaryButton}"
|
||||
Content="🔍 Rescanner local"
|
||||
Command="{Binding RefreshCommand}" />
|
||||
</StackPanel>
|
||||
|
||||
<!-- Installed versions list -->
|
||||
<TextBlock Text="Versions installées" FontSize="16" FontWeight="SemiBold"
|
||||
Margin="0,30,0,12"
|
||||
Foreground="{StaticResource Brush.Text.Primary}" />
|
||||
<ItemsControl ItemsSource="{Binding InstalledVersions}">
|
||||
<ItemsControl.ItemTemplate>
|
||||
<DataTemplate>
|
||||
<Border Background="{StaticResource Brush.Bg.Card}"
|
||||
CornerRadius="6" Margin="0,0,0,8" Padding="16,12"
|
||||
BorderBrush="{StaticResource Brush.Border}" BorderThickness="1">
|
||||
<Grid>
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="*" />
|
||||
<ColumnDefinition Width="Auto" />
|
||||
</Grid.ColumnDefinitions>
|
||||
<StackPanel Grid.Column="0">
|
||||
<TextBlock Text="{Binding Display}"
|
||||
FontSize="15" FontWeight="SemiBold" />
|
||||
<TextBlock Text="{Binding Subtitle}"
|
||||
FontSize="12"
|
||||
Foreground="{StaticResource Brush.Text.Secondary}"
|
||||
Margin="0,2,0,0" />
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
</Border>
|
||||
</DataTemplate>
|
||||
</ItemsControl.ItemTemplate>
|
||||
</ItemsControl>
|
||||
|
||||
<!-- Empty state -->
|
||||
<TextBlock Text="{Binding EmptyHint}"
|
||||
Foreground="{StaticResource Brush.Text.Secondary}"
|
||||
TextWrapping="Wrap" Margin="0,12,0,0"
|
||||
Visibility="{Binding EmptyHintVisibility}" />
|
||||
|
||||
</StackPanel>
|
||||
</ScrollViewer>
|
||||
</Grid>
|
||||
|
||||
<!-- Footer (download/install status) -->
|
||||
<Border Grid.Row="2" Background="{StaticResource Brush.Bg.Footer}"
|
||||
BorderBrush="{StaticResource Brush.Border}" BorderThickness="0,1,0,0"
|
||||
Padding="20,8"
|
||||
Visibility="{Binding FooterVisibility}">
|
||||
<Grid>
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="*" />
|
||||
<ColumnDefinition Width="Auto" />
|
||||
</Grid.ColumnDefinitions>
|
||||
<StackPanel Grid.Column="0">
|
||||
<TextBlock Text="{Binding FooterText}"
|
||||
Foreground="{StaticResource Brush.Text.Secondary}" />
|
||||
<ProgressBar Height="4" Margin="0,4,0,0"
|
||||
Value="{Binding ProgressPercent}"
|
||||
Maximum="100"
|
||||
Foreground="{StaticResource Brush.Accent}"
|
||||
Background="#2A2A30"
|
||||
BorderThickness="0" />
|
||||
</StackPanel>
|
||||
<Button Grid.Column="1"
|
||||
Style="{StaticResource SecondaryButton}"
|
||||
Content="Annuler"
|
||||
VerticalAlignment="Center"
|
||||
Margin="12,0,0,0"
|
||||
Command="{Binding CancelDownloadCommand}"
|
||||
Visibility="{Binding IsBusy, Converter={StaticResource BoolToVisibility}}" />
|
||||
</Grid>
|
||||
</Border>
|
||||
</Grid>
|
||||
</Window>
|
||||
11
src/PSLauncher.App/Views/MainWindow.xaml.cs
Normal file
11
src/PSLauncher.App/Views/MainWindow.xaml.cs
Normal file
@@ -0,0 +1,11 @@
|
||||
using System.Windows;
|
||||
|
||||
namespace PSLauncher.App.Views;
|
||||
|
||||
public partial class MainWindow : Window
|
||||
{
|
||||
public MainWindow()
|
||||
{
|
||||
InitializeComponent();
|
||||
}
|
||||
}
|
||||
50
src/PSLauncher.App/Views/UpdateAvailableDialog.xaml
Normal file
50
src/PSLauncher.App/Views/UpdateAvailableDialog.xaml
Normal file
@@ -0,0 +1,50 @@
|
||||
<Window x:Class="PSLauncher.App.Views.UpdateAvailableDialog"
|
||||
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
Title="Mise à jour disponible"
|
||||
Width="640" Height="540"
|
||||
MinWidth="480" MinHeight="400"
|
||||
WindowStartupLocation="CenterOwner"
|
||||
Background="{StaticResource Brush.Bg.Window}">
|
||||
<Grid Margin="24">
|
||||
<Grid.RowDefinitions>
|
||||
<RowDefinition Height="Auto" />
|
||||
<RowDefinition Height="Auto" />
|
||||
<RowDefinition Height="*" />
|
||||
<RowDefinition Height="Auto" />
|
||||
</Grid.RowDefinitions>
|
||||
|
||||
<TextBlock Grid.Row="0"
|
||||
Text="{Binding Title}"
|
||||
FontSize="22" FontWeight="SemiBold"
|
||||
Foreground="{StaticResource Brush.Text.Primary}" />
|
||||
|
||||
<TextBlock Grid.Row="1"
|
||||
Text="{Binding Subtitle}"
|
||||
Foreground="{StaticResource Brush.Text.Secondary}"
|
||||
Margin="0,4,0,18" />
|
||||
|
||||
<Border Grid.Row="2"
|
||||
Background="{StaticResource Brush.Bg.Card}"
|
||||
BorderBrush="{StaticResource Brush.Border}" BorderThickness="1"
|
||||
CornerRadius="6"
|
||||
Padding="16">
|
||||
<FlowDocumentScrollViewer
|
||||
Document="{Binding ReleaseNotesDocument}"
|
||||
Foreground="{StaticResource Brush.Text.Primary}"
|
||||
Background="Transparent" />
|
||||
</Border>
|
||||
|
||||
<StackPanel Grid.Row="3" Orientation="Horizontal" HorizontalAlignment="Right" Margin="0,18,0,0">
|
||||
<Button Style="{StaticResource SecondaryButton}"
|
||||
Content="Plus tard"
|
||||
IsCancel="True"
|
||||
Margin="0,0,12,0"
|
||||
Click="OnLater" />
|
||||
<Button Style="{StaticResource PrimaryButton}"
|
||||
Content="⬇ Télécharger"
|
||||
Padding="32,10"
|
||||
Click="OnDownload" />
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
</Window>
|
||||
61
src/PSLauncher.App/Views/UpdateAvailableDialog.xaml.cs
Normal file
61
src/PSLauncher.App/Views/UpdateAvailableDialog.xaml.cs
Normal file
@@ -0,0 +1,61 @@
|
||||
using System.Windows;
|
||||
using System.Windows.Documents;
|
||||
using Markdig;
|
||||
using Markdig.Wpf;
|
||||
using PSLauncher.Models;
|
||||
|
||||
namespace PSLauncher.App.Views;
|
||||
|
||||
public partial class UpdateAvailableDialog : Window
|
||||
{
|
||||
public UpdateAvailableDialog(VersionManifest version, string releaseNotesMarkdown)
|
||||
{
|
||||
InitializeComponent();
|
||||
|
||||
var pipeline = new MarkdownPipelineBuilder()
|
||||
.UseSupportedExtensions()
|
||||
.Build();
|
||||
FlowDocument doc;
|
||||
try
|
||||
{
|
||||
doc = Markdig.Wpf.Markdown.ToFlowDocument(releaseNotesMarkdown, pipeline);
|
||||
}
|
||||
catch
|
||||
{
|
||||
doc = new FlowDocument(new Paragraph(new Run(releaseNotesMarkdown)));
|
||||
}
|
||||
|
||||
DataContext = new
|
||||
{
|
||||
Title = $"Proserve v{version.Version} disponible",
|
||||
Subtitle = $"Date de release : {version.ReleasedAt.ToLocalTime():dd MMMM yyyy} • {FormatSize(version.Download.SizeBytes)}",
|
||||
ReleaseNotesDocument = doc
|
||||
};
|
||||
}
|
||||
|
||||
public bool DownloadRequested { get; private set; }
|
||||
|
||||
private void OnDownload(object sender, RoutedEventArgs e)
|
||||
{
|
||||
DownloadRequested = true;
|
||||
DialogResult = true;
|
||||
Close();
|
||||
}
|
||||
|
||||
private void OnLater(object sender, RoutedEventArgs e)
|
||||
{
|
||||
DownloadRequested = false;
|
||||
DialogResult = false;
|
||||
Close();
|
||||
}
|
||||
|
||||
private static string FormatSize(long bytes)
|
||||
{
|
||||
if (bytes <= 0) return "taille inconnue";
|
||||
string[] units = { "o", "Ko", "Mo", "Go", "To" };
|
||||
double v = bytes;
|
||||
int u = 0;
|
||||
while (v >= 1024 && u < units.Length - 1) { v /= 1024; u++; }
|
||||
return $"{v:0.#} {units[u]}";
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user