Compare commits

...

13 Commits

Author SHA1 Message Date
7fb02e4945 v0.9.0 — DL UX: cancel/restart confirmations, auto-revalidate license, fix progress refresh
UX
- Red "Annuler" button next to "↻ Reprendre" on rows with a partial download.
  Confirmation MessageBox before discarding the .partial + state.json.
- "Recommencer depuis zéro" entry in the row's "..." context menu, visible
  only when a partial exists.
- Cancel button visibility tied to a new ShowRestartFromZero property
  (HasResumableDownload && State == AvailableIdle): hides during active DL
  to avoid colliding with the inline cancel.
- ResumableBytes refreshed from state.json after cancel/error so the
  "Reprendre (X%)" label reflects the actual percentage reached, not the
  stale value from launcher startup.
- MainWindow.Closing prompts for confirmation if a download is in progress
  (HasActiveDownload), so an accidental ✕ doesn't waste a 14 GB transfer.
- "🔧 Préparation du téléchargement v…" status set immediately on click and
  kept visible during HEAD/SetLength so the user knows something's happening
  before the first byte. ProgressDetail wired with NotifyPropertyChangedFor
  (FooterText) so download speed shows during DL and not just at install
  transition.

Auto-revalidation
- CheckForUpdatesAsync now runs RefreshLicenseFromServerAsync first: every
  startup auto-check + every manual "Vérifier les MAJ" click pulls the
  latest license state from /api/license/validate. Date changes / revocations
  done in the backoffice propagate to clients without waiting for the 100-day
  cache to expire. Silent fallback to cached state if offline.

Version bumped to 0.9.0.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 19:16:14 +02:00
07d58a8854 Licensing: 100-day offline cache with anti-rollback + cached server status
- CachedValidityDays bumped 7 → 100 days. Covers the "user goes online once
  every 3 months for updates" use case without repeatedly nagging for re-auth.
- LastValidationAt now uses response.ServerTime (signed Ed25519 by the server)
  rather than DateTime.UtcNow, so the client's local clock can't be used to
  forge a fresh validation date.
- LastSeenUtc monotonic counter, persisted on every launch as max(stored, now).
  Combined with a 1h grace window: if the user rolls their PC clock backward
  to extend the offline cache, the rollback is detected (now < LastSeen - 1h)
  and the cache is invalidated → forced re-validation next time online.
- CachedStatus persists the server-returned status (valid/expired/revoked/
  invalid/machine_limit_exceeded) so a revocation done while the user is
  offline still shows correctly when they next launch with cached data.
  Auto-override to "expired" if entitlement date has passed (handles the
  valid → expired transition without needing a server round-trip).
- ILicenseService.GetDecryptedKey() exposed for the new auto-revalidation flow.

This puts the trust boundary in the right place: cached data is informational
offline, the actual download authorization always re-validates against the
server (the gate to download is /api/download-url/, which checks the live
license state on each call). User can't fake offline-only.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 19:15:49 +02:00
9cea07d6be Downloads: parallel multi-segment, sparse pre-alloc, faster SHA-256, URL auto-refresh
DownloadManager
- Parallel multi-segment download (default 8 connections, configurable up to 16)
  with per-segment Range requests. Bypasses OVH/Apache per-connection bandwidth
  throttling — typical speed-up ×4 to ×8 vs single connection.
- Reporter task on a dedicated Task with Interlocked aggregate counter (no lock
  contention with workers). Reports speed/ETA every 250ms even mid-download,
  fixes the "speed only shows at install transition" bug.
- Sparse file pre-allocation via FSCTL_SET_SPARSE before SetLength: no zero-fill,
  no SeManageVolumePrivilege, instant on any disk type. Removes 5-30s of
  preparation lag on HDD.
- HEAD probe skipped (trust manifest size, signed Ed25519). Falls back to
  single-segment if first segment returns 200 instead of 206.
- Resume URL comparison fixed: ignores HMAC querystring (?exp=&sig=) which
  changes per request, compares only host+path. Previously every resume started
  fresh because the old URL never matched the freshly signed one.
- Auto-refresh signed URL on 403/410 mid-DL: SemaphoreSlim with 5s debounce so
  8 simultaneous segment expirations trigger a single /api/download-url/ call.
  Slow-connection users (1 Mbps, 30+ hours for 14 GB) keep downloading
  transparently across multiple TTL cycles.
- Per-version hashAlgorithm:none in manifest skips client SHA-256 verification
  (still relying on Ed25519 manifest signature + HMAC URL).
- DangerButton style (red) added to Theme.xaml for the new cancel-resume action.

IntegrityService
- 16 MiB buffer (was 1 MiB), FileOptions.SequentialScan + Asynchronous,
  IncrementalHash (uses SHA-NI hardware extensions on .NET 8), double-buffering
  to overlap CPU and I/O. Typical 14 GB hash verification: 60-180s → 15-40s.

HttpClient
- MaxConnectionsPerServer=16, EnableMultipleHttp2Connections, HTTP/2 preferred,
  AutomaticDecompression=None (ZIPs are already compressed), 5min pooled
  connection lifetime.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 19:15:27 +02:00
b4f205cbe5 i18n: full pass + culture-aware dates/sizes + Software Update License wording
- Strings.cs : ~80 new keys covering status messages, progress detail
  (DL/extraction/verify), license summary, license details dialog, onboarding
  statuses, settings field labels, restart/cancel/quit confirmations, toasts,
  resume choice, copyright. Strings.FormatSize and Strings.FormatDate /
  FormatLongDate switch units (o/Ko vs B/KB) and pattern (dd/MM/yyyy vs M/d/yyyy
  vs 2026年) by active language.
- License terminology renamed across UI: "License" → "License de mise à jour"
  / "Software Update License" to clarify it gates UPDATES, not Proserve itself.
  Expired/revoked dialogs now spell out "you can still download versions
  released before this date and launch any installed version".
- "🔒 License insuffisante" → "🔒 License de mise à jour requise pour cette
  version" / "Valid update license needed for this version" so users understand
  it's a per-version eligibility check, not a global block.
- All hardcoded FR strings in views (SettingsDialog labels, LicenseDetails
  fields, Onboarding statuses, dialog titles, copyright, window chrome
  tooltips, "Sortie le", etc.) replaced with x:Static loc bindings.
- All FormatSize duplicates (5 places) and date format strings (8 places)
  delegate to Strings helpers — single source of truth for localization.
- Settings dialog: License section moved to the top before Language. It's
  the most important info and conditions what the user can download.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 19:14:57 +02:00
962f5a8ce0 Server: faster releases (cache hashes), per-version skip, longer signed-URL TTL
- gate.php : ETag now derived from size+mtime instead of md5_file($zip).
  Previously the gate hashed the entire 14 GB ZIP on every HEAD/GET call
  (including each of 8 parallel segments) → 30s-5min preparation lag for
  clients before the first byte. Now <1ms per request.
- SignManifest : disk-backed cache for SHA-256 keyed by (path,size,mtime).
  Re-signing 5×14 GB versions used to take ~25 min, now ~1s when nothing
  changed. New "Force re-hash" toggle in admin to ignore the cache.
- versions.php : per-row "🔁 Hash" button to sign a single version, plus
  a "⚙ Hash" dropdown to toggle hashAlgorithm:none for builds where the
  user accepts skipping client-side verification (manifest stays signed
  Ed25519, only the per-ZIP SHA-256 verification is bypassed).
- DownloadUrl.php : signed-URL TTL bumped 1h → 6h to cover slow ADSL users
  who need >1h to finish a 14 GB download.
- .gitignore : track server/builds/.htaccess + gate.php (still ignore the
  actual ZIP/exe binaries).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 19:14:32 +02:00
0d4f126e3a i18n: localize all MessageBox dialogs
The previous batch only covered XAML strings. The runtime MessageBox
dialogs (errors, confirmations, info popups) stayed hardcoded in
French, breaking immersion for non-FR users.

Added ~15 keys to Strings.cs for the MessageBox bodies and titles:
- error / launch error / confirm / info / patience / language change /
  release notes (titles)
- launch failed / self-update failed / install failed / uninstall
  failed / uninstall confirm with version+folder+size / no release
  notes / fetch failed / clear cache confirm / deactivate license
  (short and detailed) / language restart (bodies)

Some are parametrized (MsgLaunchFailed(detail), MsgUninstallConfirm
(version, folder, size)) so the localized strings interpolate the
runtime values cleanly across all 5 languages.

Replaced every MessageBox.Show in MainViewModel, SettingsViewModel
and LicenseDetailsDialog.xaml.cs to use these keys. The "Échec : "
prefix in the cache-clear error was dropped — the error message alone
is sufficient with Strings.MsgBoxError as the dialog title.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 13:56:52 +02:00
b9ce591d22 ComboBox fixes: clickable center + display the option name, not the record
Two issues with the language picker after the dark restyling:

1. Click on the center of the ComboBox was a no-op — only the arrow
   triggered the dropdown. The previous template had a separate
   ToggleButton in column 1 covering only the arrow region. New
   template wraps the entire body in a single ToggleButton (with its
   own template carrying the border + arrow), and the ContentPresenter
   for the selected text floats on top with IsHitTestVisible="False"
   so clicks pass through to the toggle.

2. Selected item showed "LanguageOption { Code = auto, Name = ... }"
   instead of just the name. C# record types stringify with property
   names by default. Override LanguageOption.ToString() to return Name
   so the SelectionBox falls back to a clean label even though
   DisplayMemberPath="Name" is set on the dropdown items.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 13:44:00 +02:00
5d0284f1db Theme: dark ComboBox to match the rest of the launcher chrome
The native WPF ComboBox style is light grey on white — unreadable on
the dark Settings dialog. Re-template both ComboBox and ComboBoxItem:

- Body: #1A1A20 background (same as other inputs), border with hover
  highlight in Brush.Accent.
- Custom toggle button arrow in secondary text color, brightens on
  hover.
- Popup: Brush.Bg.Card background with border, 4px radius, 300px max
  height, scrollable.
- Items: highlighted hover in Brush.Accent with white foreground
  (same convention as MenuItem). Selected item gets a subtle
  #2C2C32 strip so the current value is visible after closing.

Affects every ComboBox in the app — only one for now (language
picker in Settings) but stays consistent if more get added later.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 13:10:14 +02:00
01a11d5616 i18n: 5 languages (FR/EN/ZH/TH/AR), auto-detect from Windows + Settings picker
Localization infrastructure
---------------------------
PSLauncher.Core/Localization/Strings.cs is a static class exposing each
UI string as a static property. T(fr,en,zh,th,ar) helper switches by the
current language code. ~50 keys cover the visible top-level strings:
top bar, body, status badges, action buttons, "..." menu items, license
badge texts, Settings section headers, Onboarding dialog, Update dialog.

Bindings via x:Static in XAML:
  xmlns:loc="clr-namespace:PSLauncher.Core.Localization;assembly=PSLauncher.Core"
  Content="{x:Static loc:Strings.ActionLaunch}"

Auto-detection
--------------
LocalConfig gains a Language field defaulting to "auto". Strings.Init()
called at App.xaml.cs OnStartup before any UI:
- "auto" (or unknown code) → reads CultureInfo.CurrentUICulture
  .TwoLetterISOLanguageName, picks the matching supported language,
  falls back to English.
- explicit code → forced.
The chosen culture is then propagated to CurrentCulture / CurrentUICulture
so date/number formats follow.

Settings picker
---------------
SettingsDialog gets a top section "LANGUE" with a ComboBox bound to
SettingsViewModel.AvailableLanguages (Auto / FR / EN / ZH / TH / AR).
On Save, if the language code changed, prompt the user to confirm
restart, spawn `cmd /c timeout 1 & start PSLauncher.exe` and Shutdown
the current process — the new instance picks up the language at
bootstrap.

RTL for Arabic
--------------
Strings.IsRightToLeft is true when lang=="ar". App.xaml.cs sets
window.FlowDirection = RightToLeft on the MainWindow — WPF mirrors the
layout (icons on right, text aligned right).

Translations
------------
Done as best-effort by the assistant. The product wordmark "PROSERVE"
stays untranslated (brand). Logs and debug-level messages remain in
French in code — only user-visible UI is localized. Operator can
refine translations by editing Strings.cs and rebuilding.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 13:02:50 +02:00
9c1b34b041 Compact rows: full-height accent strip with rounded left corners 2026-05-02 12:50:27 +02:00
ddfebb8611 Featured card: round the left edge of the accent strip to match card corners 2026-05-02 12:48:13 +02:00
2e320d2ab5 Brand: install folder uses uppercase PROSERVE v{version}
The product wordmark is rendered uppercase everywhere in the UI
(PROSERVE in Pirulen). Align the install directory naming so it reads
"PROSERVE v1.4.7" instead of "Proserve v1.4.7" — same brand, same case
on disk and in dialog titles.

Changes:
- server/manifest/versions.json: installFolderTemplate updated for both
  existing entries.
- server/admin/versions.php: default template for new versions added
  via the backoffice form.
- src/PSLauncher.Models/RemoteManifest.cs: default fallback for the
  property when missing from JSON.
- src/PSLauncher.App/Views/MainWindow.xaml + dialogs + ViewModel
  toasts: UI strings now read "PROSERVE v..." consistent with the brand.

InstallationRegistry's regex was already RegexOptions.IgnoreCase, so
existing user installs in "Proserve v..." folders keep working
(case-insensitive on Windows filesystems anyway). Re-installing an
older version after the change re-creates the folder with the new
case — Windows is case-preserving but case-insensitive, so launching
remains identical.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 12:46:26 +02:00
7a12153343 LaunchVersion: restore launcher window when Proserve exits
After clicking Lancer, the launcher minimizes to the taskbar so Proserve
has the foreground. Add an Exited event handler on the launched Process
that brings the launcher back from the taskbar once the game closes.

EnableRaisingEvents is required (false by default) to receive Exited.
The handler runs on a worker thread so we marshal back to the UI via
Dispatcher.Invoke before touching WindowState. A brief Topmost=true /
false toggle nudges the window to the foreground without permanently
locking it on top.

Wrapped in try/catch — some ShellExecute spawns return a Process with
limited access (no event support), in which case we just log and let
the user click the taskbar icon manually.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 12:41:54 +02:00
38 changed files with 2661 additions and 371 deletions

5
.gitignore vendored
View File

@@ -30,8 +30,13 @@ ASTERION_VR/
# Server-side secrets — never commit (config.example.php sert de template)
server/api/config.php
# Le contenu de server/builds/ est ignoré (gros ZIPs / launcher.exe), mais on
# garde le code (.htaccess, gate.php, .gitkeep) sous version.
server/builds/*
!server/builds/.gitkeep
!server/builds/.htaccess
!server/builds/gate.php
!server/builds/launcher/.gitkeep
# Plans
.claude/

View File

@@ -70,8 +70,10 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
elseif ($action === 'sync_launcher') {
require_once "$root/tools/SignManifest.php";
$signer = new \PSLauncher\Tools\SignManifest($root);
$result = $signer->run('launcher');
$message = "Sortie du script (scope: launcher) :\n" . implode("\n", $result['log']);
$force = !empty($_POST['force']);
$result = $signer->run('launcher', $force);
$forceLabel = $force ? ' [FORCE: cache ignoré]' : '';
$message = "Sortie du script (scope: launcher{$forceLabel}) :\n" . implode("\n", $result['log']);
if (!$result['ok']) $messageType = 'error';
}
} catch (Exception $e) {
@@ -159,6 +161,9 @@ Layout::header('Launcher', 'launcher');
<?= Layout::csrfField() ?>
<input type="hidden" name="action" value="sync_launcher">
<button class="btn btn-primary" type="submit">🔁 Hasher le launcher + signer</button>
<label style="margin-left:12px;font-size:12px;color:#888" title="Recalcule le SHA-256 même si l'exe n'a pas changé.">
<input type="checkbox" name="force" value="1"> Force re-hash
</label>
</form>
<?php if ($launcher): ?>

View File

@@ -64,7 +64,7 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
'version' => $version,
'releasedAt' => $releasedAtIso,
'executable' => 'PROSERVE_UE_5_5.exe',
'installFolderTemplate' => 'Proserve v{version}',
'installFolderTemplate' => 'PROSERVE v{version}',
'download' => [
'url' => "{$base}/builds/proserve-{$version}.zip",
'sizeBytes' => 0,
@@ -136,11 +136,56 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
require_once "$root/tools/SignManifest.php";
$signer = new \PSLauncher\Tools\SignManifest($root);
$scope = $action === 'sync_versions' ? 'versions' : 'all';
$result = $signer->run($scope);
$force = !empty($_POST['force']);
$result = $signer->run($scope, $force);
$label = $scope === 'versions' ? 'versions Proserve' : 'manifest complet';
$message = "Sortie du script (scope: {$label}) :\n" . implode("\n", $result['log']);
$forceLabel = $force ? ' [FORCE: cache ignoré]' : ' [cache utilisé pour les ZIPs inchangés]';
$message = "Sortie du script (scope: {$label}{$forceLabel}) :\n" . implode("\n", $result['log']);
if (!$result['ok']) $messageType = 'error';
}
elseif ($action === 'sync_one') {
$version = $_POST['version'] ?? '';
if ($version === '') throw new Exception("Version manquante");
require_once "$root/tools/SignManifest.php";
$signer = new \PSLauncher\Tools\SignManifest($root);
$force = !empty($_POST['force']);
$result = $signer->run('versions', $force, $version);
$forceLabel = $force ? ' [FORCE]' : '';
$message = "Hash de v{$version}{$forceLabel} :\n" . implode("\n", $result['log']);
if (!$result['ok']) $messageType = 'error';
}
elseif ($action === 'set_skip_hash') {
$version = $_POST['version'] ?? '';
$skipHash = !empty($_POST['skip']);
foreach ($manifest['versions'] as &$v) {
if ($v['version'] === $version) {
if ($skipHash) {
$v['download']['hashAlgorithm'] = 'none';
$v['download']['sha256'] = '';
} else {
unset($v['download']['hashAlgorithm']);
if (empty($v['download']['sha256'])) {
$v['download']['sha256'] = 'REPLACE_AFTER_BUILD';
}
}
break;
}
}
unset($v);
saveManifest($manifestPath, $manifest);
// Re-signature auto pour garder le manifest valide après le changement.
// En mode skip, on n'a rien à hasher : on appelle run() qui se contentera
// de détecter hashAlgorithm=none et re-signera. Pas de calcul lourd.
require_once "$root/tools/SignManifest.php";
$signer = new \PSLauncher\Tools\SignManifest($root);
$resign = $signer->run('versions', false);
$message = ($skipHash
? "Vérif SHA-256 désactivée pour v{$version} et manifest re-signé."
: "Vérif SHA-256 réactivée pour v{$version}. Clique « 🔁 Hash » sur cette ligne pour calculer le SHA-256.")
. "\n" . implode("\n", $resign['log']);
if (!$resign['ok']) $messageType = 'error';
}
} catch (Exception $e) {
$message = $e->getMessage();
$messageType = 'error';
@@ -221,6 +266,9 @@ Layout::header('Versions', 'versions');
<?= Layout::csrfField() ?>
<input type="hidden" name="action" value="sync_versions">
<button class="btn btn-primary" type="submit">🔁 Hasher les versions + signer</button>
<label style="margin-left:12px;font-size:12px;color:#888" title="Recalcule TOUS les SHA-256 même si les ZIPs n'ont pas changé. Lent. Cocher seulement en cas de doute sur le cache.">
<input type="checkbox" name="force" value="1"> Force re-hash
</label>
</form>
</div>
@@ -241,6 +289,7 @@ Layout::header('Versions', 'versions');
$zipBase = basename(parse_url($v['download']['url'], PHP_URL_PATH) ?? '');
$zipExists = in_array($zipBase, $zips, true);
$hashed = !empty($v['download']['sha256']) && !str_starts_with($v['download']['sha256'], 'REPLACE');
$hashSkipped = strtolower((string)($v['download']['hashAlgorithm'] ?? 'sha256')) === 'none';
?>
<tr>
<td><strong>v<?= htmlspecialchars($v['version']) ?></strong></td>
@@ -256,7 +305,9 @@ Layout::header('Versions', 'versions');
<?php endif; ?>
</td>
<td>
<?php if ($hashed): ?>
<?php if ($hashSkipped): ?>
<span class="badge badge-warning" title="hashAlgorithm=none : la vérif SHA-256 est désactivée pour cette release">SKIP</span>
<?php elseif ($hashed): ?>
<code title="<?= htmlspecialchars($v['download']['sha256']) ?>"><?= substr($v['download']['sha256'], 0, 12) ?>…</code>
<?php else: ?>
<span class="badge badge-warning">à calculer</span>
@@ -273,6 +324,47 @@ Layout::header('Versions', 'versions');
</form>
</td>
<td style="text-align: right; white-space: nowrap;">
<?php if ($zipExists && !$hashSkipped): ?>
<form method="post" style="display:inline" title="Recalcule le SHA-256 de cette version uniquement (utilise le cache si le ZIP n'a pas changé)">
<?= Layout::csrfField() ?>
<input type="hidden" name="action" value="sync_one">
<input type="hidden" name="version" value="<?= htmlspecialchars($v['version']) ?>">
<button class="btn btn-secondary" type="submit">🔁 Hash</button>
</form>
<?php endif; ?>
<details style="display: inline-block; margin: 0 4px;">
<summary class="btn btn-secondary">⚙ Hash</summary>
<div style="margin-top: 8px; min-width: 280px;">
<p class="muted" style="font-size: 12px; margin: 0 0 8px;">
<?php if ($hashSkipped): ?>
La vérif SHA-256 est <strong>désactivée</strong> pour cette release.
Le manifest sert <code>hashAlgorithm: "none"</code>. La sécurité repose
uniquement sur la signature Ed25519 du manifest + HTTPS.
<?php else: ?>
Désactive la vérif SHA-256 chez les clients pour cette release
(utile sur très gros builds où on accepte le compromis perf/sécurité).
<?php endif; ?>
</p>
<form method="post" style="display:inline">
<?= Layout::csrfField() ?>
<input type="hidden" name="action" value="set_skip_hash">
<input type="hidden" name="version" value="<?= htmlspecialchars($v['version']) ?>">
<input type="hidden" name="skip" value="<?= $hashSkipped ? '0' : '1' ?>">
<button class="btn <?= $hashSkipped ? 'btn-success' : 'btn-warning' ?>" type="submit">
<?= $hashSkipped ? '✓ Réactiver la vérif' : '⏭ Désactiver la vérif' ?>
</button>
</form>
<?php if ($zipExists): ?>
<form method="post" style="margin-top: 8px;" title="Recalcule en ignorant le cache même si le ZIP n'a pas changé">
<?= Layout::csrfField() ?>
<input type="hidden" name="action" value="sync_one">
<input type="hidden" name="version" value="<?= htmlspecialchars($v['version']) ?>">
<input type="hidden" name="force" value="1">
<button class="btn btn-secondary" type="submit">🔄 Force re-hash</button>
</form>
<?php endif; ?>
</div>
</details>
<details style="display: inline-block; margin: 0 4px;">
<summary class="btn btn-secondary">Méta</summary>
<form method="post" style="margin-top: 8px;">

View File

@@ -96,9 +96,14 @@ final class DownloadUrl
}
// Génère l'URL HMAC-signée
// TTL : 6 h. Compromis entre :
// - sécurité (limite la fenêtre de replay si une URL fuit)
// - utilisabilité (un user en ADSL 8 Mbps mettra ~4 h pour DL 14 Go)
// Pour une connexion plus lente, le client sait auto-refresher l'URL
// pendant le DL (cf DownloadManager → 403 retry avec nouvelle URL).
$baseUrl = rtrim($config['base_url'], '/');
$relPath = '/builds/proserve-' . $version . '.zip';
$exp = time() + 3600; // 1 h
$exp = time() + 21600; // 6 h
$secret = $config['hmac_secret'] ?? '';
if ($secret === '') {
Response::error('config_error', 'hmac_secret non configuré', 500);

9
server/builds/.htaccess Normal file
View File

@@ -0,0 +1,9 @@
# Tout accès direct à un .zip passe d'abord par le gardien gate.php qui vérifie
# la signature HMAC + l'expiration. Si OK, le gardien sert le fichier (avec
# support Range pour la reprise). Sinon, 403.
RewriteEngine On
RewriteBase /PS_Launcher/builds/
RewriteCond %{REQUEST_FILENAME} -f
RewriteCond %{REQUEST_URI} \.zip$
RewriteRule ^(.*)$ gate.php?file=$1 [QSA,L]

93
server/builds/gate.php Normal file
View File

@@ -0,0 +1,93 @@
<?php
/**
* Gardien des téléchargements de builds. Sert un .zip seulement si l'URL est
* accompagnée d'une signature HMAC valide non expirée, signée par la même
* clé que /api/download-url/{version}. Supporte les Range: requests pour la
* reprise (dégradé : on utilise readfile + fseek manuellement).
*
* Pour les hébergements où on veut désactiver la protection (tests internes), il
* suffit de retirer le RewriteRule du .htaccess voisin — Apache servira alors
* directement le fichier statique avec son support natif des Range.
*/
declare(strict_types=1);
ini_set('display_errors', '0');
set_time_limit(0);
ignore_user_abort(true);
require __DIR__ . '/../api/lib/Crypto.php';
$config = require __DIR__ . '/../api/config.php';
$secret = $config['hmac_secret'] ?? '';
function deny(string $reason, int $code = 403): never
{
http_response_code($code);
header('Content-Type: text/plain; charset=utf-8');
echo "Forbidden: $reason\n";
exit;
}
$file = trim((string)($_GET['file'] ?? ''), '/');
if ($file === '' || !preg_match('/^[a-zA-Z0-9._-]+\.zip$/', $file)) deny('invalid file');
$path = __DIR__ . '/' . $file;
if (!is_file($path)) deny('not found', 404);
$exp = (int)($_GET['exp'] ?? 0);
$lic = (string)($_GET['lic'] ?? '');
$sig = (string)($_GET['sig'] ?? '');
if ($exp <= 0 || $lic === '' || $sig === '') deny('missing params');
if ($exp < time()) deny('expired');
if ($secret === '') deny('server config error', 500);
$relPath = '/builds/' . $file;
$expected = \PSLauncher\Crypto::hmacHex($relPath . '|' . $exp . '|' . $lic, $secret);
if (!hash_equals($expected, $sig)) deny('bad signature');
// Sert le fichier avec support Range
$size = filesize($path);
$mtime = filemtime($path) ?: 0;
$start = 0;
$end = $size - 1;
header('Content-Type: application/zip');
header('Accept-Ranges: bytes');
// ⚠️ NE PAS utiliser md5_file($path) ici : sur un fichier 14 Go, ça relit
// l'intégralité du ZIP à chaque requête (~30 s-5 min selon disque). Avec
// 8 segments parallèles + HEAD probe, ça multipliait par 9 le temps avant
// le premier byte. On utilise un ETag dérivé size+mtime, équivalent à ce
// qu'Apache fait nativement pour les fichiers statiques. Change quand on
// upload un nouveau ZIP, donc cache invalidation correcte.
header('ETag: "' . dechex($size) . '-' . dechex($mtime) . '"');
header('Last-Modified: ' . gmdate('D, d M Y H:i:s', $mtime) . ' GMT');
header('Cache-Control: private, max-age=3600');
if (isset($_SERVER['HTTP_RANGE']) && preg_match('/bytes=(\d+)-(\d*)/', $_SERVER['HTTP_RANGE'], $m)) {
$start = (int)$m[1];
if ($m[2] !== '') $end = min((int)$m[2], $size - 1);
if ($start > $end || $start >= $size) {
http_response_code(416);
header("Content-Range: bytes */$size");
exit;
}
http_response_code(206);
header("Content-Range: bytes $start-$end/$size");
}
$length = $end - $start + 1;
header('Content-Length: ' . $length);
$fp = fopen($path, 'rb');
fseek($fp, $start);
$bufSize = 1 << 20; // 1 MiB
$remaining = $length;
while ($remaining > 0 && !feof($fp) && !connection_aborted()) {
$chunk = fread($fp, (int)min($bufSize, $remaining));
if ($chunk === false) break;
echo $chunk;
@ob_flush();
@flush();
$remaining -= strlen($chunk);
}
fclose($fp);

View File

