The « Update license expired » header was truncating the « Expired on X.
You can still download versions… » subtitle: no TextWrapping on the
TextBlock and the inner StackPanel had no horizontal width constraint.
Switched the icon-and-text layout from a horizontal StackPanel to a
2-column Grid (Auto + *) so the text column expands to the Border width.
TextWrapping="Wrap" on title + subtitle, icon now top-aligned to follow
the wrap. Window switched to SizeToContent="Height" with MinHeight=480
so it grows when the localized message wraps to 3+ lines.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
New « BASE DE DONNÉES (XAMPP / MySQL) » section in Paramètres covering:
- Host / Port / User / Password / Database — defaults match a fresh XAMPP
install (localhost:3306, root, empty password, "proserve"). Password is
DPAPI-encrypted in config.json (same scheme as the license key).
- « Tester » button — opens a fresh MySqlConnection and runs SELECT 1.
Shows ✓ OK or the connection error inline.
- « Appliquer automatiquement les migrations à l'install » checkbox — opt-in
for the post-install migration step. Default true.
- « 🔁 Rejouer les migrations » button — manually re-runs ApplyMigrationsAsync
on the latest installed version. Useful when the post-install run failed
(XAMPP was off) or after a dev added a new SQL file. Live status « 3/5 :
0042_add_index.sql » + final « ✓ N applied, M skipped » or « ✗ failure ».
- Hint paragraph below explaining the _migrations/ convention.
SettingsViewModel
- Pulls IDatabaseMigrationService and IInstallationRegistry via DI.
- Save() now also persists the DB block, encrypting the password before write.
- TestDbAsync temporarily swaps the in-memory config so the service sees the
values being typed (without persisting until Save).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Each PROSERVE ZIP can now ship a _migrations/ subfolder with versioned SQL
scripts. The launcher applies them right after extraction, in the same
install session as the binary copy, so the user never lands on a PROSERVE
build whose schema doesn't match its code.
Service (PSLauncher.Core/Migrations)
- IDatabaseMigrationService + DatabaseMigrationService (MySqlConnector,
BSD-licensed). Each .sql runs in a transaction; on failure the DB
state is rolled back and the install completes (files only) but the
user is warned to fix the connection / replay later.
- Tracking table _launcher_migrations (filename, applied_at, checksum,
duration_ms) — same model as Flyway / Doctrine. Already-applied scripts
are skipped on subsequent installs. Modified scripts trigger a warning
log without blocking.
- Custom SQL splitter that respects strings/comments/backticks so a single
.sql file can contain multiple statements separated by `;`.
- DatabasePasswordProtector: DPAPI CurrentUser scope for the MySQL password
in config.json (same protection as the license key).
Config (PSLauncher.Models/LocalConfig.cs)
- New DatabaseConfig section: Host=localhost, Port=3306, User=root, empty
password, Database=proserve, AutoApplyMigrations=true. Defaults match a
fresh XAMPP install. Override via Settings (next commit).
Install pipeline (MainViewModel)
- After ZipInstaller.InstallAsync and before declaring the install complete,
if AutoApplyMigrations and a _migrations/ folder exists, run
ApplyMigrationsAsync with progress reporting (per-file %, filename in
footer). Failure shows MsgMigrationFailed dialog explaining XAMPP must
be running and pointing to Settings → Database for connection params.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- New action `remove_machine` releasing a single (license_id, machine_id) slot.
- Pre-fetches all machines in one query and groups by license_id (no N+1).
- Machines column "X / Y" is now clickable: opens an inline expandable row
showing each machine on that license — truncated SHA-256 ID with full hash
in tooltip, machine_label, first_seen, last_seen with a "stale" warning
badge for slots not seen in >30 days, plus a per-row "Libérer" button.
- Existing "Libérer machines" button kept but renamed "Libérer toutes" with
a beefier confirmation that hints at the per-row alternative.
Replaces the all-or-nothing reset workflow with surgical control: when one
user changed PCs you can free their old slot without touching their colleagues'.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
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>
- 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>
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>
- 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>
- 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 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>
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>
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>
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>
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>
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>
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>
Brand mark
----------
Resources/logo-asterion-white.png embedded as a WPF Resource. Placed
in the top-bar StackPanel before the PROSERVE Pirulen wordmark, at
36px height, with HighQuality bitmap scaling and SnapsToDevicePixels
to keep the white logo crisp against the dark chrome.
Minimize on launch
------------------
Once a Proserve version is started via _processLauncher.Launch(),
the MainWindow drops to WindowState.Minimized so it doesn't sit on
top of the game. The launcher stays running (so the user can come
back to install / switch / see the badge license), just out of the way.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Two related issues with the background bitmap:
1. The footer (download progress bar) appeared on top of the image,
cropping the bottom strip — including the ASTERION VR mark in the
bottom-right corner of Background.png.
2. UniformToFill scales-and-crops to maintain aspect ratio, but with
the previous setup the crop was centered, so resizing the window
wider/narrower silently pushed the bottom-right logo off-screen.
Fix:
- The bitmap now lives in a Rectangle restricted to Grid.Row="1"
(body only). When the footer appears at Row 2, it sits below the
body without overlapping the image.
- Switch from <Image Stretch="UniformToFill"> to
<Rectangle><Fill><ImageBrush AlignmentX="Right" AlignmentY="Bottom"
Stretch="UniformToFill" /></Fill></Rectangle>. The crop now
happens on the LEFT and TOP edges, keeping the logo's corner pinned
to the bottom-right and visible regardless of window proportions.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The min/max button hover was set to #FFFFFF22, intended as "white at
13% alpha". WPF Color literals are AARRGGBB though, not RRGGBBAA, so
that's actually opaque red+green+a-touch-of-blue → yellow-ish white,
unreadable on the dark chrome.
Switch to Brush.Accent (#3B82F6) on hover with white foreground,
matching the close button red on hover (#E81123) — both controls now
have clear, distinct hover states.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Previous default fell back to %UserProfile%\ASTERION_VR which is
fine for dev but unconventional for end users; the standard
deployment path is plainly C:\ASTERION_VR. New first-launch behavior:
1. If a sibling ASTERION_VR exists next to the exe (portable / dev
layout), prefer it — keeps the development workflow intact.
2. Otherwise, default to C:\ASTERION_VR. The folder is created on
the first install (Directory.CreateDirectory in ZipInstaller
handles non-existent parents).
Existing users with an explicit installRoot in their config.json are
unaffected — only fresh configs (or empty installRoot) hit this path.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
build-launcher.bat: publishes PSLauncher.App only (Release single-file).
build-updater.bat: publishes PSLauncher.Updater only.
build-all.bat: both, sequentially, fast-fail on the first error.
All three pause at the end so a double-click from Explorer shows the
result before the window closes. The existing build-installer.bat
(launcher + updater + Inno Setup .exe) stays as the full release
pipeline.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Apache FastCGI on OVH mutualisé strips Authorization headers before
PHP sees them. The mod_rewrite [E=HTTP_AUTHORIZATION:%1] workaround
in .htaccess does not survive on this hosting profile (probably
because rewrite runs after the FastCGI handler accepts the request).
The endpoint already accepts ?key= as a fallback path, so use that
from the client. HTTPS still protects the key on the wire and the
launcher never logs URLs containing the key.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
OVH mutualisé serves PHP via FastCGI which strips the Authorization
header by default for security. The user's curl test of GET
/api/download-url/{ver} returned 401 even with -H "Authorization:
Bearer <key>" because $_SERVER['HTTP_AUTHORIZATION'] was empty server-side.
Fix on two layers:
1. .htaccess at root re-injects the header into the Apache env so PHP
can read it via the standard $_SERVER variable. The mod_rewrite
one-liner `RewriteRule ^ - [E=HTTP_AUTHORIZATION:%1]` is the de-
facto FastCGI workaround.
2. DownloadUrl.php now reads from any of: $_SERVER['HTTP_AUTHORIZATION'],
$_SERVER['REDIRECT_HTTP_AUTHORIZATION'] (passthrough variant),
apache_request_headers(), getallheaders(). Belt and braces — works
regardless of host config. Falls back to ?key= as before.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
`usort($manifest['versions'] ?? [], ...)` worked in PHP 7 but is a
fatal error in PHP 8 — usort requires its first arg by reference and
the null-coalesce operator produces an rvalue, not a variable. The
existing versions.php didn't trigger it because it sorts
$manifest['versions'] directly. Fixed launcher.php to guard with an
isset/is_array check and only sort when there's actually a versions
array.
Triggered by clicking "Définir" in the new Launcher page → 500 page.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The auto-update test was looping: the manifest declared launcher
v0.6.0 but the binary uploaded as PSLauncher-0.6.0.exe was still
internally v0.5.0 (same file, just renamed). After swap, the new
launcher's AssemblyVersion still reported 0.5.0, the manifest still
advertised 0.6.0, and the prompt fired on every restart.
Bump <Version> / <AssemblyVersion> / <FileVersion> to 0.6.0 so the
freshly published PSLauncher.exe genuinely identifies as 0.6.0. After
the swap, IsNewerThanCurrent will return false and the prompt will
stop reappearing.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The updater was a black box on failure (no console window, errors went
to a discarded stderr). When the user reported "launcher closes but
doesn't relaunch" there was nothing to inspect.
Add a log sink at %LocalAppData%/PSLauncher/logs/updater.log with
simple 256 KiB rotation. Every step (args parsed, PID wait, target
unlock, backup, copy, zone-strip, relaunch) writes a timestamped
line, plus the FATAL stack trace on uncaught exceptions.
Also strip the file's :Zone.Identifier ADS after copy. Internet-
downloaded exes carry this Mark-of-the-Web stream and Windows can
silently refuse to launch them via explorer.exe with no visible
error.
Relaunch becomes two-tier: try `explorer.exe "<target>"` for
de-elevation; if that fails or nothing spawns, fall back to direct
Process.Start with UseShellExecute=true. Better to inherit admin
than not relaunch at all.
ParseArgs strips surrounding quotes from --target/--source values
since the launcher now wraps paths in quotes (necessary for
UseShellExecute=true).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Last commit accidentally captured two large binaries that landed in the
working tree during the self-update test prep:
- PSLauncher-0.6.0.exe at repo root
- installer/output/PSLauncher-Setup-0.5.0.exe
Neither belongs in version control. Add /PSLauncher-*.exe and
/installer/output/ to .gitignore and `git rm --cached` the existing
entries so the next push doesn't reupload them.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Three coordinated changes so the auto-update flow works on every
existing install location.
LauncherSelfUpdater.cs
----------------------
- Probe the target directory with a write/delete of a temp file. If
it fails (e.g. install in Program Files without admin), set
UseShellExecute=true + Verb=runas on the Updater process so UAC
prompts the user once for elevation. If the directory is writable
(user-mode install or portable layout), skip elevation entirely.
- Switched from ProcessStartInfo.ArgumentList to a quoted Arguments
string because Verb=runas requires UseShellExecute=true, which
ignores ArgumentList. Paths get explicit quotes to survive spaces.
Updater Program.cs
------------------
After the file swap, the relaunch was a direct Process.Start(target).
With UAC elevation that propagates admin to the new launcher, then to
its child PROSERVE_UE_5_5.exe — undesirable. Replace with
`explorer.exe "<target>"`: explorer is always running in the user's
normal token, opens the exe via shell, the new process inherits the
non-elevated session. Standard de-elevation trick.
installer/PSLauncher.iss
------------------------
Switch from system-wide install (Program Files, requires admin) to
per-user (DefaultDirName={localappdata}\Programs\..., PrivilegesRequired=
lowest). Auto-update then never needs UAC at all on fresh installs.
Existing installs in Program Files keep working thanks to the runas
fallback above.
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>
Reverting the previous addition. The blue "🔁 Recalculer hash + signer"
button on the launcher card called the same 'sync' action as the
global one in the manifest table — same code path, same effect. Two
identical buttons created confusion. The workflow text now points
operators to the existing global Sync button below, and only the
launcher-specific actions (Définir / Retirer) live on the launcher
card.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Operator setting up auto-update was forced to scroll down to the
"Manifest actuel" card and find the global Sync button after each step
of the launcher workflow. Add a dedicated "🔁 Recalculer hash + signer"
primary-blue button right inside the launcher card. It posts the same
action='sync' so behavior is identical — it's just discoverable from
where the operator is looking.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The previous markup had a <form action=remove_launcher> nested inside a
<form action=set_launcher>. HTML doesn't allow nested forms — browsers
flatten the structure unpredictably, and clicking the inner "Définir"
submit could end up posting the outer (or vice versa), causing the
section to be removed when the user actually wanted to set it.
Two sibling forms now, with a 12px gap between them. Same UX.
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>
The value x64compatible was added in Inno Setup 6.3 — earlier 6.x
versions reject it with "Value of [Setup] section directive
ArchitecturesAllowed is invalid". Using plain x64 keeps compatibility
with the whole 6.x line. Modern users on 6.3+ are still happy since
x64 still resolves correctly there.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Wraps installer/build-installer.ps1 with -NoProfile and -ExecutionPolicy
Bypass so a double-click works without PowerShell config tweaks. Pauses
at the end so the user sees the ISCC output even when launched from
Explorer. Forwards %* for `-ISCC <path>` overrides.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The check-updates button was anchored inside the footer band, which
felt heavy and obscured the background bitmap. Move it to a floating
position: bottom-left of the body, 32px from the left edge, 24px above
where the footer would render. The button now sits directly over the
ASTERION VR background art, giving the chrome a lighter look.
The footer goes back to its original behavior — visible only when busy
or when there's a status message — instead of being permanently shown
just to host the check button.
Implementation: wrap the body's ScrollViewer in a single-cell Grid so
a sibling Button with HorizontalAlignment=Left, VerticalAlignment=
Bottom can overlay it without interfering with the version cards or
the scroll behavior.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
After every `dotnet publish -c Release`, the single-file binaries land
in C:\ASTERION\GIT\PS_Launcher\ next to PS_Launcher.sln so they can be
launched directly without digging into bin/Release/.../publish/.
Implemented as an MSBuild AfterTargets="Publish" target on each csproj
(PSLauncher.App, PSLauncher.Updater) using $(MSBuildThisFileDirectory)
to resolve the repo root portably. Condition='Release' so debug builds
don't pollute the root.
.gitignore covers /PSLauncher.exe and /PSLauncher.Updater.exe so the
committed tree stays clean.
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>
Window
------
- Default size 1280x720 (16:9 widescreen — matches the screenshot framing
the user requested), MinWidth=980 MinHeight=600.
- WindowStyle=None + ResizeMode=CanResize: drop the OS title bar but
keep edge resize.
- WindowChrome: CaptionHeight=56 makes the top bar Border the
drag-handle for moving the window. ResizeBorderThickness=6 gives
6px of grab area on each edge, GlassFrameThickness=0 disables Aero.
Custom chrome buttons
---------------------
Three new style keys in Theme.xaml:
- WindowControlButton: 46x32 transparent button, hovers at #FFFFFF22
(semi-translucent white) — used for minimize and maximize/restore.
- WindowCloseButton: same shape, hovers at #E81123 (Win11 red).
Three handlers wired up: OnMinimizeClick, OnMaxRestoreClick, OnCloseClick.
Caption click-through
---------------------
WindowChrome reserves the caption area for window dragging by default —
buttons inside it would otherwise feel "dead". Each interactive control
in the top bar (Vérifier les MAJ / Dossier / License pill / Activer /
Paramètres / min / max / close) gets
shell:WindowChrome.IsHitTestVisibleInChrome="True" so clicks reach the
button instead of starting a drag. The text/logo region remains
draggable.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The implicit Window style in Theme.xaml WAS being applied, but defensive
coding wins: when MainWindow.Background was unset, WPF's intermediate
composition surface for elements with Opacity<1 (the Image) blended
against whatever the parent presented. In some Windows render paths
that's effectively the system white. Setting Window.Background="Black"
AND Grid.Background="Black" pins the surface under the image to pure
black explicitly, no system theme involvement.
Also drop the previous "darken overlay" Rectangle — once the surface
under the image is genuinely black, the image at 0.25 opacity is
already where we want it, no second dimming layer needed.
Launcher subtitle: FontWeight Light → Bold so it competes equally with
PROSERVE in the Pirulen wordmark.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The background image was too dominant at 50% opacity over a black
window — bright spots of the engraved logo flooded the body and made
the chrome look milky. Reduce image opacity to 0.18 and add a 35%
black rectangle on top, dimming the image to roughly 12% effective
brightness while keeping the texture readable.
"Launcher" wordmark next to PROSERVE was barely visible (14pt, secondary
grey). Now 20pt, FontWeight Light, primary white — sits naturally
beside PROSERVE without competing.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>