Commit Graph

7 Commits

Author SHA1 Message Date
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
8988d130ac Single-instance launcher + tolerant build pipeline
Single-instance
---------------
Two PSLauncher.exe instances ran in parallel during the user's last
release build, locking the repo-root copy and breaking the publish.
Two-layer protection:

1. App.xaml.cs uses a Global\ named Mutex (UUID-based key) created at
   OnStartup. If the mutex is already held, the second instance
   PostMessages a registered Windows message (PSLAUNCHER_BRING_TO_FRONT)
   to HWND_BROADCAST and Shutdown()s immediately.

2. MainWindow hooks WndProc via SourceInitialized + HwndSource.AddHook;
   when it sees the broadcast, it restores from minimized, calls
   ShowWindow(SW_RESTORE) + Activate() + SetForegroundWindow so the
   already-running instance pops to the user's foreground.

Net result: clicking the launcher icon a second time pops the existing
window instead of starting a duplicate process.

Tolerant build pipeline
-----------------------
- Both csproj post-publish copy targets now have
  ContinueOnError="WarnAndContinue". A locked PSLauncher.exe at the
  repo root no longer fails the entire publish — the binary still
  exists in bin\Release\...\publish\ and the user gets a warning
  instead of an error.

- All four .bat scripts (build-launcher / build-updater / build-all /
  build-installer) now run `taskkill /F /IM PSLauncher.exe /T` and the
  same for PSLauncher.Updater.exe before the publish step. This
  defensively closes any stray instance left behind by previous tests
  so the build pipeline is clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 12:31:11 +02:00
b7de228bc9 v0.6 + v1.0: HMAC download URLs, launcher self-update, Inno Setup installer
Three deliverables shipped together so the next deployment cycle has a
clean distribution story.

(1) Auto-update of the launcher itself
---------------------------------------
- Models: RemoteManifest gains an optional `launcher` section
  (LauncherInfo: version, minRequired, download {url,size,sha256},
   releaseNotesUrl). Server-side, sign-manifest.php passes it through
  unchanged; admins edit versions.json with the new launcher entry +
  upload PSLauncher-X.Y.Z.exe to /builds/launcher/.
- Core: ILauncherSelfUpdater compares assembly version against
  manifest.launcher.version using the existing SemVer parser, and
  reuses DownloadManager (Range/resume/sha256 already proven on the
  game ZIPs) to download the new exe into
  %LocalAppData%/PSLauncher/selfupdate/.
- New project PSLauncher.Updater (~34 MB self-contained console exe):
  spawned by the main app with --target / --source / --pid / --launch.
  Waits for the main process to exit (or for the file lock to release),
  backs up the current exe to .bak, copies the new file in place, and
  restarts. .bak survives the swap so the user can roll back manually.
- App.csproj now declares Version=0.5.0 — currently shipped baseline.
  PSLauncher.App.csproj sets a fixed AssemblyVersion so reflection-based
  comparison works deterministically.
- MainViewModel.PromptLauncherUpdate: dialog after CheckForUpdates if
  the manifest advertises a newer launcher. Download with progress in
  the existing footer, then Application.Shutdown() so the Updater can
  do its job.

(2) Inno Setup installer
------------------------
installer/PSLauncher.iss + build-installer.ps1 produce a single
PSLauncher-Setup-X.Y.Z.exe (~80 MB) that installs into
Program Files\ASTERION VR\PSLauncher\, drops both PSLauncher.exe and
PSLauncher.Updater.exe side by side (the updater MUST live next to
the target), creates Start Menu + optional Desktop shortcuts, and
registers a clean uninstall entry. The user's %LocalAppData%
(license, logs, cache) is intentionally untouched on uninstall — same
license survives a reinstall.

build-installer.ps1 chains dotnet publish for both projects and ISCC
in one command. README explains the bump-version workflow.

(3) HMAC-signed download URLs
-----------------------------
- New PHP route GET /api/download-url/{version} (Authorization: Bearer
  <licenseKey> or ?key=...). Validates the license, checks
  download_entitlement_until >= minLicenseDate of the version, and
  returns a HMAC-signed URL (path|exp|licId, hash_hmac SHA-256, valid
  1 h) + sha256 + sizeBytes for verification.
- /builds/.htaccess routes every *.zip request to gate.php. gate.php
  validates exp, lic, sig (constant-time hash_equals), then streams
  the file with Range: support so the launcher's resume keeps working.
  Audit log gets a download_url_issued entry per request.
- Client-side wired transparently: LicenseService gains
  GetSignedDownloadUrlAsync(version) that GETs the endpoint with the
  decrypted license key from DPAPI. MainViewModel calls it before
  every download; if the endpoint returns 404/401/network-error, the
  client falls back to the manifest's plain download.url (graceful
  degradation for setups that haven't deployed gate.php yet).