@@ -9,7 +9,7 @@
"version": "1.4.6",
"releasedAt": "2026-04-29T10:00:00Z",
"executable": "PROSERVE_UE_5_5.exe",
"installFolderTemplate": "Proserve v{version}",
"installFolderTemplate": "PROSERVE v{version}",
"download": {
"url": "https://asterionvr.com/PS_Launcher/builds/proserve-1.4.6.zip",
"sizeBytes": 0,
@@ -23,7 +23,7 @@
"version": "1.4.5",
"releasedAt": "2026-04-15T10:00:00Z",
"executable": "PROSERVE_UE_5_5.exe",
"installFolderTemplate": "Proserve v{version}",
"installFolderTemplate": "PROSERVE v{version}",
"download": {
"url": "https://asterionvr.com/PS_Launcher/builds/proserve-1.4.5.zip",
"sizeBytes": 0,

View File

@@ -13,6 +13,7 @@ final class SignManifest
public string $manifestPath;
public string $buildsDir;
public ?string $configPath;
public string $hashCachePath;
/** @var string[] */
public array $log = [];
@@ -21,17 +22,87 @@ final class SignManifest
$this->manifestPath = $rootDir . '/manifest/versions.json';
$this->buildsDir = $rootDir . '/builds';
$this->configPath = $rootDir . '/api/config.php';
// Cache des hashs déjà calculés. Indexé par chemin absolu du ZIP, contient
// { size, mtime, sha256 } ; on recalcule uniquement si size ou mtime ont changé.
// Vit hors du répertoire web servi (sécurité : un utilisateur n'a aucune raison
// de pouvoir lire le cache).
$this->hashCachePath = $rootDir . '/manifest/.hashcache.json';
}
private function out(string $line): void { $this->log[] = $line; }
/**
* Lit le cache des hashs précalculés. Retourne un dictionnaire
* [path => ['size' => int, 'mtime' => int, 'sha256' => string]].
* @return array<string, array{size:int,mtime:int,sha256:string}>
*/
private function loadHashCache(): array
{
if (!is_file($this->hashCachePath)) return [];
$raw = @file_get_contents($this->hashCachePath);
if ($raw === false) return [];
$j = json_decode($raw, true);
return is_array($j) ? $j : [];
}
/** @param array<string, array{size:int,mtime:int,sha256:string}> $cache */
private function saveHashCache(array $cache): void
{
@file_put_contents($this->hashCachePath, json_encode($cache, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES) . "\n");
}
/**
* Calcule (ou retourne depuis le cache) le SHA-256 d'un fichier.
* Le cache est invalidé dès que la taille OU mtime du fichier a changé,
* ce qui permet de garder le cache à jour sans intervention manuelle.
*
* @param array<string, array{size:int,mtime:int,sha256:string}> $cache
* @return array{sha256:string, fromCache:bool, durationMs:int}
*/
private function getOrComputeSha256(string $path, array &$cache): array
{
$size = filesize($path) ?: 0;
$mtime = filemtime($path) ?: 0;
$key = realpath($path) ?: $path;
if (isset($cache[$key])
&& ($cache[$key]['size'] ?? -1) === $size
&& ($cache[$key]['mtime'] ?? -1) === $mtime
&& !empty($cache[$key]['sha256'])) {
return ['sha256' => $cache[$key]['sha256'], 'fromCache' => true, 'durationMs' => 0];
}
$start = microtime(true);
$sha = hash_file('sha256', $path);
$duration = (int)((microtime(true) - $start) * 1000);
$cache[$key] = ['size' => $size, 'mtime' => $mtime, 'sha256' => $sha];
return ['sha256' => $sha, 'fromCache' => false, 'durationMs' => $duration];
}
/**
* Recompute sizes / sha256 selon le scope, puis re-signe le manifest avec Ed25519.
*
* Stratégie de hash :
* - Cache disque {$buildsDir}/.hashcache.json indexé par chemin → (size, mtime, sha256)
* - On re-hash seulement si la taille ou mtime du ZIP a changé (= upload de nouveau build)
* - Si le manifest contient déjà un sha256 valide ET que size+mtime n'ont pas changé,
* on évite carrément l'appel à hash_file()
* - Si une version a `download.hashAlgorithm = "none"` ou `download.skipHash = true`
* dans le manifest, on ne calcule pas le hash (sha256 reste à null/empty)
* - $force = true pour forcer un recalcul intégral (utile en cas de doute)
*
* Sur un mutualisé OVH avec 5 versions × 14 Go : avant ~25 min, après ~quelques secondes
* pour les versions inchangées + ~1 min par nouvelle version uploadée.
*
* @param string $scope 'all' (défaut) | 'versions' (ne touche que les Proserve) | 'launcher'
* @param bool $force Si true, ignore le cache et recalcule tous les hashs
* @param ?string $onlyVersion Si non null, ne touche QUE cette version dans la section
* versions[] (les autres restent inchangées). Le scope 'launcher'
* est ignoré dans ce cas.
* @return array{ok:bool, log:string[]}
*/
public function run(string $scope = 'all'): array
public function run(string $scope = 'all', bool $force = false, ?string $onlyVersion = null): array
{
if (!is_file($this->manifestPath)) {
$this->out("Manifest introuvable : {$this->manifestPath}");
@@ -46,10 +117,23 @@ final class SignManifest
$doVersions = ($scope === 'all' || $scope === 'versions');
$doLauncher = ($scope === 'all' || $scope === 'launcher');
if ($onlyVersion !== null) {
// Mode "hash une seule version" : on ne touche pas au launcher,
// et on ne hash que la version demandée dans la section versions[].
$doLauncher = false;
$doVersions = true;
}
// Charge le cache des hashs (indexé par chemin réel du ZIP)
$cache = $this->loadHashCache();
$cacheChanged = false;
$hashedVersions = [];
if ($doVersions) foreach ($manifest['versions'] as &$v) {
$version = $v['version'] ?? '?';
if ($onlyVersion !== null && $version !== $onlyVersion) {
continue; // skip silently les autres versions
}
$url = $v['download']['url'] ?? '';
if ($url === '') {
$this->out(" [skip] $version : pas d'URL dans le manifest");
@@ -74,12 +158,45 @@ final class SignManifest
}
}
$size = filesize($zip);
$sha = hash_file('sha256', $zip);
$this->out(" [hash] $version : " . basename($zip) . " ($size octets) sha256={$sha}");
$size = filesize($zip) ?: 0;
$v['download']['sizeBytes'] = $size;
$v['download']['sha256'] = $sha;
// Hash skipping : per-version flag dans le manifest. Si "none" ou skipHash=true,
// on neutralise le sha256 et la vérif côté client est passée. Utile pour les builds
// internes ou les très gros ZIPs où on accepte le compromis perf/sécurité (la
// signature Ed25519 du manifest reste). À utiliser avec parcimonie.
$algo = strtolower((string)($v['download']['hashAlgorithm'] ?? 'sha256'));
$skipFlag = !empty($v['download']['skipHash']);
if ($algo === 'none' || $skipFlag) {
$v['download']['sha256'] = '';
$v['download']['hashAlgorithm'] = 'none';
$this->out(" [skip-hash] $version : " . basename($zip) . " ($size octets) — hash non calculé (hashAlgorithm=none)");
$hashedVersions[] = $version;
continue;
}
// Cache lookup (sauf si --force)
if (!$force) {
$key = realpath($zip) ?: $zip;
$existingSha = (string)($v['download']['sha256'] ?? '');
$hasValidSha = $existingSha !== ''
&& !str_starts_with($existingSha, 'REPLACE');
if ($hasValidSha
&& isset($cache[$key])
&& ($cache[$key]['size'] ?? -1) === $size
&& ($cache[$key]['mtime'] ?? -1) === (filemtime($zip) ?: 0)
&& ($cache[$key]['sha256'] ?? '') === $existingSha) {
$this->out(" [cache] $version : " . basename($zip) . " ($size octets) — sha256 inchangé, hash cache hit");
$hashedVersions[] = $version;
continue;
}
}
$r = $this->getOrComputeSha256($zip, $cache);
$cacheChanged = true;
$note = $r['fromCache'] ? '(cache)' : "(calculé en {$r['durationMs']} ms)";
$this->out(" [hash] $version : " . basename($zip) . " ($size octets) sha256={$r['sha256']} $note");
$v['download']['sha256'] = $r['sha256'];
$hashedVersions[] = $version;
}
unset($v);
@@ -99,11 +216,27 @@ final class SignManifest
if (count($candidates) === 1) $lpath = $candidates[0];
}
if (is_file($lpath)) {
$lsize = filesize($lpath);
$lsha = hash_file('sha256', $lpath);
$lsize = filesize($lpath) ?: 0;
$launcher['download']['sizeBytes'] = $lsize;
$launcher['download']['sha256'] = $lsha;
$this->out(" [launcher] v{$lver} : " . basename($lpath) . " ({$lsize} octets) sha256={$lsha}");
// Cache lookup pour le launcher exe aussi
$lkey = realpath($lpath) ?: $lpath;
$existingLsha = (string)($launcher['download']['sha256'] ?? '');
$hasValidLsha = $existingLsha !== '' && !str_starts_with($existingLsha, 'REPLACE');
if (!$force
&& $hasValidLsha
&& isset($cache[$lkey])
&& ($cache[$lkey]['size'] ?? -1) === $lsize
&& ($cache[$lkey]['mtime'] ?? -1) === (filemtime($lpath) ?: 0)
&& ($cache[$lkey]['sha256'] ?? '') === $existingLsha) {
$this->out(" [launcher] v{$lver} : " . basename($lpath) . " ({$lsize} octets) — cache hit");
} else {
$r = $this->getOrComputeSha256($lpath, $cache);
$cacheChanged = true;
$launcher['download']['sha256'] = $r['sha256'];
$note = $r['fromCache'] ? '(cache)' : "(calculé en {$r['durationMs']} ms)";
$this->out(" [launcher] v{$lver} : " . basename($lpath) . " ({$lsize} octets) sha256={$r['sha256']} $note");
}
} else {
$this->out(" [launcher] v{$lver} : exe introuvable dans builds/launcher/");
}
@@ -111,16 +244,27 @@ final class SignManifest
unset($launcher);
}
// Bump auto de `latest` sur la plus haute version effectivement uploadée
// (uniquement si on a touché aux versions)
if ($doVersions && !empty($hashedVersions)) {
usort($hashedVersions, 'version_compare');
$newLatest = end($hashedVersions);
// Bump auto de `latest` : prend la plus haute version dispo (sha256 valide OU
// hashAlgorithm=none) parmi TOUTES les entrées du manifest, pas seulement celles
// qu'on a re-hashé sur ce run. Évite de redescendre `latest` quand on hash une
// ancienne version isolée via $onlyVersion.
if ($doVersions) {
$eligible = [];
foreach ($manifest['versions'] as $vEntry) {
$sha = (string)($vEntry['download']['sha256'] ?? '');
$algo = strtolower((string)($vEntry['download']['hashAlgorithm'] ?? 'sha256'));
$hasValid = ($sha !== '' && !str_starts_with($sha, 'REPLACE')) || $algo === 'none';
if ($hasValid) $eligible[] = $vEntry['version'];
}
if (!empty($eligible)) {
usort($eligible, 'version_compare');
$newLatest = end($eligible);
if (($manifest['latest'] ?? null) !== $newLatest) {
$this->out(" [latest] " . ($manifest['latest'] ?? '(none)') . " -> {$newLatest}");
$manifest['latest'] = $newLatest;
}
}
}
// Signature Ed25519
if ($this->configPath && is_file($this->configPath)) {
@@ -148,6 +292,15 @@ final class SignManifest
return ['ok' => false, 'log' => $this->log];
}
// Persiste le cache de hashs si on l'a touché. On nettoie aussi les entrées
// obsolètes (fichiers supprimés) pour éviter qu'il grossisse indéfiniment.
if ($cacheChanged) {
foreach (array_keys($cache) as $cachedPath) {
if (!is_file($cachedPath)) unset($cache[$cachedPath]);
}
$this->saveHashCache($cache);
}
$this->out("Manifest mis à jour (" . count($hashedVersions) . " version(s)).");
return ['ok' => true, 'log' => $this->log];
}

View File

@@ -2,7 +2,11 @@
/**
* Wrapper CLI pour PSLauncher\Tools\SignManifest.
*
* Usage : cd ~/www/PS_Launcher && php tools/sign-manifest.php
* Usage :
* cd ~/www/PS_Launcher && php tools/sign-manifest.php
* php tools/sign-manifest.php --scope=launcher # ne re-signe que la section launcher
* php tools/sign-manifest.php --scope=versions # ne re-signe que les builds Proserve
* php tools/sign-manifest.php --force # ignore le cache, recalcule tous les SHA-256
*
* Le backoffice admin appelle directement la classe (pas d'exec).
*/
@@ -10,7 +14,15 @@ declare(strict_types=1);
require __DIR__ . '/SignManifest.php';
$scope = 'all';
$force = false;
foreach (array_slice($argv, 1) as $arg) {
if ($arg === '--force' || $arg === '-f') $force = true;
elseif (str_starts_with($arg, '--scope=')) $scope = substr($arg, 8);
elseif (in_array($arg, ['versions', 'launcher', 'all'], true)) $scope = $arg;
}
$signer = new \PSLauncher\Tools\SignManifest(dirname(__DIR__));
$result = $signer->run();
$result = $signer->run($scope, $force);
foreach ($result['log'] as $line) echo $line . "\n";
exit($result['ok'] ? 0 : 1);

View File

@@ -14,6 +14,7 @@ using PSLauncher.Core.Downloads;
using PSLauncher.Core.Installations;
using PSLauncher.Core.Integrity;
using PSLauncher.Core.Licensing;
using PSLauncher.Core.Localization;
using PSLauncher.Core.Manifests;
using PSLauncher.Core.Process;
using PSLauncher.Core.Updates;
@@ -63,6 +64,20 @@ public partial class App : Application
"PSLauncher", "logs");
Directory.CreateDirectory(LogsDirectory);
// Bootstrap i18n : on lit d'abord la config (sans services DI) pour fixer la
// langue avant que le moindre élément XAML soit instancié.
try
{
var bootstrapStore = new ConfigStore(
Microsoft.Extensions.Logging.Abstractions.NullLogger<ConfigStore>.Instance);
var bootstrapCfg = bootstrapStore.Load();
Strings.Init(bootstrapCfg.Language);
}
catch
{
Strings.Init("auto");
}
Log.Logger = new LoggerConfiguration()
.MinimumLevel.Debug()
.Enrich.FromLogContext()
@@ -93,13 +108,25 @@ public partial class App : Application
services.AddSingleton(sp =>
{
var http = new HttpClient(new SocketsHttpHandler
// Handler tuné pour les gros téléchargements parallèles :
// - MaxConnectionsPerServer=16 pour autoriser le multi-segment (8 par défaut).
// - AutomaticDecompression sur l'API JSON uniquement (None ici car les builds
// sont des ZIPs déjà compressés ; éviter la CPU+RAM gaspillée par un éventuel
// wrapping gzip côté serveur).
// - PooledConnectionLifetime court pour éviter qu'OVH ferme nos sockets sous nous.
var handler = new SocketsHttpHandler
{
PooledConnectionLifetime = TimeSpan.FromMinutes(5),
AutomaticDecompression = System.Net.DecompressionMethods.All
})
PooledConnectionIdleTimeout = TimeSpan.FromSeconds(60),
MaxConnectionsPerServer = 16,
EnableMultipleHttp2Connections = true,
AutomaticDecompression = System.Net.DecompressionMethods.None,
};
var http = new HttpClient(handler)
{
Timeout = Timeout.InfiniteTimeSpan
Timeout = Timeout.InfiniteTimeSpan,
DefaultRequestVersion = System.Net.HttpVersion.Version20,
DefaultVersionPolicy = HttpVersionPolicy.RequestVersionOrLower,
};
http.DefaultRequestHeaders.UserAgent.Add(new ProductInfoHeaderValue("PSLauncher", "0.5"));
return http;
@@ -143,6 +170,9 @@ public partial class App : Application
var window = _host.Services.GetRequiredService<MainWindow>();
window.DataContext = _host.Services.GetRequiredService<MainViewModel>();
// Mise en page droite-à-gauche pour les langues RTL (arabe).
if (Strings.IsRightToLeft)
window.FlowDirection = FlowDirection.RightToLeft;
MainWindow = window;
window.Show();
}

View File

@@ -15,9 +15,9 @@
<Product>PROSERVE Launcher</Product>
<Copyright>© 2026 ASTERION VR — All rights reserved</Copyright>
<RootNamespace>PSLauncher.App</RootNamespace>
<Version>0.8.0</Version>
<AssemblyVersion>0.8.0.0</AssemblyVersion>
<FileVersion>0.8.0.0</FileVersion>
<Version>0.9.0</Version>
<AssemblyVersion>0.9.0.0</AssemblyVersion>
<FileVersion>0.9.0.0</FileVersion>
<!-- Single-file self-contained publish profile (used by `dotnet publish`) -->
<PublishSingleFile>true</PublishSingleFile>

View File

@@ -135,6 +135,39 @@
</Setter>
</Style>
<!--
DangerButton : action destructive (rouge). Utilisé pour le bouton « Annuler »
à côté de « ↻ Reprendre » sur les rows ayant un téléchargement partiel — pour
que l'utilisateur puisse abandonner le partial et repartir de zéro.
-->
<Style x:Key="DangerButton" TargetType="Button" BasedOn="{StaticResource PrimaryButton}">
<Setter Property="Background" Value="#DC2626" />
<Setter Property="FontSize" Value="13" />
<Setter Property="FontWeight" Value="SemiBold" />
<Setter Property="Padding" Value="14,8" />
<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="#EF4444" />
</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>
<!-- Boutons de chrome window (min / max / close), 46x40 façon Windows -->
<Style x:Key="WindowControlButton" TargetType="Button">
<Setter Property="Background" Value="Transparent" />
@@ -206,6 +239,136 @@
</Setter>
</Style>
<!-- ComboBox sombre.
Re-template minimal pour avoir le contenu, l'arrow et le popup tous en
palette dark. WPF native ComboBox style serait grisé sur blanc. -->
<Style x:Key="ComboBoxToggleButton" TargetType="ToggleButton">
<Setter Property="Background" Value="Transparent" />
<Setter Property="BorderThickness" Value="0" />
<Setter Property="Cursor" Value="Hand" />
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="ToggleButton">
<Border Background="Transparent">
<Path x:Name="Arrow"
HorizontalAlignment="Center" VerticalAlignment="Center"
Data="M 0,0 L 8,0 L 4,5 Z"
Fill="{StaticResource Brush.Text.Secondary}" />
</Border>
<ControlTemplate.Triggers>
<Trigger Property="IsMouseOver" Value="True">
<Setter TargetName="Arrow" Property="Fill" Value="{StaticResource Brush.Text.Primary}" />
</Trigger>
</ControlTemplate.Triggers>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
<Style TargetType="ComboBox">
<Setter Property="Background" Value="#1A1A20" />
<Setter Property="BorderBrush" Value="{StaticResource Brush.Border}" />
<Setter Property="BorderThickness" Value="1" />
<Setter Property="Foreground" Value="{StaticResource Brush.Text.Primary}" />
<Setter Property="Padding" Value="8,4" />
<Setter Property="MinHeight" Value="32" />
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="ComboBox">
<Grid>
<!-- ToggleButton couvre TOUT le combo : clic au centre ouvre le dropdown,
pas seulement clic sur la flèche. Le ContentPresenter par-dessus
est en IsHitTestVisible=False pour ne pas voler le clic. -->
<ToggleButton x:Name="ToggleBtn"
Background="{TemplateBinding Background}"
BorderBrush="{TemplateBinding BorderBrush}"
BorderThickness="{TemplateBinding BorderThickness}"
Cursor="Hand"
Focusable="False"
IsChecked="{Binding IsDropDownOpen, RelativeSource={RelativeSource TemplatedParent}, Mode=TwoWay}">
<ToggleButton.Template>
<ControlTemplate TargetType="ToggleButton">
<Border x:Name="Bd"
Background="{TemplateBinding Background}"
BorderBrush="{TemplateBinding BorderBrush}"
BorderThickness="{TemplateBinding BorderThickness}"
CornerRadius="4">
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*" />
<ColumnDefinition Width="28" />
</Grid.ColumnDefinitions>
<Path Grid.Column="1"
HorizontalAlignment="Center" VerticalAlignment="Center"
Data="M 0,0 L 8,0 L 4,5 Z"
Fill="{StaticResource Brush.Text.Secondary}" />
</Grid>
</Border>
<ControlTemplate.Triggers>
<Trigger Property="IsMouseOver" Value="True">
<Setter TargetName="Bd" Property="BorderBrush" Value="{StaticResource Brush.Accent}" />
</Trigger>
</ControlTemplate.Triggers>
</ControlTemplate>
</ToggleButton.Template>
</ToggleButton>
<ContentPresenter IsHitTestVisible="False"
Content="{TemplateBinding SelectionBoxItem}"
ContentTemplate="{TemplateBinding SelectionBoxItemTemplate}"
ContentStringFormat="{TemplateBinding SelectionBoxItemStringFormat}"
Margin="{TemplateBinding Padding}"
HorizontalAlignment="Left" VerticalAlignment="Center"
TextElement.Foreground="{TemplateBinding Foreground}" />
<Popup x:Name="PART_Popup"
Placement="Bottom"
IsOpen="{TemplateBinding IsDropDownOpen}"
AllowsTransparency="True"
Focusable="False"
PopupAnimation="Slide">
<Border Background="{StaticResource Brush.Bg.Card}"
BorderBrush="{StaticResource Brush.Border}"
BorderThickness="1"
CornerRadius="4"
MinWidth="{TemplateBinding ActualWidth}"
MaxHeight="300">
<ScrollViewer>
<ItemsPresenter />
</ScrollViewer>
</Border>
</Popup>
</Grid>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
<Style TargetType="ComboBoxItem">
<Setter Property="Background" Value="Transparent" />
<Setter Property="Foreground" Value="{StaticResource Brush.Text.Primary}" />
<Setter Property="Padding" Value="10,6" />
<Setter Property="Cursor" Value="Hand" />
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="ComboBoxItem">
<Border x:Name="Bd"
Background="{TemplateBinding Background}"
Padding="{TemplateBinding Padding}">
<ContentPresenter />
</Border>
<ControlTemplate.Triggers>
<Trigger Property="IsHighlighted" Value="True">
<Setter TargetName="Bd" Property="Background" Value="{StaticResource Brush.Accent}" />
<Setter Property="Foreground" Value="White" />
</Trigger>
<Trigger Property="IsSelected" Value="True">
<Setter TargetName="Bd" Property="Background" Value="#2C2C32" />
</Trigger>
</ControlTemplate.Triggers>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
<!-- Style ContextMenu sombre, cohérent avec le thème.
On retemplatise complètement pour virer la "icon column" claire que WPF
dessine par défaut sur le bord gauche du popup. -->

View File

@@ -1,3 +1,4 @@
using PSLauncher.Core.Localization;
using PSLauncher.Models;
namespace PSLauncher.App.ViewModels;
@@ -11,17 +12,7 @@ public sealed class InstalledVersionViewModel
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]}";
}
$"{Strings.FormatSize(Model.SizeBytes)} • {Strings.FormatDate(Model.InstalledAt)}";
public override string ToString() => Display;
}

View File

