v0.26.0 générait des URLs builds/{channel}/proserve-X.Y.Z.zip dans le
manifest pour les channels non-default, mais SignManifest faisait juste
basename(url) → cherchait builds/proserve-X.Y.Z.zip à plat → ZIP introuvable
au moment du Sync.
Fixes :
1. SignManifest::resolveZipPath() : extrait tout le chemin après /builds/
dans l'URL et le mappe sur {$buildsDir}/. Strip ../ pour anti-traversal
même si le manifest est de toute façon signé Ed25519.
2. SignManifest fallback fuzzy étendu à 1 niveau de sous-dossier (glob */*.zip)
pour le cas "admin a renommé le ZIP".
3. admin/versions.php $zips inclut maintenant builds/ + builds/*/ et stocke
les chemins relatifs (asterion-vr/proserve-X.Y.Z.zip) au lieu du basename.
Le check "référencé ?" matche aussi sur le chemin relatif.
4. Workflow text en haut de la page indique le bon chemin SFTP selon le
channel actif (avec rappel "crée le sous-dossier si absent").
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
DEUX features liées :
1. CHANNELS : chaque license peut être attribuée à un manifest distinct
(« channel »). Permet de servir des versions différentes selon le
client. Le serveur lit ?channel=X et sert manifest/versions-{X}.json
avec fallback transparent sur versions.json. NULL = default.
2. BÊTA : nouveau flag isBeta + betaNotes par version dans le manifest.
Visible uniquement par les licenses avec can_see_betas=1. Affichage
d'une pill orange « BÊTA » sur la row + tooltip avec les notes pour
les testeurs. Les installations locales déjà présentes restent
visibles même si l'accès BÊTA est retiré ensuite (on n'efface pas
le disque du client).
DB :
002_channel_betas.sql ajoute channel + can_see_betas sur licenses.
Idempotent (ALTER TABLE IF NOT EXISTS), zero data migration.
Serveur PHP :
- ValidateLicense.php signe channel + canSeeBetas dans la réponse
(ordre des clés CRITIQUE pour matcher le canonical client).
- Manifest.php : whitelist regex anti-traversal sur ?channel=, fallback
silencieux sur versions.json si channel inconnu (évite leak de la
liste de channels par probing).
- SignManifest.php prend un channel optionnel → l'admin peut signer
chaque manifest indépendamment.
- admin/licenses.php : dropdown channel + checkbox bêta sur create,
bouton détails repliable par-row pour edit.
- admin/versions.php : channel switcher en tête, badge BÊTA sur chaque
row, dialog repliable « Bêta » avec checkbox + notes des testeurs.
Client C# :
- License.Channel + License.CanSeeBetas (dans le canonical signé).
- VersionManifest.IsBeta + BetaNotes.
- ManifestService prend un channelProvider via DI, lu depuis license
cachée à chaque fetch (lazy, pas de circular dep).
- MainViewModel.RebuildList filtre les versions IsBeta si !CanSeeBetas
(mais conserve les installées locales — on ne retire pas l'accès
rétroactivement à ce qui est déjà sur disque).
- VersionRowViewModel : props IsBeta / BetaNotes / BetaTooltip.
- MainWindow.xaml : pill orange à côté du n° version pour le featured
et les rows compactes, tooltip dynamique avec les notes testeurs.
Backward compat signature :
Anciennes licenses cachées (signées sans channel/canSeeBetas) sont
toujours validées via un fallback canonical legacy dans VerifySignature.
Sans ce fallback, le passage à v0.26 invaliderait toutes les caches
hors-ligne et bloquerait les users en mobilité.
Migration côté admin : jouer 002_channel_betas.sql sur la base, déployer
les fichiers PHP, créer manifest/versions-{channel}.json pour les
nouveaux channels (l'admin versions.php propose un input « Créer/utiliser
un nouveau channel »). Les licenses existantes restent en channel=NULL
= default = comportement actuel.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- 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>
The launcher and the game versions are different release cadences
managed by the same operator. Mixing them on one page muddied the
workflow. Split them into two top-level nav entries.
SignManifest::run(string $scope)
--------------------------------
- 'all' (default, used by CLI tools/sign-manifest.php) — unchanged.
- 'versions' — only re-hashes the Proserve ZIPs and bumps `latest`.
- 'launcher' — only re-hashes the launcher EXE.
The Ed25519 sign step always runs at the end so the manifest stays
verifiable. Selectively hashing avoids unrelated noise (e.g. mass-hash
14 GB ZIPs when all you wanted was to update the launcher exe).
admin/launcher.php (new page)
-----------------------------
Self-contained page with the launcher state, Set/Remove forms, the
blue "🔁 Hasher le launcher + signer" button, and a list of the .exe
files present in builds/launcher/. Workflow doc inline.
admin/versions.php
------------------
Cleaned up: launcher card and its set_launcher / remove_launcher /
sync_launcher actions removed. The remaining global Sync button is
relabeled and now triggers scope='versions' (only Proserve ZIPs).
Layout::navHtml gains a "Launcher" item between Versions and Audit.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
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>
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>
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).
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>