v0.25.4 — Cache LAN P2P : un PC sert les ZIPs aux autres du LAN

Feature : sur un site multi-PCs (salle VR), un PC peut servir les ZIPs PROSERVE
qu'il a déjà téléchargés aux autres PCs du LAN au lieu de les forcer à re-DL
14 Go depuis OVH. Auto-discovery par UDP broadcast, fallback transparent OVH
si aucun peer ne répond, intégrité préservée par SHA-256 du manifest signé Ed25519.

Architecture (src/PSLauncher.Core/Lan/) :
- ZipCacheStore : cache local des ZIPs téléchargés (sidecar SHA-256 pour servir
  sans re-hash). Pruning auto au top-N par mtime, protège les versions encore
  installées. Remplace le `File.Delete(zipPath)` post-install historique.
- LanCacheServer : serveur HTTP local sur TcpListener (PAS HttpListener — il
  exigeait une URL ACL admin qui aurait obligé à coder un netsh dans l'installer).
  Routes GET /v1/has/{ver} (probe) + GET /v1/zip/{ver} (Range support manuel,
  ~80 LOC de parsing HTTP). Filtre RFC1918/link-local sur RemoteEndPoint avant
  toute IO, regex stricte sur version (anti-path-traversal), SemaphoreSlim(4)
  pour rate-limiter à 4 DL concurrents.
- LanDiscoveryService : émet beacon UDP (255.255.255.255:47624, JSON avec host+
  port+versions) toutes les 15 s en mode serveur ; écoute en mode client/serveur
  avec TTL 60 s sur les peers. Pure .NET, zero NuGet (pas de mDNS deps).
- PeerSourceResolver : avant chaque DL, probe les peers (auto-découverts +
  manuels) avec timeout 1 s × max 5 peers. Vérifie size + SHA-256 contre le
  manifest avant de retourner une URL peer. Si null → fallback OVH transparent.

Modifs flow d'install (MainViewModel) :
- Avant GetSignedDownloadUrlAsync, tente PeerSourceResolver. Si peer trouvé,
  utilise son URL directement (le DownloadManager multi-segment marche tel quel).
- RefreshUrlAsync handler : pour les peers, retourne la même URL (pas d'expiration
  HMAC). Pour OVH, refresh du HMAC inchangé.
- Post-install : remplace File.Delete(zipPath) par ZipCacheStore.Promote + Prune.

Sidebar info (visible en permanence dans le coin bas-gauche) :
- Lignes "Serveur ON/KO/—", "Client ON/—", "Peers N" — refresh auto toutes les
  10 s via DispatcherTimer. Diagnostic instantané sans naviguer dans Settings.

Footer pendant le DL : préfixe différencié pour rendre la source évidente —
"📡 LAN 10.0.0.5 • v… : … Mo/s" vs "⬇ v… : … Mo/s" (OVH).

Settings → Cache LAN P2P : checkboxes ServerEnabled/ClientEnabled, ports HTTP/UDP,
MaxCachedVersions, statut serveur live, liste peers découverts, champ multi-line
peers manuels (override/fallback de l'auto-discovery). Toggle des flags →
prompt "Restart launcher requis" car les hosted services lisent leur config au
boot uniquement.

Fix critique au passage : App.OnStartup appelait .Build() mais JAMAIS .StartAsync()
sur l'IHost — du coup AUCUN HostedService n'a jamais tourné depuis l'introduction
du pattern. Ajout de StartAsync (fire-and-forget avec log d'erreur) et StopAsync
propre dans OnExit (timeout 5 s pour libérer les sockets sans TIME_WAIT bloquant).

Installer (PSLauncher.iss) : règles firewall TCP 47623 + UDP 47624 préposées
via netsh advfirewall, profile=private,domain (PAS public — safety net même
si l'utilisateur connecte le PC à un Wi-Fi public). Inutile si firewall OFF
(cas du déploiement actuel) mais pose les rails pour les déploiements futurs.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-04 16:00:10 +02:00
parent 7de0c01b97
commit 65ff8541b7
18 changed files with 1553 additions and 43 deletions

View File

@@ -603,6 +603,64 @@ public static class Strings
public static string SettingsApiBackups => SettingsReportBackups;
public static string SettingsApiMaxBackups => SettingsReportMaxBackups;
// ---- Cache LAN P2P ----
public static string StatusDownloadingFromPeer(string host, string version) => T(
$"📡 Téléchargement de v{version} depuis {host} (LAN)…",
$"📡 Downloading v{version} from {host} (LAN)…",
$"📡 正在从 {host} 下载 v{version} (局域网)…",
$"📡 กำลังดาวน์โหลด v{version} จาก {host} (LAN)…",
$"📡 جارٍ تنزيل v{version} من {host} (LAN)…"
);
public static string SettingsLanCache => T("CACHE LAN P2P", "LAN P2P CACHE", "局域网 P2P 缓存", "แคช LAN P2P", "ذاكرة التخزين المؤقت LAN P2P");
public static string SettingsLanCacheServerEnabled => T(
"Activer le mode serveur (partager les ZIPs aux PCs du LAN)",
"Enable server mode (share ZIPs to LAN PCs)",
"启用服务器模式(与局域网 PC 共享 ZIP",
"เปิดใช้โหมดเซิร์ฟเวอร์ (แชร์ ZIP ไปยัง PC ใน LAN)",
"تمكين وضع الخادم (مشاركة ZIPs مع أجهزة الكمبيوتر في LAN)"
);
public static string SettingsLanCacheClientEnabled => T(
"Activer le mode client (chercher les ZIPs sur le LAN avant OVH)",
"Enable client mode (look for ZIPs on the LAN before OVH)",
"启用客户端模式(在 OVH 之前在局域网上查找 ZIP",
"เปิดใช้โหมดไคลเอนต์ (ค้นหา ZIP บน LAN ก่อน OVH)",
"تمكين وضع العميل (البحث عن ZIPs على LAN قبل OVH)"
);
public static string SettingsLanCacheServerPort => T("Port HTTP serveur", "Server HTTP port", "服务器 HTTP 端口", "พอร์ต HTTP เซิร์ฟเวอร์", "منفذ HTTP الخادم");
public static string SettingsLanCacheDiscoveryPort => T("Port UDP découverte", "Discovery UDP port", "发现 UDP 端口", "พอร์ต UDP การค้นพบ", "منفذ UDP الاكتشاف");
public static string SettingsLanCacheMaxCached => T("ZIPs en cache (N)", "Cached ZIPs (N)", "缓存 ZIP (N)", "ZIP ในแคช (N)", "ZIPs المخزنة (N)");
public static string SettingsLanCacheDiscoveredPeers => T("Peers découverts automatiquement", "Auto-discovered peers", "自动发现的 Peers", "Peers ที่ค้นพบอัตโนมัติ", "النظراء المكتشفون تلقائياً");
public static string SettingsLanCacheManualPeers => T("Peers manuels (1 URL par ligne, ex: http://10.0.0.5:47623)", "Manual peers (1 URL per line, e.g. http://10.0.0.5:47623)", "手动 Peers每行 1 个 URL例如 http://10.0.0.5:47623", "Peers ด้วยตนเอง (1 URL ต่อบรรทัด เช่น http://10.0.0.5:47623)", "النظراء يدوياً (URL واحد لكل سطر، مثل http://10.0.0.5:47623)");
public static string SettingsLanCacheNoDiscovered => T(
"Aucun peer détecté pour le moment (les beacons sont émis toutes les 15 s).",
"No peer detected yet (beacons are sent every 15 s).",
"目前未检测到任何 peer (信标每 15 秒发送一次).",
"ยังไม่มี peer ตรวจพบ (สัญญาณถูกส่งทุก 15 วินาที).",
"لم يتم اكتشاف أي peer بعد (يتم إرسال إشارات كل 15 ثانية)."
);
public static string SettingsLanCacheServerStatus(string endpoints, int versions) => T(
$"✓ Serveur actif sur {endpoints} — {versions} version(s) en cache",
$"✓ Server running on {endpoints} — {versions} version(s) cached",
$"✓ 服务器正在 {endpoints} 上运行 — 缓存了 {versions} 个版本",
$"✓ เซิร์ฟเวอร์ทำงานบน {endpoints} — แคช {versions} เวอร์ชัน",
$"✓ الخادم يعمل على {endpoints} — {versions} نسخة مخزنة"
);
public static string SettingsLanCacheServerOff => T(
"Mode serveur désactivé — ce PC ne sert pas les ZIPs aux peers du LAN.",
"Server mode disabled — this PC does not share ZIPs to LAN peers.",
"服务器模式已禁用 — 此 PC 不会与局域网 peers 共享 ZIP。",
"โหมดเซิร์ฟเวอร์ถูกปิด — PC นี้จะไม่แชร์ ZIP ไปยัง LAN peers",
"وضع الخادم معطل — هذا الكمبيوتر لا يشارك ZIPs مع نظراء LAN."
);
public static string MsgBoxLanCacheChange => T("Cache LAN P2P", "LAN P2P cache", "局域网 P2P 缓存", "แคช LAN P2P", "ذاكرة التخزين المؤقت LAN P2P");
public static string MsgLanCacheRestart => T(
"Le démarrage / arrêt du serveur ou client LAN nécessite un redémarrage du launcher pour prendre effet.\n\nRedémarrer maintenant ?",
"Starting / stopping the LAN server or client requires a launcher restart to take effect.\n\nRestart now?",
"启动/停止局域网服务器或客户端需要重新启动启动器才能生效。\n\n现在重新启动",
"การเริ่ม/หยุดเซิร์ฟเวอร์หรือไคลเอนต์ LAN ต้องรีสตาร์ทตัวเปิดเพื่อให้มีผล\n\nรีสตาร์ทตอนนี้?",
"يتطلب بدء / إيقاف خادم أو عميل LAN إعادة تشغيل المشغل لتفعيل التغيير.\n\nإعادة التشغيل الآن؟"
);
// ---- Backups Report tool ----
public static string SettingsReportBackups => T("Backups disponibles", "Available backups", "可用备份", "สำรองข้อมูลที่มี", "النسخ الاحتياطية المتاحة");
public static string SettingsReportMaxBackups => T("Conserver N backups", "Keep N backups", "保留 N 个备份", "เก็บสำรอง N ชุด", "الاحتفاظ بـ N نسخة احتياطية");