@@ -12,6 +12,7 @@ using PSLauncher.Core.Configuration;
using PSLauncher.Core.Downloads;
using PSLauncher.Core.Installations;
using PSLauncher.Core.Licensing;
using PSLauncher.Core.Localization;
using PSLauncher.Core.Manifests;
using PSLauncher.Core.Process;
using PSLauncher.Core.Updates;
@@ -64,24 +65,39 @@ public sealed partial class MainViewModel : ObservableObject
private bool _isBusy;
[ObservableProperty] private double _progressPercent;
[ObservableProperty] private string? _progressDetail;
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(FooterText))]
[NotifyPropertyChangedFor(nameof(FooterVisibility))]
private string? _progressDetail;
/// <summary>
/// True quand un téléchargement est actif (à utiliser pour le prompt « quitter ?» de
/// la fenêtre principale). Distinct de <see cref="IsBusy"/> qui couvre aussi
/// l'extraction et la vérification.
/// </summary>
public bool HasActiveDownload => _activeDownloadCts is not null
&& !_activeDownloadCts.IsCancellationRequested
&& _activeRow is not null
&& _activeRow.State == VersionRowState.Downloading;
/// <summary>Version actuellement en cours de DL, ou null.</summary>
public string? ActiveDownloadVersion => _activeRow?.Version;
public string LicenseSummary
{
get
{
if (_license is null) return "Aucune license";
if (_license is null) return Strings.LicenseSummaryNone;
if (_license.Status == "valid")
{
var until = _license.DownloadEntitlementUntil;
if (until is { } u && (u - DateTime.UtcNow).TotalDays < 30)
return $"{_license.OwnerName} • exp. {u:dd/MM/yyyy}";
return $"{_license.OwnerName} • exp. {until:dd/MM/yyyy}";
return Strings.LicenseSummaryValid(_license.OwnerName ?? "—",
Strings.FormatDate(_license.DownloadEntitlementUntil));
}
if (_license.Status == "expired")
return $"Expirée le {_license.DownloadEntitlementUntil:dd/MM/yyyy}";
return Strings.LicenseSummaryExpired(Strings.FormatDate(_license.DownloadEntitlementUntil));
if (_license.Status == "revoked")
return "Révoquée";
return Strings.LicenseSummaryRevoked;
return _license.Status;
}
}
@@ -132,11 +148,7 @@ public sealed partial class MainViewModel : ObservableObject
OnPropertyChanged(nameof(LicenseSeverity));
}
public string EmptyHint =>
"Aucune version locale ni distante.\n" +
"Clique « Vérifier les mises à jour » pour récupérer le catalogue depuis le serveur, " +
"ou place un dossier « Proserve v{X.Y.Z} » contenant PROSERVE_UE_5_5.exe dans :\n" +
_config.InstallRoot;
public string EmptyHint => Strings.EmptyHint(_config.InstallRoot);
public Visibility EmptyHintVisibility =>
FeaturedVersion is null && OtherVersions.Count == 0
@@ -266,19 +278,27 @@ public sealed partial class MainViewModel : ObservableObject
row.UninstallHandler = r => _ = UninstallVersionAsync(r);
row.ShowReleaseNotesHandler = r => _ = ShowReleaseNotesAsync(r);
row.OpenFolderHandler = OpenVersionFolder;
row.RestartFromZeroHandler = r => _ = RestartFromZeroAsync(r);
}
[RelayCommand(CanExecute = nameof(CanCheckUpdates))]
private async Task CheckForUpdatesAsync()
{
IsBusy = true;
StatusMessage = "Vérification des mises à jour…";
StatusMessage = Strings.StatusCheckingUpdates;
try
{
// Re-validation de la license en parallèle du fetch manifest. Permet
// au client de refléter sans délai les changements admin (modification
// de date, révocation, etc.) sans attendre l'expiration du cache offline
// de 100 jours. Si l'utilisateur est offline ou si l'API ne répond pas,
// on garde le cache silencieusement (pas de crash).
await RefreshLicenseFromServerAsync(CancellationToken.None);
var result = await _updateChecker.CheckAsync(CancellationToken.None);
if (result.Error is not null)
{
StatusMessage = $"Erreur : {result.Error}";
StatusMessage = Strings.StatusError(result.Error);
return;
}
@@ -293,16 +313,16 @@ public sealed partial class MainViewModel : ObservableObject
}
if (result.IsLatestNewerThanInstalled && result.LatestRemote is not null)
StatusMessage = $"Nouvelle version disponible : v{result.LatestRemote.Version}";
StatusMessage = Strings.StatusNewAvailable(result.LatestRemote.Version);
else if (result.LatestRemote is not null)
StatusMessage = $"À jour (v{result.LatestRemote.Version})";
StatusMessage = Strings.StatusUpToDate(result.LatestRemote.Version);
else
StatusMessage = "Aucune version distante trouvée";
StatusMessage = Strings.StatusNoRemote;
}
catch (Exception ex)
{
_logger.LogError(ex, "Update check failed");
StatusMessage = $"Erreur : {ex.Message}";
StatusMessage = Strings.StatusError(ex.Message);
}
finally
{
@@ -311,6 +331,31 @@ public sealed partial class MainViewModel : ObservableObject
}
private bool CanCheckUpdates() => !IsBusy;
/// <summary>
/// Tente une re-validation online de la license. Met à jour le cache local
/// avec les valeurs fraîches du serveur (date d'expiration, statut révoqué, etc.).
/// Silencieux en cas d'échec réseau pour ne pas casser le check des MAJ ; le
/// cache offline reste utilisable.
/// </summary>
private async Task RefreshLicenseFromServerAsync(CancellationToken ct)
{
var key = _licenseService.GetDecryptedKey();
if (string.IsNullOrEmpty(key)) return;
try
{
var resp = await _licenseService.ValidateAsync(key, ct);
_licenseService.SaveCached(key, resp);
_license = _licenseService.GetCached();
NotifyLicenseChanged();
_logger.LogInformation("License revalidated from server (status={Status}, until={Until})",
resp.Status, resp.DownloadEntitlementUntil);
}
catch (Exception ex)
{
_logger.LogInformation(ex, "License revalidation failed (offline or server error). Keeping cached state.");
}
}
private async void PromptLauncherUpdate(LauncherInfo launcher)
{
try
@@ -323,24 +368,24 @@ public sealed partial class MainViewModel : ObservableObject
if (!dialog.UpdateRequested) return;
IsBusy = true;
StatusMessage = "Téléchargement du nouveau launcher…";
StatusMessage = Strings.StatusDownloadingLauncher;
var progress = new Progress<DownloadProgress>(p =>
{
if (p.TotalBytes > 0) ProgressPercent = (double)p.BytesDownloaded / p.TotalBytes * 100.0;
ProgressDetail = $"⬇ Launcher v{launcher.Version} : {FormatSize(p.BytesDownloaded)} / {FormatSize(p.TotalBytes)}";
ProgressDetail = Strings.ProgressLauncherDl(launcher.Version, FormatSize(p.BytesDownloaded), FormatSize(p.TotalBytes));
});
await _selfUpdater.DownloadAndApplyAsync(launcher, progress, CancellationToken.None);
StatusMessage = "Mise à jour du launcher en cours… le launcher va se relancer.";
StatusMessage = Strings.StatusLauncherUpdating;
await Task.Delay(800); // laisse le temps au toast / message de s'afficher
Application.Current.Shutdown();
}
catch (Exception ex)
{
_logger.LogError(ex, "Self-update failed");
MessageBox.Show($"Mise à jour du launcher échouée :\n\n{ex.Message}",
"Erreur", MessageBoxButton.OK, MessageBoxImage.Error);
StatusMessage = $"Erreur self-update : {ex.Message}";
MessageBox.Show(Strings.MsgSelfUpdateFailed(ex.Message),
Strings.MsgBoxError, MessageBoxButton.OK, MessageBoxImage.Error);
StatusMessage = Strings.StatusSelfUpdateError(ex.Message);
IsBusy = false;
}
}
@@ -415,7 +460,49 @@ public sealed partial class MainViewModel : ObservableObject
}
[RelayCommand]
private void CancelDownload() => _activeDownloadCts?.Cancel();
private void CancelDownload()
{
if (_activeDownloadCts is null || _activeRow is null) return;
var confirm = MessageBox.Show(
Strings.MsgCancelDownloadConfirm(_activeRow.Version),
Strings.MsgBoxCancelDl,
MessageBoxButton.YesNo, MessageBoxImage.Question, MessageBoxResult.No);
if (confirm != MessageBoxResult.Yes) return;
_activeDownloadCts.Cancel();
}
/// <summary>
/// Discard total : annule + supprime le partial + state.json.
/// Permet de repartir de zéro depuis le bouton « ↻ Reprendre » contextuel.
/// </summary>
private async Task RestartFromZeroAsync(VersionRowViewModel row)
{
// Si un DL est actif sur cette même row, on doit d'abord l'annuler proprement.
var st = _downloadManager.GetResumableState(row.Version);
var sizeStr = FormatSize(st?.DownloadedBytes ?? 0);
var confirm = MessageBox.Show(
Strings.MsgRestartDownloadConfirm(row.Version, sizeStr),
Strings.MsgBoxRestartDl,
MessageBoxButton.YesNo, MessageBoxImage.Warning, MessageBoxResult.No);
if (confirm != MessageBoxResult.Yes) return;
// Cancel le DL actif si c'est cette row
if (_activeRow is not null && _activeRow == row && _activeDownloadCts is not null)
{
_activeDownloadCts.Cancel();
// Attend la fin de la tâche en cours pour libérer les handles fichier
try { await Task.Delay(200); } catch { }
}
_downloadManager.DiscardResumableState(row.Version);
row.ResumableBytes = 0;
row.State = VersionRowState.AvailableIdle;
row.ProgressDetail = null;
row.ProgressPercent = 0;
StatusMessage = null;
ProgressDetail = null;
ProgressPercent = 0;
}
// ------------ Per-row actions ------------
@@ -424,26 +511,54 @@ public sealed partial class MainViewModel : ObservableObject
if (row.Installed is null) return;
try
{
_processLauncher.Launch(row.Installed);
var proc = _processLauncher.Launch(row.Installed);
_config.LastLaunchedVersion = row.Version;
_configStore.Save(_config);
// Réduit la fenêtre du launcher pour ne pas gêner Proserve qui démarre
if (Application.Current.MainWindow is { } mw)
mw.WindowState = WindowState.Minimized;
// Quand Proserve se termine, on remet la fenêtre du launcher au premier plan.
// EnableRaisingEvents est requis pour recevoir l'event Exited.
try
{
proc.EnableRaisingEvents = true;
proc.Exited += (_, _) =>
{
_logger.LogInformation("PROSERVE v{Version} a quitté — restauration du launcher", row.Version);
Application.Current?.Dispatcher.Invoke(() =>
{
if (Application.Current.MainWindow is { } main)
{
if (main.WindowState == WindowState.Minimized)
main.WindowState = WindowState.Normal;
main.Activate();
// Pop temporaire pour amener la fenêtre au premier plan
main.Topmost = true;
main.Topmost = false;
}
});
};
}
catch (Exception ex)
{
// ShellExecute peut renvoyer un Process sans accès aux events — non bloquant
_logger.LogDebug(ex, "Could not hook Exited on launched process");
}
}
catch (Exception ex)
{
_logger.LogError(ex, "Launch failed");
MessageBox.Show($"Impossible de lancer la version :\n\n{ex.Message}",
"Erreur de lancement", MessageBoxButton.OK, MessageBoxImage.Error);
MessageBox.Show(Strings.MsgLaunchFailed(ex.Message),
Strings.MsgBoxLaunchError, MessageBoxButton.OK, MessageBoxImage.Error);
}
}
private async Task InstallVersionAsync(VersionRowViewModel row)
{
if (row.Remote is null) return;
if (IsBusy) { MessageBox.Show("Une autre opération est déjà en cours.", "Patience", MessageBoxButton.OK, MessageBoxImage.Information); return; }
if (IsBusy) { MessageBox.Show(Strings.MsgBusy, Strings.MsgBoxPatience, MessageBoxButton.OK, MessageBoxImage.Information); return; }
IsBusy = true;
_activeRow = row;
@@ -459,14 +574,14 @@ public sealed partial class MainViewModel : ObservableObject
if (!isResume)
{
// 1) Récupère release notes (best effort)
string notes = "_Aucune release note fournie._";
string notes = Strings.ReleaseNotesNone;
if (!string.IsNullOrEmpty(row.Remote.ReleaseNotesUrl))
{
try
{
notes = await _manifestService.FetchReleaseNotesAsync(row.Remote.ReleaseNotesUrl, ct);
}
catch (Exception ex) { _logger.LogWarning(ex, "Release notes fetch failed"); notes = "_Release notes indisponibles._"; }
catch (Exception ex) { _logger.LogWarning(ex, "Release notes fetch failed"); notes = Strings.ReleaseNotesUnavailable; }
}
// 2) Dialog de confirmation avec release notes
@@ -482,14 +597,49 @@ public sealed partial class MainViewModel : ObservableObject
var signed = await _licenseService.GetSignedDownloadUrlAsync(row.Version, ct);
var urlString = signed ?? row.Remote.Download.Url;
var url = new Uri(urlString);
var job = new DownloadJob(row.Version, url, row.Remote.Download.SizeBytes, row.Remote.Download.Sha256);
// Callback de refresh d'URL : appelé par DownloadManager quand un segment
// reçoit 403/410 (URL HMAC expirée mid-DL). Permet aux DLs très longs sur
// connexion lente (>= 6 h, le TTL serveur) de continuer transparemment.
// Si la license a été révoquée entre-temps, le callback renvoie null et le
// DL fail proprement.
async Task<Uri?> RefreshUrlAsync(CancellationToken c)
{
var fresh = await _licenseService.GetSignedDownloadUrlAsync(row.Version, c);
return fresh is null ? null : new Uri(fresh);
}
var job = new DownloadJob(row.Version, url, row.Remote.Download.SizeBytes, row.Remote.Download.Sha256)
{
HashAlgorithm = row.Remote.Download.HashAlgorithm,
RefreshUrlAsync = RefreshUrlAsync,
};
// Status « Préparation… » immédiat : entre le clic et la première réponse
// HEAD du serveur + pré-allocation du fichier 14 Go, il peut s'écouler
// 5-30 s silencieuses. On rassure l'utilisateur tout de suite ; le message
// sera remplacé automatiquement par « ⬇ v1.4.6 : … » dès le premier byte.
StatusMessage = Strings.StatusPreparingDownload(row.Version);
ProgressDetail = null;
ProgressPercent = 0;
row.ProgressDetail = null;
row.ProgressPercent = 0;
var dlProgress = new Progress<DownloadProgress>(p => UpdateDlProgress(row, p));
StatusMessage = $"Téléchargement v{row.Version}…";
var zipPath = await _downloadManager.DownloadAsync(job, dlProgress, ct);
// Reporter dédié pour la phase verify SHA-256 (peut prendre 30s-3min sur 14 Go).
// On bascule le StatusMessage à ce moment-là pour que l'utilisateur sache ce qui se passe.
var hashProgress = new Progress<double>(pct =>
{
var percent = (int)Math.Round(pct * 100.0);
ProgressPercent = percent;
row.ProgressPercent = percent;
ProgressDetail = Strings.ProgressVerifying(row.Version, percent);
row.ProgressDetail = ProgressDetail;
});
// NB: pas de StatusMessage = "Téléchargement…" ici — on garde « Préparation… »
// visible pendant la phase HEAD/SetLength, puis le footer passe directement
// 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);
// 4) Install
row.State = VersionRowState.Installing;
StatusMessage = $"Installation v{row.Version}…";
StatusMessage = Strings.StatusInstallingVersion(row.Version);
var folderName = row.Remote.GetInstallFolderName();
var target = Path.Combine(_config.InstallRoot, folderName);
var installProgress = new Progress<InstallProgress>(ip =>
@@ -498,27 +648,31 @@ public sealed partial class MainViewModel : ObservableObject
var pct = (double)ip.BytesDone / ip.BytesTotal * 100.0;
ProgressPercent = pct;
row.ProgressPercent = pct;
ProgressDetail = $"Extraction v{row.Version} : {ip.EntriesDone}/{ip.EntriesTotal} fichiers ({FormatSize(ip.BytesDone)} / {FormatSize(ip.BytesTotal)})";
ProgressDetail = Strings.ProgressExtraction(row.Version, ip.EntriesDone, ip.EntriesTotal, FormatSize(ip.BytesDone), FormatSize(ip.BytesTotal));
row.ProgressDetail = ProgressDetail;
});
await _zipInstaller.InstallAsync(zipPath, target, installProgress, ct);
try { File.Delete(zipPath); } catch { /* non critique */ }
StatusMessage = $"v{row.Version} installée avec succès";
StatusMessage = Strings.StatusInstalledSuccess(row.Version);
ProgressDetail = null;
ProgressPercent = 0;
RebuildList();
_toastService.ShowSuccess(
"Installation terminée",
$"Proserve v{row.Version} est prête à être lancée.");
Strings.ToastInstallSuccessTitle,
Strings.ToastInstallSuccessBody(row.Version));
}
catch (OperationCanceledException)
{
StatusMessage = "Téléchargement annulé";
StatusMessage = Strings.StatusDownloadCancelled;
row.State = VersionRowState.AvailableIdle;
row.ProgressDetail = null;
row.ProgressPercent = 0;
// Rafraîchit le pourcentage de reprise depuis le state.json sauvegardé
// au moment du cancel. Sinon le bouton « Reprendre (X%) » garderait la
// valeur d'avant le DL (souvent 0% ou l'ancien %).
RefreshResumableBytes(row);
}
catch (Exception ex)
{
@@ -527,10 +681,13 @@ public sealed partial class MainViewModel : ObservableObject
row.ProgressDetail = null;
row.ProgressPercent = 0;
MessageBox.Show(
$"Le téléchargement ou l'installation a échoué :\n\n{ex.Message}",
"Erreur", MessageBoxButton.OK, MessageBoxImage.Error);
StatusMessage = $"Erreur : {ex.Message}";
_toastService.ShowError($"Échec v{row.Version}", ex.Message);
Strings.MsgInstallFailed(ex.Message),
Strings.MsgBoxError, MessageBoxButton.OK, MessageBoxImage.Error);
StatusMessage = Strings.StatusError(ex.Message);
_toastService.ShowError(Strings.ToastInstallErrorTitle(row.Version), ex.Message);
// Idem : si une erreur réseau interrompt le DL en cours de route, le
// partial est plus gros qu'avant — il faut refléter le nouveau %.
RefreshResumableBytes(row);
}
finally
{
@@ -547,30 +704,30 @@ public sealed partial class MainViewModel : ObservableObject
var sizeStr = FormatSize(row.SizeBytes);
var confirm = MessageBox.Show(
$"Supprimer la version v{row.Version} ?\n\nDossier : {row.FolderPath}\nLibérera {sizeStr}.\n\nCette action est irréversible.",
"Confirmer la suppression",
Strings.MsgUninstallConfirm(row.Version, row.FolderPath, sizeStr),
Strings.MsgBoxConfirm,
MessageBoxButton.YesNo, MessageBoxImage.Warning, MessageBoxResult.No);
if (confirm != MessageBoxResult.Yes) return;
if (IsBusy) { MessageBox.Show("Une autre opération est déjà en cours.", "Patience", MessageBoxButton.OK, MessageBoxImage.Information); return; }
if (IsBusy) { MessageBox.Show(Strings.MsgBusy, Strings.MsgBoxPatience, MessageBoxButton.OK, MessageBoxImage.Information); return; }
IsBusy = true;
row.State = VersionRowState.Uninstalling;
StatusMessage = $"Suppression v{row.Version}…";
StatusMessage = Strings.StatusUninstallingVersion(row.Version);
try
{
await _registry.DeleteAsync(row.Version, null, CancellationToken.None);
StatusMessage = $"v{row.Version} supprimée";
StatusMessage = Strings.StatusUninstalledVersion(row.Version);
RebuildList();
}
catch (Exception ex)
{
_logger.LogError(ex, "Uninstall failed for {Version}", row.Version);
row.State = VersionRowState.InstalledIdle;
MessageBox.Show($"Suppression échouée : {ex.Message}", "Erreur",
MessageBox.Show(Strings.MsgUninstallFailed(ex.Message), Strings.MsgBoxError,
MessageBoxButton.OK, MessageBoxImage.Error);
StatusMessage = $"Erreur : {ex.Message}";
StatusMessage = Strings.StatusError(ex.Message);
}
finally
{
@@ -583,8 +740,8 @@ public sealed partial class MainViewModel : ObservableObject
var url = row.Remote?.ReleaseNotesUrl;
if (string.IsNullOrEmpty(url))
{
MessageBox.Show("Aucune release note disponible pour cette version.",
"Release notes", MessageBoxButton.OK, MessageBoxImage.Information);
MessageBox.Show(Strings.MsgNoReleaseNotes,
Strings.MsgBoxReleaseNotes, MessageBoxButton.OK, MessageBoxImage.Information);
return;
}
string notes;
@@ -592,8 +749,8 @@ public sealed partial class MainViewModel : ObservableObject
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to fetch release notes");
MessageBox.Show($"Impossible de récupérer les release notes :\n{ex.Message}",
"Erreur", MessageBoxButton.OK, MessageBoxImage.Warning);
MessageBox.Show(Strings.MsgReleaseNotesFetchFailed(ex.Message),
Strings.MsgBoxError, MessageBoxButton.OK, MessageBoxImage.Warning);
return;
}
@@ -612,6 +769,25 @@ public sealed partial class MainViewModel : ObservableObject
});
}
/// <summary>
/// Re-lit le state.json depuis disque pour cette version et met à jour
/// <see cref="VersionRowViewModel.ResumableBytes"/>. À appeler après un cancel ou
/// une exception en milieu de DL pour que le bouton « Reprendre (X%) » reflète
/// le pourcentage réellement atteint et pas la valeur d'avant le DL.
/// </summary>
private void RefreshResumableBytes(VersionRowViewModel row)
{
try
{
var st = _downloadManager.GetResumableState(row.Version);
row.ResumableBytes = st?.DownloadedBytes ?? 0;
}
catch (Exception ex)
{
_logger.LogDebug(ex, "Failed to refresh resumable bytes for {Version}", row.Version);
}
}
private void UpdateDlProgress(VersionRowViewModel row, DownloadProgress p)
{
if (p.TotalBytes > 0)
@@ -632,15 +808,7 @@ public sealed partial class MainViewModel : ObservableObject
row.ProgressDetail = s;
}
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 FormatSize(long bytes) => Strings.FormatSize(bytes);
private static string FormatEta(TimeSpan eta)
{

View File

@@ -9,10 +9,18 @@ using Microsoft.Extensions.Logging;
using PSLauncher.Core.Configuration;
using PSLauncher.Core.Downloads;
using PSLauncher.Core.Licensing;
using PSLauncher.Core.Localization;
using PSLauncher.Models;
namespace PSLauncher.App.ViewModels;
public sealed record LanguageOption(string Code, string Name)
{
// Override pour que la SelectionBox d'un ComboBox affiche le libellé clair
// (sinon WPF affiche la forme record "LanguageOption { Code = ..., Name = ... }").
public override string ToString() => Name;
}
public sealed partial class SettingsViewModel : ObservableObject
{
private readonly IConfigStore _configStore;
@@ -29,6 +37,12 @@ public sealed partial class SettingsViewModel : ObservableObject
[ObservableProperty] private string _serverBaseUrl;
[ObservableProperty] private string _installRoot;
[ObservableProperty] private string _language;
public IReadOnlyList<LanguageOption> AvailableLanguages { get; } =
PSLauncher.Core.Localization.Strings.Available
.Select(t => new LanguageOption(t.Code, t.Name))
.ToList();
[ObservableProperty] private string? _connectionStatus;
[ObservableProperty] private bool _isTestingConnection;
[ObservableProperty] private string _cacheSizeDisplay = "—";
@@ -38,11 +52,12 @@ public sealed partial class SettingsViewModel : ObservableObject
get
{
var l = _licenseService.GetCached();
if (l is null) return "Aucune license activée — clique pour saisir une clé";
if (l is null) return Strings.LicenseSummaryNone;
if (l.Status == "valid")
return $"{l.OwnerName} • valable jusqu'au {l.DownloadEntitlementUntil:dd/MM/yyyy}";
return Strings.LicenseSummaryValid(l.OwnerName ?? "—",
Strings.FormatDate(l.DownloadEntitlementUntil));
if (l.Status == "expired")
return $"{l.OwnerName} • expirée le {l.DownloadEntitlementUntil:dd/MM/yyyy}";
return Strings.LicenseSummaryExpired(Strings.FormatDate(l.DownloadEntitlementUntil));
return $"{l.OwnerName} • {l.Status}";
}
}
@@ -102,6 +117,7 @@ public sealed partial class SettingsViewModel : ObservableObject
_serverBaseUrl = config.ServerBaseUrl;
_installRoot = config.InstallRoot;
_language = string.IsNullOrWhiteSpace(config.Language) ? "auto" : config.Language;
LauncherVersion = Assembly.GetExecutingAssembly().GetName().Version?.ToString() ?? "0.0.0";
MachineId = licenseService.GetMachineId();
@@ -144,7 +160,7 @@ public sealed partial class SettingsViewModel : ObservableObject
{
var dlg = new Microsoft.Win32.OpenFolderDialog
{
Title = "Choisir le dossier d'installation",
Title = Strings.SettingsInstallRoot,
InitialDirectory = Directory.Exists(InstallRoot) ? InstallRoot : Environment.GetFolderPath(Environment.SpecialFolder.UserProfile),
};
if (dlg.ShowDialog() == true)
@@ -171,8 +187,8 @@ public sealed partial class SettingsViewModel : ObservableObject
private void ClearCache()
{
var confirm = MessageBox.Show(
"Vider le cache de téléchargements ? Les téléchargements en cours/pause seront perdus.",
"Confirmer",
Strings.MsgClearCacheConfirm,
Strings.MsgBoxConfirm,
MessageBoxButton.YesNo, MessageBoxImage.Warning, MessageBoxResult.No);
if (confirm != MessageBoxResult.Yes) return;
@@ -187,7 +203,7 @@ public sealed partial class SettingsViewModel : ObservableObject
catch (Exception ex)
{
_logger.LogError(ex, "ClearCache failed");
MessageBox.Show("Échec : " + ex.Message, "Erreur", MessageBoxButton.OK, MessageBoxImage.Error);
MessageBox.Show(ex.Message, Strings.MsgBoxError, MessageBoxButton.OK, MessageBoxImage.Error);
}
}
@@ -201,18 +217,56 @@ public sealed partial class SettingsViewModel : ObservableObject
[RelayCommand]
private void Save()
{
var languageChanged = !string.Equals(
(_config.Language ?? "auto").ToLowerInvariant(),
(Language ?? "auto").ToLowerInvariant(),
StringComparison.Ordinal);
_config.ServerBaseUrl = ServerBaseUrl.TrimEnd('/');
_config.InstallRoot = InstallRoot;
_config.Language = Language ?? "auto";
_configStore.Save(_config);
_logger.LogInformation("Settings saved");
_logger.LogInformation("Settings saved (language={Lang}, changed={Changed})", _config.Language, languageChanged);
if (languageChanged)
{
// Le launcher doit être relancé pour que les bindings x:Static reflètent
// la nouvelle langue. Plutôt que de demander à l'utilisateur de fermer/rouvrir,
// on spawne un cmd qui attend la fermeture puis relance.
var ok = MessageBox.Show(
Strings.MsgLanguageRestart,
Strings.MsgBoxLanguageChange,
MessageBoxButton.OKCancel, MessageBoxImage.Information, MessageBoxResult.OK);
if (ok == MessageBoxResult.OK)
{
try
{
var exe = System.Diagnostics.Process.GetCurrentProcess().MainModule?.FileName;
if (!string.IsNullOrEmpty(exe))
{
var pid = Environment.ProcessId;
var cmd = $"/c (echo Wait && timeout /t 1 /nobreak > nul) & start \"\" \"{exe}\"";
Process.Start(new ProcessStartInfo
{
FileName = "cmd.exe",
Arguments = cmd,
UseShellExecute = false,
CreateNoWindow = true,
});
Application.Current.Shutdown();
}
}
catch (Exception ex) { _logger.LogError(ex, "Restart on language change failed"); }
}
}
}
[RelayCommand]
private void DeactivateLicense()
{
var confirm = MessageBox.Show(
"Désactiver la license sur cette machine ?\nTu pourras la réactiver avec ta clé.",
"Confirmer",
Strings.MsgDeactivateLicenseConfirm,
Strings.MsgBoxConfirm,
MessageBoxButton.YesNo, MessageBoxImage.Question, MessageBoxResult.No);
if (confirm != MessageBoxResult.Yes) return;
_licenseService.Clear();
@@ -234,12 +288,5 @@ public sealed partial class SettingsViewModel : ObservableObject
catch { CacheSizeDisplay = "—"; }
}
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 FormatSize(long bytes) => Strings.FormatSize(bytes);
}

View File

@@ -1,5 +1,6 @@
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using PSLauncher.Core.Localization;
using PSLauncher.Models;
namespace PSLauncher.App.ViewModels;
@@ -29,6 +30,7 @@ public sealed partial class VersionRowViewModel : ObservableObject
[NotifyPropertyChangedFor(nameof(IsBusy))]
[NotifyPropertyChangedFor(nameof(IsInstalled))]
[NotifyPropertyChangedFor(nameof(IsRemoteOnly))]
[NotifyPropertyChangedFor(nameof(ShowRestartFromZero))]
[NotifyCanExecuteChangedFor(nameof(LaunchCommand))]
[NotifyCanExecuteChangedFor(nameof(InstallCommand))]
[NotifyCanExecuteChangedFor(nameof(UninstallCommand))]
@@ -40,6 +42,8 @@ public sealed partial class VersionRowViewModel : ObservableObject
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(InstallButtonLabel))]
[NotifyPropertyChangedFor(nameof(HasResumableDownload))]
[NotifyPropertyChangedFor(nameof(ShowRestartFromZero))]
[NotifyCanExecuteChangedFor(nameof(RestartFromZeroCommand))]
private long _resumableBytes;
[ObservableProperty]
@@ -51,15 +55,23 @@ public sealed partial class VersionRowViewModel : ObservableObject
public bool HasResumableDownload => ResumableBytes > 0;
public bool LicenseAllowsInstall => LicenseAllowsDownload;
/// <summary>
/// Visibilité du bouton rouge « Annuler » à côté de « ↻ Reprendre ».
/// Doit être vrai uniquement quand un partial existe ET qu'aucun DL n'est en
/// cours sur cette row (sinon le bouton « Annuler » se mélange visuellement
/// avec la barre de progression du DL actif, qui a son propre cancel).
/// </summary>
public bool ShowRestartFromZero => HasResumableDownload && State == VersionRowState.AvailableIdle;
public string InstallButtonLabel
{
get
{
if (!LicenseAllowsDownload) return "🔒 License insuffisante";
if (!HasResumableDownload) return "⬇ Installer";
if (Remote is null || Remote.Download.SizeBytes <= 0) return "↻ Reprendre";
var pct = (double)ResumableBytes / Remote.Download.SizeBytes * 100.0;
return $"↻ Reprendre ({pct:0}%)";
if (!LicenseAllowsDownload) return Strings.ActionLicenseInsufficient;
if (!HasResumableDownload) return Strings.ActionInstall;
if (Remote is null || Remote.Download.SizeBytes <= 0) return Strings.ResumeButtonNoPercent;
var pct = (int)Math.Round((double)ResumableBytes / Remote.Download.SizeBytes * 100.0);
return Strings.ResumeButton(pct);
}
}
@@ -69,16 +81,16 @@ public sealed partial class VersionRowViewModel : ObservableObject
public string StateLabel => State switch
{
VersionRowState.InstalledIdle => "● Installée",
VersionRowState.AvailableIdle => "○ Disponible",
VersionRowState.Downloading => "⬇ Téléchargement…",
VersionRowState.Installing => "📦 Installation…",
VersionRowState.Uninstalling => "🗑 Suppression…",
VersionRowState.InstalledIdle => Strings.StatusInstalled,
VersionRowState.AvailableIdle => Strings.StatusAvailable,
VersionRowState.Downloading => Strings.StatusDownloading,
VersionRowState.Installing => Strings.StatusInstalling,
VersionRowState.Uninstalling => Strings.StatusUninstalling,
_ => string.Empty
};
public string PrimaryDate => ReleasedAt > DateTime.MinValue
? ReleasedAt.ToLocalTime().ToString("dd/MM/yyyy")
? Strings.FormatDate(ReleasedAt)
: "—";
public string SizeDisplay => FormatSize(SizeBytes);
@@ -134,6 +146,7 @@ public sealed partial class VersionRowViewModel : ObservableObject
public Action<VersionRowViewModel>? UninstallHandler { get; set; }
public Action<VersionRowViewModel>? ShowReleaseNotesHandler { get; set; }
public Action<VersionRowViewModel>? OpenFolderHandler { get; set; }
public Action<VersionRowViewModel>? RestartFromZeroHandler { get; set; }
[RelayCommand(CanExecute = nameof(CanLaunch))]
private void Launch() => LaunchHandler?.Invoke(this);
@@ -153,13 +166,10 @@ public sealed partial class VersionRowViewModel : ObservableObject
[RelayCommand]
private void OpenFolder() => OpenFolderHandler?.Invoke(this);
[RelayCommand(CanExecute = nameof(CanRestartFromZero))]
private void RestartFromZero() => RestartFromZeroHandler?.Invoke(this);
private bool CanRestartFromZero() => HasResumableDownload;
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]}";
}
=> bytes <= 0 ? "—" : Strings.FormatSize(bytes);
}

View File