Note on PHP streaming for 14 GB ZIPs: gate.php uses set_time_limit(0)
+ ignore_user_abort(true) + 1 MiB chunked fread with periodic flush.
Works on OVH mutualisé but holds a PHP-FPM slot for the duration. If
parallel downloads scale past a few clients, switch to
mod_xsendfile or migrate /builds/ to Cloudflare R2 with native
S3-presigned URLs and remove the gate entirely.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 09:38:13 +02:00
eeff3c007b v0.5: settings dialog, persistent Serilog logs, Windows toasts
Settings (⚙ button in top bar)
------------------------------
SettingsDialog opens via OpenSettings command in MainViewModel. Sections:
- Serveur: server URL field + "Tester" button that GETs /api/health
  and reports status inline.
- Installation: installRoot path with Browse button
  (Microsoft.Win32.OpenFolderDialog, .NET 8 native).
- License: shows status / owner / exp / machine ID (read-only,
  copy-to-clipboard button), "Désactiver" wipes the cached license.
- Cache: shows download cache path + current size, opens or empties it.
- Logs & Application: launcher version + logs path with "Open" button.

Apply persists to LocalConfig via IConfigStore. After save, MainViewModel
RebuildList()s so changes to ServerBaseUrl or InstallRoot take effect
without restart.

Persistent Serilog logs
-----------------------
Logs now live in %LocalAppData%/PSLauncher/logs/app-YYYYMMDD.log, rolling
daily, 10 days kept. Output template includes source context and stack
traces. Generic Host wired up via .UseSerilog() so all
ILogger<T>-injected types share the sink. Unhandled AppDomain and
Dispatcher exceptions are routed to Serilog before propagation.

