Two things change so the operator never has to SSH for a launcher
release.
1. SignManifest::run() now also re-hashes the manifest's `launcher`
section. It looks for builds/launcher/<basename of launcher.url>;
falls back to a tolerant glob *<version>*.exe if the exact name
isn't found. Updates sizeBytes + sha256 in place. The Ed25519 sign
step at the end already covered this branch — it was just blank
data before.
2. admin/versions.php has a new "Auto-update du launcher" card above
the manifest table. Shows the announced version + minRequired, the
exe presence badge and the hash status, and a small form to set or
update the launcher entry (version + minRequired only — URL is
derived from base_url + version automatically). A "Retirer la
section" button disables the auto-update by deleting the launcher
key from versions.json. Lists the .exe files present in
builds/launcher/ for visibility.
Workflow now: edit the version in the form → SFTP-upload
PSLauncher-X.Y.Z.exe to builds/launcher/ → click "🔁 Sync" once →
manifest is hashed and signed.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
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>
Top bar (3-column layout)
-------------------------
- Col 1: PROSERVE Launcher wordmark.
- Col 2: license badge centered, the badge IS the click target now (a
Border with an InputBindings MouseBinding LeftClick to OpenLicense).
No more separate "🔑 Activer / changer" button cluttering the right.
- Col 3: ⚙ Paramètres + window chrome (min/max/close).
- "📁 Dossier" button removed from the top bar — install root is still
reachable from Settings.
Footer (always visible)
-----------------------
- Row 1: "🔄 Vérifier les MAJ" pinned bottom-left, always shown. The
optional "Annuler" button stays bottom-right while a download runs.
- Row 2: status text + progress bar, only shown when busy or after a
status message — the previous "footer entirely hidden when idle"
hid the check button too.
License flow split in two
-------------------------
Click on the license badge:
- License is active (valid / expired / revoked) → LicenseDetailsDialog
opens. Header pill in matching status color (green/amber/red), shows
owner, validity, issued date, machine ID with copy-to-clipboard. Two
buttons: "🗑 Désactiver la license" (with confirmation) and Close.
- No license OR after deactivation → falls through to the existing
OnboardingDialog for re-keying.
Settings rework
---------------
LICENSE section is now first in SettingsDialog with the same
green/amber/red colored chrome as the top bar — at a glance the user
sees the same status everywhere. Machine ID copy moved into this card.
Sign-manifest no longer needs exec()
------------------------------------
The "🔁 Sync" button in admin/versions.php previously shelled out to
`php tools/sign-manifest.php` via exec(). OVH mutualisé often disables
exec(), causing silent no-ops and the symptom the user just hit: the
ZIP changed (new size 469,657,770 vs manifest's stale 469,831,428) and
the launcher rejected it as size mismatch.
Refactor:
- New PSLauncher\Tools\SignManifest class with ->run() that does the
hashing, latest-bump and Ed25519 signing in-process.
- tools/sign-manifest.php is now a 6-line wrapper for the class.
- admin/versions.php's 'sync' action calls the class directly via
require_once + new — works on any host, no exec dependency.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The canonical config example is server/api/config.example.php — there's
no reason for a copy in tools/. Likely a leftover from an earlier sync
back from the OVH host.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Symptom: "Réponse serveur non authentifiée (signature invalide)" on
license activation. Root cause: PHP \DateTimeInterface::ATOM emits the
server's local timezone offset (e.g. +02:00 on a CET host), while the
C# canonicalizer emitted +00:00 after ToUniversalTime(). Same instant,
different string, different bytes, signature mismatch.
Server (ValidateLicense.php):
- All timestamps now built with `new DateTimeZone('UTC')` and formatted
as 'Y-m-d\TH:i:s\Z' — fixed string, no offset variation.
- Reads issued_at / download_entitlement_until from MySQL as UTC; the
display is consistent with what the client sees.
Client (LicenseService.cs):
- FormatDateAtom now produces "yyyy-MM-ddTHH:mm:ssZ" with literal Z and
handles null safely (previous version would have produced "Z" alone
for a null input thanks to string + null concatenation).
Both sides therefore agree on the canonical bytes for any datetime,
including across daylight savings transitions.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
OVH shared hosting MySQL hostnames (*.mysql.db) only resolve from inside
OVH's network — phpMyAdmin running locally or any external client gets
"getaddrinfo failed". Either the operator uses OVH-hosted phpMyAdmin (one
click in the manager), or runs SQL through a PHP script that already lives
on the OVH machine.
migrate.php applies every *.sql file in migrations/ in lexicographic order,
using config.php credentials. Works equally from CLI (php tools/migrate.php
in SSH) and from a browser hit one-shot. Statements split on ";\n", skips
SQL comment lines. Idempotent thanks to CREATE TABLE IF NOT EXISTS.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Self-contained admin under /PS_Launcher/admin/ on the same OVH host. No
JS framework, no Composer deps — just PHP 8.3 + sessions + a small CSS.
Auth & infrastructure
---------------------
- admin/lib/Auth.php: session + CSRF helper. Login via password_hash /
password_verify. session_regenerate_id on successful login.
config.example.php gains admin_password_hash, generated by:
php -r "echo password_hash('PWD', PASSWORD_DEFAULT);"
- admin/lib/Layout.php: shared header/footer/nav, formatBytes,
csrfField helpers.
- admin/.htaccess: noindex / X-Frame-Options DENY / X-Content-Type
nosniff / blocks lib/*.php from web access.
- admin/assets/style.css: matches launcher's dark theme — same
Brush.* palette mapped to CSS vars, vivid green/blue/amber status
pills consistent with the WPF UI.
Pages
-----
- index.php (Dashboard): KPIs (active/expired/revoked licenses, machines
seen 30d, validations 24h), manifest signature status, last 10
audit_log entries.
- licenses.php: full CRUD.
* Émettre: owner, expiration date, max machines, internal notes →
generates a PRSRV-XXXX-XXXX-XXXX-XXXX key, displays it ONCE in a
green callout (DB stores the key; the message stays only on this
request, never shown again).
* Prolonger (per-row, expandable form), Revoke / Unrevoke,
Reset machines (frees all slots for that license).
* Status badge: active / expired / revoked.
- versions.php: edit the manifest from the web.
* Add a version: number + release date + minLicenseDate + release
notes Markdown (creates releasenotes/{version}.md). Sets default
download URL to {base_url}/builds/proserve-{version}.zip.
* Per-row Méta (edit minLicenseDate / releasedAt), Notes (edit md
inline), toggle availableForDownload, Delete entry.
* 🔁 Sync (sign-manifest) button: shells out to
`php tools/sign-manifest.php` and shows its stdout — recomputes
sha256/sizeBytes for every uploaded ZIP, bumps `latest`, signs
Ed25519. Visual indicators on each row: zip presence, hash
computed yes/no, signature status.
* Lists orphan ZIPs in builds/ that no manifest entry references.
- audit.php: paginated audit_log viewer (100/page) with event-type
filter dropdown. JOINs licenses to show owner_name. Color-codes
events (validate_ok green, expired amber, invalid/revoked red).
Server README rewritten to document the full setup flow:
1. Create MySQL DB, run migrations/001_init.sql
2. Copy config.example.php → config.php, fill db credentials
3. php tools/generate-keypair.php → paste into config.php and into the
client's Resources/server-pubkey.txt
4. Set admin_password_hash in config.php
5. Login at /PS_Launcher/admin/
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
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>
Two robustness fixes after a real-world miss where v1.4.7 was uploaded but
the launcher kept reporting v1.4.6 as latest:
1. UpdateChecker: ignore the `latest` field of the manifest entirely.
Always pick the highest SemVer in the versions[] array (filtered by
availableForDownload). Removes a class of "I forgot to bump latest"
bugs at the server.
2. ManifestService: send Cache-Control: no-cache, no-store + Pragma:
no-cache when fetching. The user explicitly clicked "Check for
updates", they want fresh data — bypass any intermediate cache
(browser-style HTTP cache, OVH static handler default 2-day expires).
3. sign-manifest.php: after hashing the uploaded ZIPs, auto-update
`manifest.latest` to the highest version that actually has a ZIP
on the server. Prevents the same drift the client now ignores, but
keeps the field meaningful for any consumer that reads it.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Previously the script assumed every ZIP was named proserve-{version}.zip.
That broke when an upload was named "Proserve v1.4.6.zip" (with space and
capital P, the natural name produced by Compress-Archive on a Proserve
v1.4.6/ folder).
Now the script:
1. Reads download.url from each version entry, takes basename().
2. If that exact file is missing, falls back to a glob *{version}*.zip
in builds/ — succeeds if exactly one match.
3. Logs a clear message in either case.
Also fix manifest example hostname (www.exemple-asterion.com → asterionvr.com).
The previous .htaccess used PATH_INFO (api/index.php/$1) which OVH mutualisé
does not always allow, producing an Apache 500 HTML page that masked our own
JSON error reporting.
Switch to query-string routing (?route=...): same effect, works everywhere.
Add a global exception handler in index.php that emits JSON errors only —
no more opaque Apache 500.
Add a /api/debug endpoint that reports PHP version, sodium availability,
pdo_mysql, and whether versions.json is found. Useful for diagnosing
shared-hosting setup before adding license logic.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
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>