@@ -1,7 +1,8 @@
<Window x:Class="PSLauncher.App.Views.LauncherUpdateDialog"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
Title="Mise à jour du launcher"
xmlns:loc="clr-namespace:PSLauncher.Core.Localization;assembly=PSLauncher.Core"
Title="{x:Static loc:Strings.LauncherUpdateTitle}"
Icon="pack://application:,,,/Resources/favicon.ico"
Width="500" Height="320"
ResizeMode="NoResize"
@@ -16,7 +17,7 @@
</Grid.RowDefinitions>
<TextBlock Grid.Row="0"
Text="Mise à jour du launcher disponible"
Text="{x:Static loc:Strings.LauncherUpdateAvailable}"
FontSize="20" FontWeight="SemiBold"
Foreground="{StaticResource Brush.Text.Primary}" />
@@ -33,7 +34,7 @@
<Border Grid.Row="2" Background="{StaticResource Brush.Bg.Card}"
BorderBrush="{StaticResource Brush.Border}" BorderThickness="1"
CornerRadius="6" Padding="16" Margin="0,16,0,0">
<TextBlock Text="Le launcher va se mettre à jour : il téléchargera le nouveau binaire, se fermera, sera remplacé, puis se relancera automatiquement. Cela prend quelques secondes."
<TextBlock Text="{x:Static loc:Strings.LauncherUpdateBody}"
Foreground="{StaticResource Brush.Text.Secondary}"
TextWrapping="Wrap" />
</Border>
@@ -41,11 +42,11 @@
<StackPanel Grid.Row="3" Orientation="Horizontal"
HorizontalAlignment="Right" Margin="0,18,0,0">
<Button Style="{StaticResource SecondaryButton}"
Content="Plus tard" IsCancel="True"
Content="{x:Static loc:Strings.ActionLater}" IsCancel="True"
Margin="0,0,12,0"
Click="OnLater" />
<Button Style="{StaticResource AccentButton}"
Content="⬇ Mettre à jour"
Content="{x:Static loc:Strings.LauncherUpdateNow}"
Padding="32,8"
IsDefault="True"
Click="OnUpdate" />

View File

@@ -1,4 +1,5 @@
using System.Windows;
using PSLauncher.Core.Localization;
using PSLauncher.Models;
namespace PSLauncher.App.Views;
@@ -12,7 +13,7 @@ public partial class LauncherUpdateDialog : Window
InitializeComponent();
VersionText.Text = $"v{currentVersion} → v{info.Version}";
SizeText.Text = info.Download.SizeBytes > 0
? $"Taille : {FormatSize(info.Download.SizeBytes)}"
? Strings.SizeLabel(Strings.FormatSize(info.Download.SizeBytes))
: "";
}
@@ -29,13 +30,4 @@ public partial class LauncherUpdateDialog : Window
DialogResult = false;
Close();
}
private static string FormatSize(long bytes)
{
if (bytes <= 0) return "—";
string[] units = { "o", "Ko", "Mo", "Go" };
double v = bytes; int u = 0;
while (v >= 1024 && u < units.Length - 1) { v /= 1024; u++; }
return $"{v:0.#} {units[u]}";
}
}

View File

@@ -1,6 +1,7 @@
<Window x:Class="PSLauncher.App.Views.LicenseDetailsDialog"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:loc="clr-namespace:PSLauncher.Core.Localization;assembly=PSLauncher.Core"
Title="License"
Icon="pack://application:,,,/Resources/favicon.ico"
Width="500" Height="480"
@@ -45,7 +46,7 @@
<ColumnDefinition Width="160" />
<ColumnDefinition Width="*" />
</Grid.ColumnDefinitions>
<TextBlock Grid.Column="0" Text="Client"
<TextBlock Grid.Column="0" Text="{x:Static loc:Strings.LicenseDetailsClient}"
Foreground="{StaticResource Brush.Text.Secondary}" FontSize="12" />
<TextBlock Grid.Column="1" x:Name="OwnerText"
FontWeight="SemiBold"
@@ -56,7 +57,7 @@
<ColumnDefinition Width="160" />
<ColumnDefinition Width="*" />
</Grid.ColumnDefinitions>
<TextBlock Grid.Column="0" Text="Validité téléchargements"
<TextBlock Grid.Column="0" Text="{x:Static loc:Strings.LicenseDetailsValidity}"
Foreground="{StaticResource Brush.Text.Secondary}" FontSize="12" />
<TextBlock Grid.Column="1" x:Name="UntilText"
FontWeight="SemiBold"
@@ -67,7 +68,7 @@
<ColumnDefinition Width="160" />
<ColumnDefinition Width="*" />
</Grid.ColumnDefinitions>
<TextBlock Grid.Column="0" Text="Activée le"
<TextBlock Grid.Column="0" Text="{x:Static loc:Strings.LicenseDetailsActivatedOn}"
Foreground="{StaticResource Brush.Text.Secondary}" FontSize="12" />
<TextBlock Grid.Column="1" x:Name="IssuedText"
Foreground="{StaticResource Brush.Text.Primary}" />
@@ -77,7 +78,7 @@
<ColumnDefinition Width="160" />
<ColumnDefinition Width="*" />
</Grid.ColumnDefinitions>
<TextBlock Grid.Column="0" Text="ID machine"
<TextBlock Grid.Column="0" Text="{x:Static loc:Strings.LicenseDetailsMachineId}"
Foreground="{StaticResource Brush.Text.Secondary}" FontSize="12"
VerticalAlignment="Center" />
<Grid Grid.Column="1">
@@ -91,7 +92,7 @@
TextTrimming="CharacterEllipsis" VerticalAlignment="Center" />
<Button Grid.Column="1" Style="{StaticResource SecondaryButton}"
Content="📋"
ToolTip="Copier"
ToolTip="{x:Static loc:Strings.CopyTooltip}"
Click="OnCopyMachineId" />
</Grid>
</Grid>
@@ -101,11 +102,11 @@
<!-- Footer actions -->
<StackPanel Grid.Row="3" Orientation="Horizontal" HorizontalAlignment="Right" Margin="0,18,0,0">
<Button Style="{StaticResource SecondaryButton}"
Content="🗑 Désactiver la license"
Content="{x:Static loc:Strings.LicenseDetailsDeactivate}"
Click="OnDeactivate"
Margin="0,0,12,0" />
<Button Style="{StaticResource AccentButton}"
Content="Fermer"
Content="{x:Static loc:Strings.ActionClose}"
IsCancel="True" IsDefault="True"
Padding="32,8"
Click="OnClose" />

View File