Windows toasts
--------------
IToastService + ToastService backed by Microsoft.Toolkit.Uwp.Notifications
(ToastContentBuilder.Show()). Required bumping the App TFM from
net8.0-windows to net8.0-windows10.0.17763.0 (with explicit
WindowsSdkPackageVersion=10.0.17763.41 to satisfy CommunityToolkit.Mvvm
8.3.2's MVVMTKCFG0003 check). Triggered on:
- successful install completion: "Proserve v{X} est prête à être lancée"
- install/download error: short error excerpt

Misc
----
- InverseBoolConverter for "disable button while busy" patterns.
- Added Markdig.Wpf import to ReleaseNotesViewerDialog (was implicit
  before, now required explicitly).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 11:35:22 +02:00
7a29dbb049 v0.4: licensing — server validation, DPAPI cache, Ed25519 signed responses
UX bonus: clicking "Reprendre" on an interrupted download skips the
release-notes confirmation dialog (the user already approved when they
first clicked Installer).

Server side
-----------
- migrations/001_init.sql: licenses, license_machines, rate_limit,
  audit_log on InnoDB/utf8mb4. Foreign keys, unique on license_key, slot
  uniqueness per (license_id, machine_id).
- api/lib/Db.php: thin PDO singleton (exception mode, prepared, no emul).
- api/lib/Crypto.php: Ed25519 sign/verify via libsodium (sodium_crypto_*),
  HMAC-SHA-256 helper for v0.6, canonicalJson() that strips `signature`
  before serializing — must match exactly the encoding done client-side
  before verify.
- api/routes/ValidateLicense.php: POST /license/validate. Looks up the
  key, walks the machine slot logic (insert or update last_seen),
  enforces max_machines, returns a payload signed Ed25519 + status of
  valid/expired/revoked/machine_limit_exceeded/invalid. Audit logs every
  outcome. Rate-limit 10/min/IP via the rate_limit table.
- tools/generate-keypair.php: prints a fresh sodium keypair so the
  operator drops the hex into config.php and the public_key_hex into the
  launcher resource.
- tools/issue-license.php: PRSRV-XXXX-XXXX-XXXX-XXXX generator (32-char
  unambiguous alphabet), inserts the license, prints the key once.
- tools/sign-manifest.php: now also signs the manifest itself with
  Ed25519 after computing the per-zip sha256s.
- config.example.php: schema rewritten with sections db / hmac / ed25519
  / jwt / rate-limit. config.php remains gitignored.

Client side
-----------
- Models/License.cs: LicenseValidationRequest + LicenseValidationResponse
  with CanDownload(VersionManifest) — entitlement_until vs version's
  minLicenseDate. The status valid|expired|revoked|machine_limit_exceeded
  flow is preserved end-to-end.
- Core/Licensing/LicenseService.cs:
  * machineId = SHA-256 of HKLM/Software/Microsoft/Cryptography/MachineGuid
    + UserName (stable, no PII leak)
  * online ValidateAsync calls /license/validate with launcher version
  * embedded server-pubkey.txt drives Ed25519 verification of the
    response (skipped gracefully if pubkey not yet provisioned)
  * SaveCached / GetCached use DPAPI CurrentUser scope on the license
    key; the cleartext key never touches disk
  * GetCached has a 7-day offline grace window after the last successful
    validation, so going offline doesn't lock the user out
- Core/Resources/server-pubkey.txt: EmbeddedResource. Default content is
  a comment, which the service treats as "no pubkey configured" and
  bypasses verification. Operator pastes the real hex post-deploy and
  rebuilds.
- Core/PSLauncher.Core.csproj: Polly, NSec.Cryptography (Ed25519),
  System.Security.Cryptography.ProtectedData (DPAPI).
- App/Views/OnboardingDialog.xaml(.cs): first-launch / "🔑 Activer"
  modal. Calls LicenseService, displays status messages with red
  foreground on errors and green-tinted secondary text otherwise.
- ViewModels/VersionRowViewModel.cs: new LicenseAllowsDownload property.
  Install button label switches to "🔒 License insuffisante" when the
  user's entitlement_until precedes the version's minLicenseDate;
  CanInstall is false in that case so the click is a no-op too.
- ViewModels/MainViewModel.cs: loads the cached license at startup (no
  network call), surfaces it as LicenseSummary in the top bar, exposes
  ActivateLicenseCommand to (re)open the onboarding dialog. RebuildList
  applies the per-version license filter so older installed versions
  remain launchable but newer-than-license ones can't be downloaded.
- Views/MainWindow.xaml: top bar gains a "🔑 Activer / changer" button
  next to the license summary.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 10:12:37 +02:00
9bdcdabb9e v0.3: resumable downloads with HTTP Range, Polly retry, persistent state
This is the robustness layer needed for real 14 GB builds. A 99%-complete
download that gets interrupted no longer means re-downloading 14 GB.

DownloadManager
---------------
- Range: bytes={resumeFrom}- on every (re)attempt; reads resumeFrom from
  the actual size of the .partial file so retries always resume from real
  on-disk state, not from a remembered counter.
- If-Range: ETag (or Last-Modified fallback). When the server returns 200
  instead of 206 we know the resource changed under us, so we discard
  .partial and start fresh.
- Polly resilience pipeline: 6 retries, exponential 1-32s with jitter,
  on HttpRequestException / IOException / TimeoutException / 5xx / 408 /
  429. Each retry re-evaluates resumeFrom from disk, so the server is
  asked only for what's actually missing.
- state.json persisted every 5s OR every 100 MiB, whichever comes first,
  via atomic write-then-rename. Holds url, total, downloaded, sha256,
  etag, last-modified, and the .partial path.
- Disk-space check happens once at fresh-start (1.05x expected size); a
  resume doesn't redo it.
- On final success: SHA-256 of the assembled .partial verified, then
  atomic rename to .zip and state.json deleted.

DownloadStateStore
------------------
- New IDownloadStateStore in PSLauncher.Core/Downloads.
- Stores under %LocalAppData%/PSLauncher/downloads/.
- Save / Load / Discard / ScanResumable. Tolerates malformed state files
  by ignoring them.

UI hint
-------
VersionRowViewModel now has ResumableBytes; when > 0, the install button
label switches to "↻  Reprendre (X%)" computed from
ResumableBytes / Remote.Download.SizeBytes. MainViewModel.RebuildList
queries IDownloadManager.GetResumableState(version) for each remote-only
row and populates ResumableBytes. Both the featured hero card and the
compact rows bind to InstallButtonLabel.

API change
----------
IDownloadManager gains GetResumableState(version) and
DiscardResumableState(version) so callers (and the UI) can reason about
in-progress downloads without poking at the filesystem directly.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 09:55:31 +02:00
1c8c6803e8 Initial scaffolding: PS_Launcher v0.2
Client (C# / .NET 8 / WPF, MVVM via CommunityToolkit.Mvvm):
- PSLauncher.App: WPF UI dark theme (Epic-style sidebar + hero + big play button)
  with UpdateAvailableDialog rendering Markdown release notes via Markdig.Wpf.
- PSLauncher.Core: services for installation registry (scans Proserve v{X.Y.Z}/),
  process launcher, manifest fetch, SHA-256 integrity, HTTP download with
  progress, ZIP install via .tmp + atomic rename, update orchestrator.
- PSLauncher.Models: RemoteManifest, InstalledVersion, LocalConfig DTOs.

Server (PHP 8 for OVH mutualisé, deployed under www/PS_Launcher/):
- Front controller + routes /manifest and /releasenotes/{version}.
- Static signed-manifest workflow with tools/sign-manifest.php CLI to
  recompute SHA-256 and sizeBytes after each ZIP upload.
- .htaccess: HTTPS redirect, rewrite, security headers.
- config.example.php template; real config.php is gitignored.

Cohabiting versions: each release lives in its own Proserve v{version}/ folder
under installRoot. Old versions are never deleted automatically.

Roadmap: v0.3 = HTTP Range resume + Polly retry + state.json,
v0.4 = MySQL license + Ed25519 signatures + DPAPI, v0.5 = settings/UX polish.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 08:54:45 +02:00