@@ -1,6 +1,7 @@
using System.Windows;
using System.Windows.Media;
using PSLauncher.Core.Licensing;
using PSLauncher.Core.Localization;
using PSLauncher.Models;
namespace PSLauncher.App.Views;
@@ -28,11 +29,11 @@ public partial class LicenseDetailsDialog : Window
OwnerText.Text = license.OwnerName ?? "—";
UntilText.Text = license.DownloadEntitlementUntil is { } until
? $"jusqu'au {until.ToLocalTime():dddd dd MMMM yyyy}"
? Strings.FormatLongDate(until)
: "—";
IssuedText.Text = license.IssuedAt is { } issued
? issued.ToLocalTime().ToString("dd/MM/yyyy")
: (license.ServerTime?.ToLocalTime().ToString("dd/MM/yyyy") ?? "—");
? Strings.FormatDate(issued)
: Strings.FormatDate(license.ServerTime);
MachineIdText.Text = licenseService.GetMachineId();
}
@@ -42,19 +43,19 @@ public partial class LicenseDetailsDialog : Window
if (l.Status == "valid")
{
if (until is { } u && (u - DateTime.UtcNow).TotalDays < 30)
return ("⏰", "License valide", $"Expire bientôt — le {u.ToLocalTime():dd/MM/yyyy}",
return ("⏰", Strings.LicenseDetailsValidTitle, Strings.LicenseDetailsExpiringSoon(Strings.FormatDate(u)),
Color.FromRgb(0xF5, 0x9E, 0x0B), Color.FromRgb(0x3A, 0x2A, 0x0E));
return ("✓", "License valide", "Téléchargements de nouvelles versions autorisés",
return ("✓", Strings.LicenseDetailsValidTitle, Strings.LicenseDetailsValidSubtitle,
Color.FromRgb(0x16, 0xA3, 0x4A), Color.FromRgb(0x0E, 0x2A, 0x1B));
}
if (l.Status == "expired")
return ("⚠", "License expirée",
until is { } u ? $"Expirée le {u.ToLocalTime():dd/MM/yyyy}" : "Plus de téléchargements possibles",
return ("⚠", Strings.LicenseDetailsExpiredTitle,
until is { } u ? Strings.LicenseDetailsExpiredSubtitle(Strings.FormatDate(u)) : Strings.LicenseDetailsExpiredFallback,
Color.FromRgb(0xEF, 0x44, 0x44), Color.FromRgb(0x2A, 0x14, 0x14));
if (l.Status == "revoked")
return ("🚫", "License révoquée", "Contacte ASTERION pour plus d'informations",
return ("🚫", Strings.LicenseDetailsRevokedTitle, Strings.LicenseDetailsRevokedSubtitle,
Color.FromRgb(0xEF, 0x44, 0x44), Color.FromRgb(0x2A, 0x14, 0x14));
return ("❓", "License inconnue", l.Message ?? l.Status,
return ("❓", Strings.LicenseDetailsUnknownTitle, l.Message ?? l.Status,
Color.FromRgb(0xEF, 0x44, 0x44), Color.FromRgb(0x2A, 0x14, 0x14));
}
@@ -66,10 +67,8 @@ public partial class LicenseDetailsDialog : Window
private void OnDeactivate(object sender, RoutedEventArgs e)
{
var confirm = MessageBox.Show(
"Désactiver la license sur cette machine ?\n\n" +
"Tu pourras la réactiver plus tard avec ta clé. " +
"Les versions déjà installées restent utilisables.",
"Confirmer la désactivation",
Strings.MsgDeactivateLicenseDetailedConfirm,
Strings.MsgBoxConfirm,
MessageBoxButton.YesNo, MessageBoxImage.Question, MessageBoxResult.No);
if (confirm != MessageBoxResult.Yes) return;
_licenseService.Clear();

View File

@@ -2,6 +2,7 @@
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:loc="clr-namespace:PSLauncher.Core.Localization;assembly=PSLauncher.Core"
xmlns:shell="clr-namespace:System.Windows.Shell;assembly=PresentationFramework"
Title="PROSERVE Launcher"
Icon="pack://application:,,,/Resources/favicon.ico"
@@ -54,22 +55,23 @@
<ColumnDefinition Width="Auto" />
</Grid.ColumnDefinitions>
<!-- Strip latérale colorée -->
<Rectangle Grid.Column="0" RadiusX="2" RadiusY="2" Margin="0,8">
<Rectangle.Style>
<Style TargetType="Rectangle">
<Setter Property="Fill" Value="{StaticResource Brush.Status.Installed}" />
<!-- Strip latérale colorée : Border avec coins arrondis à gauche
pour suivre la rondeur (CornerRadius=6) de la card parent. -->
<Border Grid.Column="0" CornerRadius="5,0,0,5">
<Border.Style>
<Style TargetType="Border">
<Setter Property="Background" Value="{StaticResource Brush.Status.Installed}" />
<Style.Triggers>
<DataTrigger Binding="{Binding IsRemoteOnly}" Value="True">
<Setter Property="Fill" Value="{StaticResource Brush.Status.Available}" />
<Setter Property="Background" Value="{StaticResource Brush.Status.Available}" />
</DataTrigger>
<DataTrigger Binding="{Binding IsBusy}" Value="True">
<Setter Property="Fill" Value="{StaticResource Brush.Status.Busy}" />
<Setter Property="Background" Value="{StaticResource Brush.Status.Busy}" />
</DataTrigger>
</Style.Triggers>
</Style>
</Rectangle.Style>
</Rectangle>
</Border.Style>
</Border>
<Grid Grid.Column="1" Margin="16,12">
<Grid.RowDefinitions>
@@ -112,13 +114,21 @@
<!-- Action button -->
<StackPanel Grid.Column="2" Orientation="Horizontal" VerticalAlignment="Center" Margin="12,0,0,0">
<Button Style="{StaticResource PrimaryButton}"
Content="Lancer" Padding="22,8" FontSize="13"
Content="{x:Static loc:Strings.ActionLaunch}" Padding="22,8" FontSize="13"
Command="{Binding LaunchCommand}"
Visibility="{Binding IsInstalled, Converter={StaticResource BoolToVisibility}}" />
<Button Style="{StaticResource AccentButton}"
Content="{Binding InstallButtonLabel}" Padding="22,8" FontSize="13"
Command="{Binding InstallCommand}"
Visibility="{Binding IsRemoteOnly, Converter={StaticResource BoolToVisibility}}" />
<!-- Bouton rouge « Annuler » : visible uniquement quand on a un partial.
Permet d'abandonner le partial et repartir de zéro (avec confirmation). -->
<Button Style="{StaticResource DangerButton}"
Content="{x:Static loc:Strings.ActionCancel}"
Padding="14,8" FontSize="13" Margin="6,0,0,0"
Command="{Binding RestartFromZeroCommand}"
ToolTip="{x:Static loc:Strings.MenuRestartFromZero}"
Visibility="{Binding ShowRestartFromZero, Converter={StaticResource BoolToVisibility}}" />
<StackPanel Orientation="Horizontal"
Visibility="{Binding IsBusy, Converter={StaticResource BoolToVisibility}}">
<ProgressBar Width="140" Height="6"
@@ -142,13 +152,16 @@
Click="OnMoreMenuClick">
<Button.ContextMenu>
<ContextMenu>
<MenuItem Header="📜 Voir les release notes"
<MenuItem Header="{x:Static loc:Strings.MenuReleaseNotes}"
Command="{Binding PlacementTarget.Tag.ShowReleaseNotesCommand, RelativeSource={RelativeSource AncestorType=ContextMenu}}" />
<MenuItem Header="📁 Ouvrir le dossier"
<MenuItem Header="{x:Static loc:Strings.MenuOpenFolder}"
Command="{Binding PlacementTarget.Tag.OpenFolderCommand, RelativeSource={RelativeSource AncestorType=ContextMenu}}"
Visibility="{Binding PlacementTarget.Tag.IsInstalled, RelativeSource={RelativeSource AncestorType=ContextMenu}, Converter={StaticResource BoolToVisibility}}" />
<MenuItem Header="{x:Static loc:Strings.MenuRestartFromZero}"
Command="{Binding PlacementTarget.Tag.RestartFromZeroCommand, RelativeSource={RelativeSource AncestorType=ContextMenu}}"
Visibility="{Binding PlacementTarget.Tag.HasResumableDownload, RelativeSource={RelativeSource AncestorType=ContextMenu}, Converter={StaticResource BoolToVisibility}}" />
<Separator Visibility="{Binding PlacementTarget.Tag.IsInstalled, RelativeSource={RelativeSource AncestorType=ContextMenu}, Converter={StaticResource BoolToVisibility}}" />
<MenuItem Header="🗑 Supprimer cette version…"
<MenuItem Header="{x:Static loc:Strings.MenuUninstall}"
Command="{Binding PlacementTarget.Tag.UninstallCommand, RelativeSource={RelativeSource AncestorType=ContextMenu}}"
Visibility="{Binding PlacementTarget.Tag.IsInstalled, RelativeSource={RelativeSource AncestorType=ContextMenu}, Converter={StaticResource BoolToVisibility}}" />
</ContextMenu>
@@ -218,7 +231,7 @@
Cursor="Hand"
CornerRadius="16" Padding="14,6"
shell:WindowChrome.IsHitTestVisibleInChrome="True"
ToolTip="Voir / changer la license">
ToolTip="{x:Static loc:Strings.LicenseTooltip}">
<Border.Style>
<Style TargetType="Border">
<Setter Property="Background" Value="#2A1414" />
@@ -282,20 +295,20 @@
<StackPanel Grid.Column="2" Orientation="Horizontal"
HorizontalAlignment="Right" VerticalAlignment="Center">
<Button Style="{StaticResource SecondaryButton}"
Content="⚙ Paramètres"
Content="{x:Static loc:Strings.TopBarSettings}"
shell:WindowChrome.IsHitTestVisibleInChrome="True"
Command="{Binding OpenSettingsCommand}"
Margin="0,0,16,0" />
<Button Style="{StaticResource WindowControlButton}"
Content="—" ToolTip="Réduire"
Content="—" ToolTip="{x:Static loc:Strings.Minimize}"
shell:WindowChrome.IsHitTestVisibleInChrome="True"
Click="OnMinimizeClick" />
<Button Style="{StaticResource WindowControlButton}"
Content="☐" ToolTip="Agrandir / Restaurer"
Content="☐" ToolTip="{x:Static loc:Strings.Maximize}"
shell:WindowChrome.IsHitTestVisibleInChrome="True"
Click="OnMaxRestoreClick" />
<Button Style="{StaticResource WindowCloseButton}"
Content="✕" ToolTip="Fermer"
Content="✕" ToolTip="{x:Static loc:Strings.CloseTooltip}"
shell:WindowChrome.IsHitTestVisibleInChrome="True"
Click="OnCloseClick" />
</StackPanel>
@@ -333,22 +346,25 @@
<ColumnDefinition Width="*" />
</Grid.ColumnDefinitions>
<!-- Bande latérale large -->
<Rectangle Grid.Column="0">
<Rectangle.Style>
<Style TargetType="Rectangle">
<Setter Property="Fill" Value="{StaticResource Brush.Status.Installed}" />
<!-- Bande latérale large : Border avec coins arrondis seulement à
gauche pour suivre la rondeur de la card parent (CornerRadius=10).
Rectangle ne se clippe pas sur le parent ⇒ on utilisait un visuel
qui dépassait les coins. -->
<Border Grid.Column="0" CornerRadius="9,0,0,9">
<Border.Style>
<Style TargetType="Border">
<Setter Property="Background" Value="{StaticResource Brush.Status.Installed}" />
<Style.Triggers>
<DataTrigger Binding="{Binding FeaturedVersion.IsRemoteOnly}" Value="True">
<Setter Property="Fill" Value="{StaticResource Brush.Status.Available}" />
<Setter Property="Background" Value="{StaticResource Brush.Status.Available}" />
</DataTrigger>
<DataTrigger Binding="{Binding FeaturedVersion.IsBusy}" Value="True">
<Setter Property="Fill" Value="{StaticResource Brush.Status.Busy}" />
<Setter Property="Background" Value="{StaticResource Brush.Status.Busy}" />
</DataTrigger>
</Style.Triggers>
</Style>
</Rectangle.Style>
</Rectangle>
</Border.Style>
</Border>
<Grid Grid.Column="1" Margin="32,28">
<Grid.ColumnDefinitions>
@@ -364,14 +380,14 @@
<!-- Label "Version courante" -->
<TextBlock Grid.Row="0" Grid.Column="0"
Text="VERSION COURANTE"
Text="{x:Static loc:Strings.FeaturedCurrent}"
FontSize="11" FontWeight="Bold"
Foreground="{StaticResource Brush.Text.Secondary}"
Margin="0,0,0,4" />
<!-- Numéro version + badge -->
<StackPanel Grid.Row="1" Grid.Column="0" Orientation="Horizontal" VerticalAlignment="Center">
<TextBlock Text="{Binding FeaturedVersion.Version, StringFormat='Proserve v{0}'}"
<TextBlock Text="{Binding FeaturedVersion.Version, StringFormat='PROSERVE v{0}'}"
FontSize="32" FontWeight="Bold"
Foreground="{StaticResource Brush.Text.Primary}" />
<Border CornerRadius="12" Padding="10,4" Margin="16,8,0,0"
@@ -399,7 +415,7 @@
FontSize="13"
Foreground="{StaticResource Brush.Text.Secondary}"
Margin="0,8,0,0">
<Run Text="Sortie le " />
<Run Text="{x:Static loc:Strings.ReleasedOn}" />
<Run Text="{Binding FeaturedVersion.PrimaryDate, Mode=OneWay}" />
<Run Text=" • " />
<Run Text="{Binding FeaturedVersion.SizeDisplay, Mode=OneWay}" />
@@ -408,19 +424,29 @@
<!-- Action principale (gros bouton) -->
<Button Grid.Row="0" Grid.RowSpan="3" Grid.Column="1"
Style="{StaticResource PrimaryButton}"
Content="▶ LANCER"
Content="{x:Static loc:Strings.ActionLaunchBig}"
FontSize="20" Padding="56,16"
VerticalAlignment="Center"
Command="{Binding FeaturedVersion.LaunchCommand}"
Visibility="{Binding FeaturedVersion.IsInstalled, Converter={StaticResource BoolToVisibility}}" />
<Button Grid.Row="0" Grid.RowSpan="3" Grid.Column="1"
Style="{StaticResource AccentButton}"
<StackPanel Grid.Row="0" Grid.RowSpan="3" Grid.Column="1"
Orientation="Vertical" VerticalAlignment="Center"
Visibility="{Binding FeaturedVersion.IsRemoteOnly, Converter={StaticResource BoolToVisibility}}">
<Button Style="{StaticResource AccentButton}"
Content="{Binding FeaturedVersion.InstallButtonLabel}"
FontSize="20" Padding="48,16"
VerticalAlignment="Center"
Command="{Binding FeaturedVersion.InstallCommand}"
Visibility="{Binding FeaturedVersion.IsRemoteOnly, Converter={StaticResource BoolToVisibility}}" />
Command="{Binding FeaturedVersion.InstallCommand}" />
<!-- Bouton rouge « Annuler » : visible uniquement quand un partial existe.
Permet d'abandonner et repartir de zéro (confirmation requise). -->
<Button Style="{StaticResource DangerButton}"
Content="{x:Static loc:Strings.ActionCancel}"
FontSize="13" Padding="20,8" Margin="0,8,0,0"
HorizontalAlignment="Center"
Command="{Binding FeaturedVersion.RestartFromZeroCommand}"
ToolTip="{x:Static loc:Strings.MenuRestartFromZero}"
Visibility="{Binding FeaturedVersion.ShowRestartFromZero, Converter={StaticResource BoolToVisibility}}" />
</StackPanel>
<StackPanel Grid.Row="0" Grid.RowSpan="3" Grid.Column="1"
Orientation="Vertical" VerticalAlignment="Center" Width="220"
@@ -450,13 +476,16 @@
Click="OnMoreMenuClick">
<Button.ContextMenu>
<ContextMenu>
<MenuItem Header="📜 Voir les release notes"
<MenuItem Header="{x:Static loc:Strings.MenuReleaseNotes}"
Command="{Binding PlacementTarget.Tag.ShowReleaseNotesCommand, RelativeSource={RelativeSource AncestorType=ContextMenu}}" />
<MenuItem Header="📁 Ouvrir le dossier"
<MenuItem Header="{x:Static loc:Strings.MenuOpenFolder}"
Command="{Binding PlacementTarget.Tag.OpenFolderCommand, RelativeSource={RelativeSource AncestorType=ContextMenu}}"
Visibility="{Binding PlacementTarget.Tag.IsInstalled, RelativeSource={RelativeSource AncestorType=ContextMenu}, Converter={StaticResource BoolToVisibility}}" />
<MenuItem Header="{x:Static loc:Strings.MenuRestartFromZero}"
Command="{Binding PlacementTarget.Tag.RestartFromZeroCommand, RelativeSource={RelativeSource AncestorType=ContextMenu}}"
Visibility="{Binding PlacementTarget.Tag.HasResumableDownload, RelativeSource={RelativeSource AncestorType=ContextMenu}, Converter={StaticResource BoolToVisibility}}" />
<Separator Visibility="{Binding PlacementTarget.Tag.IsInstalled, RelativeSource={RelativeSource AncestorType=ContextMenu}, Converter={StaticResource BoolToVisibility}}" />
<MenuItem Header="🗑 Supprimer cette version…"
<MenuItem Header="{x:Static loc:Strings.MenuUninstall}"
Command="{Binding PlacementTarget.Tag.UninstallCommand, RelativeSource={RelativeSource AncestorType=ContextMenu}}"
Visibility="{Binding PlacementTarget.Tag.IsInstalled, RelativeSource={RelativeSource AncestorType=ContextMenu}, Converter={StaticResource BoolToVisibility}}" />
</ContextMenu>
@@ -473,7 +502,7 @@
<ColumnDefinition Width="Auto" />
<ColumnDefinition Width="*" />
</Grid.ColumnDefinitions>
<TextBlock Grid.Column="0" Text="AUTRES VERSIONS"
<TextBlock Grid.Column="0" Text="{x:Static loc:Strings.SectionOther}"
FontSize="12" FontWeight="Bold"
Foreground="{StaticResource Brush.Text.Secondary}"
VerticalAlignment="Center" />
@@ -504,7 +533,7 @@
<Button HorizontalAlignment="Left" VerticalAlignment="Bottom"
Margin="32,0,0,24"
Style="{StaticResource SecondaryButton}"
Content="🔄 Vérifier les MAJ"
Content="{x:Static loc:Strings.TopBarCheckUpdates}"
Command="{Binding CheckForUpdatesCommand}" />
<!-- Copyright flottant en bas centré, sur une pill sombre pour rester
@@ -514,7 +543,7 @@
Background="#A0000000"
CornerRadius="10"
Padding="10,4">
<TextBlock Text="© 2026 ASTERION VR — Tous droits réservés"
<TextBlock Text="{x:Static loc:Strings.Copyright}"
FontSize="11"
Foreground="White" />
</Border>
@@ -542,7 +571,7 @@
</StackPanel>
<Button Grid.Column="1"
Style="{StaticResource SecondaryButton}"
Content="Annuler"
Content="{x:Static loc:Strings.ActionCancel}"
VerticalAlignment="Center" Margin="12,0,0,0"
Command="{Binding CancelDownloadCommand}"
Visibility="{Binding IsBusy, Converter={StaticResource BoolToVisibility}}" />

View File

@@ -3,6 +3,8 @@ using System.Windows;
using System.Windows.Controls;
using System.Windows.Controls.Primitives;
using System.Windows.Interop;
using PSLauncher.App.ViewModels;
using PSLauncher.Core.Localization;
namespace PSLauncher.App.Views;
@@ -27,6 +29,25 @@ public partial class MainWindow : Window
{
InitializeComponent();
SourceInitialized += MainWindow_SourceInitialized;
Closing += MainWindow_Closing;
}
/// <summary>
/// Si un DL est en cours, demande confirmation avant de fermer le launcher.
/// La progression est de toute façon préservée pour reprise au prochain lancement,
/// mais on évite à l'utilisateur de cliquer ✕ par accident en plein milieu d'un 14 Go.
/// </summary>
private void MainWindow_Closing(object? sender, System.ComponentModel.CancelEventArgs e)
{
if (DataContext is MainViewModel vm && vm.HasActiveDownload)
{
var version = vm.ActiveDownloadVersion ?? "?";
var result = MessageBox.Show(
Strings.MsgQuitWhileDownloadingConfirm(version),
Strings.MsgBoxQuit,
MessageBoxButton.YesNo, MessageBoxImage.Question, MessageBoxResult.No);
if (result != MessageBoxResult.Yes) e.Cancel = true;
}
}
/// <summary>

View File

@@ -1,7 +1,8 @@
<Window x:Class="PSLauncher.App.Views.OnboardingDialog"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
Title="Activation PROSERVE Launcher"
xmlns:loc="clr-namespace:PSLauncher.Core.Localization;assembly=PSLauncher.Core"
Title="{x:Static loc:Strings.OnboardingTitle}"
Icon="pack://application:,,,/Resources/favicon.ico"
Width="540" Height="420"
MinWidth="480" MinHeight="380"
@@ -18,17 +19,17 @@
<RowDefinition Height="Auto" />
</Grid.RowDefinitions>
<TextBlock Grid.Row="0" Text="Activer votre license"
<TextBlock Grid.Row="0" Text="{x:Static loc:Strings.OnboardingHeading}"
FontSize="22" FontWeight="SemiBold"
Foreground="{StaticResource Brush.Text.Primary}" />
<TextBlock Grid.Row="1"
Text="Saisis la clé fournie par ASTERION. Elle te donne accès aux téléchargements jusqu'à la date de validité associée."
Text="{x:Static loc:Strings.OnboardingBody}"
TextWrapping="Wrap"
Foreground="{StaticResource Brush.Text.Secondary}"
Margin="0,8,0,18" />
<TextBlock Grid.Row="2" Text="Clé de license"
<TextBlock Grid.Row="2" Text="{x:Static loc:Strings.OnboardingLicenseKey}"
FontSize="12" FontWeight="Bold"
Foreground="{StaticResource Brush.Text.Secondary}" />
<TextBox Grid.Row="3" x:Name="KeyBox"
@@ -52,12 +53,12 @@
<StackPanel Grid.Row="5" Orientation="Horizontal" HorizontalAlignment="Right" Margin="0,18,0,0">
<Button Style="{StaticResource SecondaryButton}"
Content="Plus tard"
Content="{x:Static loc:Strings.ActionLater}"
IsCancel="True"
Margin="0,0,12,0"
Click="OnLater" />
<Button Style="{StaticResource AccentButton}"
Content="Activer"
Content="{x:Static loc:Strings.OnboardingActivate}"
Padding="32,10"
IsDefault="True"
Click="OnActivate"

View File

@@ -1,6 +1,7 @@
using System.Windows;
using System.Windows.Media;
using PSLauncher.Core.Licensing;
using PSLauncher.Core.Localization;
namespace PSLauncher.App.Views;
@@ -24,12 +25,12 @@ public partial class OnboardingDialog : Window
var key = KeyBox.Text?.Trim().ToUpperInvariant() ?? string.Empty;
if (string.IsNullOrEmpty(key) || key.Contains("XXXX"))
{
ShowStatus("Saisis une clé valide au format PRSRV-XXXX-XXXX-XXXX-XXXX.", isError: true);
ShowStatus(Strings.OnboardingInvalidKey, isError: true);
return;
}
ActivateButton.IsEnabled = false;
ShowStatus("Validation en cours…", isError: false);
ShowStatus(Strings.OnboardingValidating, isError: false);
try
{
@@ -38,7 +39,8 @@ public partial class OnboardingDialog : Window
{
case "valid":
_licenseService.SaveCached(key, resp);
ShowStatus($"License activée ({resp.OwnerName}). Téléchargements autorisés jusqu'au {resp.DownloadEntitlementUntil:dd/MM/yyyy}.", isError: false);
ShowStatus(Strings.OnboardingActivated(resp.OwnerName ?? "—",
Strings.FormatDate(resp.DownloadEntitlementUntil)), isError: false);
LicenseActivated = true;
DialogResult = true;
Close();
@@ -46,27 +48,27 @@ public partial class OnboardingDialog : Window
case "expired":
_licenseService.SaveCached(key, resp); // on cache quand même pour permettre de lancer les versions installées
ShowStatus($"License expirée le {resp.DownloadEntitlementUntil:dd/MM/yyyy}. Tu peux toujours utiliser les versions déjà installées, mais plus en télécharger de nouvelles.", isError: true);
ShowStatus(Strings.OnboardingExpired(Strings.FormatDate(resp.DownloadEntitlementUntil)), isError: true);
LicenseActivated = true;
DialogResult = true;
Close();
return;
case "revoked":
ShowStatus("Cette license a été révoquée. Contacte ASTERION.", isError: true);
ShowStatus(Strings.OnboardingRevoked, isError: true);
break;
case "machine_limit_exceeded":
ShowStatus(resp.Message ?? "Cette license a atteint son nombre maximum de machines.", isError: true);
ShowStatus(resp.Message ?? Strings.OnboardingMachineLimit, isError: true);
break;
case "invalid":
default:
ShowStatus(resp.Message ?? "Clé de license inconnue.", isError: true);
ShowStatus(resp.Message ?? Strings.OnboardingInvalid, isError: true);
break;
}
}
catch (Exception ex)
{
ShowStatus($"Erreur de communication avec le serveur :\n{ex.Message}", isError: true);
ShowStatus(Strings.OnboardingServerError(ex.Message), isError: true);
}
finally
{

View File

@@ -1,7 +1,8 @@
<Window x:Class="PSLauncher.App.Views.ReleaseNotesViewerDialog"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
Title="Notes de version"
xmlns:loc="clr-namespace:PSLauncher.Core.Localization;assembly=PSLauncher.Core"
Title="{x:Static loc:Strings.MsgBoxReleaseNotes}"
Icon="pack://application:,,,/Resources/favicon.ico"
Width="640" Height="540"
MinWidth="480" MinHeight="400"
@@ -33,7 +34,7 @@
<Button Grid.Row="2"
Style="{StaticResource SecondaryButton}"
Content="Fermer"
Content="{x:Static loc:Strings.ActionClose}"
IsCancel="True"
IsDefault="True"
HorizontalAlignment="Right"

View File

@@ -2,7 +2,8 @@
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"
Title="Paramètres"
xmlns:loc="clr-namespace:PSLauncher.Core.Localization;assembly=PSLauncher.Core"
Title="{x:Static loc:Strings.SettingsTitle}"
Icon="pack://application:,,,/Resources/favicon.ico"
Width="640" Height="720"
MinWidth="540" MinHeight="500"
@@ -22,14 +23,16 @@
<!-- Header -->
<Border Grid.Row="0" Padding="32,24,32,16">
<TextBlock Text="Paramètres" FontSize="22" FontWeight="SemiBold"
<TextBlock Text="{x:Static loc:Strings.SettingsTitle}" FontSize="22" FontWeight="SemiBold"
Foreground="{StaticResource Brush.Text.Primary}" />
</Border>
<ScrollViewer Grid.Row="1" VerticalScrollBarVisibility="Auto" Padding="32,0,32,16">
<StackPanel>
<!-- License (en tête, bordure colorée selon état) -->
<!-- License de mise à jour (en tête : c'est l'info la plus importante,
elle conditionne ce que l'utilisateur peut télécharger.
Bordure colorée selon l'état : vert/jaune/rouge). -->
<Border BorderThickness="2"
CornerRadius="6" Padding="20" Margin="0,0,0,16">
<Border.Style>
@@ -83,7 +86,7 @@
<!-- Header text -->
<TextBlock Grid.Row="0" Grid.Column="1"
Text="LICENSE"
Text="{x:Static loc:Strings.SettingsLicense}"
FontSize="11" FontWeight="Bold"
Foreground="{StaticResource Brush.Text.Secondary}" />
<TextBlock Grid.Row="1" Grid.Column="1"
@@ -100,7 +103,7 @@
<ColumnDefinition Width="Auto" />
</Grid.ColumnDefinitions>
<StackPanel Grid.Column="0">
<TextBlock Text="Identifiant machine (anonyme)"
<TextBlock Text="{x:Static loc:Strings.SettingsMachineId}"
FontSize="11"
Foreground="{StaticResource Brush.Text.Secondary}" />
<TextBlock Text="{Binding MachineId}"
@@ -110,22 +113,45 @@
</StackPanel>
<Button Grid.Column="1"
Style="{StaticResource SecondaryButton}"
Content="📋 Copier"
Content="{x:Static loc:Strings.SettingsCopyMachineId}"
Command="{Binding CopyMachineIdCommand}" />
</Grid>
</Grid>
</Border>
<!-- Langue de l'interface -->
<Border Background="{StaticResource Brush.Bg.Card}"
BorderBrush="{StaticResource Brush.Border}" BorderThickness="1"
CornerRadius="6" Padding="20" Margin="0,0,0,16">
<StackPanel>
<TextBlock Text="{x:Static loc:Strings.SettingsLanguage}"
FontSize="11" FontWeight="Bold"
Foreground="{StaticResource Brush.Text.Secondary}"
Margin="0,0,0,8" />
<ComboBox ItemsSource="{Binding AvailableLanguages}"
DisplayMemberPath="Name"
SelectedValuePath="Code"
SelectedValue="{Binding Language, Mode=TwoWay}"
Background="#1A1A20"
Foreground="{StaticResource Brush.Text.Primary}"
BorderBrush="{StaticResource Brush.Border}"
BorderThickness="1" Padding="8" />
<TextBlock Text="{x:Static loc:Strings.SettingsLanguageHint}"
FontSize="11" Foreground="{StaticResource Brush.Text.Secondary}"
FontStyle="Italic" Margin="0,6,0,0" TextWrapping="Wrap" />
</StackPanel>
</Border>
<!-- Serveur -->
<Border Background="{StaticResource Brush.Bg.Card}"
BorderBrush="{StaticResource Brush.Border}" BorderThickness="1"
CornerRadius="6" Padding="20" Margin="0,0,0,16">
<StackPanel>
<TextBlock Text="SERVEUR"
<TextBlock Text="{x:Static loc:Strings.SettingsServer}"
FontSize="11" FontWeight="Bold"
Foreground="{StaticResource Brush.Text.Secondary}"
Margin="0,0,0,8" />
<TextBlock Text="URL de l'API"
<TextBlock Text="{x:Static loc:Strings.SettingsApiUrl}"
Foreground="{StaticResource Brush.Text.Secondary}"
FontSize="12" Margin="0,0,0,4" />
<Grid>
@@ -141,7 +167,7 @@
BorderThickness="1" Padding="8" />
<Button Grid.Column="1" Margin="8,0,0,0"
Style="{StaticResource SecondaryButton}"
Content="Tester"
Content="{x:Static loc:Strings.SettingsTest}"
Command="{Binding TestConnectionCommand}"
IsEnabled="{Binding IsTestingConnection, Converter={StaticResource InverseBoolToBool}}" />
</Grid>
@@ -156,11 +182,11 @@
BorderBrush="{StaticResource Brush.Border}" BorderThickness="1"
CornerRadius="6" Padding="20" Margin="0,0,0,16">
<StackPanel>
<TextBlock Text="INSTALLATION"
<TextBlock Text="{x:Static loc:Strings.SettingsInstallation}"
FontSize="11" FontWeight="Bold"
Foreground="{StaticResource Brush.Text.Secondary}"
Margin="0,0,0,8" />
<TextBlock Text="Dossier où sont installées les versions"
<TextBlock Text="{x:Static loc:Strings.SettingsInstallRoot}"
Foreground="{StaticResource Brush.Text.Secondary}"
FontSize="12" Margin="0,0,0,4" />
<Grid>
@@ -177,7 +203,7 @@
FontFamily="Consolas" />
<Button Grid.Column="1" Margin="8,0,0,0"
Style="{StaticResource SecondaryButton}"
Content="📁 Parcourir"
Content="{x:Static loc:Strings.SettingsBrowse}"
Command="{Binding BrowseInstallRootCommand}" />
</Grid>
</StackPanel>
@@ -188,7 +214,7 @@
BorderBrush="{StaticResource Brush.Border}" BorderThickness="1"
CornerRadius="6" Padding="20" Margin="0,0,0,16">
<StackPanel>
<TextBlock Text="CACHE DE TÉLÉCHARGEMENTS"
<TextBlock Text="{x:Static loc:Strings.SettingsCache}"
FontSize="11" FontWeight="Bold"
Foreground="{StaticResource Brush.Text.Secondary}"
Margin="0,0,0,8" />
@@ -205,7 +231,8 @@
TextTrimming="CharacterEllipsis" />
<TextBlock FontSize="12"
Foreground="{StaticResource Brush.Text.Secondary}">
<Run Text="Taille actuelle : " />
<Run Text="{x:Static loc:Strings.SettingsCacheSize}" />
<Run Text=" " />
<Run Text="{Binding CacheSizeDisplay}" FontWeight="SemiBold" />
</TextBlock>
</StackPanel>
@@ -215,7 +242,7 @@
Command="{Binding OpenCacheFolderCommand}" />
<Button Grid.Column="2" Margin="8,0,0,0"
Style="{StaticResource SecondaryButton}"
Content="🗑 Vider"
Content="{x:Static loc:Strings.SettingsClearCache}"
Command="{Binding ClearCacheCommand}" />
</Grid>
</StackPanel>
@@ -226,16 +253,17 @@
BorderBrush="{StaticResource Brush.Border}" BorderThickness="1"
CornerRadius="6" Padding="20" Margin="0,0,0,16">
<StackPanel>
<TextBlock Text="LOGS &amp; APPLICATION"
<TextBlock Text="{x:Static loc:Strings.SettingsLogs}"
FontSize="11" FontWeight="Bold"
Foreground="{StaticResource Brush.Text.Secondary}"
Margin="0,0,0,8" />
<TextBlock FontSize="13"
Foreground="{StaticResource Brush.Text.Primary}">
<Run Text="Version : " />
<Run Text="{x:Static loc:Strings.SettingsLauncherVersion}" />
<Run Text=" " />
<Run Text="{Binding LauncherVersion, Mode=OneWay}" FontWeight="SemiBold" />
</TextBlock>
<TextBlock Text="© 2026 ASTERION VR — Tous droits réservés"
<TextBlock Text="{x:Static loc:Strings.Copyright}"
FontSize="12"
Foreground="{StaticResource Brush.Text.Secondary}"
Margin="0,4,0,0" />
@@ -249,14 +277,14 @@
FontFamily="Consolas" FontSize="11"
Foreground="{StaticResource Brush.Text.Primary}"
TextTrimming="CharacterEllipsis" />
<TextBlock Text="Logs rotation quotidienne, 10 jours conservés"
<TextBlock Text="{x:Static loc:Strings.SettingsCacheRotation}"
FontSize="12"
Foreground="{StaticResource Brush.Text.Secondary}"
Margin="0,2,0,0" />
</StackPanel>
<Button Grid.Column="1" Margin="8,0,0,0"
Style="{StaticResource SecondaryButton}"
Content="📁 Ouvrir"
Content="{x:Static loc:Strings.OpenButton}"
Command="{Binding OpenLogsFolderCommand}" />
</Grid>
</StackPanel>
@@ -271,12 +299,12 @@
Padding="20,12">
<StackPanel Orientation="Horizontal" HorizontalAlignment="Right">
<Button Style="{StaticResource SecondaryButton}"
Content="Annuler"
Content="{x:Static loc:Strings.ActionCancel}"
IsCancel="True"
Margin="0,0,12,0"
Click="OnCancel" />
<Button Style="{StaticResource AccentButton}"
Content="Enregistrer"
Content="{x:Static loc:Strings.ActionSave}"
Padding="32,8"
IsDefault="True"
Click="OnSave" />

View File

@@ -1,7 +1,8 @@
<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"
xmlns:loc="clr-namespace:PSLauncher.Core.Localization;assembly=PSLauncher.Core"
Title="{x:Static loc:Strings.UpdateAvailableTitle}"
Icon="pack://application:,,,/Resources/favicon.ico"
Width="640" Height="540"
MinWidth="480" MinHeight="400"
@@ -38,12 +39,12 @@
<StackPanel Grid.Row="3" Orientation="Horizontal" HorizontalAlignment="Right" Margin="0,18,0,0">
<Button Style="{StaticResource SecondaryButton}"
Content="Plus tard"
Content="{x:Static loc:Strings.ActionLater}"
IsCancel="True"
Margin="0,0,12,0"
Click="OnLater" />
<Button Style="{StaticResource PrimaryButton}"
Content="⬇ Télécharger"
Content="{x:Static loc:Strings.UpdateDownload}"
Padding="32,10"
Click="OnDownload" />
</StackPanel>

View File

@@ -1,4 +1,5 @@
using System.Windows;
using PSLauncher.Core.Localization;
using PSLauncher.Models;
namespace PSLauncher.App.Views;
@@ -9,10 +10,14 @@ public partial class UpdateAvailableDialog : Window
{
InitializeComponent();
var sizeText = version.Download.SizeBytes > 0
? Strings.FormatSize(version.Download.SizeBytes)
: Strings.SizeUnknown;
DataContext = new
{
Title = $"Proserve v{version.Version} disponible",
Subtitle = $"Date de release : {version.ReleasedAt.ToLocalTime():dd MMMM yyyy} • {FormatSize(version.Download.SizeBytes)}",
Title = Strings.UpdateAvailableHeading(version.Version),
Subtitle = Strings.UpdateAvailableSubtitle(Strings.FormatLongDate(version.ReleasedAt), sizeText),
ReleaseNotesDocument = MarkdownTheming.BuildThemedDocument(releaseNotesMarkdown)
};
}
@@ -32,14 +37,4 @@ public partial class UpdateAvailableDialog : Window
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]}";
}
}

View File

@@ -1,35 +1,62 @@
using System.Diagnostics;
using System.Net;
using System.Net.Http.Headers;
using System.Runtime.InteropServices;
using Microsoft.Extensions.Logging;
using Microsoft.Win32.SafeHandles;
using Polly;
using Polly.Retry;
using PSLauncher.Core.Configuration;
using PSLauncher.Core.Integrity;
using PSLauncher.Models;
namespace PSLauncher.Core.Downloads;
/// <summary>
/// Téléchargement HTTP avec :
/// - reprise par Range requests (If-Range sur ETag/Last-Modified) ;
/// - retry exponentiel via Polly (1-32 s, 6 tentatives, jitter) ;
/// - téléchargement parallèle multi-segments (config <see cref="LocalConfig.ParallelDownloadSegments"/>) ;
/// - vérif SHA-256 streaming en fin de DL ;
/// - persistance d'état (state.json) toutes les 5 s ou 100 Mo pour reprise après crash.
///
/// Le multi-segments contourne le throttling per-connection d'Apache (notamment OVH mutualisé)
/// : chaque segment ouvre une connexion TCP séparée et écrit à son offset dans le fichier
/// pré-alloué via <c>SetLength</c>. Speed-up typique observé : ×4 à ×10.
/// </summary>
public sealed class DownloadManager : IDownloadManager
{
private const int BufferSize = 1 << 20; // 1 MiB
private const int StateFlushBytesInterval = 100 * 1024 * 1024; // 100 MiB
private const int BufferSize = 4 << 20; // 4 MiB par segment (avant 1 MiB)
private const int StateFlushBytesInterval = 50 * 1024 * 1024; // 50 MiB
private const double StateFlushSecondsInterval = 5.0;
private const long MultiSegmentMinSize = 50 * 1024 * 1024; // < 50 MiB → single-segment
private readonly HttpClient _http;
private readonly IDownloadStateStore _stateStore;
private readonly IIntegrityService _integrity;
private readonly LocalConfig _config;
private readonly ILogger<DownloadManager> _logger;
private readonly ResiliencePipeline _retryPipeline;
// URL active partagée entre tous les segments d'un DL en cours, et lock pour
// refresher de façon coordonnée quand l'URL HMAC expire (TTL serveur = 6 h).
// Plusieurs segments peuvent recevoir 403 simultanément — un seul doit faire
// l'appel /api/download-url/.
private Uri _currentUrl = new("about:blank");
private DateTime _lastUrlRefresh = DateTime.MinValue;
private readonly SemaphoreSlim _urlRefreshLock = new(1, 1);
public DownloadManager(
HttpClient http,
IDownloadStateStore stateStore,
IIntegrityService integrity,
LocalConfig config,
ILogger<DownloadManager> logger)
{
_http = http;
_stateStore = stateStore;
_integrity = integrity;
_config = config;
_logger = logger;
_retryPipeline = new ResiliencePipelineBuilder()
@@ -60,28 +87,400 @@ public sealed class DownloadManager : IDownloadManager
public void DiscardResumableState(string version) => _stateStore.Discard(version);
public async Task<string> DownloadAsync(
public Task<string> DownloadAsync(
DownloadJob job,
IProgress<DownloadProgress>? progress,
CancellationToken ct)
=> DownloadAsync(job, progress, null, ct);
/// <summary>
/// Récupère l'URL active. Si la dernière récup date de moins de 5 s,
/// retourne celle en cache (évite que 8 segments en 403 simultanés
/// déclenchent 8 requêtes /api/download-url/). Sinon refresh via le
/// callback du job. Si pas de callback, retourne l'URL initiale du job.
/// </summary>
private async Task<Uri> GetOrRefreshUrlAsync(DownloadJob job, bool forceRefresh, CancellationToken ct)
{
await _urlRefreshLock.WaitAsync(ct).ConfigureAwait(false);
try
{
// Premier appel : initialise avec l'URL du job
if (_currentUrl.Scheme == "about")
{
_currentUrl = job.Url;
_lastUrlRefresh = DateTime.UtcNow;
}
// Si refresh forcé (un segment a vu 403), tente de fetcher une nouvelle URL.
// Debounce 5 s pour fusionner les refresh en rafale des 8 segments.
if (forceRefresh && (DateTime.UtcNow - _lastUrlRefresh).TotalSeconds >= 5
&& job.RefreshUrlAsync is not null)
{
_logger.LogInformation("Refreshing signed URL for v{Version} (URL expired or 403)", job.Version);
var fresh = await job.RefreshUrlAsync(ct).ConfigureAwait(false);
if (fresh is not null)
{
_currentUrl = fresh;
_lastUrlRefresh = DateTime.UtcNow;
_logger.LogInformation("Signed URL refreshed for v{Version}", job.Version);
}
}
return _currentUrl;
}
finally
{
_urlRefreshLock.Release();
}
}
public async Task<string> DownloadAsync(
DownloadJob job,
IProgress<DownloadProgress>? progress,
IProgress<double>? hashProgress,
CancellationToken ct)
{
// Reset URL state pour ce job (DownloadManager est singleton DI)
_currentUrl = job.Url;
_lastUrlRefresh = DateTime.UtcNow;
var partialPath = _stateStore.GetPartialPath(job.Version);
var finalPath = Path.ChangeExtension(partialPath, null); // strip ".partial" → .zip
// 1. Récupère l'état existant (s'il y en a un, et qu'il correspond à la même URL/sha)
// 1. Récupère l'état existant. On compare la ressource par son SHA-256 (signé via le
// manifest) plutôt que par URL : les URLs HMAC-signées changent à chaque appel
// (?exp=…&sig=…), donc une comparaison stricte d'URL invaliderait toujours le resume.
// On compare aussi le path (host+path sans query) pour détecter un changement de
// serveur/fichier, mais on ignore la querystring.
var existing = _stateStore.Load(job.Version);
if (existing is not null && (existing.Url != job.Url.ToString() || existing.ExpectedSha256 != job.ExpectedSha256))
if (existing is not null)
{
_logger.LogInformation("Cached state for {Version} mismatches new job (url/sha changed) — discarding", job.Version);
bool shaMismatch = !string.IsNullOrEmpty(job.ExpectedSha256)
&& !string.IsNullOrEmpty(existing.ExpectedSha256)
&& !string.Equals(existing.ExpectedSha256, job.ExpectedSha256, StringComparison.OrdinalIgnoreCase);
bool pathMismatch = false;
if (Uri.TryCreate(existing.Url, UriKind.Absolute, out var oldUri))
{
pathMismatch = oldUri.Host != job.Url.Host || oldUri.AbsolutePath != job.Url.AbsolutePath;
}
if (shaMismatch || pathMismatch)
{
_logger.LogInformation("Cached state for {Version} mismatches new job (sha={Sha} path={Path}) — discarding",
job.Version, shaMismatch, pathMismatch);
_stateStore.Discard(job.Version);
existing = null;
}
else
{
// Même ressource : on rafraîchit juste l'URL stockée (utile pour les futures requêtes
// au sein de cette session, même si en pratique chaque segment requête via job.Url).
existing.Url = job.Url.ToString();
}
}
// 2. Choix single vs multi-segment
var requestedSegments = Math.Clamp(_config.ParallelDownloadSegments, 1, 16);
var useMultiSegment = requestedSegments > 1
&& job.ExpectedSize >= MultiSegmentMinSize
&& (existing is null || existing.Segments.Count > 0);
// Si l'état existant est en mode legacy (single-segment) et qu'on veut multi → on conserve le legacy
// pour pouvoir reprendre proprement (sinon on jetterait son DL).
if (existing is { Segments.Count: 0 } && File.Exists(partialPath))
{
_logger.LogInformation("Existing single-segment partial detected — staying single-segment for resume");
useMultiSegment = false;
}
// 3. Vérif espace disque (uniquement à l'initial, pas au resume)
if (existing is null && job.ExpectedSize > 0)
{
var drive = new DriveInfo(Path.GetPathRoot(_stateStore.GetDownloadsDirectory())!);
var needed = (long)(job.ExpectedSize * 1.05);
if (drive.AvailableFreeSpace < needed)
throw new IOException(
$"Espace disque insuffisant sur {drive.Name} : besoin de {needed:N0} octets, disponible {drive.AvailableFreeSpace:N0}");
}
if (useMultiSegment)
{
return await DownloadMultiSegmentAsync(
job, partialPath, finalPath, existing, requestedSegments, progress, hashProgress, ct).ConfigureAwait(false);
}
return await DownloadSingleSegmentAsync(
job, partialPath, finalPath, existing, progress, hashProgress, ct).ConfigureAwait(false);
}
// ===================================================================
// ============== MULTI-SEGMENT (parallèle, fast path) ==============
// ===================================================================
private async Task<string> DownloadMultiSegmentAsync(
DownloadJob job,
string partialPath,
string finalPath,
DownloadState? existing,
int segmentCount,
IProgress<DownloadProgress>? progress,
IProgress<double>? hashProgress,
CancellationToken ct)
{
var swPhase = Stopwatch.StartNew();
// 1. On fait CONFIANCE au manifest : pas de HEAD probe.
// Le HEAD ajoutait jusqu'à 30 s-5 min de latence quand le serveur faisait
// un md5_file() sur 14 Go à chaque requête (vu sur OVH/gate.php). Le manifest
// est signé Ed25519 donc sa taille est de toute façon fiable. Si le serveur
// ne supporte pas Range, on le détecte sur le premier segment (200 au lieu
// de 206) et on fallback sur single-segment.
long total = job.ExpectedSize;
if (total <= 0)
{
_logger.LogInformation("ExpectedSize=0 — falling back to single-segment");
return await DownloadSingleSegmentAsync(job, partialPath, finalPath, existing, progress, hashProgress, ct).ConfigureAwait(false);
}
// 2. State (resume vs fresh)
DownloadState state;
if (existing is not null && existing.Segments.Count > 0 && existing.TotalBytes == total && File.Exists(partialPath))
{
state = existing;
_logger.LogInformation("Resuming multi-segment DL for v{Version} ({Segments} segments, {Done}/{Total} bytes)",
job.Version, state.Segments.Count, state.DownloadedBytes, state.TotalBytes);
}
else
{
// Discard tout résidu (state mismatch ou partial sans state)
_stateStore.Discard(job.Version);
state = new DownloadState
{
Version = job.Version,
Url = job.Url.ToString(),
TotalBytes = total,
ExpectedSha256 = job.ExpectedSha256,
PartialPath = partialPath,
StartedAtUtc = DateTime.UtcNow,
LastChunkAtUtc = DateTime.UtcNow,
Segments = BuildSegments(total, segmentCount),
};
_stateStore.Save(state);
}
// 3. Pré-allocation du fichier final.
// Stratégie : on tente de marquer le fichier en SPARSE (FSCTL_SET_SPARSE)
// avant le SetLength, ce qui rend l'opération métadata-only (~milliseconde
// au lieu de 5-30 s sur HDD à cause du zero-fill NTFS).
// Pas besoin d'admin (contrairement à SetFileValidData). Aucune fuite de
// données : les zones non écrites sont lues comme zéros virtuels par NTFS.
// Si FSCTL_SET_SPARSE échoue (volume FAT32 ou autre), fallback sur SetLength
// classique.
var swAlloc = Stopwatch.StartNew();
await Task.Run(() =>
{
using var alloc = new FileStream(partialPath, FileMode.OpenOrCreate, FileAccess.Write, FileShare.ReadWrite);
if (alloc.Length != total)
{
bool sparseOk = SparseFileSupport.TrySetSparse(alloc.SafeFileHandle);
_logger.LogInformation("Pre-allocating {Path} to {Bytes} bytes (sparse={Sparse})", partialPath, total, sparseOk);
alloc.SetLength(total);
}
}, ct).ConfigureAwait(false);
swAlloc.Stop();
_logger.LogInformation("Pre-alloc done in {Ms} ms (HEAD skipped, total phase {TotalMs} ms)", swAlloc.ElapsedMilliseconds, swPhase.ElapsedMilliseconds);
// Une fois la pré-alloc terminée, on émet immédiatement un progress 0/total
// pour que le footer passe de « Préparation… » à « ⬇ v1.4.6 : 0 / 14 Go ».
// La vraie progression suit ~250 ms après quand le reporter task tick.
progress?.Report(new DownloadProgress(state.DownloadedBytes, total, 0, null));
// 4. Lance les workers en parallèle.
// Compteur agrégé via Interlocked → pas de lock dans le hot path.
long aggregateBytes = state.DownloadedBytes;
// Action passée aux segments : juste un Add atomique (zéro contention).
void OnSegmentBytes(int _, long deltaBytes)
=> Interlocked.Add(ref aggregateBytes, deltaBytes);
// Reporter dédié : toutes les 250 ms, snapshot le compteur, calcule bps/ETA, reporte.
// Tous les 5 s ou 50 Mo, persist le state.json.
// Tourne sur un thread séparé pour ne jamais être starvé par les workers DL.
using var reporterCts = CancellationTokenSource.CreateLinkedTokenSource(ct);
var reporterTask = Task.Run(async () =>
{
var sw = Stopwatch.StartNew();
var lastReport = TimeSpan.Zero;
long lastReportBytes = Interlocked.Read(ref aggregateBytes);
var lastFlush = TimeSpan.Zero;
long lastFlushBytes = lastReportBytes;
try
{
while (!reporterCts.IsCancellationRequested)
{
await Task.Delay(250, reporterCts.Token).ConfigureAwait(false);
var snapshot = Interlocked.Read(ref aggregateBytes);
var elapsed = sw.Elapsed;
var deltaSec = (elapsed - lastReport).TotalSeconds;
var bps = deltaSec > 0 ? (snapshot - lastReportBytes) / deltaSec : 0;
TimeSpan? eta = null;
if (bps > 0 && total > snapshot) eta = TimeSpan.FromSeconds((total - snapshot) / bps);
progress?.Report(new DownloadProgress(snapshot, total, bps, eta));
lastReport = elapsed;
lastReportBytes = snapshot;
if ((elapsed - lastFlush).TotalSeconds >= StateFlushSecondsInterval ||
snapshot - lastFlushBytes >= StateFlushBytesInterval)
{
state.DownloadedBytes = snapshot;
state.LastChunkAtUtc = DateTime.UtcNow;
_stateStore.Save(state);
lastFlush = elapsed;
lastFlushBytes = snapshot;
}
}
}
catch (OperationCanceledException) { /* attendu en fin de DL */ }
}, reporterCts.Token);
var tasks = state.Segments
.Where(s => !s.Completed)
.Select(s => Task.Run(() =>
_retryPipeline.ExecuteAsync(async innerCt =>
{
await DownloadSegmentAsync(job, state, s, partialPath, OnSegmentBytes, innerCt).ConfigureAwait(false);
}, ct).AsTask(), ct))
.ToArray();
try
{
await Task.WhenAll(tasks).ConfigureAwait(false);
}
catch
{
reporterCts.Cancel();
try { await reporterTask.ConfigureAwait(false); } catch { /* ignore */ }
state.DownloadedBytes = Interlocked.Read(ref aggregateBytes);
_stateStore.Save(state);
throw;
}
// Stoppe le reporter et fais un dernier flush
reporterCts.Cancel();
try { await reporterTask.ConfigureAwait(false); } catch { /* ignore */ }
state.DownloadedBytes = Interlocked.Read(ref aggregateBytes);
_stateStore.Save(state);
progress?.Report(new DownloadProgress(state.DownloadedBytes, total, 0, TimeSpan.Zero));
// 5. Vérif taille
var actualSize = new FileInfo(partialPath).Length;
if (actualSize != total)
{
_stateStore.Discard(job.Version);
throw new InvalidDataException($"Taille téléchargée incorrecte : attendu {total}, obtenu {actualSize}");
}
// 6. Vérif SHA-256
await VerifyAndFinalizeAsync(job, partialPath, finalPath, hashProgress, ct).ConfigureAwait(false);
return finalPath;
}
private static List<DownloadSegment> BuildSegments(long total, int count)
{
var segments = new List<DownloadSegment>(count);
long segSize = total / count;
for (int i = 0; i < count; i++)
{
long start = i * segSize;
long end = (i == count - 1) ? total - 1 : (start + segSize - 1);
segments.Add(new DownloadSegment
{
Index = i,
Start = start,
End = end,
DownloadedBytes = 0,
Completed = false,
});
}
return segments;
}
private async Task DownloadSegmentAsync(
DownloadJob job,
DownloadState state,
DownloadSegment seg,
string partialPath,
Action<int, long> onBytes,
CancellationToken ct)
{
if (seg.Completed) return;
var segStart = seg.Start + seg.DownloadedBytes;
if (segStart > seg.End) { seg.Completed = true; return; }
// Récupère l'URL active (peut avoir été refreshée par un autre segment)
var url = await GetOrRefreshUrlAsync(job, forceRefresh: false, ct).ConfigureAwait(false);
using var req = new HttpRequestMessage(HttpMethod.Get, url);
req.Headers.Range = new RangeHeaderValue(segStart, seg.End);
if (!string.IsNullOrEmpty(state.Etag))
req.Headers.IfRange = new RangeConditionHeaderValue(new EntityTagHeaderValue(state.Etag));
using var resp = await _http.SendAsync(req, HttpCompletionOption.ResponseHeadersRead, ct).ConfigureAwait(false);
if (resp.StatusCode == HttpStatusCode.OK)
{
// Le serveur a renvoyé tout au lieu du range : la ressource a probablement changé
// (ou il n'a en fait pas accepté le range). On signale un retry à blanc.
throw new HttpResumableException("Server returned 200 for segment range request", isTransient: true);
}
// 403 / 410 = URL HMAC expirée (gate.php renvoie 403 "Forbidden: expired").
// On force un refresh d'URL via le callback du job, puis on jette une
// exception transitoire pour que Polly retry avec la nouvelle URL.
if (resp.StatusCode == HttpStatusCode.Forbidden || resp.StatusCode == HttpStatusCode.Gone)
{
if (job.RefreshUrlAsync is not null)
{
await GetOrRefreshUrlAsync(job, forceRefresh: true, ct).ConfigureAwait(false);
throw new HttpResumableException("Signed URL expired, refreshed for next try", isTransient: true);
}
}
if (!resp.IsSuccessStatusCode)
{
var transient = (int)resp.StatusCode is 408 or 429 or >= 500;
throw new HttpResumableException($"HTTP {(int)resp.StatusCode} on segment {seg.Index}", isTransient: transient);
}
if (resp.Headers.ETag is { } etag) state.Etag = etag.Tag;
if (resp.Content.Headers.LastModified is { } lm) state.LastModified = lm.ToString("R");
await using var src = await resp.Content.ReadAsStreamAsync(ct).ConfigureAwait(false);
await using var dst = new FileStream(partialPath, FileMode.Open, FileAccess.Write, FileShare.ReadWrite, BufferSize, useAsync: true);
dst.Seek(segStart, SeekOrigin.Begin);
var buffer = new byte[BufferSize];
int n;
while ((n = await src.ReadAsync(buffer.AsMemory(0, BufferSize), ct).ConfigureAwait(false)) > 0)
{
await dst.WriteAsync(buffer.AsMemory(0, n), ct).ConfigureAwait(false);
seg.DownloadedBytes += n;
onBytes(seg.Index, n);
}
await dst.FlushAsync(ct).ConfigureAwait(false);
seg.Completed = (seg.DownloadedBytes >= seg.Length);
}
// ===================================================================
// ============= SINGLE-SEGMENT (legacy / fallback path) ============
// ===================================================================
private async Task<string> DownloadSingleSegmentAsync(
DownloadJob job,
string partialPath,
string finalPath,
DownloadState? existing,
IProgress<DownloadProgress>? progress,
IProgress<double>? hashProgress,
CancellationToken ct)
{
long resumeFrom = 0;
if (existing is not null && File.Exists(partialPath))
{
resumeFrom = new FileInfo(partialPath).Length;
// Si la state.json est en avance par rapport au .partial réel, on s'aligne sur le .partial
if (resumeFrom != existing.DownloadedBytes)
{
_logger.LogInformation(
@@ -92,21 +491,10 @@ public sealed class DownloadManager : IDownloadManager
}
else if (File.Exists(partialPath))
{
// Fichier partiel sans state : on repart à 0 par sécurité
File.Delete(partialPath);
resumeFrom = 0;
}
// 2. Vérif espace disque (uniquement à l'initial, pas au resume)
if (resumeFrom == 0 && job.ExpectedSize > 0)
{
var drive = new DriveInfo(Path.GetPathRoot(_stateStore.GetDownloadsDirectory())!);
var needed = (long)(job.ExpectedSize * 1.05);
if (drive.AvailableFreeSpace < needed)
throw new IOException(
$"Espace disque insuffisant sur {drive.Name} : besoin de {needed:N0} octets, disponible {drive.AvailableFreeSpace:N0}");
}
var state = existing ?? new DownloadState
{
Version = job.Version,
@@ -119,13 +507,11 @@ public sealed class DownloadManager : IDownloadManager
LastChunkAtUtc = DateTime.UtcNow,
};
// 3. Tentatives avec retry — chaque tentative continue depuis la taille actuelle du .partial
await _retryPipeline.ExecuteAsync(async innerCt =>
{
await DownloadChunkAsync(job, state, partialPath, progress, innerCt).ConfigureAwait(false);
}, ct).ConfigureAwait(false);
// 4. Vérif taille finale
var actualSize = new FileInfo(partialPath).Length;
if (job.ExpectedSize > 0 && actualSize != job.ExpectedSize)
{
@@ -134,27 +520,7 @@ public sealed class DownloadManager : IDownloadManager
$"Taille téléchargée incorrecte : attendu {job.ExpectedSize}, obtenu {actualSize}");
}
// 5. Vérif SHA-256
if (!string.IsNullOrEmpty(job.ExpectedSha256) &&
!job.ExpectedSha256.StartsWith("REPLACE", StringComparison.OrdinalIgnoreCase))
{
_logger.LogInformation("Verifying SHA-256 of {Path}…", partialPath);
var sha = await _integrity.ComputeSha256Async(partialPath, null, ct).ConfigureAwait(false);
if (!string.Equals(sha, job.ExpectedSha256, StringComparison.OrdinalIgnoreCase))
{
_stateStore.Discard(job.Version);
throw new InvalidDataException(
$"Checksum SHA-256 invalide : attendu {job.ExpectedSha256}, calculé {sha}");
}
}
else
{
_logger.LogWarning("SHA-256 absent du manifest, vérification ignorée");
}
if (File.Exists(finalPath)) File.Delete(finalPath);
File.Move(partialPath, finalPath);
_stateStore.Discard(job.Version); // efface aussi le state.json puisqu'on a fini
await VerifyAndFinalizeAsync(job, partialPath, finalPath, hashProgress, ct).ConfigureAwait(false);
return finalPath;
}
@@ -166,39 +532,31 @@ public sealed class DownloadManager : IDownloadManager
CancellationToken ct)
{
var resumeFrom = File.Exists(partialPath) ? new FileInfo(partialPath).Length : 0;
if (state.TotalBytes > 0 && resumeFrom >= state.TotalBytes) return; // déjà tout là
if (state.TotalBytes > 0 && resumeFrom >= state.TotalBytes) return;
using var req = new HttpRequestMessage(HttpMethod.Get, job.Url);
if (resumeFrom > 0)
{
req.Headers.Range = new RangeHeaderValue(resumeFrom, null);
// If-Range : si l'ETag du serveur a changé entre temps, on veut recommencer proprement
if (!string.IsNullOrEmpty(state.Etag))
{
req.Headers.IfRange = new RangeConditionHeaderValue(new EntityTagHeaderValue(state.Etag));
}
else if (!string.IsNullOrEmpty(state.LastModified) &&
DateTimeOffset.TryParse(state.LastModified, out var lm))
{
req.Headers.IfRange = new RangeConditionHeaderValue(lm);
}
}
_logger.LogInformation("HTTP GET {Url} range={Resume}-", job.Url, resumeFrom);
using var resp = await _http.SendAsync(req, HttpCompletionOption.ResponseHeadersRead, ct).ConfigureAwait(false);
if (resumeFrom > 0 && resp.StatusCode == HttpStatusCode.OK)
{
// Le serveur a renvoyé 200 alors qu'on demandait du Range : la ressource a changé.
_logger.LogWarning("Server returned 200 for ranged request — resource changed, restarting fresh");
resp.Dispose();
File.Delete(partialPath);
_stateStore.Discard(job.Version);
// On retry sera traité par Polly via une exception
throw new HttpResumableException("Resource changed on server, restart", isTransient: true);
}
// 5xx / 408 / 429 → exception transitoire pour Polly
if (!resp.IsSuccessStatusCode)
{
var transient = (int)resp.StatusCode is 408 or 429 or >= 500;
@@ -207,11 +565,9 @@ public sealed class DownloadManager : IDownloadManager
isTransient: transient);
}
// Capture ETag et Last-Modified du serveur si présents (pour les futures reprises)
if (resp.Headers.ETag is { } etag) state.Etag = etag.Tag;
if (resp.Content.Headers.LastModified is { } lm2) state.LastModified = lm2.ToString("R");
// Total : si le serveur ne fournit pas Content-Length, on garde la valeur du manifest
var contentLength = resp.Content.Headers.ContentLength ?? 0;
var total = state.TotalBytes;
if (resp.StatusCode == HttpStatusCode.PartialContent && resp.Content.Headers.ContentRange is { Length: { } cl })
@@ -220,8 +576,6 @@ public sealed class DownloadManager : IDownloadManager
total = contentLength;
if (total > 0) state.TotalBytes = total;
// Si on est sur un 200 alors qu'on partait de 0, on écrit en append (FileMode.Append depuis position 0)
// Si on est sur un 206 avec resumeFrom>0, on append.
var mode = resumeFrom > 0 ? FileMode.Append : FileMode.Create;
await using var src = await resp.Content.ReadAsStreamAsync(ct).ConfigureAwait(false);
await using var dst = new FileStream(partialPath, mode, FileAccess.Write, FileShare.None, BufferSize, useAsync: true);
@@ -244,7 +598,6 @@ public sealed class DownloadManager : IDownloadManager
var elapsed = sw.Elapsed;
// Progression UI
if ((elapsed - lastReport).TotalMilliseconds >= 250)
{
var deltaBytes = read - lastReportBytes;
@@ -257,7 +610,6 @@ public sealed class DownloadManager : IDownloadManager
lastReportBytes = read;
}
// Persist state.json toutes les 5s ou tous les 100 Mo
if ((elapsed - lastFlush).TotalSeconds >= StateFlushSecondsInterval ||
read - lastFlushBytes >= StateFlushBytesInterval)
{
@@ -272,6 +624,59 @@ public sealed class DownloadManager : IDownloadManager
_stateStore.Save(state);
progress?.Report(new DownloadProgress(read, state.TotalBytes, 0, TimeSpan.Zero));
}
// ===================================================================
// ====================== FINALISATION COMMUNE ======================
// ===================================================================
private async Task VerifyAndFinalizeAsync(
DownloadJob job,
string partialPath,
string finalPath,
IProgress<double>? hashProgress,
CancellationToken ct)
{
// Évalue si on doit vérifier le hash :
// 1. Le manifest doit fournir un sha256 valide (pas vide, pas "REPLACE…")
// 2. L'algo demandé n'est pas "none"
// 3. La config locale n'a pas désactivé la vérification globale
bool hashAlgoSkipped = string.Equals(job.HashAlgorithm, "none", StringComparison.OrdinalIgnoreCase);
bool hasUsableSha = !string.IsNullOrEmpty(job.ExpectedSha256)
&& !job.ExpectedSha256.StartsWith("REPLACE", StringComparison.OrdinalIgnoreCase);
if (hasUsableSha && !hashAlgoSkipped && _config.VerifyDownloadHash)
{
_logger.LogInformation("Verifying SHA-256 of {Path}…", partialPath);
var sw = Stopwatch.StartNew();
var sha = await _integrity.ComputeSha256Async(partialPath, hashProgress, ct).ConfigureAwait(false);
sw.Stop();
_logger.LogInformation("SHA-256 verified in {Elapsed} ({MBps:0.#} MB/s)",
sw.Elapsed,
sw.Elapsed.TotalSeconds > 0 ? new FileInfo(partialPath).Length / sw.Elapsed.TotalSeconds / (1024.0 * 1024.0) : 0);
if (!string.Equals(sha, job.ExpectedSha256, StringComparison.OrdinalIgnoreCase))
{
_stateStore.Discard(job.Version);
throw new InvalidDataException(
$"Checksum SHA-256 invalide : attendu {job.ExpectedSha256}, calculé {sha}");
}
}
else if (hashAlgoSkipped)
{
_logger.LogInformation("Hash verification skipped (manifest specifies hashAlgorithm=none)");
}
else if (!_config.VerifyDownloadHash)
{
_logger.LogInformation("Hash verification skipped (VerifyDownloadHash=false in local config)");
}
else
{
_logger.LogWarning("SHA-256 absent du manifest, vérification ignorée");
}
if (File.Exists(finalPath)) File.Delete(finalPath);
File.Move(partialPath, finalPath);
_stateStore.Discard(job.Version);
}
}
/// <summary>Exception interne pour signaler à Polly qu'on doit retry.</summary>
@@ -283,3 +688,42 @@ internal sealed class HttpResumableException : Exception
IsTransient = isTransient;
}
}
internal static class SparseFileSupport
{
private const uint FSCTL_SET_SPARSE = 0x000900C4;
[DllImport("kernel32.dll", SetLastError = true, CharSet = CharSet.Auto)]
[return: MarshalAs(UnmanagedType.Bool)]
private static extern bool DeviceIoControl(
SafeFileHandle hDevice,
uint dwIoControlCode,
IntPtr lpInBuffer,
uint nInBufferSize,
IntPtr lpOutBuffer,
uint nOutBufferSize,
out uint lpBytesReturned,
IntPtr lpOverlapped);
/// <summary>
/// Marque le fichier comme sparse via FSCTL_SET_SPARSE.
/// Une fois sparse, <c>SetLength(14 GB)</c> est métadata-only (&lt; 100 ms partout).
/// Les zones non écrites sont lues comme zéros virtuels (pas de fuite de données
/// disque, contrairement à SetFileValidData qui exigerait SeManageVolumePrivilege).
/// Retourne false si le volume ne supporte pas sparse (ex. FAT32) — auquel cas
/// on retombe sur le SetLength classique avec zero-fill.
/// </summary>
public static bool TrySetSparse(SafeFileHandle handle)
{
if (!OperatingSystem.IsWindows()) return false;
try
{
return DeviceIoControl(handle, FSCTL_SET_SPARSE,
IntPtr.Zero, 0, IntPtr.Zero, 0, out _, IntPtr.Zero);
}
catch
{
return false;
}
}
}

View File

@@ -9,6 +9,17 @@ public interface IDownloadManager
IProgress<DownloadProgress>? progress,
CancellationToken ct);
/// <summary>
/// Variante avec callback de progression dédié pour la phase de vérification SHA-256.
/// La phase verify est CPU/IO-bound et peut prendre plusieurs minutes sur un build 14 Go ;
/// la UI doit pouvoir l'afficher distinctement du DL.
/// </summary>
Task<string> DownloadAsync(
DownloadJob job,
IProgress<DownloadProgress>? progress,
IProgress<double>? hashProgress,
CancellationToken ct);
/// <summary>Retourne l'état de DL persistant pour cette version, ou null s'il n'y en a pas.</summary>
DownloadState? GetResumableState(string version);
@@ -20,4 +31,20 @@ public sealed record DownloadJob(
string Version,
Uri Url,
long ExpectedSize,
string ExpectedSha256);
string ExpectedSha256)
{
/// <summary>
/// Algo de vérif d'intégrité — "sha256" (défaut) ou "none" pour sauter la vérif.
/// Pris depuis <see cref="PSLauncher.Models.DownloadDescriptor.HashAlgorithm"/>.
/// </summary>
public string? HashAlgorithm { get; init; }
/// <summary>
/// Callback pour obtenir une URL HMAC-signée fraîche en cas d'expiration mid-DL
/// (le DL d'un 14 Go sur connexion lente peut dépasser le TTL serveur de 6 h).
/// Le DownloadManager appelle ce callback quand un segment reçoit un 403/410
/// (indices d'URL expirée), récupère une URL fraîche, et reprend transparemment
/// le DL sans l'interrompre. Si null, pas d'auto-refresh : le DL fail proprement.
/// </summary>
public Func<CancellationToken, Task<Uri?>>? RefreshUrlAsync { get; init; }
}

View File

@@ -8,7 +8,9 @@ public sealed partial class InstallationRegistry : IInstallationRegistry
{
private const string ExecutableName = "PROSERVE_UE_5_5.exe";
[GeneratedRegex(@"^Proserve v(?<v>\d+\.\d+\.\d+)$", RegexOptions.IgnoreCase)]
// IgnoreCase pour matcher à la fois les anciens dossiers "Proserve vX.Y.Z" et les
// nouveaux "PROSERVE vX.Y.Z" (changement de casse de la marque).
[GeneratedRegex(@"^PROSERVE v(?<v>\d+\.\d+\.\d+)$", RegexOptions.IgnoreCase)]
private static partial Regex VersionFolderRegex();
private readonly ILogger<InstallationRegistry> _logger;

View File

@@ -1,26 +1,63 @@
using System.Buffers;
using System.Security.Cryptography;
namespace PSLauncher.Core.Integrity;
/// <summary>
/// Calcul SHA-256 streaming optimisé pour gros fichiers (14+ Go) :
/// - Buffer 16 MiB (vs 1 MiB historique) pour saturer le throughput SSD/HDD ;
/// - <see cref="FileOptions.SequentialScan"/> hint pour que Windows fasse du read-ahead agressif ;
/// - <see cref="FileOptions.Asynchronous"/> pour ne pas bloquer le ThreadPool en I/O sync ;
/// - <see cref="IncrementalHash"/> (au lieu de TransformBlock/TransformFinalBlock) qui est plus moderne,
/// moins GC-heavy, et bénéficie sur .NET 8 des intrinsics SHA hardware (SHA-NI Intel/AMD,
/// ARMv8 Crypto Extensions) quand la CPU les supporte → typiquement 2-5× plus rapide.
/// - Double-buffering : on lit le buffer N+1 pendant qu'on hash le buffer N, ce qui permet de
/// recouvrir CPU (hash) et I/O (read) sur les machines où ils ne sont pas le même goulot.
///
/// Sur un build 14 Go, l'impact typique constaté :
/// ~1 MiB buffer + TransformBlock : 60-180 s
/// ~16 MiB buffer + IncrementalHash + double-buffer : 15-40 s
/// </summary>
public sealed class IntegrityService : IIntegrityService
{
private const int BufferSize = 16 << 20; // 16 MiB
public async Task<string> ComputeSha256Async(string filePath, IProgress<double>? progress, CancellationToken ct)
{
const int bufferSize = 1 << 20; // 1 MiB
await using var fs = new FileStream(filePath, FileMode.Open, FileAccess.Read, FileShare.Read, bufferSize, useAsync: true);
using var sha = SHA256.Create();
var fileOptions = FileOptions.Asynchronous | FileOptions.SequentialScan;
await using var fs = new FileStream(filePath, FileMode.Open, FileAccess.Read, FileShare.Read, BufferSize, fileOptions);
using var hasher = IncrementalHash.CreateHash(HashAlgorithmName.SHA256);
var buffer = new byte[bufferSize];
// Pool pour ne pas allouer 32 Mo à chaque appel.
var pool = ArrayPool<byte>.Shared;
byte[] bufA = pool.Rent(BufferSize);
byte[] bufB = pool.Rent(BufferSize);
try
{
long total = fs.Length;
long read = 0;
int n;
while ((n = await fs.ReadAsync(buffer.AsMemory(0, bufferSize), ct).ConfigureAwait(false)) > 0)
// Premier read amorce le pipeline.
int n = await fs.ReadAsync(bufA.AsMemory(0, BufferSize), ct).ConfigureAwait(false);
while (n > 0)
{
sha.TransformBlock(buffer, 0, n, null, 0);
// Lance le prochain read en parallèle pendant qu'on hash le buffer courant.
var nextReadTask = fs.ReadAsync(bufB.AsMemory(0, BufferSize), ct);
hasher.AppendData(bufA, 0, n);
read += n;
if (total > 0) progress?.Report((double)read / total);
int next = await nextReadTask.ConfigureAwait(false);
// Swap : bufB devient le buffer courant, bufA le futur prochain read.
(bufA, bufB) = (bufB, bufA);
n = next;
}
return Convert.ToHexString(hasher.GetHashAndReset()).ToLowerInvariant();
}
finally
{
pool.Return(bufA);
pool.Return(bufB);
}
sha.TransformFinalBlock(Array.Empty<byte>(), 0, 0);
return Convert.ToHexString(sha.Hash!).ToLowerInvariant();
}
}

View File

@@ -18,4 +18,11 @@ public interface ILicenseService
/// auquel cas l'appelant retombe sur l'URL publique du manifest.
/// </summary>
Task<string?> GetSignedDownloadUrlAsync(string version, CancellationToken ct);
/// <summary>
/// Décrypte la clé de license stockée en cache (DPAPI CurrentUser). Retourne null
/// si pas de clé stockée ou si le déchiffrement échoue (cache déplacé vers une
/// autre machine ou autre user Windows).
/// </summary>
string? GetDecryptedKey();
}

View File

@@ -13,7 +13,19 @@ namespace PSLauncher.Core.Licensing;
public sealed class LicenseService : ILicenseService
{
private const int CachedValidityDays = 7;
/// <summary>
/// Durée de validité du cache offline. 100 jours = ~3 mois + marge pour un user
/// qui se connecte en moyenne tous les 3 mois. Au-delà, le launcher exige une
/// nouvelle validation online (la license elle-même peut être valide sur le serveur,
/// c'est juste qu'on ne peut plus le vérifier sans réseau).
/// </summary>
private const int CachedValidityDays = 100;
/// <summary>
/// Tolérance pour les corrections d'horloge légitimes (NTP sync, daylight saving,
/// reboot après débranchement long, etc.). Au-delà, on suspecte une manipulation.
/// </summary>
private static readonly TimeSpan ClockRollbackGrace = TimeSpan.FromHours(1);
private static readonly JsonSerializerOptions JsonOptions = new()
{
@@ -113,21 +125,85 @@ public sealed class LicenseService : ILicenseService
return parsed;
}
/// <summary>
/// Retourne la license cachée avec deux protections :
///
/// 1. ANTI-ROLLBACK : si l'horloge système est en retard de plus d'1h par
/// rapport au plus récent <c>LastSeenUtc</c> jamais vu par le launcher,
/// on suspecte une manipulation (recul de date PC pour gagner du crédit
/// offline) → cache invalidé, forçage d'une re-validation online.
///
/// 2. COMPTEUR MONOTONE : <c>LastSeenUtc</c> ne peut que progresser. Mis à
/// jour à <c>max(stored, now)</c> à chaque démarrage et persisté immédiatement.
/// Donc même si l'utilisateur recule son horloge APRÈS un démarrage, la
/// valeur stockée reste la plus récente jamais vue.
///
/// Retourne null si :
/// - pas de license stockée
/// - rollback détecté (horloge manipulée)
/// - cache trop ancien (> CachedValidityDays jours depuis LastValidationAt)
/// </summary>
public LicenseValidationResponse? GetCached()
{
if (string.IsNullOrEmpty(_config.License.EncryptedKey)) return null;
if (_config.License.LastValidationAt is null) return null;
// Cache offline valide 7 jours après la dernière validation réussie
var age = DateTime.UtcNow - _config.License.LastValidationAt.Value;
if (age.TotalDays > CachedValidityDays) return null;
var now = DateTime.UtcNow;
var lastValidation = _config.License.LastValidationAt.Value;
// Si LastSeenUtc absent (1ère exécution avec cette nouvelle version), on initialise
// sur LastValidationAt — référence sûre car signée par le serveur.
var lastSeen = _config.License.LastSeenUtc ?? lastValidation;
// 1. Détection rollback : horloge actuelle significativement en retard
if (now < lastSeen - ClockRollbackGrace)
{
_logger.LogWarning(
"Clock rollback detected (now={Now}, lastSeen={LastSeen}, delta={Delta}). " +
"Cache invalidated — re-validation requise dès que online.",
now, lastSeen, lastSeen - now);
return null;
}
// 2. Mise à jour monotone : on ne descend jamais
var newLastSeen = lastSeen > now ? lastSeen : now;
if (_config.License.LastSeenUtc != newLastSeen)
{
_config.License.LastSeenUtc = newLastSeen;
try { _configStore.Save(_config); }
catch (Exception ex) { _logger.LogDebug(ex, "Failed to persist LastSeenUtc update"); }
}
// 3. Âge du cache calculé via le compteur monotone (résistant au rollback).
// Si l'utilisateur a fait avancer l'horloge artificiellement, ça consomme
// son crédit offline plus vite — pas grave, il ré-valide quand vraiment online.
var ageDays = (newLastSeen - lastValidation).TotalDays;
if (ageDays > CachedValidityDays)
{
_logger.LogInformation(
"Offline license cache expired (age {Age:N1} days > max {Max} days). " +
"Re-validation requise dès que online.",
ageDays, CachedValidityDays);
return null;
}
// Statut : on prend la valeur cachée par le serveur (révoquée, valide, etc.)
// mais avec auto-override "expired" si la date d'entitlement est dépassée.
// Ça gère le passage automatique valide → expirée même si le user reste
// offline pendant des semaines.
var cachedStatus = _config.License.CachedStatus ?? "valid";
if (cachedStatus == "valid"
&& _config.License.CachedEntitlementUntil is { } entUntil
&& entUntil < newLastSeen)
{
cachedStatus = "expired";
}
return new LicenseValidationResponse
{
Status = _config.License.CachedEntitlementUntil >= DateTime.UtcNow ? "valid" : "expired",
Status = cachedStatus,
OwnerName = _config.License.CachedOwnerName,
DownloadEntitlementUntil = _config.License.CachedEntitlementUntil,
ServerTime = _config.License.LastValidationAt,
ServerTime = lastValidation,
};
}
@@ -135,9 +211,14 @@ public sealed class LicenseService : ILicenseService
{
var encrypted = ProtectKey(licenseKey);
_config.License.EncryptedKey = encrypted;
_config.License.LastValidationAt = DateTime.UtcNow;
// ServerTime fait partie du payload signé Ed25519 par le serveur, donc non
// manipulable par l'utilisateur. Si absent (cas dégradé), on retombe sur
// l'horloge locale.
_config.License.LastValidationAt = response.ServerTime ?? DateTime.UtcNow;
_config.License.LastSeenUtc = DateTime.UtcNow;
_config.License.CachedOwnerName = response.OwnerName;
_config.License.CachedEntitlementUntil = response.DownloadEntitlementUntil;
_config.License.CachedStatus = response.Status;
_configStore.Save(_config);
}

View File

@@ -0,0 +1,754 @@
using System.Globalization;
namespace PSLauncher.Core.Localization;
/// <summary>
/// Strings localisés du launcher. Approche statique : la langue est résolue au
/// startup (auto-détection ou setting utilisateur) puis fixée pour toute la
/// session. Tout changement de langue nécessite un redémarrage du launcher
/// (les bindings XAML x:Static ne refresh pas dynamiquement).
///
/// Langues supportées : fr (par défaut), en, zh (Simplified), th, ar.
/// Pour Arabic, FlowDirection doit aussi être basculé en RTL côté Window.
/// </summary>
public static class Strings
{
private static string _lang = "fr";
/// <summary>Code ISO 639-1 (2 lettres) actuellement actif.</summary>
public static string Lang => _lang;
/// <summary>True quand la langue actuelle s'écrit de droite à gauche.</summary>
public static bool IsRightToLeft => _lang == "ar";
/// <summary>Langues proposées au sélecteur des Settings (code → libellé natif).</summary>
public static readonly (string Code, string Name)[] Available =
{
("auto", "Automatique (langue du système)"),
("fr", "Français"),
("en", "English"),
("zh", "中文"),
("th", "ไทย"),
("ar", "العربية"),
};
/// <summary>
/// Initialise la langue à partir de la valeur enregistrée dans la config.
/// "auto" ou code inconnu → on prend la langue Windows si elle correspond
/// à une des langues supportées, sinon on tombe sur l'anglais.
/// </summary>
public static void Init(string? configValue)
{
var supported = new[] { "fr", "en", "zh", "th", "ar" };
var v = configValue?.Trim().ToLowerInvariant() ?? "auto";
if (v == "auto" || string.IsNullOrEmpty(v) || Array.IndexOf(supported, v) < 0)
{
// Auto-détection depuis la culture utilisateur Windows
var sys = CultureInfo.CurrentUICulture.TwoLetterISOLanguageName;
v = Array.IndexOf(supported, sys) >= 0 ? sys : "en";
}
_lang = v;
// On force aussi la culture système sur cette langue (formats date / nombre)
try
{
var ci = CultureInfo.GetCultureInfo(_lang);
CultureInfo.DefaultThreadCurrentCulture = ci;
CultureInfo.DefaultThreadCurrentUICulture = ci;
Thread.CurrentThread.CurrentCulture = ci;
Thread.CurrentThread.CurrentUICulture = ci;
}
catch { /* culture inconnue, on garde le défaut */ }
}
private static string T(string fr, string en, string zh, string th, string ar) => _lang switch
{
"en" => en,
"zh" => zh,
"th" => th,
"ar" => ar,
_ => fr,
};
// ==================== TOP BAR ====================
public static string TopBarCheckUpdates => T("🔄 Vérifier les MAJ", "🔄 Check for updates", "🔄 检查更新", "🔄 ตรวจสอบการอัปเดต", "🔄 التحقق من التحديثات");
public static string TopBarSettings => T("⚙ Paramètres", "⚙ Settings", "⚙ 设置", "⚙ ตั้งค่า", "⚙ الإعدادات");
// ==================== BODY ====================
public static string BodyVersions => T("Versions", "Versions", "版本", "เวอร์ชัน", "النسخ");
public static string BodyVersionsHint => T(
"Lance, installe ou supprime une version. Les versions installées et celles disponibles sur le serveur cohabitent.",
"Launch, install or remove a version. Installed and available versions can coexist.",
"启动、安装或删除版本。已安装的版本与服务器上可用的版本可以共存。",
"เปิด ติดตั้ง หรือลบเวอร์ชัน เวอร์ชันที่ติดตั้งและที่มีในเซิร์ฟเวอร์อยู่ร่วมกันได้",
"تشغيل أو تثبيت أو إزالة نسخة. النسخ المثبتة والمتاحة على الخادم يمكن أن تتعايش."
);
public static string FeaturedCurrent => T("VERSION COURANTE", "CURRENT VERSION", "当前版本", "เวอร์ชันปัจจุบัน", "النسخة الحالية");
public static string SectionOther => T("AUTRES VERSIONS", "OTHER VERSIONS", "其他版本", "เวอร์ชันอื่นๆ", "نسخ أخرى");
public static string ReleasedOn => T("Sortie le ", "Released ", "发布于 ", "เผยแพร่เมื่อ ", "صدر في ");
// ==================== STATUS BADGES ====================
public static string StatusInstalled => T("● Installée", "● Installed", "● 已安装", "● ติดตั้งแล้ว", "● مثبتة");
public static string StatusAvailable => T("○ Disponible", "○ Available", "○ 可用", "○ พร้อมใช้งาน", "○ متاحة");
public static string StatusDownloading => T("⬇ Téléchargement…", "⬇ Downloading…", "⬇ 下载中…", "⬇ กำลังดาวน์โหลด…", "⬇ جارٍ التنزيل…");
public static string StatusInstalling => T("📦 Installation…", "📦 Installing…", "📦 安装中…", "📦 กำลังติดตั้ง…", "📦 جارٍ التثبيت…");
public static string StatusUninstalling => T("🗑 Suppression…", "🗑 Uninstalling…", "🗑 卸载中…", "🗑 กำลังถอนการติดตั้ง…", "🗑 جارٍ الإزالة…");
// ==================== ACTIONS ====================
public static string ActionLaunch => T("▶ Lancer", "▶ Launch", "▶ 启动", "▶ เปิด", "▶ تشغيل");
public static string ActionLaunchBig => T("▶ LANCER", "▶ LAUNCH", "▶ 启动", "▶ เปิด", "▶ تشغيل");
public static string ActionInstall => T("⬇ Installer", "⬇ Install", "⬇ 安装", "⬇ ติดตั้ง", "⬇ تثبيت");
public static string ActionInstallBig => T("⬇ INSTALLER", "⬇ INSTALL", "⬇ 安装", "⬇ ติดตั้ง", "⬇ تثبيت");
public static string ActionCancel => T("Annuler", "Cancel", "取消", "ยกเลิก", "إلغاء");
public static string ActionLater => T("Plus tard", "Later", "稍后", "ภายหลัง", "لاحقاً");
public static string ActionSave => T("Enregistrer", "Save", "保存", "บันทึก", "حفظ");
public static string ActionClose => T("Fermer", "Close", "关闭", "ปิด", "إغلاق");
// Affiché sur le bouton install d'une version dont le minLicenseDate dépasse l'entitlement de la license.
// Concrètement : ta license expire le 2024-12-31 mais cette version a été sortie le 2025-03-15 → tu ne peux pas la télécharger.
// Tu peux toujours installer/lancer une version antérieure couverte par ta période de license.
public static string ActionLicenseInsufficient => T(
"🔒 License de mise à jour requise pour cette version",
"🔒 Valid update license needed for this version",
"🔒 此版本需要有效的更新许可证",
"🔒 เวอร์ชันนี้ต้องมีใบอนุญาตอัปเดตที่ใช้ได้",
"🔒 هذه النسخة تتطلب ترخيص تحديث صالح"
);
// ==================== MENU "..." ====================
public static string MenuReleaseNotes => T("📜 Voir les release notes", "📜 View release notes", "📜 查看版本说明", "📜 ดูบันทึกการเปลี่ยนแปลง", "📜 عرض ملاحظات الإصدار");
public static string MenuOpenFolder => T("📁 Ouvrir le dossier", "📁 Open folder", "📁 打开文件夹", "📁 เปิดโฟลเดอร์", "📁 فتح المجلد");
public static string MenuUninstall => T("🗑 Supprimer cette version…", "🗑 Uninstall this version…", "🗑 删除此版本…", "🗑 ถอนการติดตั้งเวอร์ชันนี้…", "🗑 إزالة هذه النسخة…");
// ==================== LICENSE BADGE ====================
// Note : on parle de « License de mise à jour » (Software Update License) plutôt
// que de « License Proserve » : Proserve lui-même est librement utilisable une
// fois installé, la license sert UNIQUEMENT à donner accès aux téléchargements
// de nouvelles versions. Un user expiré garde l'accès à toutes les versions
// sorties pendant sa période de license + peut lancer ses versions installées.
public static string LicenseNone => T("Aucune license de MAJ", "No update license", "无更新许可证", "ไม่มีใบอนุญาตอัปเดต", "لا يوجد ترخيص تحديث");
public static string LicenseRevoked => T("Révoquée", "Revoked", "已撤销", "ถูกเพิกถอน", "تم إلغاؤه");
public static string LicenseTooltip => T("Voir / changer la license de mise à jour", "View / change update license", "查看 / 更改更新许可证", "ดู / เปลี่ยนใบอนุญาตอัปเดต", "عرض / تغيير ترخيص التحديث");
// ==================== SETTINGS ====================
public static string SettingsTitle => T("Paramètres", "Settings", "设置", "ตั้งค่า", "الإعدادات");
public static string SettingsLicense => T("LICENSE DE MISE À JOUR", "SOFTWARE UPDATE LICENSE", "软件更新许可证", "ใบอนุญาตอัปเดตซอฟต์แวร์", "ترخيص تحديث البرنامج");
public static string SettingsServer => T("SERVEUR", "SERVER", "服务器", "เซิร์ฟเวอร์", "الخادم");
public static string SettingsInstallation => T("INSTALLATION", "INSTALLATION", "安装", "การติดตั้ง", "التثبيت");
public static string SettingsCache => T("CACHE DE TÉLÉCHARGEMENTS", "DOWNLOAD CACHE", "下载缓存", "แคชดาวน์โหลด", "ذاكرة التخزين المؤقت للتنزيلات");
public static string SettingsLogs => T("LOGS & APPLICATION", "LOGS & APP", "日志与应用", "บันทึกและแอป", "السجلات والتطبيق");
public static string SettingsLanguage => T("LANGUE", "LANGUAGE", "语言", "ภาษา", "اللغة");
public static string SettingsLanguageHint => T(
"Le launcher redémarrera pour appliquer le changement.",
"The launcher will restart to apply the change.",
"启动器将重启以应用更改。",
"Launcher จะรีสตาร์ทเพื่อใช้การเปลี่ยนแปลง",
"سيتم إعادة تشغيل المُشغِّل لتطبيق التغيير."
);
public static string SettingsTest => T("Tester", "Test", "测试", "ทดสอบ", "اختبار");
public static string SettingsBrowse => T("📁 Parcourir", "📁 Browse", "📁 浏览", "📁 เรียกดู", "📁 استعراض");
public static string SettingsClearCache => T("🗑 Vider", "🗑 Clear", "🗑 清空", "🗑 ล้าง", "🗑 مسح");
// ==================== ONBOARDING ====================
public static string OnboardingTitle => T("Activation PROSERVE Launcher", "PROSERVE Launcher activation", "PROSERVE Launcher 激活", "เปิดใช้งาน PROSERVE Launcher", "تنشيط PROSERVE Launcher");
public static string OnboardingHeading => T("Activer votre license de mise à jour", "Activate your software update license", "激活您的软件更新许可证", "เปิดใช้งานใบอนุญาตอัปเดตซอฟต์แวร์", "تنشيط ترخيص تحديث البرنامج");
public static string OnboardingBody => T(
"Saisis la clé fournie par ASTERION. Elle te donne accès aux téléchargements de toutes les versions sorties jusqu'à la date de validité associée. Une fois installées, les versions sont utilisables sans connexion.",
"Enter the key provided by ASTERION. It grants download access to all versions released until the associated expiration date. Once installed, versions can be used offline.",
"输入 ASTERION 提供的密钥。它授予您在关联到期日期之前发布的所有版本的下载权限。一旦安装,版本可离线使用。",
"ป้อนคีย์ที่ ASTERION ให้มา ซึ่งให้สิทธิ์ดาวน์โหลดเวอร์ชันทั้งหมดที่เผยแพร่จนถึงวันหมดอายุที่เกี่ยวข้อง เมื่อติดตั้งแล้วเวอร์ชันสามารถใช้งานได้แบบออฟไลน์",
"أدخل المفتاح المقدم من ASTERION. يمنح حق تنزيل جميع النسخ الصادرة حتى تاريخ انتهاء الصلاحية المرتبط. بمجرد التثبيت، يمكن استخدام النسخ دون اتصال."
);
public static string OnboardingLicenseKey => T("Clé de license", "License key", "许可证密钥", "คีย์ใบอนุญาต", "مفتاح الترخيص");
public static string OnboardingActivate => T("Activer", "Activate", "激活", "เปิดใช้งาน", "تنشيط");
// ==================== UPDATE ====================
public static string UpdateAvailableTitle => T("Mise à jour disponible", "Update available", "有可用更新", "มีการอัปเดต", "تحديث متاح");
public static string UpdateDownload => T("⬇ Télécharger", "⬇ Download", "⬇ 下载", "⬇ ดาวน์โหลด", "⬇ تنزيل");
public static string LauncherUpdateTitle => T("Mise à jour du launcher", "Launcher update", "启动器更新", "อัปเดต Launcher", "تحديث المُشغِّل");
public static string LauncherUpdateAvailable => T("Mise à jour du launcher disponible", "Launcher update available", "启动器更新可用", "มีการอัปเดต Launcher", "تحديث المُشغِّل متاح");
public static string LauncherUpdateBody => T(
"Le launcher va se mettre à jour : il téléchargera le nouveau binaire, se fermera, sera remplacé, puis se relancera automatiquement. Cela prend quelques secondes.",
"The launcher will update: it will download the new binary, close, be replaced, then restart automatically. This takes a few seconds.",
"启动器将进行更新:下载新二进制文件、关闭、替换,然后自动重新启动。这需要几秒钟。",
"Launcher จะอัปเดต: ดาวน์โหลดไฟล์ใหม่ ปิด แทนที่ แล้วรีสตาร์ทอัตโนมัติ ใช้เวลาไม่กี่วินาที",
"سيتم تحديث المُشغِّل: سينزل البرنامج الجديد، يغلق، يُستبدل، ثم يعيد التشغيل تلقائياً. يستغرق ذلك بضع ثوان."
);
public static string LauncherUpdateNow => T("⬇ Mettre à jour", "⬇ Update now", "⬇ 立即更新", "⬇ อัปเดตเดี๋ยวนี้", "⬇ تحديث الآن");
// ==================== MESSAGEBOX TITLES ====================
public static string MsgBoxError => T("Erreur", "Error", "错误", "ข้อผิดพลาด", "خطأ");
public static string MsgBoxLaunchError => T("Erreur de lancement", "Launch error", "启动错误", "เปิดใช้งานล้มเหลว", "خطأ في التشغيل");
public static string MsgBoxConfirm => T("Confirmer", "Confirm", "确认", "ยืนยัน", "تأكيد");
public static string MsgBoxInfo => T("Information", "Information", "信息", "ข้อมูล", "معلومة");
public static string MsgBoxPatience => T("Patience", "Please wait", "请稍候", "โปรดรอ", "يرجى الانتظار");
public static string MsgBoxLanguageChange => T("Changement de langue", "Language change", "更改语言", "เปลี่ยนภาษา", "تغيير اللغة");
public static string MsgBoxReleaseNotes => T("Release notes", "Release notes", "版本说明", "บันทึกการเปลี่ยนแปลง", "ملاحظات الإصدار");
// ==================== MESSAGEBOX MESSAGES ====================
public static string MsgBusy => T(
"Une autre opération est déjà en cours.",
"Another operation is already in progress.",
"另一个操作正在进行中。",
"มีการดำเนินการอื่นอยู่แล้ว",
"هناك عملية أخرى قيد التنفيذ بالفعل."
);
public static string MsgLaunchFailed(string detail) => T(
$"Impossible de lancer la version :\n\n{detail}",
$"Could not launch the version:\n\n{detail}",
$"无法启动该版本:\n\n{detail}",
$"ไม่สามารถเปิดเวอร์ชันนี้ได้:\n\n{detail}",
$"تعذر تشغيل النسخة:\n\n{detail}"
);
public static string MsgSelfUpdateFailed(string detail) => T(
$"Mise à jour du launcher échouée :\n\n{detail}",
$"Launcher update failed:\n\n{detail}",
$"启动器更新失败:\n\n{detail}",
$"การอัปเดต Launcher ล้มเหลว:\n\n{detail}",
$"فشل تحديث المُشغِّل:\n\n{detail}"
);
public static string MsgInstallFailed(string detail) => T(
$"Le téléchargement ou l'installation a échoué :\n\n{detail}",
$"Download or installation failed:\n\n{detail}",
$"下载或安装失败:\n\n{detail}",
$"การดาวน์โหลดหรือติดตั้งล้มเหลว:\n\n{detail}",
$"فشل التنزيل أو التثبيت:\n\n{detail}"
);
public static string MsgUninstallFailed(string detail) => T(
$"Suppression échouée : {detail}",
$"Uninstall failed: {detail}",
$"卸载失败:{detail}",
$"การถอนการติดตั้งล้มเหลว: {detail}",
$"فشل الإزالة: {detail}"
);
public static string MsgUninstallConfirm(string version, string folder, string size) => T(
$"Supprimer la version v{version} ?\n\nDossier : {folder}\nLibérera {size}.\n\nCette action est irréversible.",
$"Uninstall version v{version}?\n\nFolder: {folder}\nWill free {size}.\n\nThis action is irreversible.",
$"卸载版本 v{version}\n\n文件夹{folder}\n将释放 {size}。\n\n此操作不可逆。",
$"ถอนการติดตั้งเวอร์ชัน v{version}?\n\nโฟลเดอร์: {folder}\nจะคืนพื้นที่ {size}\n\nการกระทำนี้ไม่สามารถย้อนกลับได้",
$"إلغاء تثبيت النسخة v{version}؟\n\nالمجلد: {folder}\nسيتم تحرير {size}.\n\nهذا الإجراء لا رجعة فيه."
);
public static string MsgNoReleaseNotes => T(
"Aucune release note disponible pour cette version.",
"No release notes available for this version.",
"此版本没有可用的版本说明。",
"ไม่มีบันทึกการเปลี่ยนแปลงสำหรับเวอร์ชันนี้",
"لا توجد ملاحظات إصدار متاحة لهذه النسخة."
);
public static string MsgReleaseNotesFetchFailed(string detail) => T(
$"Impossible de récupérer les release notes :\n{detail}",
$"Could not fetch release notes:\n{detail}",
$"无法获取版本说明:\n{detail}",
$"ไม่สามารถดึงบันทึกการเปลี่ยนแปลงได้:\n{detail}",
$"تعذر جلب ملاحظات الإصدار:\n{detail}"
);
public static string MsgClearCacheConfirm => T(
"Vider le cache de téléchargements ? Les téléchargements en cours/pause seront perdus.",
"Clear the download cache? In-progress / paused downloads will be lost.",
"清空下载缓存?正在进行或暂停的下载将丢失。",
"ล้างแคชดาวน์โหลด? การดาวน์โหลดที่กำลังดำเนินการ/หยุดชั่วคราวจะสูญหาย",
"مسح ذاكرة التخزين المؤقت للتنزيلات؟ ستفقد التنزيلات قيد التقدم أو الموقوفة مؤقتًا."
);
public static string MsgDeactivateLicenseConfirm => T(
"Désactiver la license sur cette machine ?\nTu pourras la réactiver avec ta clé.",
"Deactivate the license on this machine?\nYou can reactivate it later with your key.",
"在此机器上停用许可证?\n您可以稍后用密钥重新激活。",
"ปิดใช้งานใบอนุญาตบนเครื่องนี้?\nคุณสามารถเปิดใช้งานใหม่ในภายหลังด้วยคีย์ของคุณ",
"إلغاء تنشيط الترخيص على هذا الجهاز؟\nيمكنك إعادة تنشيطه لاحقًا بمفتاحك."
);
public static string MsgDeactivateLicenseDetailedConfirm => T(
"Désactiver la license sur cette machine ?\n\nTu pourras la réactiver plus tard avec ta clé. Les versions déjà installées restent utilisables.",
"Deactivate the license on this machine?\n\nYou can reactivate it later with your key. Already-installed versions remain usable.",
"在此机器上停用许可证?\n\n您可以稍后用密钥重新激活。已安装的版本仍可使用。",
"ปิดใช้งานใบอนุญาตบนเครื่องนี้?\n\nคุณสามารถเปิดใช้งานใหม่ในภายหลังด้วยคีย์ของคุณ เวอร์ชันที่ติดตั้งแล้วยังคงใช้งานได้",
"إلغاء تنشيط الترخيص على هذا الجهاز؟\n\nيمكنك إعادة تنشيطه لاحقًا بمفتاحك. تظل النسخ المثبتة قابلة للاستخدام."
);
public static string MsgLanguageRestart => T(
"Le launcher va redémarrer pour appliquer la nouvelle langue.",
"The launcher will restart to apply the new language.",
"启动器将重启以应用新语言。",
"Launcher จะรีสตาร์ทเพื่อใช้ภาษาใหม่",
"سيتم إعادة تشغيل المُشغِّل لتطبيق اللغة الجديدة."
);
// ==================== STATUS MESSAGES (footer) ====================
public static string StatusCheckingUpdates => T("Vérification des mises à jour…", "Checking for updates…", "正在检查更新…", "กำลังตรวจสอบการอัปเดต…", "جارٍ التحقق من التحديثات…");
public static string StatusNoRemote => T("Aucune version distante trouvée", "No remote version found", "未找到远程版本", "ไม่พบเวอร์ชันระยะไกล", "لم يتم العثور على نسخة عن بُعد");
public static string StatusDownloadCancelled => T("Téléchargement annulé", "Download canceled", "下载已取消", "ยกเลิกการดาวน์โหลด", "تم إلغاء التنزيل");
public static string StatusDownloadingLauncher => T("Téléchargement du nouveau launcher…", "Downloading new launcher…", "正在下载新启动器…", "กำลังดาวน์โหลด Launcher ใหม่…", "جارٍ تنزيل المُشغِّل الجديد…");
public static string StatusLauncherUpdating => T(
"Mise à jour du launcher en cours… le launcher va se relancer.",
"Launcher updating… it will restart.",
"启动器正在更新…即将重启。",
"Launcher กำลังอัปเดต… จะรีสตาร์ท",
"جارٍ تحديث المُشغِّل… سيتم إعادة تشغيله."
);
public static string StatusError(string detail) => T(
$"Erreur : {detail}",
$"Error: {detail}",
$"错误:{detail}",
$"ข้อผิดพลาด: {detail}",
$"خطأ: {detail}"
);
public static string StatusSelfUpdateError(string detail) => T(
$"Erreur self-update : {detail}",
$"Self-update error: {detail}",
$"自更新错误:{detail}",
$"ข้อผิดพลาดอัปเดตตัวเอง: {detail}",
$"خطأ في التحديث الذاتي: {detail}"
);
public static string StatusUpToDate(string version) => T(
$"À jour (v{version})",
$"Up to date (v{version})",
$"已是最新v{version}",
$"เป็นเวอร์ชันล่าสุด (v{version})",
$"محدّثة (v{version})"
);
public static string StatusNewAvailable(string version) => T(
$"Nouvelle version disponible : v{version}",
$"New version available: v{version}",
$"有新版本可用v{version}",
$"มีเวอร์ชันใหม่: v{version}",
$"نسخة جديدة متاحة: v{version}"
);
public static string StatusDownloadingVersion(string version) => T(
$"Téléchargement v{version}…",
$"Downloading v{version}…",
$"正在下载 v{version}…",
$"กำลังดาวน์โหลด v{version}…",
$"جارٍ تنزيل v{version}…"
);
public static string StatusInstallingVersion(string version) => T(
$"Installation v{version}…",
$"Installing v{version}…",
$"正在安装 v{version}…",
$"กำลังติดตั้ง v{version}…",
$"جارٍ تثبيت v{version}…"
);
public static string StatusInstalledSuccess(string version) => T(
$"v{version} installée avec succès",
$"v{version} installed successfully",
$"v{version} 安装成功",
$"ติดตั้ง v{version} สำเร็จ",
$"تم تثبيت v{version} بنجاح"
);
public static string StatusUninstallingVersion(string version) => T(
$"Suppression v{version}…",
$"Uninstalling v{version}…",
$"正在卸载 v{version}…",
$"กำลังถอน v{version}…",
$"جارٍ إزالة v{version}…"
);
public static string StatusUninstalledVersion(string version) => T(
$"v{version} supprimée",
$"v{version} uninstalled",
$"v{version} 已卸载",
$"ถอน v{version} แล้ว",
$"تمت إزالة v{version}"
);
// ==================== PROGRESS DETAIL ====================
public static string ProgressLauncherDl(string version, string done, string total) => T(
$"⬇ Launcher v{version} : {done} / {total}",
$"⬇ Launcher v{version}: {done} / {total}",
$"⬇ 启动器 v{version}{done} / {total}",
$"⬇ Launcher v{version}: {done} / {total}",
$"⬇ المُشغِّل v{version}: {done} / {total}"
);
public static string ProgressExtraction(string version, long entriesDone, long entriesTotal, string bytesDone, string bytesTotal) => T(
$"Extraction v{version} : {entriesDone}/{entriesTotal} fichiers ({bytesDone} / {bytesTotal})",
$"Extracting v{version}: {entriesDone}/{entriesTotal} files ({bytesDone} / {bytesTotal})",
$"正在解压 v{version}{entriesDone}/{entriesTotal} 文件 ({bytesDone} / {bytesTotal})",
$"กำลังแตก v{version}: {entriesDone}/{entriesTotal} ไฟล์ ({bytesDone} / {bytesTotal})",
$"جارٍ فك v{version}: {entriesDone}/{entriesTotal} ملف ({bytesDone} / {bytesTotal})"
);
public static string StatusVerifyingIntegrity(string version) => T(
$"Vérification de l'intégrité v{version}…",
$"Verifying integrity of v{version}…",
$"正在校验 v{version} 的完整性…",
$"กำลังตรวจสอบความถูกต้อง v{version}…",
$"جارٍ التحقق من سلامة v{version}…"
);
public static string ProgressVerifying(string version, int percent) => T(
$"🔍 Vérification SHA-256 v{version} : {percent}%",
$"🔍 SHA-256 verification v{version}: {percent}%",
$"🔍 SHA-256 校验 v{version}{percent}%",
$"🔍 ตรวจสอบ SHA-256 v{version}: {percent}%",
$"🔍 التحقق من SHA-256 v{version}: {percent}%"
);
public static string StatusPreparingDownload(string version) => T(
$"Préparation du téléchargement v{version}…",
$"Preparing download for v{version}…",
$"正在准备 v{version} 的下载…",
$"กำลังเตรียมการดาวน์โหลด v{version}…",
$"جارٍ تحضير تنزيل v{version}…"
);
// ==================== CONFIRM DIALOGS ====================
public static string MsgCancelDownloadConfirm(string version) => T(
$"Annuler le téléchargement de v{version} ?\n\nLa progression sera conservée — tu pourras reprendre plus tard.",
$"Cancel the download of v{version}?\n\nProgress will be kept — you'll be able to resume later.",
$"取消 v{version} 的下载?\n\n进度将被保留您稍后可以继续。",
$"ยกเลิกการดาวน์โหลด v{version}?\n\nความคืบหน้าจะถูกบันทึกไว้ คุณสามารถดำเนินการต่อได้ภายหลัง",
$"إلغاء تنزيل v{version}؟\n\nسيتم الاحتفاظ بالتقدم — يمكنك الاستئناف لاحقاً."
);
public static string MsgRestartDownloadConfirm(string version, string partialSize) => T(
$"Recommencer le téléchargement de v{version} depuis zéro ?\n\nLe fichier partiel ({partialSize}) sera supprimé. Cette action est irréversible.",
$"Restart the download of v{version} from scratch?\n\nThe partial file ({partialSize}) will be deleted. This is irreversible.",
$"从头开始重新下载 v{version}\n\n部分文件 ({partialSize}) 将被删除。此操作不可逆。",
$"เริ่มดาวน์โหลด v{version} ใหม่ตั้งแต่ต้น?\n\nไฟล์บางส่วน ({partialSize}) จะถูกลบ การกระทำนี้ย้อนกลับไม่ได้",
$"إعادة تنزيل v{version} من البداية؟\n\nسيتم حذف الملف الجزئي ({partialSize}). هذا الإجراء لا رجعة فيه."
);
public static string MsgQuitWhileDownloadingConfirm(string version) => T(
$"Un téléchargement est en cours (v{version}).\n\nQuitter le launcher ? La progression sera conservée pour reprise au prochain lancement.",
$"A download is in progress (v{version}).\n\nQuit the launcher? Progress will be kept for resume on next launch.",
$"下载正在进行 (v{version})。\n\n要退出启动器吗进度将被保留下次启动时可继续。",
$"การดาวน์โหลดกำลังดำเนินอยู่ (v{version})\n\nออกจาก Launcher? ความคืบหน้าจะถูกเก็บไว้สำหรับการดำเนินการต่อในการเปิดครั้งถัดไป",
$"يوجد تنزيل قيد التنفيذ (v{version}).\n\nمغادرة المُشغِّل؟ سيتم الاحتفاظ بالتقدم للاستئناف في التشغيل التالي."
);
public static string MsgBoxQuit => T("Quitter ?", "Quit?", "退出?", "ออก?", "خروج؟");
public static string MsgBoxCancelDl => T("Annuler le DL ?", "Cancel download?", "取消下载?", "ยกเลิกการดาวน์โหลด?", "إلغاء التنزيل؟");
public static string MsgBoxRestartDl => T("Recommencer ?", "Restart?", "重新开始?", "เริ่มใหม่?", "إعادة البدء؟");
public static string MenuRestartFromZero => T(
"🔄 Recommencer depuis zéro…",
"🔄 Restart from scratch…",
"🔄 从头开始…",
"🔄 เริ่มใหม่ตั้งแต่ต้น…",
"🔄 البدء من جديد…"
);
// ==================== RESUME-OR-RESTART DIALOG ====================
public static string ResumeDialogTitle => T(
"Téléchargement interrompu",
"Download interrupted",
"下载已中断",
"การดาวน์โหลดถูกขัดจังหวะ",
"تم إيقاف التنزيل"
);
public static string ResumeDialogHeading(string version) => T(
$"Que faire pour v{version} ?",
$"What to do for v{version}?",
$"v{version} 怎么办?",
$"จะทำอะไรกับ v{version}?",
$"ماذا تفعل لـ v{version}؟"
);
public static string ResumeDialogBody(string done, string total, int percent) => T(
$"Un téléchargement précédent a été interrompu à {done} sur {total} ({percent}%).",
$"A previous download was interrupted at {done} of {total} ({percent}%).",
$"上一次下载在 {done}/{total} ({percent}%) 时中断。",
$"การดาวน์โหลดครั้งก่อนถูกขัดจังหวะที่ {done}/{total} ({percent}%)",
$"تم إيقاف تنزيل سابق عند {done}/{total} ({percent}%)."
);
public static string ResumeDialogResumeButton(int percent) => T(
$"▶ Reprendre depuis {percent}%",
$"▶ Resume from {percent}%",
$"▶ 从 {percent}% 继续",
$"▶ ดำเนินการต่อจาก {percent}%",
$"▶ استئناف من {percent}%"
);
public static string ResumeDialogResumeHint => T(
"Le téléchargement reprendra là où il s'était arrêté. Aucun byte ne sera re-téléchargé.",
"The download will continue from where it stopped. No bytes will be re-downloaded.",
"下载将从中断处继续。不会重新下载任何字节。",
"การดาวน์โหลดจะดำเนินต่อจากจุดที่หยุดไว้ จะไม่มีการดาวน์โหลดซ้ำ",
"سيستمر التنزيل من حيث توقف. لن يتم إعادة تنزيل أي بايت."
);
public static string ResumeDialogRestartButton => T(
"🔄 Recommencer depuis zéro",
"🔄 Restart from scratch",
"🔄 从头开始",
"🔄 เริ่มใหม่ตั้งแต่ต้น",
"🔄 البدء من جديد"
);
public static string ResumeDialogRestartHint(string done) => T(
$"Le fichier partiel ({done}) sera supprimé et le téléchargement repartira de zéro.",
$"The partial file ({done}) will be deleted and the download will restart from zero.",
$"部分文件 ({done}) 将被删除,下载将从零开始。",
$"ไฟล์บางส่วน ({done}) จะถูกลบและจะเริ่มดาวน์โหลดใหม่ตั้งแต่ต้น",
$"سيتم حذف الملف الجزئي ({done}) وسيُعاد التنزيل من الصفر."
);
// ==================== LICENSE SUMMARY (top bar badge text) ====================
public static string LicenseSummaryNone => LicenseNone;
public static string LicenseSummaryRevoked => LicenseRevoked;
public static string LicenseSummaryExpired(string date) => T(
$"Expirée le {date}",
$"Expired on {date}",
$"于 {date} 过期",
$"หมดอายุเมื่อ {date}",
$"انتهت في {date}"
);
public static string LicenseSummaryValid(string owner, string date) => T(
$"{owner} • exp. {date}",
$"{owner} • exp. {date}",
$"{owner} • 到期 {date}",
$"{owner} • หมดอายุ {date}",
$"{owner} • تنتهي {date}"
);
// ==================== ROW STATE LABELS ====================
// (déjà définies plus haut : StatusInstalled, StatusAvailable, StatusDownloading, StatusInstalling, StatusUninstalling)
// ==================== INSTALL BUTTON LABEL VARIANTS ====================
public static string ResumeButton(int percent) => T(
$"↻ Reprendre ({percent}%)",
$"↻ Resume ({percent}%)",
$"↻ 恢复 ({percent}%)",
$"↻ ดำเนินการต่อ ({percent}%)",
$"↻ استئناف ({percent}%)"
);
public static string ResumeButtonNoPercent => T("↻ Reprendre", "↻ Resume", "↻ 恢复", "↻ ดำเนินการต่อ", "↻ استئناف");
// ==================== LICENSE DETAILS DIALOG ====================
public static string LicenseDetailsValidTitle => T("License de mise à jour valide", "Valid update license", "更新许可证有效", "ใบอนุญาตอัปเดตใช้ได้", "ترخيص التحديث صالح");
public static string LicenseDetailsValidSubtitle => T(
"Toutes les versions sont téléchargeables tant que ta license n'est pas dépassée.",
"All versions are downloadable while your license is current.",
"您的许可证有效期内可以下载所有版本。",
"ดาวน์โหลดได้ทุกเวอร์ชันตราบใดที่ใบอนุญาตยังใช้งานได้",
"يمكن تنزيل جميع النسخ طالما ترخيصك ساري."
);
public static string LicenseDetailsExpiringSoon(string date) => T(
$"Expire bientôt — le {date}",
$"Expiring soon — {date}",
$"即将过期 — {date}",
$"กำลังหมดอายุ — {date}",
$"تنتهي قريباً — {date}"
);
public static string LicenseDetailsExpiredTitle => T("License de mise à jour expirée", "Update license expired", "更新许可证已过期", "ใบอนุญาตอัปเดตหมดอายุ", "انتهى ترخيص التحديث");
public static string LicenseDetailsExpiredSubtitle(string date) => T(
$"Expirée le {date}. Tu peux encore télécharger les versions sorties avant cette date, et lancer toutes les versions installées.",
$"Expired on {date}. You can still download versions released before that date and launch any installed version.",
$"于 {date} 过期。您仍可下载该日期之前发布的版本,并运行任何已安装的版本。",
$"หมดอายุเมื่อ {date} คุณยังคงดาวน์โหลดเวอร์ชันที่เผยแพร่ก่อนวันที่นั้นและเปิดเวอร์ชันที่ติดตั้งแล้วทุกเวอร์ชัน",
$"انتهت في {date}. لا يزال بإمكانك تنزيل النسخ الصادرة قبل هذا التاريخ وتشغيل أي نسخة مثبتة."
);
public static string LicenseDetailsExpiredFallback => T(
"Plus de nouveaux téléchargements possibles. Les versions installées restent utilisables.",
"No new downloads available. Installed versions remain usable.",
"无法下载新版本。已安装的版本仍可使用。",
"ไม่สามารถดาวน์โหลดเวอร์ชันใหม่ได้ เวอร์ชันที่ติดตั้งแล้วยังใช้งานได้",
"لا يمكن إجراء تنزيلات جديدة. تظل النسخ المثبتة قابلة للاستخدام."
);
public static string LicenseDetailsRevokedTitle => T("License de mise à jour révoquée", "Update license revoked", "更新许可证已撤销", "ใบอนุญาตอัปเดตถูกเพิกถอน", "تم إلغاء ترخيص التحديث");
public static string LicenseDetailsRevokedSubtitle => T("Contacte ASTERION pour plus d'informations", "Contact ASTERION for more information", "请联系 ASTERION 了解详情", "ติดต่อ ASTERION สำหรับข้อมูลเพิ่มเติม", "اتصل بـ ASTERION لمزيد من المعلومات");
public static string LicenseDetailsUnknownTitle => T("License inconnue", "Unknown license", "未知许可证", "ใบอนุญาตที่ไม่รู้จัก", "ترخيص غير معروف");
// ==================== ONBOARDING STATUS ====================
public static string OnboardingInvalidKey => T(
"Saisis une clé valide au format PRSRV-XXXX-XXXX-XXXX-XXXX.",
"Enter a valid key (format PRSRV-XXXX-XXXX-XXXX-XXXX).",
"输入有效的密钥(格式 PRSRV-XXXX-XXXX-XXXX-XXXX。",
"ป้อนคีย์ที่ถูกต้อง (รูปแบบ PRSRV-XXXX-XXXX-XXXX-XXXX)",
"أدخل مفتاحًا صالحًا (تنسيق PRSRV-XXXX-XXXX-XXXX-XXXX)."
);
public static string OnboardingValidating => T(
"Validation en cours…", "Validating…", "正在验证…", "กำลังตรวจสอบ…", "جارٍ التحقق…"
);
public static string OnboardingActivated(string owner, string date) => T(
$"License activée ({owner}). Téléchargements autorisés jusqu'au {date}.",
$"License activated ({owner}). Downloads allowed until {date}.",
$"许可证已激活({owner})。下载授权至 {date}。",
$"เปิดใช้งานใบอนุญาต ({owner}) ดาวน์โหลดได้ถึง {date}",
$"تم تنشيط الترخيص ({owner}). التنزيلات مسموحة حتى {date}."
);
public static string OnboardingExpired(string date) => T(
$"License de mise à jour expirée le {date}. Tu peux encore télécharger les versions sorties avant cette date et lancer toutes les versions installées. Pour accéder aux versions plus récentes, contacte ASTERION pour renouveler.",
$"Update license expired on {date}. You can still download versions released before that date and launch any installed version. To access newer versions, contact ASTERION to renew.",
$"更新许可证于 {date} 过期。您仍可下载该日期之前发布的版本,并运行任何已安装的版本。如需访问更新版本,请联系 ASTERION 续订。",
$"ใบอนุญาตอัปเดตหมดอายุเมื่อ {date} คุณยังคงดาวน์โหลดเวอร์ชันที่เผยแพร่ก่อนวันที่นั้นและเปิดเวอร์ชันที่ติดตั้งแล้วทุกเวอร์ชัน หากต้องการเข้าถึงเวอร์ชันใหม่กว่า ติดต่อ ASTERION เพื่อต่ออายุ",
$"انتهى ترخيص التحديث في {date}. لا يزال بإمكانك تنزيل النسخ الصادرة قبل هذا التاريخ وتشغيل أي نسخة مثبتة. للوصول إلى نسخ أحدث، اتصل بـ ASTERION للتجديد."
);
public static string OnboardingRevoked => T(
"Cette license a été révoquée. Contacte ASTERION.",
"This license has been revoked. Contact ASTERION.",
"此许可证已被撤销。请联系 ASTERION。",
"ใบอนุญาตนี้ถูกเพิกถอน ติดต่อ ASTERION",
"تم إلغاء هذا الترخيص. اتصل بـ ASTERION."
);
public static string OnboardingMachineLimit => T(
"Cette license a atteint son nombre maximum de machines.",
"This license has reached its maximum number of machines.",
"此许可证已达到最大机器数。",
"ใบอนุญาตถึงจำนวนเครื่องสูงสุดแล้ว",
"وصل هذا الترخيص إلى الحد الأقصى لعدد الأجهزة."
);
public static string OnboardingInvalid => T(
"Clé de license inconnue.",
"Unknown license key.",
"未知的许可证密钥。",
"คีย์ใบอนุญาตที่ไม่รู้จัก",
"مفتاح ترخيص غير معروف."
);
public static string OnboardingServerError(string detail) => T(
$"Erreur de communication avec le serveur :\n{detail}",
$"Server communication error:\n{detail}",
$"与服务器通信错误:\n{detail}",
$"การสื่อสารกับเซิร์ฟเวอร์ผิดพลาด:\n{detail}",
$"خطأ في الاتصال بالخادم:\n{detail}"
);
// ==================== SETTINGS LABELS ====================
public static string SettingsApiUrl => T("URL de l'API", "API URL", "API 地址", "URL ของ API", "عنوان API");
public static string SettingsInstallRoot => T("Dossier où sont installées les versions", "Folder where versions are installed", "版本安装的文件夹", "โฟลเดอร์สำหรับติดตั้งเวอร์ชัน", "المجلد الذي تُثبَّت فيه النسخ");
public static string SettingsLicenseStatus => T("Statut", "Status", "状态", "สถานะ", "الحالة");
public static string SettingsMachineId => T("Identifiant machine (anonyme)", "Machine ID (anonymous)", "机器 ID匿名", "ID เครื่อง (ไม่ระบุตัวตน)", "معرّف الجهاز (مجهول)");
public static string SettingsCopyMachineId => T("📋 Copier", "📋 Copy", "📋 复制", "📋 คัดลอก", "📋 نسخ");
public static string SettingsCacheSize => T("Taille actuelle :", "Current size:", "当前大小:", "ขนาดปัจจุบัน:", "الحجم الحالي:");
public static string SettingsCacheRotation => T("Logs rotation quotidienne, 10 jours conservés", "Daily log rotation, 10 days kept", "日志每日轮换,保留 10 天", "หมุนเวียนบันทึกรายวัน เก็บ 10 วัน", "تدوير السجل يومياً، الاحتفاظ 10 أيام");
public static string SettingsLauncherVersion => T("Version :", "Version:", "版本:", "เวอร์ชัน:", "النسخة:");
public static string SettingsDeactivate => T("Désactiver la license sur cette machine", "Deactivate license on this machine", "在此机器上停用许可证", "ปิดใช้งานใบอนุญาตบนเครื่องนี้", "إلغاء تنشيط الترخيص على هذا الجهاز");
// ==================== LICENSE DETAILS DIALOG LABELS ====================
public static string LicenseDetailsClient => T("Client", "Customer", "客户", "ลูกค้า", "العميل");
public static string LicenseDetailsValidity => T("Validité téléchargements", "Download validity", "下载有效期", "ระยะเวลาดาวน์โหลด", "صلاحية التنزيل");
public static string LicenseDetailsActivatedOn => T("Activée le", "Activated on", "激活日期", "เปิดใช้งานเมื่อ", "تم التنشيط في");
public static string LicenseDetailsMachineId => T("ID machine", "Machine ID", "机器 ID", "ID เครื่อง", "معرّف الجهاز");
public static string LicenseDetailsDeactivate => T("🗑 Désactiver la license", "🗑 Deactivate license", "🗑 停用许可证", "🗑 ปิดใช้งานใบอนุญาต", "🗑 إلغاء تنشيط الترخيص");
// ==================== EMPTY STATE ====================
public static string EmptyHint(string installRoot) => T(
$"Aucune version locale ni distante.\nClique « Vérifier les MAJ » pour récupérer le catalogue depuis le serveur, ou place un dossier « PROSERVE v{{X.Y.Z}} » contenant PROSERVE_UE_5_5.exe dans :\n{installRoot}",
$"No local or remote version.\nClick « Check for updates » to fetch the catalog from the server, or place a folder « PROSERVE v{{X.Y.Z}} » containing PROSERVE_UE_5_5.exe in:\n{installRoot}",
$"未找到本地或远程版本。\n点击「检查更新」以从服务器获取目录或将包含 PROSERVE_UE_5_5.exe 的「PROSERVE v{{X.Y.Z}}」文件夹放置在:\n{installRoot}",
$"ไม่พบเวอร์ชันในเครื่องหรือบนเซิร์ฟเวอร์\nคลิก « ตรวจสอบการอัปเดต » เพื่อดึงแคตตาล็อกจากเซิร์ฟเวอร์ หรือวางโฟลเดอร์ « PROSERVE v{{X.Y.Z}} » ที่มี PROSERVE_UE_5_5.exe ใน:\n{installRoot}",
$"لا توجد نسخة محلية أو عن بُعد.\nانقر « التحقق من التحديثات » لجلب الكتالوج من الخادم، أو ضع مجلداً « PROSERVE v{{X.Y.Z}} » يحتوي على PROSERVE_UE_5_5.exe في:\n{installRoot}"
);
// ==================== TOAST NOTIFICATIONS ====================
public static string ToastInstallSuccessTitle => T("Installation terminée", "Installation complete", "安装完成", "ติดตั้งเสร็จแล้ว", "اكتمل التثبيت");
public static string ToastInstallSuccessBody(string version) => T(
$"PROSERVE v{version} est prête à être lancée.",
$"PROSERVE v{version} is ready to launch.",
$"PROSERVE v{version} 已就绪。",
$"PROSERVE v{version} พร้อมเปิดใช้งานแล้ว",
$"PROSERVE v{version} جاهز للتشغيل."
);
public static string ToastInstallErrorTitle(string version) => T(
$"Échec v{version}",
$"v{version} failed",
$"v{version} 失败",
$"v{version} ล้มเหลว",
$"v{version} فشل"
);
// ==================== RELEASE NOTES PLACEHOLDERS ====================
public static string ReleaseNotesNone => T("_Aucune release note fournie._", "_No release notes provided._", "_未提供版本说明。_", "_ไม่มีบันทึกการเปลี่ยนแปลง_", "_لا توجد ملاحظات إصدار._");
public static string ReleaseNotesUnavailable => T("_Release notes indisponibles._", "_Release notes unavailable._", "_版本说明不可用。_", "_บันทึกการเปลี่ยนแปลงไม่พร้อมใช้งาน_", "_ملاحظات الإصدار غير متاحة._");
// ==================== MISC ====================
public static string OpenButton => T("📁 Ouvrir", "📁 Open", "📁 打开", "📁 เปิด", "📁 فتح");
public static string CopyTooltip => T("Copier", "Copy", "复制", "คัดลอก", "نسخ");
public static string Minimize => T("Réduire", "Minimize", "最小化", "ย่อลง", "تصغير");
public static string Maximize => T("Agrandir", "Maximize", "最大化", "ขยาย", "تكبير");
public static string Restore => T("Restaurer", "Restore", "还原", "คืนค่า", "استعادة");
public static string CloseTooltip=> T("Fermer", "Close", "关闭", "ปิด", "إغلاق");
/// <summary>
/// Unités de taille fichier localisées (octet, kilo-octet, méga-octet, giga-octet, téra-octet).
/// FR : o/Ko/Mo/Go/To • EN : B/KB/MB/GB/TB • ZH : 字节/KB/MB/GB/TB • TH : ไบต์/KB/MB/GB/TB • AR : ب/ك.ب/م.ب/ج.ب/ت.ب
/// </summary>
private static string[] SizeUnits => _lang switch
{
"en" => new[] { "B", "KB", "MB", "GB", "TB" },
"zh" => new[] { "字节", "KB", "MB", "GB", "TB" },
"th" => new[] { "ไบต์", "KB", "MB", "GB", "TB" },
"ar" => new[] { "ب", "ك.ب", "م.ب", "ج.ب", "ت.ب" },
_ => new[] { "o", "Ko", "Mo", "Go", "To" },
};
/// <summary>
/// Format date court (ex. 29/04/2026 en FR, 4/29/2026 en US, 2026/4/29 en ZH).
/// Utilise la culture courante (réglée par Init()).
/// </summary>
public static string FormatDate(DateTime dt) =>
dt.ToLocalTime().ToString("d", CultureInfo.GetCultureInfo(_lang));
/// <summary>Format date court — overload sur Nullable&lt;DateTime&gt; (renvoie « — » si null).</summary>
public static string FormatDate(DateTime? dt) => dt is { } d ? FormatDate(d) : "—";
/// <summary>
/// Format date long (jour de la semaine + jour + mois en lettres + année).
/// Ex. « lundi 29 avril 2026 » en FR, « Monday, April 29, 2026 » en EN.
/// </summary>
public static string FormatLongDate(DateTime dt) =>
dt.ToLocalTime().ToString("D", CultureInfo.GetCultureInfo(_lang));
/// <summary>Formatage canonique d'une taille en octets dans la langue active.</summary>
public static string FormatSize(long bytes)
{
if (bytes <= 0) return $"0 {SizeUnits[0]}";
var units = SizeUnits;
double v = bytes; int u = 0;
while (v >= 1024 && u < units.Length - 1) { v /= 1024; u++; }
return $"{v:0.#} {units[u]}";
}
public static string SizeLabel(string size) => T(
$"Taille : {size}", $"Size: {size}", $"大小:{size}", $"ขนาด: {size}", $"الحجم: {size}"
);
public static string SizeUnknown => T("taille inconnue", "unknown size", "未知大小", "ขนาดไม่ทราบ", "حجم غير معروف");
public static string UpdateAvailableHeading(string version) => T(
$"PROSERVE v{version} disponible",
$"PROSERVE v{version} available",
$"PROSERVE v{version} 可用",
$"PROSERVE v{version} พร้อมใช้งาน",
$"PROSERVE v{version} متاح"
);
public static string UpdateAvailableSubtitle(string date, string size) => T(
$"Date de release : {date} • {size}",
$"Release date: {date} • {size}",
$"发布日期:{date} • {size}",
$"วันเผยแพร่: {date} • {size}",
$"تاريخ الإصدار: {date} • {size}"
);
// ==================== COPYRIGHT ====================
public static string Copyright => T(
"© 2026 ASTERION VR — Tous droits réservés",
"© 2026 ASTERION VR — All rights reserved",
"© 2026 ASTERION VR — 保留所有权利",
"© 2026 ASTERION VR — สงวนลิขสิทธิ์",
"© 2026 ASTERION VR — جميع الحقوق محفوظة"
);
}

View File

@@ -3,10 +3,12 @@ namespace PSLauncher.Models;
/// <summary>
/// État persistant d'un téléchargement en cours, écrit régulièrement sur disque
/// pour permettre la reprise si le launcher est fermé / crashe pendant le DL.
///
/// Schema 2 : ajout de <see cref="Segments"/> pour le téléchargement parallèle multi-segments.
/// </summary>
public sealed class DownloadState
{
public int SchemaVersion { get; set; } = 1;
public int SchemaVersion { get; set; } = 2;
public string Version { get; set; } = string.Empty;
public string Url { get; set; } = string.Empty;
public long TotalBytes { get; set; }
@@ -17,4 +19,22 @@ public sealed class DownloadState
public string PartialPath { get; set; } = string.Empty;
public DateTime StartedAtUtc { get; set; }
public DateTime LastChunkAtUtc { get; set; }
/// <summary>
/// Si non vide, indique un téléchargement parallèle multi-segments.
/// Chaque segment couvre un range exclusif [Start, End] du fichier final.
/// </summary>
public List<DownloadSegment> Segments { get; set; } = new();
}
public sealed class DownloadSegment
{
public int Index { get; set; }
public long Start { get; set; }
public long End { get; set; } // inclusif
public long DownloadedBytes { get; set; }
public bool Completed { get; set; }
public long Length => End - Start + 1;
public long RemainingBytes => Length - DownloadedBytes;
}

View File

@@ -6,13 +6,60 @@ public sealed class LocalConfig
public string ServerBaseUrl { get; set; } = "https://example.com/PS_Launcher/api";
public string InstallRoot { get; set; } = string.Empty;
public string? LastLaunchedVersion { get; set; }
/// <summary>
/// Code de langue de l'UI : "auto" (suit la langue Windows), "fr", "en",
/// "zh", "th" ou "ar". Tout changement nécessite un redémarrage du launcher.
/// </summary>
public string Language { get; set; } = "auto";
/// <summary>
/// Nombre de connexions HTTP parallèles utilisées pour télécharger un build.
/// 1 = comportement legacy single-connection. 4-8 contourne le throttling
/// per-connection d'Apache/OVH mutualisé. 8 est un bon défaut.
/// </summary>
public int ParallelDownloadSegments { get; set; } = 8;
/// <summary>
/// Si false, ignore la vérification SHA-256 même si le manifest l'exige.
/// Utile sur les machines lentes ou pour gagner 30 s-3 min en fin de DL d'un gros build.
/// La sécurité repose alors uniquement sur la signature Ed25519 du manifest + HTTPS,
/// ce qui reste robuste.
/// </summary>
public bool VerifyDownloadHash { get; set; } = true;
public LicenseConfig License { get; set; } = new();
}
public sealed class LicenseConfig
{
public string? EncryptedKey { get; set; }
/// <summary>
/// Date UTC de la dernière validation online réussie. Utilise <c>response.ServerTime</c>
/// (signé Ed25519) plutôt que l'horloge locale, donc non manipulable par l'utilisateur.
/// Sert de point de départ au calcul d'expiration du cache offline.
/// </summary>
public DateTime? LastValidationAt { get; set; }
public DateTime? CachedEntitlementUntil { get; set; }
public string? CachedOwnerName { get; set; }
/// <summary>
/// Statut tel que renvoyé par le serveur lors de la dernière validation : "valid",
/// "expired", "revoked", "invalid", "machine_limit_exceeded". Persisté pour qu'une
/// révocation effectuée pendant que le user est offline soit toujours visible
/// même après son expiration de cache local. Le calcul "valid → expired" basé
/// sur la date est aussi appliqué côté lecture (sanity) si le temps a passé.
/// </summary>
public string? CachedStatus { get; set; }
/// <summary>
/// Compteur monotone : date UTC la plus récente jamais observée par le launcher
/// sur cette machine. Mise à jour à chaque démarrage si <c>now</c> est postérieur.
/// Sert d'anti-rollback : si l'utilisateur recule l'horloge PC, on détecte
/// (now &lt; LastSeenUtc - grace) et on invalide le cache → forçage d'une
/// re-validation online la prochaine fois qu'il sera connecté.
/// </summary>
public DateTime? LastSeenUtc { get; set; }
}

View File

@@ -56,7 +56,7 @@ public sealed class VersionManifest
public string Executable { get; set; } = "PROSERVE_UE_5_5.exe";
[JsonPropertyName("installFolderTemplate")]
public string InstallFolderTemplate { get; set; } = "Proserve v{version}";
public string InstallFolderTemplate { get; set; } = "PROSERVE v{version}";
[JsonPropertyName("download")]
public DownloadDescriptor Download { get; set; } = new();
@@ -86,4 +86,21 @@ public sealed class DownloadDescriptor
[JsonPropertyName("sha256")]
public string Sha256 { get; set; } = string.Empty;
/// <summary>
/// Algo de vérif d'intégrité du ZIP téléchargé.
/// Valeurs : "sha256" (défaut, voir <see cref="Sha256"/>) — "none" (pas de vérif).
/// La sécurité est déjà assurée par la signature Ed25519 du manifest et HTTPS ;
/// le hash sert surtout à détecter la corruption disque/réseau sur 14 Go.
/// Pour les très gros builds où on accepte le compromis, mettre "none" évite
/// 30 s à 3 min de calcul à la fin du DL.
/// </summary>
[JsonPropertyName("hashAlgorithm")]
public string? HashAlgorithm { get; set; }
/// <summary>True si on doit vérifier le hash (sha256 non vide, non "REPLACE", algo != "none").</summary>
public bool ShouldVerifyHash =>
!string.IsNullOrEmpty(Sha256)
&& !Sha256.StartsWith("REPLACE", StringComparison.OrdinalIgnoreCase)
&& !string.Equals(HashAlgorithm, "none", StringComparison.OrdinalIgnoreCase);
}