Compare commits

...

27 Commits

Author SHA1 Message Date
c50abeb8d0 infos pass 2026-05-03 10:58:51 +02:00
fd1e9e9e6b gitignore : catch-all WebView2 cache and renamed launcher exes
- *.exe.WebView2/ : couvre les variantes de noms (PSLauncher.exe.WebView2
  legacy + PS_Launcher.exe.WebView2 actuel + tout build de test futur)
- /PS_Launcher*.exe : un seul glob pour PS_Launcher.exe, PS_Launcher.Updater.exe,
  PS_Launcher-X.Y.Z.exe, PS_Launcher_X.Y.Z.exe (séparateur tiret OU underscore)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 10:51:50 +02:00
886245cc61 v0.16.0 — ThemedMessageBox replacing native MessageBox everywhere
The Win32 MessageBox uses the system white-on-gray look that clashes
with the launcher's dark theme (jarring transition every time we
confirm a cancel, show an error, etc.). Replaced with a custom WPF
dialog that:

- Drops in : same Show(message, title, button, icon, default) API as
  System.Windows.MessageBox, returns the same MessageBoxResult.
- Looks native to the launcher : dark Brush.Bg.Window background,
  Brush.Border outline, Brush.Text.Primary content, AccentButton for
  the default action and SecondaryButton for the others.
- Iconography by emoji + colour code :  red (Error), ⚠ amber (Warning),
   neutral (Question), ℹ blue (Information). Mapped from MessageBoxImage.
- Buttons localised via Strings.ActionOk / ActionYes / ActionNo /
  ActionCancel — works in fr/en/zh/th/ar like the rest of the UI.
- Owner = currently active window (falls back to MainWindow then
  CenterScreen) so it positions correctly even from a child dialog.
- Esc resolves to Cancel/No matching MessageBox semantics.

The 21 MessageBox.Show call sites across MainViewModel, SettingsViewModel,
LicenseDetailsDialog and MainWindow now use ThemedMessageBox.Show with
no signature change — full grep replace + tiny `using` adjustments.

Versions bumped to 0.16.0.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 10:46:53 +02:00
42dd0701f5 Footer : drop the leftover Margin=200 hack
The 200px left margin was a workaround when the footer was at outer
Row=2 with no column constraint and had to dodge the sidebar visually.
Now that Row=2 + Col=1 cleanly cantons the footer in the content
column (commit ab40b62), the margin pushes everything 200px further
right and creates a black gap at the bottom-left of the content area.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 10:43:01 +02:00
ab40b62430 Layout : sidebar spans full window height, footer scoped to content column
Restructured the outer Grid to a proper 2×3 layout instead of stacked
rows-with-margins :

  Outer Grid       Col 0 (200)   |   Col 1 (*)
  Row 0 (Auto)   ───── Top bar (ColSpan 2) ─────
  Row 1 (*)        Sidebar      |  Content area
                   (RowSpan 2)  |  (Library / Report / Doc)
  Row 2 (Auto)                  |  Footer (only on Library)

Previously the sidebar lived inside an inner body Grid limited to Row 1,
and the footer was at outer Row 2 with a hand-crafted 200px left margin
to fake the sidebar carve-out — which left a visible footer-background
strip across the bottom and broke the visual continuity of the sidebar.

Now :
- Sidebar Grid.Row=1 + RowSpan=2 → reaches the bottom of the window
  regardless of footer visibility
- Footer Grid.Row=2 + Grid.Column=1 → cleanly cantonné dans la colonne
  content, ne déborde plus
- Top bar Grid.Row=0 + Grid.ColumnSpan=2 → toujours full width
- Background image limitée à Row=1 + Col=1 → ne déborde ni sous la
  sidebar ni sous le footer

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 10:38:07 +02:00
24d1841844 v0.15.0 — release notes Markdown : readable inline code
Bumps to consolidate the dark-theme fix for inline `code` in the
release notes viewer (commit 6d11b43) into a versioned client.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 10:33:23 +02:00
6d11b43d17 Markdown : restyle inline code for the dark theme
Markdig.Wpf's default inline-code style applies a light Background +
"fancy" Foreground that becomes a near-illegible mustard-on-charcoal
mess in our dark theme.

Detect inline code by its FontFamily (Consolas / Courier / Typewriter,
applied by Markdig's default style) and rebrand:
  Background = #1A1F2A  (dark, blends with card)
  Foreground = #CDD6E4  (soft off-white, easy contrast)
  FontFamily = Cascadia Code, Consolas, monospace

Affects both the UpdateAvailableDialog and the ReleaseNotesViewerDialog
since they share BuildThemedDocument.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 10:25:37 +02:00
89c78c9b37 1.5.3 release notes: switch to English
The project is multi-language with English support across the launcher
UI; release notes follow suit. Same content, English wording.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 10:22:41 +02:00
27fa793351 PROSERVE v1.5.3 release notes
Markdown rendered by the launcher (Markdig) in the "Mise à jour
disponible" dialog. Sections : Nouveautés, Améliorations, Corrections,
Migration, Notes — same template as 1.4.6.

Covers : MM CQB scenario, weapon persistence per session, stat icon
refresh, VR-controller-only quick calibration, instructor UI fix.

Also documents the 3 SQL migrations the launcher will run automatically
+ the report tool backup/revert mechanism, so support can point users
to the right Settings panel if anything goes sideways post-install.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 10:19:46 +02:00
d09deaddb7 build-proserve-zip.bat : zippe une release PROSERVE prête à uploader
Usage : build-proserve-zip.bat 1.5.3
  → lit version\proserve-1.5.3\
  → produit version\proserve-1.5.3.zip (binaires UE5 + _migrations + _report)
  → affiche taille + SHA-256

Auto-détecte 7-Zip s'il est installé (multi-thread, 3-5x plus rapide sur
14 Go). Fallback sur PowerShell Compress-Archive (mono-thread) sinon.

Sans argument : liste les versions disponibles dans version\.
Avec checks : warn si _migrations/ ou _report/ ou PROSERVE_UE_5_5.exe
manquent (continue après 5s).

Sortie pédagogique à la fin : prochaines étapes (SFTP upload + actions
backoffice à effectuer).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 10:12:58 +02:00
c706d09e72 Drop legacy PSLauncher.exe backward compat (not in prod yet)
Simplifies the rename done in ce3979d : PS_Launcher.exe is the only
name. Removes the dual-path lookup in LauncherSelfUpdater, the
duplicated taskkill blocks in the .bat scripts, the legacy patterns
in .gitignore, and the explanatory comments about the migration.

Cleaner code, single source of truth for the binary name.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 10:08:23 +02:00
ce3979d131 v0.14.0 — rename PSLauncher.exe → PS_Launcher.exe (with backward compat)
Aligns the executable name with the repo / product naming. Backward-
compatible: existing installs that have PSLauncher.exe on disk continue
to work — the self-updater overwrites the file at the running .exe path
without renaming, so the historical filename persists on those machines.

Changes
- AssemblyName : PSLauncher → PS_Launcher (both App and Updater csproj)
- Inno Setup : MyAppExeName, MyUpdaterExeName, OutputBaseFilename all
  now use PS_Launcher prefix
- LauncherSelfUpdater : looks for PS_Launcher.Updater.exe first, falls
  back to legacy PSLauncher.Updater.exe so old installs keep updating
- Build scripts (build-launcher / build-updater / build-installer /
  build-all) : taskkill both legacy AND new names; output paths printed
  with new names
- .gitignore : added PS_Launcher.exe / PS_Launcher.Updater.exe /
  PS_Launcher-*.exe patterns alongside the legacy ones; also ignored
  the WebView2 user-data folder and Office ~$ lock files
- Server admin/launcher.php : URL pattern now generates
  PS_Launcher-{ver}.exe ; SignManifest's existing tolerant glob
  *{ver}*.exe still matches both names
- Versions bumped to 0.14.0 (App + Updater + installer .iss)

Migration story for clients
- Brand-new install via PS_Launcher-Setup-0.14.0.exe → PS_Launcher.exe
  on disk
- Existing install (PSLauncher.exe) auto-updates to v0.14 → file stays
  named PSLauncher.exe but contains v0.14 code; self-updater fallback
  ensures future updates keep working

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 09:55:41 +02:00
64fbda0191 Footer : align under content area, not under the left sidebar
The footer was at outer Grid.Row=2 with no column constraint, so it
spanned the full window width — visually overflowing under the 200px
left sidebar. Added a 200px left margin so the footer starts where
the content area starts. Comment makes the magic-number coupling
explicit so it stays in sync if the sidebar width changes.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 09:43:02 +02:00
1acb735641 PROSERVE v1.5.3 migrations : idempotent SQLs (FK syntax fix)
Schema source-of-truth for the proserveapi DB, versioned outside the
release ZIPs (the actual binaries live in version/ but are gitignored).

0001_Init.sql — full bootstrap of proserveapi (12 tables + view +
reference data + FKs). Reworked from a phpMyAdmin dump to be safely
re-runnable on:
  - fresh install (everything created)
  - existing customer DB pre-populated manually before launcher rollout
  - re-import via phpMyAdmin or mysql CLI
Idempotency tools used:
  - CREATE TABLE IF NOT EXISTS
  - INSERT IGNORE INTO ...  for reference rows
  - ADD PRIMARY KEY / KEY / UNIQUE KEY IF NOT EXISTS
  - ADD FOREIGN KEY IF NOT EXISTS `name` (...)  REFERENCES ...
    (note: MariaDB does NOT accept `ADD CONSTRAINT IF NOT EXISTS name
     FOREIGN KEY ...` — the IF NOT EXISTS goes after FOREIGN KEY, not
     after CONSTRAINT)
  - CREATE OR REPLACE VIEW
  - Removed the dump's `START TRANSACTION` / `COMMIT` (the launcher
    wraps each script in its own tx; embedded BEGIN/COMMIT would break
    the rollback semantics)
  - Removed `DEFINER=root@localhost` (uses CURRENT_USER, more portable)

0002_SessionType_ps_1.4.5.sql — adds id=7 'LongRange' + renames id=6
to 'Rescue'. Now uses INSERT IGNORE for the new row (skips if 0001
already inserted it on a fresh install) and the UPDATE is naturally
idempotent.

0003_UserSize_ps_1.5.0.sql — adds users.size column. Now uses
ADD COLUMN IF NOT EXISTS (the column is also in the recent 0001
baseline, so on a fresh install this will skip; on an upgrade from a
pre-0003 DB it actually adds it).

Validated locally on MariaDB 10.4 (XAMPP) with two consecutive runs of
each file: exit code 0 on every run, no errors.

.gitignore tightened so version/ only tracks _migrations/*.sql — the
~14 GB UE5 binaries and the _report/ build artifacts stay out of git.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 09:35:14 +02:00
562b4e5ed0 v0.13.0 — Report tool : timestamped backups + manual revert
The previous deploy mechanism kept the old version under {target}.old
just long enough to swap, then deleted it — no way back if the new
report had a runtime bug only visible at use time.

Now each deploy keeps the previous active version under
{FolderName}.backup-yyyyMMdd-HHmmss/. The N most recent are kept
(MaxBackups, default 3) and older ones are pruned in best-effort.

UI in Settings → Avancés → Outil Report :
- "Conserver N backups" input (0 = no history, back to v0.12 behavior)
- List of backups with date + size + cryptic folder name + per-row "↶ Revert"
- Confirmation dialog: "Revert to {date}? The currently deployed
  version will itself be saved as a new backup, so you can switch back."

Implementation
- IReportToolDeployer: + ListBackupsAsync, + RevertAsync, + BackupInfo,
  + DeployStatus.BackupNotFound.
- ReportToolDeployer: deploy renames {target} → backup-{ts}; PruneOldBackups
  trims to MaxBackups; RevertAsync atomically swaps current ↔ chosen
  backup, the previous current becoming itself a fresh backup.
- ReportBackupViewModel + DataTemplate for the list rows.
- Strings: backup labels, "↶ Revert", confirmation message.

Caveats documented in the design discussion:
- Backups cover ONLY the report tool files. DB migrations are not reverted
  (irreversible). If a migration broke the schema, that's a separate fix.
- No HTTP-HEAD post-deploy auto-revert: too fragile for false positives.
  Revert stays a deliberate manual action.

Versions bumped to 0.13.0.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 09:15:09 +02:00
67f422539d v0.12.0 — Report tool auto-deploy from PROSERVE ZIP
Each PROSERVE ZIP can ship a _report/ subfolder alongside _migrations/.
After install + DB migration, the launcher copies _report/ to the local
XAMPP htdocs (default C:\xampp\htdocs\ProserveReport\) so the Reports
tab in the sidebar always serves the matching version.

Service (PSLauncher.Core/ReportTool)
- IReportToolDeployer + ReportToolDeployer.
- Atomic deploy: copy to {target}.new/ → rename current to .old/ →
  rename .new → final → cleanup .old. Apache never sees a half-state.
- Skip silencieux si _report/ absent du ZIP (PROSERVE release qui ne
  touche pas au Report).
- DeployStatus enum (SkippedNoSourceFolder, XamppNotFound, Failed,
  Deployed) renvoyé pour gestion UI claire.

Config (LocalConfig)
- ReportToolConfig (HtdocsRoot, FolderName, AutoDeploy). Defaults
  matchent l'install standard ASTERION (C:\xampp\htdocs\ProserveReport).

Install pipeline (MainViewModel)
- Étape 6 après extraction + migrations. Progress reporté via le footer
  Library : « 📂 Déploiement Report : 47/120 — assets/main.js ».
  XAMPP introuvable → dialog avec lien vers Settings → Avancés.

Settings UI (SettingsDialog → Avancés)
- Nouveau bloc « OUTIL REPORT (XAMPP htdocs) » : 2 champs path + checkbox
  AutoDeploy + bouton « 📂 Re-déployer maintenant » qui rejoue le deploy
  sur la dernière version installée. Utile après changement de chemin
  htdocs ou si l'auto-deploy avait foiré (XAMPP éteint).

Versions bumped to 0.12.0 (App + Updater + installer .iss).

Côté release : tu ajoutes _report/ dans le source de PROSERVE, tu zippes,
tu uploades. Les clients récupèrent ZIP + migrations + Report en une
seule install.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 09:05:40 +02:00
a2e67d5405 Footer is Library-only: hide on Report/Documentation tabs
The download/install progress footer made no visual sense on the
WebView pages — it would float above the embedded site. It now only
appears when CurrentPage = Library. The DL itself keeps running in
the background while the user browses Reports or Docs; switching
back to Library restores the live footer.

Implementation: FooterVisibility now ANDs with IsLibrary, and
CurrentPage notifies FooterVisibility/FooterText so the footer
collapses/reveals on tab switch.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 09:00:26 +02:00
7a09f73c44 Sidebar: move from right to left
Left-sidebar layout matches the convention of Steam, Discord, Spotify,
VSCode, etc. — nav anchored to the leading edge. Also feels less
intrusive: in a Library-centric default view the eye lands on the hero
card first, sidebar fades into the chrome on the left.

Just swaps the two ColumnDefinitions, the Grid.Column attributes and
the Border's BorderThickness (left edge → right edge of the sidebar).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 08:56:07 +02:00
6d251e9eac v0.11.0 — right sidebar nav with Reports / Documentation tabs
User-visible feature change (sidebar appears on every screen) deserves
a minor bump rather than a patch. WebView2 dependency adds ~5 MB to the
single-file exe (the Edge runtime itself is system-installed, not
shipped).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 08:53:14 +02:00
288cad585a Right sidebar nav: Library / Reports / Documentation tabs (WebView2)
The body of the launcher is now a 2-column layout:
- Left : swappable content area
- Right (200px) : sidebar with 3 nav buttons + active-state highlighting

Three pages, all under the same window chrome / footer:

1. **Library** (default) — the existing main view (featured version,
   other versions list, floating "Vérifier les MAJ" button, copyright).
2. **Reports** — embedded WebView2 navigated to a configurable URL.
   Default http://localhost/ProserveReport/ which matches the local
   stats tool installed on each customer machine alongside XAMPP.
3. **Documentation** — WebView2 with a configurable URL, falls back
   to a placeholder explaining where to set it if empty.

Implementation
- LocalConfig gains ReportUrl + DocumentationUrl (string).
- MainViewModel gains LauncherPage enum + CurrentPage state with
  Navigate{Library,Report,Documentation}Command. ReportUri /
  DocumentationUri parse the strings to Uri for WebView2.Source.
- Theme.xaml: NavButton style (transparent, left-aligned, hover
  darken, active state highlighted via Tag bool + accent left-border).
- InverseBoolToVisibilityConverter added (true → Collapsed) for the
  documentation placeholder fallback.
- Microsoft.Web.WebView2 NuGet (1.0.2792.45). Runtime is pre-installed
  on Win11 and auto-pushed via Windows Update on Win10. If absent,
  WebView2 surfaces an error which the user sees inline.
- Settings → Avancés → Serveur extended with the two URL fields.
- Strings.cs: NavLibrary / NavReport / NavDocumentation,
  SettingsReportUrl / SettingsDocsUrl, DocsPlaceholder, WebViewLoadError.
  Sidebar localized in 5 languages.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 08:52:51 +02:00
f62b212fc1 Settings: About goes above Advanced (Advanced is the last block)
Reorders the bottom of the Settings dialog so the "À propos" section
(version + copyright) sits between Langue and the collapsed Advanced
expander. The Expander becomes the very last item, since power-user
plumbing is naturally what the eye should reach last.

DB default name was already set to "proserveapi" in the previous commit
(LocalConfig.cs line 64) — re-confirmed.

Final order:
  1. License de mise à jour       [expanded]
  2. Langue                        [expanded]
  3. À propos                      [expanded]
  4. ▸ Paramètres avancés          [collapsed]

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 08:41:43 +02:00
f86895553a Settings: collapse advanced sections + dedicated About + default DB name
- DB default name: "proserve" → "proserveapi" (matches the ASTERION install
  script that provisions XAMPP on client machines).
- Settings dialog reorganized:
   1. License de mise à jour     [expanded] ← business-critical
   2. Langue                     [expanded] ← user preference
   3. ▸ PARAMÈTRES AVANCÉS       [collapsed by default]
        contains Server, Installation, Cache, Database, Logs
   4. À PROPOS                   [expanded] ← version + copyright
- The Expander hides plumbing the casual user shouldn't touch (server URL,
  install root, MySQL config, cache directory) but keeps it one click away
  for power users / support diagnostics.
- About card kept always-visible because version info is what support asks
  for first when troubleshooting.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 08:36:57 +02:00
1473e5185c v0.10.0 — DB migration support
Bumps version across the three places it lives:
- PSLauncher.App.csproj : 0.9.0 → 0.10.0
- PSLauncher.Updater.csproj : 0.8.0 → 0.10.0 (catch-up; the updater
  hadn't been bumped since 0.8 — version doesn't gate behavior, just
  matches the .exe metadata)
- installer/PSLauncher.iss : 0.8.0 → 0.10.0 (installer artifact name
  PSLauncher-Setup-0.10.0.exe)

This release adds bundled SQL migrations applied automatically after
each PROSERVE install, so a new build that needs schema changes ships
the .sql alongside its binaries and the launcher rolls them out without
manual intervention.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 08:29:02 +02:00
333eb24db6 LicenseDetailsDialog: header wraps long subtitles + auto-resize
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>
2026-05-03 08:25:56 +02:00
750bb8cfc9 Settings: Database section with connection params, test, and replay button
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>
2026-05-03 08:25:32 +02:00
6f8a320d69 DB migrations: bundled SQL applied automatically after install
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>
2026-05-03 08:21:53 +02:00
48d601176d admin/licenses : per-machine view with individual remove
- 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>
2026-05-03 07:57:01 +02:00
38 changed files with 2883 additions and 109 deletions

30
.gitignore vendored
View File

@@ -28,6 +28,17 @@ packages/
# Large game build (sample only — should not be in repo) # Large game build (sample only — should not be in repo)
ASTERION_VR/ ASTERION_VR/
# Sources des releases PROSERVE en attente de zip/upload :
# on ignore tout sauf les _migrations/ (SQL source-of-truth de la DB).
# Le _report/ est un build artifact (Vue/React minifié) maintenu ailleurs.
# Les binaires UE5 sont gigaoctets, à pousser uniquement via SFTP.
version/*
!version/.gitkeep
!version/*/
version/*/*
!version/*/_migrations/
version/*/_migrations/.htaccess
# Server-side secrets — never commit (config.example.php sert de template) # Server-side secrets — never commit (config.example.php sert de template)
server/api/config.php server/api/config.php
# Le contenu de server/builds/ est ignoré (gros ZIPs / launcher.exe), mais on # Le contenu de server/builds/ est ignoré (gros ZIPs / launcher.exe), mais on
@@ -41,11 +52,20 @@ server/builds/*
# Plans # Plans
.claude/ .claude/
# Binaires copiés par le post-publish à la racine du repo # Binaires copiés par le post-publish à la racine du repo (glob unique qui
/PSLauncher.exe # couvre PS_Launcher.exe, PS_Launcher.Updater.exe, PS_Launcher-X.Y.Z.exe,
/PSLauncher.Updater.exe # PS_Launcher_X.Y.Z.exe — séparateur tiret ou underscore — et les .bak).
/PSLauncher-*.exe /PS_Launcher*.exe
/PSLauncher*.bak /PS_Launcher*.bak
# Sortie de l'installeur Inno Setup # Sortie de l'installeur Inno Setup
/installer/output/ /installer/output/
# WebView2 user data folder créé à côté de l'exe au runtime (cache, cookies,
# sync data Edge Chromium). Peut faire plusieurs Mo. Le pattern * couvre les
# 2 noms historiques (PSLauncher.exe.WebView2, PS_Launcher.exe.WebView2) et
# tout build de test ponctuel avec un autre nom d'exe.
*.exe.WebView2/
# Fichiers de lock Office (Word/PowerPoint) commençant par ~$
~$*

View File

@@ -6,8 +6,8 @@ setlocal
set "REPO=%~dp0" set "REPO=%~dp0"
echo. echo.
echo ==^> Termine les eventuelles instances qui verrouilleraient les .exe echo ==^> Termine les eventuelles instances qui verrouilleraient les .exe
taskkill /F /IM PSLauncher.exe /T >nul 2>&1 taskkill /F /IM PS_Launcher.exe /T >nul 2>&1
taskkill /F /IM PSLauncher.Updater.exe /T >nul 2>&1 taskkill /F /IM PS_Launcher.Updater.exe /T >nul 2>&1
echo. echo.
echo ==^> dotnet publish PSLauncher.App (Release single-file) echo ==^> dotnet publish PSLauncher.App (Release single-file)
echo. echo.
@@ -22,8 +22,8 @@ if %ERRORLEVEL% NEQ 0 goto :fail
echo. echo.
echo [OK] Les deux exes sont disponibles a la racine du repo : echo [OK] Les deux exes sont disponibles a la racine du repo :
echo %REPO%PSLauncher.exe echo %REPO%PS_Launcher.exe
echo %REPO%PSLauncher.Updater.exe echo %REPO%PS_Launcher.Updater.exe
echo. echo.
pause pause
endlocal & exit /b 0 endlocal & exit /b 0

View File

@@ -13,8 +13,8 @@ setlocal
set "SCRIPT_DIR=%~dp0" set "SCRIPT_DIR=%~dp0"
REM Termine les eventuelles instances en cours qui verrouilleraient les .exe REM Termine les eventuelles instances en cours qui verrouilleraient les .exe
taskkill /F /IM PSLauncher.exe /T >nul 2>&1 taskkill /F /IM PS_Launcher.exe /T >nul 2>&1
taskkill /F /IM PSLauncher.Updater.exe /T >nul 2>&1 taskkill /F /IM PS_Launcher.Updater.exe /T >nul 2>&1
powershell -NoProfile -ExecutionPolicy Bypass -File "%SCRIPT_DIR%installer\build-installer.ps1" %* powershell -NoProfile -ExecutionPolicy Bypass -File "%SCRIPT_DIR%installer\build-installer.ps1" %*
set "EXITCODE=%ERRORLEVEL%" set "EXITCODE=%ERRORLEVEL%"

View File

@@ -1,13 +1,13 @@
@echo off @echo off
REM Build du launcher principal (PSLauncher.exe). REM Build du launcher principal (PS_Launcher.exe).
REM Sortie : C:\ASTERION\GIT\PS_Launcher\PSLauncher.exe (~78 Mo single-file self-contained) REM Sortie : C:\ASTERION\GIT\PS_Launcher\PS_Launcher.exe (~78 Mo single-file self-contained)
setlocal setlocal
set "REPO=%~dp0" set "REPO=%~dp0"
echo. echo.
echo ==^> Termine les eventuelles instances de PSLauncher qui verrouilleraient les .exe echo ==^> Termine les eventuelles instances de PS_Launcher qui verrouilleraient les .exe
taskkill /F /IM PSLauncher.exe /T >nul 2>&1 taskkill /F /IM PS_Launcher.exe /T >nul 2>&1
taskkill /F /IM PSLauncher.Updater.exe /T >nul 2>&1 taskkill /F /IM PS_Launcher.Updater.exe /T >nul 2>&1
echo. echo.
echo ==^> dotnet publish PSLauncher.App (Release single-file) echo ==^> dotnet publish PSLauncher.App (Release single-file)
echo. echo.
@@ -19,9 +19,9 @@ echo.
if %EXITCODE% NEQ 0 ( if %EXITCODE% NEQ 0 (
echo [ECHEC] Build du launcher echoue avec le code %EXITCODE%. echo [ECHEC] Build du launcher echoue avec le code %EXITCODE%.
) else ( ) else (
echo [OK] PSLauncher.exe disponible a la racine du repo : echo [OK] PS_Launcher.exe disponible a la racine du repo :
echo %REPO%PSLauncher.exe echo %REPO%PS_Launcher.exe
) )
echo. echo.
pause pause
endlocal & exit /b %EXITCODE% endlocal ^& exit /b %EXITCODE%

137
build-proserve-zip.bat Normal file
View File

@@ -0,0 +1,137 @@
@echo off
REM ========================================================================
REM build-proserve-zip.bat
REM Crée le ZIP de release d'une version PROSERVE prêt à uploader sur OVH.
REM
REM Usage :
REM build-proserve-zip.bat 1.5.3
REM
REM Le script :
REM 1. Lit version\proserve-X.Y.Z\ (binaires UE5 + _migrations\ + _report\)
REM 2. Compresse en version\proserve-X.Y.Z.zip
REM 3. Affiche taille + SHA-256 (à recopier dans le backoffice si tu veux
REM eviter le hash automatique côté serveur, sinon laisse le serveur le
REM faire au moment du « Hasher + signer »)
REM
REM Outils :
REM - 7-Zip si installé (multi-threading, ~3-5x plus rapide sur 14 Go)
REM - PowerShell Compress-Archive en fallback (mono-thread, lent)
REM
REM Sortie : version\proserve-X.Y.Z.zip à uploader vers
REM www/PS_Launcher/builds/proserve-X.Y.Z.zip via SFTP.
REM ========================================================================
setlocal EnableDelayedExpansion
if "%~1"=="" (
echo.
echo Usage : build-proserve-zip.bat ^<version^>
echo Exemple : build-proserve-zip.bat 1.5.3
echo.
echo Versions disponibles dans version\ :
for /D %%D in ("%~dp0version\proserve-*") do echo %%~nxD
echo.
exit /b 1
)
set "VERSION=%~1"
set "REPO=%~dp0"
set "SRC=%REPO%version\proserve-%VERSION%"
set "OUT=%REPO%version\proserve-%VERSION%.zip"
if not exist "%SRC%" (
echo [ERREUR] Dossier source introuvable : %SRC%
echo Liste des versions disponibles :
for /D %%D in ("%REPO%version\proserve-*") do echo %%~nxD
exit /b 1
)
REM Sanity-check que les sous-dossiers attendus sont là
if not exist "%SRC%\PROSERVE_UE_5_5.exe" (
echo [WARN] %SRC%\PROSERVE_UE_5_5.exe absent — c'est normal ? Continuer dans 5s, Ctrl+C pour annuler...
timeout /t 5 >nul
)
if not exist "%SRC%\_migrations" (
echo [WARN] %SRC%\_migrations\ absent — pas de migrations DB dans cette release.
)
if not exist "%SRC%\_report" (
echo [WARN] %SRC%\_report\ absent — pas d'outil Report dans cette release.
)
if exist "%OUT%" (
echo [INFO] Suppression du zip existant : %OUT%
del /Q "%OUT%"
)
REM ----- 7-Zip si disponible -----
set "SEVENZIP="
if exist "C:\Program Files\7-Zip\7z.exe" set "SEVENZIP=C:\Program Files\7-Zip\7z.exe"
if exist "C:\Program Files (x86)\7-Zip\7z.exe" set "SEVENZIP=C:\Program Files (x86)\7-Zip\7z.exe"
echo.
echo ========================================================================
echo Compression de proserve-%VERSION% (peut prendre 5-30 min selon la taille)
echo Source : %SRC%
echo Sortie : %OUT%
echo ========================================================================
echo.
if defined SEVENZIP (
echo [INFO] Utilisation de 7-Zip multi-thread (rapide)
REM -tzip format ZIP standard ^(compatible avec ce que le launcher attend^)
REM -mx=5 compression normale ^(mx=9 ultra serait 2x plus lent pour ~5%% de gain^)
REM -mmt=on multi-thread
REM -bsp1 progress sur stderr
REM Le * en fin de chemin source = on zippe le CONTENU du dossier, pas le dossier lui-meme
"%SEVENZIP%" a -tzip -mx=5 -mmt=on -bsp1 "%OUT%" "%SRC%\*"
set "EXITCODE=!ERRORLEVEL!"
) else (
echo [INFO] 7-Zip non trouve, fallback sur PowerShell Compress-Archive ^(lent^).
echo Pour accelerer, installe 7-Zip depuis https://www.7-zip.org/
echo.
powershell -NoProfile -ExecutionPolicy Bypass -Command ^
"Compress-Archive -Path '%SRC%\*' -DestinationPath '%OUT%' -CompressionLevel Optimal -Force"
set "EXITCODE=!ERRORLEVEL!"
)
if !EXITCODE! NEQ 0 (
echo.
echo [ECHEC] Compression echouee avec le code !EXITCODE!.
exit /b !EXITCODE!
)
if not exist "%OUT%" (
echo [ECHEC] Le fichier %OUT% n'a pas ete cree.
exit /b 1
)
echo.
echo ========================================================================
echo [OK] Zip cree avec succes
echo ========================================================================
echo.
echo Fichier : %OUT%
for %%I in ("%OUT%") do (
set "SIZE=%%~zI"
echo Taille : !SIZE! octets ^(~%%~zI bytes^)
)
echo.
echo Calcul du SHA-256 ^(peut prendre 30s-2min selon la taille^)...
powershell -NoProfile -Command ^
"$h = (Get-FileHash '%OUT%' -Algorithm SHA256).Hash.ToLower(); Write-Host SHA-256 : $h"
echo.
echo ========================================================================
echo Prochaines etapes :
echo 1. SFTP upload vers : www/PS_Launcher/builds/proserve-%VERSION%.zip
echo 2. Backoffice -^> Versions -^> verifier que l'entree v%VERSION% existe
echo ^(sinon ajoute-la avec date d'expiration min_license_date^)
echo 3. Cliquer ligne v%VERSION% -^> bouton "Hasher cette version"
echo ^(ou bouton global "Hasher les versions + signer"^)
echo 4. Le manifest est re-signe Ed25519, les clients verront la version
echo au prochain "Verifier les MAJ".
echo ========================================================================
echo.
endlocal
pause

View File

@@ -1,14 +1,14 @@
@echo off @echo off
REM Build de l'updater (PSLauncher.Updater.exe). REM Build de l'updater (PS_Launcher.Updater.exe).
REM Sortie : C:\ASTERION\GIT\PS_Launcher\PSLauncher.Updater.exe (~34 Mo single-file) REM Sortie : C:\ASTERION\GIT\PS_Launcher\PS_Launcher.Updater.exe (~34 Mo single-file)
REM L'updater accompagne PSLauncher.exe — il doit etre dans le meme dossier. REM L'updater accompagne PS_Launcher.exe — il doit etre dans le meme dossier.
setlocal setlocal
set "REPO=%~dp0" set "REPO=%~dp0"
echo. echo.
echo ==^> Termine les eventuelles instances qui verrouilleraient les .exe echo ==^> Termine les eventuelles instances qui verrouilleraient les .exe
taskkill /F /IM PSLauncher.exe /T >nul 2>&1 taskkill /F /IM PS_Launcher.exe /T >nul 2>&1
taskkill /F /IM PSLauncher.Updater.exe /T >nul 2>&1 taskkill /F /IM PS_Launcher.Updater.exe /T >nul 2>&1
echo. echo.
echo ==^> dotnet publish PSLauncher.Updater (Release single-file) echo ==^> dotnet publish PSLauncher.Updater (Release single-file)
echo. echo.
@@ -20,9 +20,9 @@ echo.
if %EXITCODE% NEQ 0 ( if %EXITCODE% NEQ 0 (
echo [ECHEC] Build de l'updater echoue avec le code %EXITCODE%. echo [ECHEC] Build de l'updater echoue avec le code %EXITCODE%.
) else ( ) else (
echo [OK] PSLauncher.Updater.exe disponible a la racine du repo : echo [OK] PS_Launcher.Updater.exe disponible a la racine du repo :
echo %REPO%PSLauncher.Updater.exe echo %REPO%PS_Launcher.Updater.exe
) )
echo. echo.
pause pause
endlocal & exit /b %EXITCODE% endlocal ^& exit /b %EXITCODE%

View File

@@ -4,18 +4,18 @@
; "C:\Program Files (x86)\Inno Setup 6\ISCC.exe" PSLauncher.iss ; "C:\Program Files (x86)\Inno Setup 6\ISCC.exe" PSLauncher.iss
; ;
; Avant compile : ; Avant compile :
; 1. Faire `dotnet publish src/PSLauncher.App -c Release` (génère PSLauncher.exe ~77 Mo) ; 1. Faire `dotnet publish src/PSLauncher.App -c Release` (génère PS_Launcher.exe ~77 Mo)
; 2. Faire `dotnet publish src/PSLauncher.Updater -c Release` (génère PSLauncher.Updater.exe ~10 Mo) ; 2. Faire `dotnet publish src/PSLauncher.Updater -c Release` (génère PS_Launcher.Updater.exe ~10 Mo)
; ;
; Le script picore directement les binaires dans bin/Release/.../publish/. ; Le script picore directement les binaires dans bin/Release/.../publish/.
#define MyAppName "PROSERVE Launcher" #define MyAppName "PROSERVE Launcher"
#define MyAppShortName "PSLauncher" #define MyAppShortName "PS_Launcher"
#define MyAppVersion "0.8.0" #define MyAppVersion "0.16.0"
#define MyAppPublisher "ASTERION VR" #define MyAppPublisher "ASTERION VR"
#define MyAppURL "https://asterionvr.com" #define MyAppURL "https://asterionvr.com"
#define MyAppExeName "PSLauncher.exe" #define MyAppExeName "PS_Launcher.exe"
#define MyUpdaterExeName "PSLauncher.Updater.exe" #define MyUpdaterExeName "PS_Launcher.Updater.exe"
[Setup] [Setup]
; AppId est UNIQUE pour PSLauncher — ne pas changer entre deux versions sinon ; AppId est UNIQUE pour PSLauncher — ne pas changer entre deux versions sinon
@@ -29,12 +29,12 @@ AppPublisherURL={#MyAppURL}
AppSupportURL={#MyAppURL} AppSupportURL={#MyAppURL}
AppUpdatesURL={#MyAppURL} AppUpdatesURL={#MyAppURL}
; Installation user-mode (pas d'admin requis) : le launcher pourra se mettre à jour ; Installation user-mode (pas d'admin requis) : le launcher pourra se mettre à jour
; tout seul via PSLauncher.Updater.exe sans déclencher de UAC. ; tout seul via PS_Launcher.Updater.exe sans déclencher de UAC.
DefaultDirName={localappdata}\Programs\{#MyAppPublisher}\{#MyAppShortName} DefaultDirName={localappdata}\Programs\{#MyAppPublisher}\{#MyAppShortName}
DefaultGroupName={#MyAppName} DefaultGroupName={#MyAppName}
DisableProgramGroupPage=yes DisableProgramGroupPage=yes
OutputDir=output OutputDir=output
OutputBaseFilename=PSLauncher-Setup-{#MyAppVersion} OutputBaseFilename=PS_Launcher-Setup-{#MyAppVersion}
SetupIconFile=..\src\PSLauncher.App\Resources\favicon.ico SetupIconFile=..\src\PSLauncher.App\Resources\favicon.ico
Compression=lzma2/ultra Compression=lzma2/ultra
SolidCompression=yes SolidCompression=yes

View File

@@ -19,3 +19,6 @@ ndom_bytes(32)) . PHP_EOL;'hmac_secret = 4343ec086ef4fafb7c0a575e56ac1b83b5eb29c
Secret2: jwt_secret Secret2: jwt_secret
php -r 'echo "jwt_secret = " . bin2hex(random_bytes(32)) . PHP_EOL;' php -r 'echo "jwt_secret = " . bin2hex(random_bytes(32)) . PHP_EOL;'
jwt_secret = 3bf50765f8af638c143c137e0f1ec539db0bd761a11af602db0a5c000a3872ea jwt_secret = 3bf50765f8af638c143c137e0f1ec539db0bd761a11af602db0a5c000a3872ea
Clé : PRSRV-WY4S-S485-D2WD-D7YP
Clé2 : PRSRV-V652-MPFX-7FJ3-L9QJ

View File

@@ -53,14 +53,14 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
'version' => $lver, 'version' => $lver,
'minRequired' => $lmin !== '' ? $lmin : '0.5.0', 'minRequired' => $lmin !== '' ? $lmin : '0.5.0',
'download' => [ 'download' => [
'url' => "{$base}/builds/launcher/PSLauncher-{$lver}.exe", 'url' => "{$base}/builds/launcher/PS_Launcher-{$lver}.exe",
'sizeBytes' => 0, 'sizeBytes' => 0,
'sha256' => 'REPLACE_AFTER_BUILD', 'sha256' => 'REPLACE_AFTER_BUILD',
], ],
'releaseNotesUrl' => null, 'releaseNotesUrl' => null,
]; ];
saveManifest($manifestPath, $manifest); saveManifest($manifestPath, $manifest);
$message = "Section launcher mise à jour (v{$lver}). Upload PSLauncher-{$lver}.exe dans builds/launcher/ puis clique « Hasher le launcher + signer »."; $message = "Section launcher mise à jour (v{$lver}). Upload PS_Launcher-{$lver}.exe dans builds/launcher/ puis clique « Hasher le launcher + signer ».";
} }
elseif ($action === 'remove_launcher') { elseif ($action === 'remove_launcher') {
unset($manifest['launcher']); unset($manifest['launcher']);
@@ -130,7 +130,7 @@ Layout::header('Launcher', 'launcher');
<ol class="muted"> <ol class="muted">
<li>Remplis le formulaire <strong>Définir / mettre à jour</strong> ci-dessous (ex : <code>0.6.0</code>).</li> <li>Remplis le formulaire <strong>Définir / mettre à jour</strong> ci-dessous (ex : <code>0.6.0</code>).</li>
<li>Sur ton poste : <code>dotnet publish src\PSLauncher.App -c Release</code> et <code>dotnet publish src\PSLauncher.Updater -c Release</code> (les exes sont copiés à la racine du repo).</li> <li>Sur ton poste : <code>dotnet publish src\PSLauncher.App -c Release</code> et <code>dotnet publish src\PSLauncher.Updater -c Release</code> (les exes sont copiés à la racine du repo).</li>
<li>Renomme la copie : <code>PSLauncher.exe → PSLauncher-X.Y.Z.exe</code>.</li> <li>Renomme la copie : <code>PS_Launcher.exe → PS_Launcher-X.Y.Z.exe</code>.</li>
<li>Upload via SFTP dans <code>www/PS_Launcher/builds/launcher/</code>.</li> <li>Upload via SFTP dans <code>www/PS_Launcher/builds/launcher/</code>.</li>
<li>Clique <strong>🔁 Hasher le launcher + signer</strong>.</li> <li>Clique <strong>🔁 Hasher le launcher + signer</strong>.</li>
<li>Les launchers déjà déployés détecteront la nouvelle version au prochain « Vérifier les MAJ ».</li> <li>Les launchers déjà déployés détecteront la nouvelle version au prochain « Vérifier les MAJ ».</li>

View File

@@ -78,7 +78,21 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
elseif ($action === 'reset_machines') { elseif ($action === 'reset_machines') {
$id = (int)($_POST['id'] ?? 0); $id = (int)($_POST['id'] ?? 0);
$db->prepare('DELETE FROM license_machines WHERE license_id = ?')->execute([$id]); $db->prepare('DELETE FROM license_machines WHERE license_id = ?')->execute([$id]);
$message = "Machines libérées pour la license #{$id}."; $message = "Toutes les machines libérées pour la license #{$id}.";
}
elseif ($action === 'remove_machine') {
$licenseId = (int)($_POST['license_id'] ?? 0);
$machineId = trim((string)($_POST['machine_id'] ?? ''));
if ($licenseId <= 0 || $machineId === '') {
throw new Exception('license_id et machine_id requis');
}
$stmt = $db->prepare('DELETE FROM license_machines WHERE license_id = ? AND machine_id = ?');
$stmt->execute([$licenseId, $machineId]);
$count = $stmt->rowCount();
$shortId = substr($machineId, 0, 12);
$message = $count > 0
? "Machine {$shortId}… libérée pour la license #{$licenseId}."
: "Aucune machine correspondante trouvée pour license #{$licenseId}.";
} }
} catch (Exception $e) { } catch (Exception $e) {
$message = $e->getMessage(); $message = $e->getMessage();
@@ -93,6 +107,18 @@ $licenses = $db->query(
ORDER BY l.id DESC' ORDER BY l.id DESC'
)->fetchAll(); )->fetchAll();
// Pré-fetch toutes les machines de toutes les licenses en une seule requête,
// indexées par license_id pour l'affichage par-row sans N+1.
$allMachines = $db->query(
'SELECT license_id, machine_id, machine_label, first_seen, last_seen
FROM license_machines
ORDER BY last_seen DESC'
)->fetchAll();
$machinesByLicense = [];
foreach ($allMachines as $m) {
$machinesByLicense[(int)$m['license_id']][] = $m;
}
Layout::header('Licenses', 'licenses'); Layout::header('Licenses', 'licenses');
?> ?>
<h1>Licenses</h1> <h1>Licenses</h1>
@@ -165,7 +191,17 @@ Layout::header('Licenses', 'licenses');
</td> </td>
<td class="muted"><?= date('d/m/Y', strtotime($l['issued_at'])) ?></td> <td class="muted"><?= date('d/m/Y', strtotime($l['issued_at'])) ?></td>
<td><?= date('d/m/Y', strtotime($l['download_entitlement_until'])) ?></td> <td><?= date('d/m/Y', strtotime($l['download_entitlement_until'])) ?></td>
<td><?= $l['machines_count'] ?> / <?= $l['max_machines'] ?></td> <td>
<?php if ($l['machines_count'] > 0): ?>
<a href="#" onclick="document.getElementById('machines-<?= $l['id'] ?>').open = !document.getElementById('machines-<?= $l['id'] ?>').open; return false;"
style="color: var(--text); text-decoration: none; cursor: pointer;"
title="Cliquer pour voir les machines">
<strong><?= $l['machines_count'] ?></strong> / <?= $l['max_machines'] ?> ▾
</a>
<?php else: ?>
0 / <?= $l['max_machines'] ?>
<?php endif; ?>
</td>
<td><span class="badge <?= $cls ?>"><?= $status ?></span></td> <td><span class="badge <?= $cls ?>"><?= $status ?></span></td>
<td style="text-align:right; white-space: nowrap;"> <td style="text-align:right; white-space: nowrap;">
<details style="display: inline-block; margin: 0 4px;"> <details style="display: inline-block; margin: 0 4px;">
@@ -179,11 +215,11 @@ Layout::header('Licenses', 'licenses');
</form> </form>
</details> </details>
<?php if ($l['machines_count'] > 0): ?> <?php if ($l['machines_count'] > 0): ?>
<form method="post" style="display:inline" onsubmit="return confirm('Libérer toutes les machines de cette license ?')"> <form method="post" style="display:inline" onsubmit="return confirm('Libérer TOUTES les machines de cette license ?\n\nUtilise plutôt « Voir machines » ci-dessous pour libérer un slot précis.')">
<?= Layout::csrfField() ?> <?= Layout::csrfField() ?>
<input type="hidden" name="action" value="reset_machines"> <input type="hidden" name="action" value="reset_machines">
<input type="hidden" name="id" value="<?= $l['id'] ?>"> <input type="hidden" name="id" value="<?= $l['id'] ?>">
<button class="btn btn-secondary" type="submit" title="Libère les slots machines occupés">Libérer machines</button> <button class="btn btn-secondary" type="submit" title="Libère TOUS les slots machines de cette license d'un coup">Libérer toutes</button>
</form> </form>
<?php endif; ?> <?php endif; ?>
<?php if (!$isRevoked): ?> <?php if (!$isRevoked): ?>
@@ -203,6 +239,73 @@ Layout::header('Licenses', 'licenses');
<?php endif; ?> <?php endif; ?>
</td> </td>
</tr> </tr>
<?php
// Sous-row dépliable : liste des machines avec remove individuel.
// Le <details> est piloté par le clic sur la cellule "X / Y" ci-dessus.
$machines = $machinesByLicense[(int)$l['id']] ?? [];
if (!empty($machines)):
?>
<tr>
<td colspan="7" style="padding: 0;">
<details id="machines-<?= $l['id'] ?>" style="margin: 0;">
<summary style="display: none;"></summary>
<div style="background: rgba(0,0,0,0.2); padding: 12px 16px; border-top: 1px solid var(--border);">
<div style="font-size: 12px; color: var(--text-secondary); margin-bottom: 8px;">
Machines actives sur la license #<?= $l['id'] ?> (<?= htmlspecialchars($l['owner_name']) ?>) :
</div>
<table style="width: 100%; font-size: 12px; margin: 0;">
<thead>
<tr>
<th style="width: 280px;">Machine ID (SHA-256)</th>
<th>Label</th>
<th style="width: 130px;">1ère activation</th>
<th style="width: 130px;">Dernière vue</th>
<th style="width: 110px; text-align:right;">Action</th>
</tr>
</thead>
<tbody>
<?php foreach ($machines as $m):
$mid = $m['machine_id'];
$isStale = strtotime($m['last_seen']) < time() - 86400 * 30;
?>
<tr>
<td>
<code title="<?= htmlspecialchars($mid) ?>"
style="font-family: 'Cascadia Code', Consolas, monospace;">
<?= htmlspecialchars(substr($mid, 0, 16)) ?>…<?= htmlspecialchars(substr($mid, -8)) ?>
</code>
</td>
<td><?= htmlspecialchars($m['machine_label'] ?? '—') ?></td>
<td class="muted"><?= date('d/m/Y H:i', strtotime($m['first_seen'])) ?></td>
<td class="muted">
<?= date('d/m/Y H:i', strtotime($m['last_seen'])) ?>
<?php if ($isStale): ?>
<span class="badge badge-warning" title="Pas vu depuis &gt; 30 jours, candidat à libération">stale</span>
<?php endif; ?>
</td>
<td style="text-align:right;">
<form method="post" style="display:inline"
onsubmit="return confirm('Libérer cette machine de la license ?\n\nLe slot redeviendra disponible pour une autre activation.')">
<?= Layout::csrfField() ?>
<input type="hidden" name="action" value="remove_machine">
<input type="hidden" name="license_id" value="<?= $l['id'] ?>">
<input type="hidden" name="machine_id" value="<?= htmlspecialchars($mid) ?>">
<button class="btn btn-danger" type="submit"
style="font-size: 11px; padding: 4px 10px;">
Libérer
</button>
</form>
</td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</div>
</details>
</td>
</tr>
<?php endif; ?>
<?php endforeach; ?> <?php endforeach; ?>
<?php if (empty($licenses)): ?> <?php if (empty($licenses)): ?>
<tr><td colspan="7" class="muted" style="text-align:center; padding: 32px;">Aucune license émise.</td></tr> <tr><td colspan="7" class="muted" style="text-align:center; padding: 32px;">Aucune license émise.</td></tr>

View File

@@ -0,0 +1,32 @@
# Proserve v1.5.3
*Release date: May 3, 2026*
## What's new
- **New MM CQB scenario** (close-quarters combat in an urban environment)
- **Persistent weapon selection** for the entire PROSERVE session — the default weapon stored in the database is only restored when PROSERVE is restarted
## Improvements
- Refreshed **statistics icon** in the UI
- **Quick calibration** in the Calibration Scene is now restricted to users equipped with a VR controller (works around a bug specific to Firefighter mode)
## Bug fixes
- Fixed a bug in the **instructor view** affecting the player description
- Miscellaneous stability fixes
## Database migration
This version automatically applies 3 database migrations through the launcher:
- `0001_Init.sql` — baseline schema (no-op if the database already exists)
- `0002_SessionType_ps_1.4.5.sql` — adds the **LongRange** session type and renames type 6 to **Rescue**
- `0003_UserSize_ps_1.5.0.sql` — adds the `size` column (in cm) to the `users` table
No manual action required. If XAMPP is offline at install time, you can replay the migrations later via **Settings → Advanced → Database → Replay migrations**.
## Notes
- Deployment of the local reporting tool (`/ProserveReport/`) is synchronized with PROSERVE: a timestamped backup of the previous version is kept under `C:\xampp\htdocs\` and a revert is available via **Settings → Advanced → Report Tool**.

View File

@@ -16,7 +16,9 @@ using PSLauncher.Core.Integrity;
using PSLauncher.Core.Licensing; using PSLauncher.Core.Licensing;
using PSLauncher.Core.Localization; using PSLauncher.Core.Localization;
using PSLauncher.Core.Manifests; using PSLauncher.Core.Manifests;
using PSLauncher.Core.Migrations;
using PSLauncher.Core.Process; using PSLauncher.Core.Process;
using PSLauncher.Core.ReportTool;
using PSLauncher.Core.Updates; using PSLauncher.Core.Updates;
using PSLauncher.Models; using PSLauncher.Models;
using Serilog; using Serilog;
@@ -162,6 +164,24 @@ public partial class App : Application
services.AddSingleton<IToastService, ToastService>(); services.AddSingleton<IToastService, ToastService>();
// Migration DB MySQL : appliquée juste après extraction d'un build PROSERVE
// si le ZIP contient un sous-répertoire _migrations/. La config DB et le
// password (DPAPI) sont lus à la demande pour refléter les changements
// utilisateur dans Settings sans recréer le service.
services.AddSingleton<IDatabaseMigrationService>(sp =>
new DatabaseMigrationService(
configProvider: () => sp.GetRequiredService<LocalConfig>().Database,
passwordProvider: () => DatabasePasswordProtector.Unprotect(
sp.GetRequiredService<LocalConfig>().Database.EncryptedPassword),
sp.GetRequiredService<ILogger<DatabaseMigrationService>>()));
// Déploiement de l'outil Report (page web XAMPP) à l'install d'une
// nouvelle version : copie atomique de _report/ → C:\xampp\htdocs\ProserveReport\
services.AddSingleton<IReportToolDeployer>(sp =>
new ReportToolDeployer(
configProvider: () => sp.GetRequiredService<LocalConfig>().ReportTool,
sp.GetRequiredService<ILogger<ReportToolDeployer>>()));
services.AddTransient<SettingsViewModel>(); services.AddTransient<SettingsViewModel>();
services.AddSingleton<MainViewModel>(); services.AddSingleton<MainViewModel>();
services.AddSingleton<MainWindow>(); services.AddSingleton<MainWindow>();

View File

@@ -10,14 +10,14 @@
<LangVersion>latest</LangVersion> <LangVersion>latest</LangVersion>
<ImplicitUsings>enable</ImplicitUsings> <ImplicitUsings>enable</ImplicitUsings>
<ApplicationIcon>Resources\favicon.ico</ApplicationIcon> <ApplicationIcon>Resources\favicon.ico</ApplicationIcon>
<AssemblyName>PSLauncher</AssemblyName> <AssemblyName>PS_Launcher</AssemblyName>
<Company>ASTERION VR</Company> <Company>ASTERION VR</Company>
<Product>PROSERVE Launcher</Product> <Product>PROSERVE Launcher</Product>
<Copyright>© 2026 ASTERION VR — All rights reserved</Copyright> <Copyright>© 2026 ASTERION VR — All rights reserved</Copyright>
<RootNamespace>PSLauncher.App</RootNamespace> <RootNamespace>PSLauncher.App</RootNamespace>
<Version>0.9.0</Version> <Version>0.16.0</Version>
<AssemblyVersion>0.9.0.0</AssemblyVersion> <AssemblyVersion>0.16.0.0</AssemblyVersion>
<FileVersion>0.9.0.0</FileVersion> <FileVersion>0.16.0.0</FileVersion>
<!-- Single-file self-contained publish profile (used by `dotnet publish`) --> <!-- Single-file self-contained publish profile (used by `dotnet publish`) -->
<PublishSingleFile>true</PublishSingleFile> <PublishSingleFile>true</PublishSingleFile>
@@ -40,6 +40,11 @@
<PackageReference Include="Serilog.Sinks.File" Version="6.0.0" /> <PackageReference Include="Serilog.Sinks.File" Version="6.0.0" />
<PackageReference Include="Serilog.Sinks.Debug" Version="3.0.0" /> <PackageReference Include="Serilog.Sinks.Debug" Version="3.0.0" />
<PackageReference Include="Microsoft.Toolkit.Uwp.Notifications" Version="7.1.3" /> <PackageReference Include="Microsoft.Toolkit.Uwp.Notifications" Version="7.1.3" />
<!-- WebView2 (Edge Chromium) pour les onglets Report et Documentation.
Le runtime est pré-installé sur Win11 et auto-mis-à-jour via Windows
Update sur Win10 récent. Si absent, le contrôle affiche une erreur
et on aiguille l'user vers https://go.microsoft.com/fwlink/p/?LinkId=2124703 -->
<PackageReference Include="Microsoft.Web.WebView2" Version="1.0.2792.45" />
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
@@ -55,7 +60,7 @@
</ItemGroup> </ItemGroup>
<!-- Après chaque `dotnet publish -c Release`, copie le single-file exe à la racine du repo <!-- Après chaque `dotnet publish -c Release`, copie le single-file exe à la racine du repo
(C:\ASTERION\GIT\PS_Launcher\PSLauncher.exe). ContinueOnError pour ne pas casser le (C:\ASTERION\GIT\PS_Launcher\PS_Launcher.exe). ContinueOnError pour ne pas casser le
publish si une instance du launcher tourne et verrouille la cible — l'opérateur peut publish si une instance du launcher tourne et verrouille la cible — l'opérateur peut
killer et réessayer, ou simplement utiliser le binaire depuis bin\Release\... --> killer et réessayer, ou simplement utiliser le binaire depuis bin\Release\... -->
<Target Name="CopyPublishedToRepoRoot" AfterTargets="Publish" <Target Name="CopyPublishedToRepoRoot" AfterTargets="Publish"

View File

@@ -0,0 +1,18 @@
using System.Globalization;
using System.Windows;
using System.Windows.Data;
namespace PSLauncher.App.Resources;
/// <summary>
/// Inverse de <see cref="System.Windows.Controls.BooleanToVisibilityConverter"/> : true → Collapsed, false → Visible.
/// Utile pour afficher un placeholder quand un binding bool est false (ex. "DocumentationUrl est vide").
/// </summary>
public sealed class InverseBoolToVisibilityConverter : IValueConverter
{
public object Convert(object? value, Type targetType, object? parameter, CultureInfo culture)
=> value is bool b && b ? Visibility.Collapsed : Visibility.Visible;
public object ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture)
=> value is Visibility v && v != Visibility.Visible;
}

View File

@@ -3,6 +3,7 @@
xmlns:res="clr-namespace:PSLauncher.App.Resources"> xmlns:res="clr-namespace:PSLauncher.App.Resources">
<BooleanToVisibilityConverter x:Key="BoolToVisibility" /> <BooleanToVisibilityConverter x:Key="BoolToVisibility" />
<res:InverseBoolToVisibilityConverter x:Key="InverseBoolToVisibility" />
<!-- Custom font Pirulen pour les titres de marque --> <!-- Custom font Pirulen pour les titres de marque -->
<FontFamily x:Key="Font.Brand">pack://application:,,,/Resources/#Pirulen</FontFamily> <FontFamily x:Key="Font.Brand">pack://application:,,,/Resources/#Pirulen</FontFamily>
@@ -72,6 +73,47 @@
</Setter> </Setter>
</Style> </Style>
<!--
NavButton : item de la sidebar de droite (Library / Report / Documentation).
Plein-largeur, alignement à gauche, pas de bordure, surligné quand actif
via Tag (bool) qui est lié à IsLibrary/IsReport/IsDocumentation.
-->
<Style x:Key="NavButton" TargetType="Button">
<Setter Property="Background" Value="Transparent" />
<Setter Property="Foreground" Value="{StaticResource Brush.Text.Secondary}" />
<Setter Property="FontSize" Value="13" />
<Setter Property="FontWeight" Value="SemiBold" />
<Setter Property="Padding" Value="20,12" />
<Setter Property="HorizontalContentAlignment" Value="Left" />
<Setter Property="HorizontalAlignment" Value="Stretch" />
<Setter Property="BorderThickness" Value="0" />
<Setter Property="Cursor" Value="Hand" />
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="Button">
<Border x:Name="Bd"
Background="{TemplateBinding Background}"
BorderThickness="3,0,0,0"
BorderBrush="Transparent"
Padding="{TemplateBinding Padding}">
<ContentPresenter HorizontalAlignment="Left" VerticalAlignment="Center" />
</Border>
<ControlTemplate.Triggers>
<Trigger Property="IsMouseOver" Value="True">
<Setter TargetName="Bd" Property="Background" Value="#1A1F2A" />
</Trigger>
<!-- "Tag" est utilisé comme flag actif (IsLibrary/IsReport/IsDocumentation) -->
<DataTrigger Binding="{Binding RelativeSource={RelativeSource Self}, Path=Tag}" Value="True">
<Setter TargetName="Bd" Property="Background" Value="#1F2937" />
<Setter TargetName="Bd" Property="BorderBrush" Value="{StaticResource Brush.Accent}" />
<Setter Property="Foreground" Value="{StaticResource Brush.Text.Primary}" />
</DataTrigger>
</ControlTemplate.Triggers>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
<Style x:Key="PrimaryButton" TargetType="Button"> <Style x:Key="PrimaryButton" TargetType="Button">
<Setter Property="Background" Value="{StaticResource Brush.Play}" /> <Setter Property="Background" Value="{StaticResource Brush.Play}" />
<Setter Property="Foreground" Value="White" /> <Setter Property="Foreground" Value="White" />

View File

@@ -14,12 +14,20 @@ using PSLauncher.Core.Installations;
using PSLauncher.Core.Licensing; using PSLauncher.Core.Licensing;
using PSLauncher.Core.Localization; using PSLauncher.Core.Localization;
using PSLauncher.Core.Manifests; using PSLauncher.Core.Manifests;
using PSLauncher.Core.Migrations;
using PSLauncher.Core.Process; using PSLauncher.Core.Process;
using PSLauncher.Core.ReportTool;
using PSLauncher.Core.Updates; using PSLauncher.Core.Updates;
using PSLauncher.Models; using PSLauncher.Models;
namespace PSLauncher.App.ViewModels; namespace PSLauncher.App.ViewModels;
/// <summary>
/// Onglets affichés dans la sidebar droite du launcher. Chacun correspond à un
/// bloc de contenu visible/caché dans la fenêtre principale.
/// </summary>
public enum LauncherPage { Library, Report, Documentation }
public sealed partial class MainViewModel : ObservableObject public sealed partial class MainViewModel : ObservableObject
{ {
private readonly IInstallationRegistry _registry; private readonly IInstallationRegistry _registry;
@@ -33,6 +41,8 @@ public sealed partial class MainViewModel : ObservableObject
private readonly ILicenseService _licenseService; private readonly ILicenseService _licenseService;
private readonly ILauncherSelfUpdater _selfUpdater; private readonly ILauncherSelfUpdater _selfUpdater;
private readonly IToastService _toastService; private readonly IToastService _toastService;
private readonly IDatabaseMigrationService _migrationService;
private readonly IReportToolDeployer _reportDeployer;
private readonly IServiceProvider _serviceProvider; private readonly IServiceProvider _serviceProvider;
private readonly ILogger<MainViewModel> _logger; private readonly ILogger<MainViewModel> _logger;
@@ -66,6 +76,39 @@ public sealed partial class MainViewModel : ObservableObject
[ObservableProperty] private double _progressPercent; [ObservableProperty] private double _progressPercent;
/// <summary>
/// Onglet actuellement actif dans la sidebar gauche. La UI montre le bon bloc
/// via les converters IsLibrary / IsReport / IsDocumentation. Default = Library
/// pour que le launcher s'ouvre directement sur l'écran principal.
/// On notifie aussi FooterVisibility/FooterText : le footer (barre de DL,
/// status d'install) appartient à la page Library uniquement — il n'a aucun
/// sens visuel sur Report/Documentation qui sont des WebView pleine page.
/// </summary>
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(IsLibrary))]
[NotifyPropertyChangedFor(nameof(IsReport))]
[NotifyPropertyChangedFor(nameof(IsDocumentation))]
[NotifyPropertyChangedFor(nameof(FooterVisibility))]
[NotifyPropertyChangedFor(nameof(FooterText))]
private LauncherPage _currentPage = LauncherPage.Library;
public bool IsLibrary => CurrentPage == LauncherPage.Library;
public bool IsReport => CurrentPage == LauncherPage.Report;
public bool IsDocumentation => CurrentPage == LauncherPage.Documentation;
/// <summary>URL résolue pour le panneau Reports. WebView2.Source attend un Uri.</summary>
public Uri? ReportUri => TryParseUri(_config.ReportUrl);
/// <summary>URL résolue pour le panneau Documentation. Null si non configurée.</summary>
public Uri? DocumentationUri => TryParseUri(_config.DocumentationUrl);
public bool HasDocumentationUrl => DocumentationUri is not null;
private static Uri? TryParseUri(string? s)
=> !string.IsNullOrWhiteSpace(s) && Uri.TryCreate(s, UriKind.Absolute, out var u) ? u : null;
[RelayCommand] private void NavigateLibrary() => CurrentPage = LauncherPage.Library;
[RelayCommand] private void NavigateReport() => CurrentPage = LauncherPage.Report;
[RelayCommand] private void NavigateDocumentation() => CurrentPage = LauncherPage.Documentation;
[ObservableProperty] [ObservableProperty]
[NotifyPropertyChangedFor(nameof(FooterText))] [NotifyPropertyChangedFor(nameof(FooterText))]
[NotifyPropertyChangedFor(nameof(FooterVisibility))] [NotifyPropertyChangedFor(nameof(FooterVisibility))]
@@ -154,8 +197,16 @@ public sealed partial class MainViewModel : ObservableObject
FeaturedVersion is null && OtherVersions.Count == 0 FeaturedVersion is null && OtherVersions.Count == 0
? Visibility.Visible : Visibility.Collapsed; ? Visibility.Visible : Visibility.Collapsed;
/// <summary>
/// Footer visible UNIQUEMENT sur la page Library : c'est elle qui héberge
/// les téléchargements et installs, donc le seul contexte où la barre
/// de progression a du sens. Sur Report/Documentation (WebView pleine page),
/// le footer disparaît même si IsBusy ou StatusMessage non vide — l'opération
/// continue en arrière-plan, le user retrouve son état en revenant sur Library.
/// </summary>
public Visibility FooterVisibility => public Visibility FooterVisibility =>
IsBusy || !string.IsNullOrEmpty(StatusMessage) ? Visibility.Visible : Visibility.Collapsed; IsLibrary && (IsBusy || !string.IsNullOrEmpty(StatusMessage))
? Visibility.Visible : Visibility.Collapsed;
public string FooterText => public string FooterText =>
!string.IsNullOrEmpty(ProgressDetail) ? ProgressDetail : !string.IsNullOrEmpty(ProgressDetail) ? ProgressDetail :
@@ -173,6 +224,8 @@ public sealed partial class MainViewModel : ObservableObject
ILicenseService licenseService, ILicenseService licenseService,
ILauncherSelfUpdater selfUpdater, ILauncherSelfUpdater selfUpdater,
IToastService toastService, IToastService toastService,
IDatabaseMigrationService migrationService,
IReportToolDeployer reportDeployer,
IServiceProvider serviceProvider, IServiceProvider serviceProvider,
ILogger<MainViewModel> logger) ILogger<MainViewModel> logger)
{ {
@@ -187,6 +240,8 @@ public sealed partial class MainViewModel : ObservableObject
_licenseService = licenseService; _licenseService = licenseService;
_selfUpdater = selfUpdater; _selfUpdater = selfUpdater;
_toastService = toastService; _toastService = toastService;
_migrationService = migrationService;
_reportDeployer = reportDeployer;
_serviceProvider = serviceProvider; _serviceProvider = serviceProvider;
_logger = logger; _logger = logger;
@@ -383,7 +438,7 @@ public sealed partial class MainViewModel : ObservableObject
catch (Exception ex) catch (Exception ex)
{ {
_logger.LogError(ex, "Self-update failed"); _logger.LogError(ex, "Self-update failed");
MessageBox.Show(Strings.MsgSelfUpdateFailed(ex.Message), ThemedMessageBox.Show(Strings.MsgSelfUpdateFailed(ex.Message),
Strings.MsgBoxError, MessageBoxButton.OK, MessageBoxImage.Error); Strings.MsgBoxError, MessageBoxButton.OK, MessageBoxImage.Error);
StatusMessage = Strings.StatusSelfUpdateError(ex.Message); StatusMessage = Strings.StatusSelfUpdateError(ex.Message);
IsBusy = false; IsBusy = false;
@@ -463,7 +518,7 @@ public sealed partial class MainViewModel : ObservableObject
private void CancelDownload() private void CancelDownload()
{ {
if (_activeDownloadCts is null || _activeRow is null) return; if (_activeDownloadCts is null || _activeRow is null) return;
var confirm = MessageBox.Show( var confirm = ThemedMessageBox.Show(
Strings.MsgCancelDownloadConfirm(_activeRow.Version), Strings.MsgCancelDownloadConfirm(_activeRow.Version),
Strings.MsgBoxCancelDl, Strings.MsgBoxCancelDl,
MessageBoxButton.YesNo, MessageBoxImage.Question, MessageBoxResult.No); MessageBoxButton.YesNo, MessageBoxImage.Question, MessageBoxResult.No);
@@ -480,7 +535,7 @@ public sealed partial class MainViewModel : ObservableObject
// Si un DL est actif sur cette même row, on doit d'abord l'annuler proprement. // Si un DL est actif sur cette même row, on doit d'abord l'annuler proprement.
var st = _downloadManager.GetResumableState(row.Version); var st = _downloadManager.GetResumableState(row.Version);
var sizeStr = FormatSize(st?.DownloadedBytes ?? 0); var sizeStr = FormatSize(st?.DownloadedBytes ?? 0);
var confirm = MessageBox.Show( var confirm = ThemedMessageBox.Show(
Strings.MsgRestartDownloadConfirm(row.Version, sizeStr), Strings.MsgRestartDownloadConfirm(row.Version, sizeStr),
Strings.MsgBoxRestartDl, Strings.MsgBoxRestartDl,
MessageBoxButton.YesNo, MessageBoxImage.Warning, MessageBoxResult.No); MessageBoxButton.YesNo, MessageBoxImage.Warning, MessageBoxResult.No);
@@ -550,7 +605,7 @@ public sealed partial class MainViewModel : ObservableObject
catch (Exception ex) catch (Exception ex)
{ {
_logger.LogError(ex, "Launch failed"); _logger.LogError(ex, "Launch failed");
MessageBox.Show(Strings.MsgLaunchFailed(ex.Message), ThemedMessageBox.Show(Strings.MsgLaunchFailed(ex.Message),
Strings.MsgBoxLaunchError, MessageBoxButton.OK, MessageBoxImage.Error); Strings.MsgBoxLaunchError, MessageBoxButton.OK, MessageBoxImage.Error);
} }
} }
@@ -558,7 +613,7 @@ public sealed partial class MainViewModel : ObservableObject
private async Task InstallVersionAsync(VersionRowViewModel row) private async Task InstallVersionAsync(VersionRowViewModel row)
{ {
if (row.Remote is null) return; if (row.Remote is null) return;
if (IsBusy) { MessageBox.Show(Strings.MsgBusy, Strings.MsgBoxPatience, MessageBoxButton.OK, MessageBoxImage.Information); return; } if (IsBusy) { ThemedMessageBox.Show(Strings.MsgBusy, Strings.MsgBoxPatience, MessageBoxButton.OK, MessageBoxImage.Information); return; }
IsBusy = true; IsBusy = true;
_activeRow = row; _activeRow = row;
@@ -655,6 +710,89 @@ public sealed partial class MainViewModel : ObservableObject
try { File.Delete(zipPath); } catch { /* non critique */ } try { File.Delete(zipPath); } catch { /* non critique */ }
// 5) Migrations DB MySQL bundled dans _migrations/. Skip si la config
// a désactivé l'auto-apply ou si le ZIP ne contient pas de répertoire.
if (_config.Database.AutoApplyMigrations)
{
StatusMessage = Strings.StatusApplyingMigrations(row.Version);
ProgressDetail = null;
ProgressPercent = 0;
var migProgress = new Progress<MigrationProgress>(mp =>
{
if (mp.Total > 0)
{
var pct = (double)mp.Done / mp.Total * 100.0;
ProgressPercent = pct;
row.ProgressPercent = pct;
}
var label = string.IsNullOrEmpty(mp.CurrentFilename)
? Strings.StatusApplyingMigrations(row.Version)
: Strings.ProgressMigration(mp.Done + 1, mp.Total, mp.CurrentFilename);
ProgressDetail = label;
row.ProgressDetail = label;
});
var migResult = await _migrationService.ApplyMigrationsAsync(target, migProgress, ct);
if (!migResult.AllSucceeded)
{
// L'install a réussi côté fichiers ; on garde la version installée mais on
// notifie l'utilisateur que la DB n'a pas été migrée — la prochaine relance
// de l'install (ou un Settings → "Rejouer migrations") tentera à nouveau.
_logger.LogError("DB migration failed for v{Version}: {Detail}", row.Version, migResult.FailureMessage);
ThemedMessageBox.Show(
Strings.MsgMigrationFailed(migResult.FailureMessage ?? "?"),
Strings.MsgBoxMigrationFailed,
MessageBoxButton.OK, MessageBoxImage.Warning);
}
else if (migResult.AppliedCount > 0)
{
_logger.LogInformation("Applied {N} DB migration(s) for v{Version}", migResult.AppliedCount, row.Version);
}
}
// 6) Déploiement de l'outil Report (page web XAMPP).
// Cherche _report/ dans le ZIP extrait et copie vers htdocs en
// atomic rename. Skip silencieux si _report/ absent du ZIP.
if (_config.ReportTool.AutoDeploy)
{
StatusMessage = Strings.StatusDeployingReport(row.Version);
ProgressDetail = null;
ProgressPercent = 0;
var rdProgress = new Progress<DeployProgress>(dp =>
{
if (dp.FilesTotal > 0)
{
var pct = (double)dp.FilesDone / dp.FilesTotal * 100.0;
ProgressPercent = pct;
row.ProgressPercent = pct;
}
var label = Strings.ProgressReportDeploy(dp.FilesDone, dp.FilesTotal, dp.CurrentFilename);
ProgressDetail = label;
row.ProgressDetail = label;
});
var rdResult = await _reportDeployer.DeployAsync(target, rdProgress, ct);
switch (rdResult.Status)
{
case DeployStatus.Deployed:
_logger.LogInformation("Report tool deployed ({N} files) for v{Version}", rdResult.FilesDeployed, row.Version);
break;
case DeployStatus.SkippedNoSourceFolder:
_logger.LogInformation("No _report/ in v{Version} ZIP, report tool unchanged", row.Version);
break;
case DeployStatus.XamppNotFound:
ThemedMessageBox.Show(
Strings.MsgReportXamppNotFound(_config.ReportTool.HtdocsRoot),
Strings.MsgBoxReportDeployFailed,
MessageBoxButton.OK, MessageBoxImage.Warning);
break;
case DeployStatus.Failed:
ThemedMessageBox.Show(
Strings.MsgReportDeployFailed(rdResult.ErrorMessage ?? "?"),
Strings.MsgBoxReportDeployFailed,
MessageBoxButton.OK, MessageBoxImage.Warning);
break;
}
}
StatusMessage = Strings.StatusInstalledSuccess(row.Version); StatusMessage = Strings.StatusInstalledSuccess(row.Version);
ProgressDetail = null; ProgressDetail = null;
ProgressPercent = 0; ProgressPercent = 0;
@@ -680,7 +818,7 @@ public sealed partial class MainViewModel : ObservableObject
row.State = VersionRowState.AvailableIdle; row.State = VersionRowState.AvailableIdle;
row.ProgressDetail = null; row.ProgressDetail = null;
row.ProgressPercent = 0; row.ProgressPercent = 0;
MessageBox.Show( ThemedMessageBox.Show(
Strings.MsgInstallFailed(ex.Message), Strings.MsgInstallFailed(ex.Message),
Strings.MsgBoxError, MessageBoxButton.OK, MessageBoxImage.Error); Strings.MsgBoxError, MessageBoxButton.OK, MessageBoxImage.Error);
StatusMessage = Strings.StatusError(ex.Message); StatusMessage = Strings.StatusError(ex.Message);
@@ -703,14 +841,14 @@ public sealed partial class MainViewModel : ObservableObject
if (row.Installed is null) return; if (row.Installed is null) return;
var sizeStr = FormatSize(row.SizeBytes); var sizeStr = FormatSize(row.SizeBytes);
var confirm = MessageBox.Show( var confirm = ThemedMessageBox.Show(
Strings.MsgUninstallConfirm(row.Version, row.FolderPath, sizeStr), Strings.MsgUninstallConfirm(row.Version, row.FolderPath, sizeStr),
Strings.MsgBoxConfirm, Strings.MsgBoxConfirm,
MessageBoxButton.YesNo, MessageBoxImage.Warning, MessageBoxResult.No); MessageBoxButton.YesNo, MessageBoxImage.Warning, MessageBoxResult.No);
if (confirm != MessageBoxResult.Yes) return; if (confirm != MessageBoxResult.Yes) return;
if (IsBusy) { MessageBox.Show(Strings.MsgBusy, Strings.MsgBoxPatience, MessageBoxButton.OK, MessageBoxImage.Information); return; } if (IsBusy) { ThemedMessageBox.Show(Strings.MsgBusy, Strings.MsgBoxPatience, MessageBoxButton.OK, MessageBoxImage.Information); return; }
IsBusy = true; IsBusy = true;
row.State = VersionRowState.Uninstalling; row.State = VersionRowState.Uninstalling;
@@ -725,7 +863,7 @@ public sealed partial class MainViewModel : ObservableObject
{ {
_logger.LogError(ex, "Uninstall failed for {Version}", row.Version); _logger.LogError(ex, "Uninstall failed for {Version}", row.Version);
row.State = VersionRowState.InstalledIdle; row.State = VersionRowState.InstalledIdle;
MessageBox.Show(Strings.MsgUninstallFailed(ex.Message), Strings.MsgBoxError, ThemedMessageBox.Show(Strings.MsgUninstallFailed(ex.Message), Strings.MsgBoxError,
MessageBoxButton.OK, MessageBoxImage.Error); MessageBoxButton.OK, MessageBoxImage.Error);
StatusMessage = Strings.StatusError(ex.Message); StatusMessage = Strings.StatusError(ex.Message);
} }
@@ -740,7 +878,7 @@ public sealed partial class MainViewModel : ObservableObject
var url = row.Remote?.ReleaseNotesUrl; var url = row.Remote?.ReleaseNotesUrl;
if (string.IsNullOrEmpty(url)) if (string.IsNullOrEmpty(url))
{ {
MessageBox.Show(Strings.MsgNoReleaseNotes, ThemedMessageBox.Show(Strings.MsgNoReleaseNotes,
Strings.MsgBoxReleaseNotes, MessageBoxButton.OK, MessageBoxImage.Information); Strings.MsgBoxReleaseNotes, MessageBoxButton.OK, MessageBoxImage.Information);
return; return;
} }
@@ -749,7 +887,7 @@ public sealed partial class MainViewModel : ObservableObject
catch (Exception ex) catch (Exception ex)
{ {
_logger.LogWarning(ex, "Failed to fetch release notes"); _logger.LogWarning(ex, "Failed to fetch release notes");
MessageBox.Show(Strings.MsgReleaseNotesFetchFailed(ex.Message), ThemedMessageBox.Show(Strings.MsgReleaseNotesFetchFailed(ex.Message),
Strings.MsgBoxError, MessageBoxButton.OK, MessageBoxImage.Warning); Strings.MsgBoxError, MessageBoxButton.OK, MessageBoxImage.Warning);
return; return;
} }

View File

@@ -6,10 +6,14 @@ using System.Windows;
using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input; using CommunityToolkit.Mvvm.Input;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
using PSLauncher.App.Views;
using PSLauncher.Core.Configuration; using PSLauncher.Core.Configuration;
using PSLauncher.Core.Downloads; using PSLauncher.Core.Downloads;
using PSLauncher.Core.Installations;
using PSLauncher.Core.Licensing; using PSLauncher.Core.Licensing;
using PSLauncher.Core.Localization; using PSLauncher.Core.Localization;
using PSLauncher.Core.Migrations;
using PSLauncher.Core.ReportTool;
using PSLauncher.Models; using PSLauncher.Models;
namespace PSLauncher.App.ViewModels; namespace PSLauncher.App.ViewModels;
@@ -27,6 +31,9 @@ public sealed partial class SettingsViewModel : ObservableObject
private readonly LocalConfig _config; private readonly LocalConfig _config;
private readonly ILicenseService _licenseService; private readonly ILicenseService _licenseService;
private readonly IDownloadStateStore _downloadStore; private readonly IDownloadStateStore _downloadStore;
private readonly IDatabaseMigrationService _migrationService;
private readonly IReportToolDeployer _reportDeployer;
private readonly IInstallationRegistry _registry;
private readonly HttpClient _http; private readonly HttpClient _http;
private readonly ILogger<SettingsViewModel> _logger; private readonly ILogger<SettingsViewModel> _logger;
@@ -38,6 +45,31 @@ public sealed partial class SettingsViewModel : ObservableObject
[ObservableProperty] private string _serverBaseUrl; [ObservableProperty] private string _serverBaseUrl;
[ObservableProperty] private string _installRoot; [ObservableProperty] private string _installRoot;
[ObservableProperty] private string _language; [ObservableProperty] private string _language;
[ObservableProperty] private string _reportUrl;
[ObservableProperty] private string _documentationUrl;
// ----- DB Migration settings -----
[ObservableProperty] private string _dbHost;
[ObservableProperty] private uint _dbPort;
[ObservableProperty] private string _dbUser;
[ObservableProperty] private string _dbPassword;
[ObservableProperty] private string _dbName;
[ObservableProperty] private bool _dbAutoApplyMigrations;
[ObservableProperty] private string? _dbConnectionStatus;
[ObservableProperty] private bool _isTestingDb;
[ObservableProperty] private string? _migrationsReplayStatus;
[ObservableProperty] private bool _isReplayingMigrations;
// ----- Report Tool deploy settings -----
[ObservableProperty] private string _reportHtdocsRoot;
[ObservableProperty] private string _reportFolderName;
[ObservableProperty] private bool _reportAutoDeploy;
[ObservableProperty] private int _reportMaxBackups;
[ObservableProperty] private string? _reportDeployStatus;
[ObservableProperty] private bool _isDeployingReport;
public System.Collections.ObjectModel.ObservableCollection<ReportBackupViewModel> ReportBackups { get; }
= new();
public bool HasReportBackups => ReportBackups.Count > 0;
public IReadOnlyList<LanguageOption> AvailableLanguages { get; } = public IReadOnlyList<LanguageOption> AvailableLanguages { get; } =
PSLauncher.Core.Localization.Strings.Available PSLauncher.Core.Localization.Strings.Available
@@ -105,6 +137,9 @@ public sealed partial class SettingsViewModel : ObservableObject
LocalConfig config, LocalConfig config,
ILicenseService licenseService, ILicenseService licenseService,
IDownloadStateStore downloadStore, IDownloadStateStore downloadStore,
IDatabaseMigrationService migrationService,
IReportToolDeployer reportDeployer,
IInstallationRegistry registry,
HttpClient http, HttpClient http,
ILogger<SettingsViewModel> logger) ILogger<SettingsViewModel> logger)
{ {
@@ -112,12 +147,31 @@ public sealed partial class SettingsViewModel : ObservableObject
_config = config; _config = config;
_licenseService = licenseService; _licenseService = licenseService;
_downloadStore = downloadStore; _downloadStore = downloadStore;
_migrationService = migrationService;
_reportDeployer = reportDeployer;
_registry = registry;
_http = http; _http = http;
_logger = logger; _logger = logger;
_serverBaseUrl = config.ServerBaseUrl; _serverBaseUrl = config.ServerBaseUrl;
_installRoot = config.InstallRoot; _installRoot = config.InstallRoot;
_language = string.IsNullOrWhiteSpace(config.Language) ? "auto" : config.Language; _language = string.IsNullOrWhiteSpace(config.Language) ? "auto" : config.Language;
_reportUrl = config.ReportUrl;
_documentationUrl = config.DocumentationUrl;
_dbHost = config.Database.Host;
_dbPort = config.Database.Port;
_dbUser = config.Database.User;
_dbPassword = DatabasePasswordProtector.Unprotect(config.Database.EncryptedPassword) ?? string.Empty;
_dbName = config.Database.Database;
_dbAutoApplyMigrations = config.Database.AutoApplyMigrations;
_reportHtdocsRoot = config.ReportTool.HtdocsRoot;
_reportFolderName = config.ReportTool.FolderName;
_reportAutoDeploy = config.ReportTool.AutoDeploy;
_reportMaxBackups = config.ReportTool.MaxBackups;
// Charge la liste de backups en arrière-plan (UI immédiate puis remplie quand prête)
_ = LoadReportBackupsAsync();
LauncherVersion = Assembly.GetExecutingAssembly().GetName().Version?.ToString() ?? "0.0.0"; LauncherVersion = Assembly.GetExecutingAssembly().GetName().Version?.ToString() ?? "0.0.0";
MachineId = licenseService.GetMachineId(); MachineId = licenseService.GetMachineId();
@@ -186,7 +240,7 @@ public sealed partial class SettingsViewModel : ObservableObject
[RelayCommand] [RelayCommand]
private void ClearCache() private void ClearCache()
{ {
var confirm = MessageBox.Show( var confirm = ThemedMessageBox.Show(
Strings.MsgClearCacheConfirm, Strings.MsgClearCacheConfirm,
Strings.MsgBoxConfirm, Strings.MsgBoxConfirm,
MessageBoxButton.YesNo, MessageBoxImage.Warning, MessageBoxResult.No); MessageBoxButton.YesNo, MessageBoxImage.Warning, MessageBoxResult.No);
@@ -203,7 +257,7 @@ public sealed partial class SettingsViewModel : ObservableObject
catch (Exception ex) catch (Exception ex)
{ {
_logger.LogError(ex, "ClearCache failed"); _logger.LogError(ex, "ClearCache failed");
MessageBox.Show(ex.Message, Strings.MsgBoxError, MessageBoxButton.OK, MessageBoxImage.Error); ThemedMessageBox.Show(ex.Message, Strings.MsgBoxError, MessageBoxButton.OK, MessageBoxImage.Error);
} }
} }
@@ -225,6 +279,24 @@ public sealed partial class SettingsViewModel : ObservableObject
_config.ServerBaseUrl = ServerBaseUrl.TrimEnd('/'); _config.ServerBaseUrl = ServerBaseUrl.TrimEnd('/');
_config.InstallRoot = InstallRoot; _config.InstallRoot = InstallRoot;
_config.Language = Language ?? "auto"; _config.Language = Language ?? "auto";
_config.ReportUrl = ReportUrl?.Trim() ?? "";
_config.DocumentationUrl = DocumentationUrl?.Trim() ?? "";
// DB settings
_config.Database.Host = DbHost.Trim();
_config.Database.Port = DbPort > 0 ? DbPort : 3306;
_config.Database.User = DbUser.Trim();
_config.Database.EncryptedPassword = string.IsNullOrEmpty(DbPassword)
? null
: DatabasePasswordProtector.Protect(DbPassword);
_config.Database.Database = DbName.Trim();
_config.Database.AutoApplyMigrations = DbAutoApplyMigrations;
_config.ReportTool.HtdocsRoot = ReportHtdocsRoot.Trim();
_config.ReportTool.FolderName = ReportFolderName.Trim();
_config.ReportTool.AutoDeploy = ReportAutoDeploy;
_config.ReportTool.MaxBackups = Math.Max(0, ReportMaxBackups);
_configStore.Save(_config); _configStore.Save(_config);
_logger.LogInformation("Settings saved (language={Lang}, changed={Changed})", _config.Language, languageChanged); _logger.LogInformation("Settings saved (language={Lang}, changed={Changed})", _config.Language, languageChanged);
@@ -233,7 +305,7 @@ public sealed partial class SettingsViewModel : ObservableObject
// Le launcher doit être relancé pour que les bindings x:Static reflètent // Le launcher doit être relancé pour que les bindings x:Static reflètent
// la nouvelle langue. Plutôt que de demander à l'utilisateur de fermer/rouvrir, // la nouvelle langue. Plutôt que de demander à l'utilisateur de fermer/rouvrir,
// on spawne un cmd qui attend la fermeture puis relance. // on spawne un cmd qui attend la fermeture puis relance.
var ok = MessageBox.Show( var ok = ThemedMessageBox.Show(
Strings.MsgLanguageRestart, Strings.MsgLanguageRestart,
Strings.MsgBoxLanguageChange, Strings.MsgBoxLanguageChange,
MessageBoxButton.OKCancel, MessageBoxImage.Information, MessageBoxResult.OK); MessageBoxButton.OKCancel, MessageBoxImage.Information, MessageBoxResult.OK);
@@ -264,7 +336,7 @@ public sealed partial class SettingsViewModel : ObservableObject
[RelayCommand] [RelayCommand]
private void DeactivateLicense() private void DeactivateLicense()
{ {
var confirm = MessageBox.Show( var confirm = ThemedMessageBox.Show(
Strings.MsgDeactivateLicenseConfirm, Strings.MsgDeactivateLicenseConfirm,
Strings.MsgBoxConfirm, Strings.MsgBoxConfirm,
MessageBoxButton.YesNo, MessageBoxImage.Question, MessageBoxResult.No); MessageBoxButton.YesNo, MessageBoxImage.Question, MessageBoxResult.No);
@@ -289,4 +361,218 @@ public sealed partial class SettingsViewModel : ObservableObject
} }
private static string FormatSize(long bytes) => Strings.FormatSize(bytes); private static string FormatSize(long bytes) => Strings.FormatSize(bytes);
/// <summary>
/// Re-déploie l'outil Report depuis la dernière version PROSERVE installée
/// vers htdocs. Utile si le déploiement post-install avait échoué (XAMPP
/// déplacé, htdocs absent), ou pour resync après changement du chemin htdocs
/// dans Settings.
/// </summary>
[RelayCommand]
private async Task RedeployReportAsync()
{
var installed = _registry.Scan().OrderByDescending(v => v.Version).FirstOrDefault();
if (installed is null)
{
ReportDeployStatus = "Aucune version installée — installe d'abord une version qui contient _report/.";
return;
}
IsDeployingReport = true;
ReportDeployStatus = $"Déploiement depuis v{installed.Version}…";
try
{
// Persiste les éventuels changements de chemin avant le deploy
Save();
var progress = new Progress<DeployProgress>(dp =>
{
if (dp.FilesTotal > 0)
ReportDeployStatus = $"{dp.FilesDone}/{dp.FilesTotal} : {dp.CurrentFilename}";
});
var result = await _reportDeployer.DeployAsync(installed.FolderPath, progress, CancellationToken.None);
ReportDeployStatus = result.Status switch
{
DeployStatus.Deployed => $"✓ {result.FilesDeployed} fichiers déployés vers {result.TargetPath}",
DeployStatus.SkippedNoSourceFolder => $"⚠ La version v{installed.Version} ne contient pas de _report/ — rien à déployer",
DeployStatus.XamppNotFound => $"✗ {result.ErrorMessage}",
DeployStatus.Failed => $"✗ {result.ErrorMessage}",
_ => "?",
};
}
catch (Exception ex)
{
_logger.LogError(ex, "Redeploy report failed");
ReportDeployStatus = $"✗ {ex.Message}";
}
finally
{
IsDeployingReport = false;
}
}
// ===================== DB MIGRATIONS =====================
/// <summary>
/// Test la connexion à la DB MySQL avec les paramètres en cours de saisie.
/// Persiste temporairement les valeurs sur _config.Database avant le test
/// pour que le service les voie via son configProvider, puis remet (et le
/// Save() final écrasera quand l'utilisateur cliquera Enregistrer).
/// </summary>
[RelayCommand]
private async Task TestDbAsync()
{
DbConnectionStatus = Strings.SettingsTest + "…";
IsTestingDb = true;
// On applique temporairement les valeurs saisies
var oldHost = _config.Database.Host;
var oldPort = _config.Database.Port;
var oldUser = _config.Database.User;
var oldPwd = _config.Database.EncryptedPassword;
var oldDb = _config.Database.Database;
try
{
_config.Database.Host = DbHost.Trim();
_config.Database.Port = DbPort > 0 ? DbPort : 3306;
_config.Database.User = DbUser.Trim();
_config.Database.EncryptedPassword = string.IsNullOrEmpty(DbPassword)
? null
: DatabasePasswordProtector.Protect(DbPassword);
_config.Database.Database = DbName.Trim();
var err = await _migrationService.TestConnectionAsync(CancellationToken.None);
DbConnectionStatus = err is null ? "✓ OK" : $"✗ {err}";
}
catch (Exception ex) { DbConnectionStatus = $"✗ {ex.Message}"; }
finally
{
// Restaure : seul Save() doit écrire pour de bon
_config.Database.Host = oldHost;
_config.Database.Port = oldPort;
_config.Database.User = oldUser;
_config.Database.EncryptedPassword = oldPwd;
_config.Database.Database = oldDb;
IsTestingDb = false;
}
}
/// <summary>
/// Re-joue les migrations bundled dans le _migrations/ de la version actuellement
/// installée la plus récente. Utile si l'auto-apply post-install avait échoué (ex.
/// XAMPP éteint à ce moment-là), ou pour rejouer manuellement après ajout d'une
/// nouvelle migration côté dev.
/// </summary>
[RelayCommand]
private async Task ReplayMigrationsAsync()
{
var installed = _registry.Scan().OrderByDescending(v => v.Version).FirstOrDefault();
if (installed is null)
{
MigrationsReplayStatus = "Aucune version installée — installe d'abord une version avec _migrations/.";
return;
}
IsReplayingMigrations = true;
MigrationsReplayStatus = $"Réapplication des migrations pour v{installed.Version}…";
try
{
var progress = new Progress<MigrationProgress>(mp =>
{
if (mp.Total > 0)
MigrationsReplayStatus = $"{mp.Done + 1}/{mp.Total} : {mp.CurrentFilename}";
});
// Il faut persister les éventuelles modifs DB avant le test (le service lit la config courante)
Save();
var result = await _migrationService.ApplyMigrationsAsync(
installed.FolderPath, progress, CancellationToken.None);
if (result.AllSucceeded)
{
MigrationsReplayStatus = result.AppliedCount > 0
? $"✓ {result.AppliedCount} migration(s) appliquée(s), {result.SkippedCount} déjà en place."
: $"✓ Tout est à jour ({result.SkippedCount} déjà appliquée(s))";
}
else
{
MigrationsReplayStatus = $"✗ Échec : {result.FailureMessage}";
}
}
catch (Exception ex)
{
_logger.LogError(ex, "Replay migrations failed");
MigrationsReplayStatus = $"✗ Erreur : {ex.Message}";
}
finally
{
IsReplayingMigrations = false;
}
}
// ===================== REPORT TOOL BACKUPS =====================
private async Task LoadReportBackupsAsync()
{
try
{
var list = await _reportDeployer.ListBackupsAsync(CancellationToken.None);
ReportBackups.Clear();
foreach (var b in list)
ReportBackups.Add(new ReportBackupViewModel(b, RevertReportToBackupAsync));
OnPropertyChanged(nameof(HasReportBackups));
}
catch (Exception ex)
{
_logger.LogDebug(ex, "Could not list report backups");
}
}
/// <summary>Appelé par chaque ligne du tableau de backups via son own command.</summary>
private async Task RevertReportToBackupAsync(BackupInfo backup)
{
var localDate = backup.TimestampUtc.ToLocalTime();
var dateLabel = Strings.FormatLongDate(localDate);
var confirm = ThemedMessageBox.Show(
Strings.MsgRevertReportConfirm(dateLabel),
Strings.MsgBoxRevertReport,
MessageBoxButton.YesNo, MessageBoxImage.Question, MessageBoxResult.No);
if (confirm != MessageBoxResult.Yes) return;
IsDeployingReport = true;
ReportDeployStatus = $"Revert vers {dateLabel}…";
try
{
var result = await _reportDeployer.RevertAsync(backup.FolderName, CancellationToken.None);
ReportDeployStatus = result.Status == DeployStatus.Deployed
? Strings.MsgRevertReportSuccess(dateLabel)
: $"✗ {result.ErrorMessage}";
await LoadReportBackupsAsync();
}
catch (Exception ex)
{
_logger.LogError(ex, "Revert report failed");
ReportDeployStatus = $"✗ {ex.Message}";
}
finally
{
IsDeployingReport = false;
}
}
}
/// <summary>
/// Ligne du tableau "Backups disponibles" dans Settings → Avancés → Outil Report.
/// Chaque instance porte sa propre commande Revert paramétrée pour ce backup.
/// </summary>
public sealed partial class ReportBackupViewModel : ObservableObject
{
public BackupInfo Info { get; }
public string DateDisplay => Strings.FormatLongDate(Info.TimestampUtc.ToLocalTime());
public string SizeDisplay => Strings.SettingsReportBackupSize(Strings.FormatSize(Info.SizeBytes));
public string FolderName => Info.FolderName;
private readonly Func<BackupInfo, Task> _revertHandler;
public ReportBackupViewModel(BackupInfo info, Func<BackupInfo, Task> revertHandler)
{
Info = info;
_revertHandler = revertHandler;
}
[RelayCommand]
private Task Revert() => _revertHandler(Info);
} }

View File

@@ -4,7 +4,9 @@
xmlns:loc="clr-namespace:PSLauncher.Core.Localization;assembly=PSLauncher.Core" xmlns:loc="clr-namespace:PSLauncher.Core.Localization;assembly=PSLauncher.Core"
Title="License" Title="License"
Icon="pack://application:,,,/Resources/favicon.ico" Icon="pack://application:,,,/Resources/favicon.ico"
Width="500" Height="480" Width="540" Height="540"
SizeToContent="Height"
MinHeight="480"
ResizeMode="NoResize" ResizeMode="NoResize"
WindowStartupLocation="CenterOwner" WindowStartupLocation="CenterOwner"
Background="{StaticResource Brush.Bg.Window}"> Background="{StaticResource Brush.Bg.Window}">
@@ -16,24 +18,34 @@
<RowDefinition Height="Auto" /> <RowDefinition Height="Auto" />
</Grid.RowDefinitions> </Grid.RowDefinitions>
<!-- Header avec badge couleur --> <!-- Header avec badge couleur. On utilise un Grid (Auto + *) pour que la
colonne texte prenne tout l'espace disponible et que le sous-titre
puisse wrapper sur plusieurs lignes (les messages "License expirée
le X. Vous pouvez encore..." font facilement 2-3 lignes). -->
<Border Grid.Row="0" CornerRadius="8" Padding="20,16" <Border Grid.Row="0" CornerRadius="8" Padding="20,16"
BorderThickness="2" BorderThickness="2"
x:Name="HeaderBorder"> x:Name="HeaderBorder">
<StackPanel Orientation="Horizontal"> <Grid>
<TextBlock x:Name="StatusIconText" <Grid.ColumnDefinitions>
<ColumnDefinition Width="Auto" />
<ColumnDefinition Width="*" />
</Grid.ColumnDefinitions>
<TextBlock Grid.Column="0"
x:Name="StatusIconText"
FontSize="32" FontWeight="Bold" FontSize="32" FontWeight="Bold"
VerticalAlignment="Center" Margin="0,0,16,0" /> VerticalAlignment="Top" Margin="0,0,16,0" />
<StackPanel VerticalAlignment="Center"> <StackPanel Grid.Column="1" VerticalAlignment="Center">
<TextBlock x:Name="StatusTitleText" <TextBlock x:Name="StatusTitleText"
FontSize="18" FontWeight="SemiBold" FontSize="18" FontWeight="SemiBold"
TextWrapping="Wrap"
Foreground="{StaticResource Brush.Text.Primary}" /> Foreground="{StaticResource Brush.Text.Primary}" />
<TextBlock x:Name="StatusSubtitleText" <TextBlock x:Name="StatusSubtitleText"
FontSize="13" FontSize="13"
TextWrapping="Wrap"
Foreground="{StaticResource Brush.Text.Secondary}" Foreground="{StaticResource Brush.Text.Secondary}"
Margin="0,2,0,0" /> Margin="0,4,0,0" />
</StackPanel>
</StackPanel> </StackPanel>
</Grid>
</Border> </Border>
<!-- Détails --> <!-- Détails -->

View File

@@ -66,7 +66,7 @@ public partial class LicenseDetailsDialog : Window
private void OnDeactivate(object sender, RoutedEventArgs e) private void OnDeactivate(object sender, RoutedEventArgs e)
{ {
var confirm = MessageBox.Show( var confirm = ThemedMessageBox.Show(
Strings.MsgDeactivateLicenseDetailedConfirm, Strings.MsgDeactivateLicenseDetailedConfirm,
Strings.MsgBoxConfirm, Strings.MsgBoxConfirm,
MessageBoxButton.YesNo, MessageBoxImage.Question, MessageBoxResult.No); MessageBoxButton.YesNo, MessageBoxImage.Question, MessageBoxResult.No);

View File

@@ -4,6 +4,7 @@
xmlns:vm="clr-namespace:PSLauncher.App.ViewModels" xmlns:vm="clr-namespace:PSLauncher.App.ViewModels"
xmlns:loc="clr-namespace:PSLauncher.Core.Localization;assembly=PSLauncher.Core" xmlns:loc="clr-namespace:PSLauncher.Core.Localization;assembly=PSLauncher.Core"
xmlns:shell="clr-namespace:System.Windows.Shell;assembly=PresentationFramework" xmlns:shell="clr-namespace:System.Windows.Shell;assembly=PresentationFramework"
xmlns:wv2="clr-namespace:Microsoft.Web.WebView2.Wpf;assembly=Microsoft.Web.WebView2.Wpf"
Title="PROSERVE Launcher" Title="PROSERVE Launcher"
Icon="pack://application:,,,/Resources/favicon.ico" Icon="pack://application:,,,/Resources/favicon.ico"
Background="Black" Background="Black"
@@ -172,19 +173,41 @@
</DataTemplate> </DataTemplate>
</Window.Resources> </Window.Resources>
<!--
Layout principal — grille 2 colonnes × 3 rangées :
┌─────────────────────────────────────────────────────────────┐
│ Top bar (ColSpan 2) │ Row 0
├──────────┬──────────────────────────────────────────────────┤
│ │ │
│ Sidebar │ Content area │ Row 1
│ (Library │ (Library / Report / Doc) │
│ Report │ │
│ Doc) ├──────────────────────────────────────────────────┤
│ RowSpan2 │ Footer (DL/install) │ Row 2
│ │ visible only on Library │
└──────────┴──────────────────────────────────────────────────┘
200px *
La sidebar a RowSpan=2 → s'étend du sous-topbar jusqu'au bas
de fenêtre. Le footer est confiné à la colonne content.
-->
<Grid Background="Black"> <Grid Background="Black">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="200" />
<ColumnDefinition Width="*" />
</Grid.ColumnDefinitions>
<Grid.RowDefinitions> <Grid.RowDefinitions>
<RowDefinition Height="Auto" /> <RowDefinition Height="Auto" />
<RowDefinition Height="*" /> <RowDefinition Height="*" />
<RowDefinition Height="Auto" /> <RowDefinition Height="Auto" />
</Grid.RowDefinitions> </Grid.RowDefinitions>
<!-- Background image : limité au body uniquement (Grid.Row="1") pour ne pas <!-- Background image : limité à la zone content (Row 1, Col 1) pour ne pas
se faire chevaucher par le footer quand un download tourne. ImageBrush passer derrière la sidebar ni le footer. ImageBrush avec AlignmentX=Right
avec AlignmentX=Right + AlignmentY=Bottom : si le ratio fenêtre force + AlignmentY=Bottom : si le ratio fenêtre force un crop, on rogne en
un crop, on rogne en haut/à gauche — le logo ASTERION VR en bas-droite haut/à gauche — le logo ASTERION VR en bas-droite reste TOUJOURS visible. -->
reste TOUJOURS visible. --> <Rectangle Grid.Row="1" Grid.Column="1" IsHitTestVisible="False">
<Rectangle Grid.Row="1" IsHitTestVisible="False">
<Rectangle.Fill> <Rectangle.Fill>
<ImageBrush ImageSource="pack://application:,,,/Resources/Background.png" <ImageBrush ImageSource="pack://application:,,,/Resources/Background.png"
Stretch="UniformToFill" Stretch="UniformToFill"
@@ -194,8 +217,9 @@
</Rectangle.Fill> </Rectangle.Fill>
</Rectangle> </Rectangle>
<!-- Top bar : 3 colonnes (brand / license center / chrome) --> <!-- Top bar : 3 colonnes (brand / license center / chrome).
<Border Grid.Row="0" Background="{StaticResource Brush.Bg.Sidebar}" Grid.ColumnSpan=2 → s'étend sur sidebar + content. -->
<Border Grid.Row="0" Grid.ColumnSpan="2" Background="{StaticResource Brush.Bg.Sidebar}"
BorderBrush="{StaticResource Brush.Border}" BorderThickness="0,0,0,1" BorderBrush="{StaticResource Brush.Border}" BorderThickness="0,0,0,1"
Padding="20,12"> Padding="20,12">
<Grid> <Grid>
@@ -315,8 +339,13 @@
</Grid> </Grid>
</Border> </Border>
<!-- Body : Grid pour pouvoir superposer le bouton flottant "Vérifier les MAJ" --> <!-- ============== Content area (Row 1, Col 1, swap par CurrentPage) ==============
<Grid Grid.Row="1"> Superpose plusieurs blocs (Library / Report / Documentation) gérés par
leur Visibility liée à CurrentPage. -->
<Grid Grid.Row="1" Grid.Column="1">
<!-- ====================== LIBRARY PAGE ====================== -->
<Grid Visibility="{Binding IsLibrary, Converter={StaticResource BoolToVisibility}}">
<ScrollViewer VerticalScrollBarVisibility="Auto"> <ScrollViewer VerticalScrollBarVisibility="Auto">
<StackPanel Margin="32,28"> <StackPanel Margin="32,28">
@@ -548,10 +577,74 @@
Foreground="White" /> Foreground="White" />
</Border> </Border>
</Grid> </Grid>
<!-- ====================== END LIBRARY PAGE ====================== -->
<!-- Footer : visible uniquement quand busy / status à afficher. <!-- ====================== REPORT PAGE (WebView2) ====================== -->
Le bouton "Vérifier les MAJ" a été déplacé en flottant au-dessus du body. --> <Grid Background="{StaticResource Brush.Bg.Window}"
<Border Grid.Row="2" Background="{StaticResource Brush.Bg.Footer}" Visibility="{Binding IsReport, Converter={StaticResource BoolToVisibility}}">
<wv2:WebView2 x:Name="ReportWebView"
Source="{Binding ReportUri, Mode=OneWay}" />
</Grid>
<!-- ====================== DOCUMENTATION PAGE ====================== -->
<Grid Background="{StaticResource Brush.Bg.Window}"
Visibility="{Binding IsDocumentation, Converter={StaticResource BoolToVisibility}}">
<!-- WebView2 si une URL est configurée, sinon un placeholder explicatif -->
<wv2:WebView2 x:Name="DocsWebView"
Source="{Binding DocumentationUri, Mode=OneWay}"
Visibility="{Binding HasDocumentationUrl, Converter={StaticResource BoolToVisibility}}" />
<TextBlock Text="{x:Static loc:Strings.DocsPlaceholder}"
HorizontalAlignment="Center" VerticalAlignment="Center"
Foreground="{StaticResource Brush.Text.Secondary}"
FontSize="14" TextAlignment="Center" TextWrapping="Wrap"
MaxWidth="500"
Visibility="{Binding HasDocumentationUrl, Converter={StaticResource InverseBoolToVisibility}}" />
</Grid>
</Grid>
<!-- ============== END Content area ============== -->
<!-- ============== Left sidebar : nav buttons ==============
Row 1 + Row 2 (RowSpan=2) → la sidebar s'étend depuis le bas du
top bar jusqu'en bas de la fenêtre, peu importe la visibilité du
footer ou de la taille de la zone content. -->
<Border Grid.Row="1" Grid.RowSpan="2" Grid.Column="0"
Background="#0A0A0E"
BorderBrush="{StaticResource Brush.Border}"
BorderThickness="0,0,1,0">
<StackPanel Orientation="Vertical" Margin="0,16,0,0">
<!-- Style des boutons définis dans Theme.xaml (NavButton) -->
<Button Style="{StaticResource NavButton}"
Command="{Binding NavigateLibraryCommand}"
Tag="{Binding IsLibrary}">
<StackPanel Orientation="Horizontal">
<TextBlock Text="📚" FontSize="16" Margin="0,0,12,0" VerticalAlignment="Center" />
<TextBlock Text="{x:Static loc:Strings.NavLibrary}" VerticalAlignment="Center" />
</StackPanel>
</Button>
<Button Style="{StaticResource NavButton}"
Command="{Binding NavigateReportCommand}"
Tag="{Binding IsReport}">
<StackPanel Orientation="Horizontal">
<TextBlock Text="📊" FontSize="16" Margin="0,0,12,0" VerticalAlignment="Center" />
<TextBlock Text="{x:Static loc:Strings.NavReport}" VerticalAlignment="Center" />
</StackPanel>
</Button>
<Button Style="{StaticResource NavButton}"
Command="{Binding NavigateDocumentationCommand}"
Tag="{Binding IsDocumentation}">
<StackPanel Orientation="Horizontal">
<TextBlock Text="📖" FontSize="16" Margin="0,0,12,0" VerticalAlignment="Center" />
<TextBlock Text="{x:Static loc:Strings.NavDocumentation}" VerticalAlignment="Center" />
</StackPanel>
</Button>
</StackPanel>
</Border>
<!-- Footer : Row 2, Col 1 → cantonné à la colonne content, sous la zone
Library/Report/Doc. La sidebar (RowSpan=2) le contourne par sa colonne. -->
<Border Grid.Row="2" Grid.Column="1"
Background="{StaticResource Brush.Bg.Footer}"
BorderBrush="{StaticResource Brush.Border}" BorderThickness="0,1,0,0" BorderBrush="{StaticResource Brush.Border}" BorderThickness="0,1,0,0"
Padding="20,10" Padding="20,10"
Visibility="{Binding FooterVisibility}"> Visibility="{Binding FooterVisibility}">

View File

@@ -42,7 +42,7 @@ public partial class MainWindow : Window
if (DataContext is MainViewModel vm && vm.HasActiveDownload) if (DataContext is MainViewModel vm && vm.HasActiveDownload)
{ {
var version = vm.ActiveDownloadVersion ?? "?"; var version = vm.ActiveDownloadVersion ?? "?";
var result = MessageBox.Show( var result = ThemedMessageBox.Show(
Strings.MsgQuitWhileDownloadingConfirm(version), Strings.MsgQuitWhileDownloadingConfirm(version),
Strings.MsgBoxQuit, Strings.MsgBoxQuit,
MessageBoxButton.YesNo, MessageBoxImage.Question, MessageBoxResult.No); MessageBoxButton.YesNo, MessageBoxImage.Question, MessageBoxResult.No);

View File

@@ -102,6 +102,24 @@ internal static class MarkdownTheming
foreach (var i in h.Inlines.ToList()) foreach (var i in h.Inlines.ToList())
ApplyToInline(i, accent, fgSecondary, accent); ApplyToInline(i, accent, fgSecondary, accent);
break; break;
case Run r:
// Markdig.Wpf rend `inline code` en tant que <Run> avec un Style
// par défaut qui pose un Background clair + Foreground "fancy" — illisible
// sur notre thème sombre. On détecte le code inline par sa FontFamily
// (monospace appliquée par le style Markdig) et on rebadge avec des couleurs
// qui matchent le reste de la UI : fond noir foncé + texte primaire.
var fontFamilyName = r.FontFamily?.Source ?? string.Empty;
if (fontFamilyName.Contains("Consolas", StringComparison.OrdinalIgnoreCase)
|| fontFamilyName.Contains("Courier", StringComparison.OrdinalIgnoreCase)
|| fontFamilyName.Contains("Typewriter", StringComparison.OrdinalIgnoreCase))
{
r.Background = new SolidColorBrush(Color.FromRgb(0x1A, 0x1F, 0x2A));
r.Foreground = new SolidColorBrush(Color.FromRgb(0xCD, 0xD6, 0xE4));
r.FontFamily = new FontFamily("Cascadia Code, Consolas, monospace");
}
break;
case Span s: case Span s:
foreach (var i in s.Inlines.ToList()) foreach (var i in s.Inlines.ToList())
ApplyToInline(i, fg, fgSecondary, accent); ApplyToInline(i, fg, fgSecondary, accent);

View File

@@ -142,6 +142,45 @@
</StackPanel> </StackPanel>
</Border> </Border>
<!--
Section "À propos" : version + copyright. Visible (pas dans l'Expander),
placée entre Langue et les paramètres avancés repliés. C'est l'info que
le support demande en premier en cas de bug.
-->
<Border Background="{StaticResource Brush.Bg.Card}"
BorderBrush="{StaticResource Brush.Border}" BorderThickness="1"
CornerRadius="6" Padding="20" Margin="0,0,0,16">
<StackPanel>
<TextBlock Text="{x:Static loc:Strings.SettingsAbout}"
FontSize="11" FontWeight="Bold"
Foreground="{StaticResource Brush.Text.Secondary}"
Margin="0,0,0,8" />
<TextBlock FontSize="13"
Foreground="{StaticResource Brush.Text.Primary}">
<Run Text="{x:Static loc:Strings.SettingsLauncherVersion}" />
<Run Text=" " />
<Run Text="{Binding LauncherVersion, Mode=OneWay}" FontWeight="SemiBold" />
</TextBlock>
<TextBlock Text="{x:Static loc:Strings.Copyright}"
FontSize="12"
Foreground="{StaticResource Brush.Text.Secondary}"
Margin="0,4,0,0" />
</StackPanel>
</Border>
<!--
Sections "techniques" : Server / Installation / Cache / Database / Logs
repliées par défaut sous un Expander pour ne pas noyer les sections
importantes (License, Langue, About) qui restent dépliées.
Placé en BAS de la fenêtre car ce sont des trucs de power-user / support.
-->
<Expander Header="{x:Static loc:Strings.SettingsAdvanced}"
Foreground="{StaticResource Brush.Text.Secondary}"
FontSize="11" FontWeight="Bold"
Background="Transparent" BorderThickness="0"
Margin="0,0,0,16" IsExpanded="False">
<StackPanel Margin="0,12,0,0">
<!-- Serveur --> <!-- Serveur -->
<Border Background="{StaticResource Brush.Bg.Card}" <Border Background="{StaticResource Brush.Bg.Card}"
BorderBrush="{StaticResource Brush.Border}" BorderThickness="1" BorderBrush="{StaticResource Brush.Border}" BorderThickness="1"
@@ -174,6 +213,23 @@
<TextBlock Text="{Binding ConnectionStatus}" <TextBlock Text="{Binding ConnectionStatus}"
Foreground="{StaticResource Brush.Text.Secondary}" Foreground="{StaticResource Brush.Text.Secondary}"
FontSize="12" Margin="0,8,0,0" TextWrapping="Wrap" /> FontSize="12" Margin="0,8,0,0" TextWrapping="Wrap" />
<!-- URLs des onglets latéraux Report / Documentation -->
<TextBlock Text="{x:Static loc:Strings.SettingsReportUrl}"
Foreground="{StaticResource Brush.Text.Secondary}"
FontSize="12" Margin="0,16,0,4" />
<TextBox Text="{Binding ReportUrl, UpdateSourceTrigger=PropertyChanged}"
Background="#1A1A20" Foreground="{StaticResource Brush.Text.Primary}"
BorderBrush="{StaticResource Brush.Border}" BorderThickness="1" Padding="8"
FontFamily="Consolas" />
<TextBlock Text="{x:Static loc:Strings.SettingsDocsUrl}"
Foreground="{StaticResource Brush.Text.Secondary}"
FontSize="12" Margin="0,12,0,4" />
<TextBox Text="{Binding DocumentationUrl, UpdateSourceTrigger=PropertyChanged}"
Background="#1A1A20" Foreground="{StaticResource Brush.Text.Primary}"
BorderBrush="{StaticResource Brush.Border}" BorderThickness="1" Padding="8"
FontFamily="Consolas" />
</StackPanel> </StackPanel>
</Border> </Border>
@@ -248,7 +304,228 @@
</StackPanel> </StackPanel>
</Border> </Border>
<!-- Logs / About --> <!-- Base de données (XAMPP / MySQL) — config + test + replay -->
<Border Background="{StaticResource Brush.Bg.Card}"
BorderBrush="{StaticResource Brush.Border}" BorderThickness="1"
CornerRadius="6" Padding="20" Margin="0,0,0,16">
<StackPanel>
<TextBlock Text="{x:Static loc:Strings.SettingsDatabase}"
FontSize="11" FontWeight="Bold"
Foreground="{StaticResource Brush.Text.Secondary}"
Margin="0,0,0,8" />
<Grid Margin="0,0,0,8">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*" />
<ColumnDefinition Width="100" />
</Grid.ColumnDefinitions>
<StackPanel Grid.Column="0" Margin="0,0,8,0">
<TextBlock Text="{x:Static loc:Strings.SettingsDbHost}"
Foreground="{StaticResource Brush.Text.Secondary}"
FontSize="12" Margin="0,0,0,4" />
<TextBox Text="{Binding DbHost, UpdateSourceTrigger=PropertyChanged}"
Background="#1A1A20" Foreground="{StaticResource Brush.Text.Primary}"
BorderBrush="{StaticResource Brush.Border}" BorderThickness="1" Padding="8" />
</StackPanel>
<StackPanel Grid.Column="1">
<TextBlock Text="{x:Static loc:Strings.SettingsDbPort}"
Foreground="{StaticResource Brush.Text.Secondary}"
FontSize="12" Margin="0,0,0,4" />
<TextBox Text="{Binding DbPort, UpdateSourceTrigger=PropertyChanged}"
Background="#1A1A20" Foreground="{StaticResource Brush.Text.Primary}"
BorderBrush="{StaticResource Brush.Border}" BorderThickness="1" Padding="8" />
</StackPanel>
</Grid>
<Grid Margin="0,0,0,8">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*" />
<ColumnDefinition Width="*" />
</Grid.ColumnDefinitions>
<StackPanel Grid.Column="0" Margin="0,0,8,0">
<TextBlock Text="{x:Static loc:Strings.SettingsDbUser}"
Foreground="{StaticResource Brush.Text.Secondary}"
FontSize="12" Margin="0,0,0,4" />
<TextBox Text="{Binding DbUser, UpdateSourceTrigger=PropertyChanged}"
Background="#1A1A20" Foreground="{StaticResource Brush.Text.Primary}"
BorderBrush="{StaticResource Brush.Border}" BorderThickness="1" Padding="8" />
</StackPanel>
<StackPanel Grid.Column="1">
<TextBlock Text="{x:Static loc:Strings.SettingsDbPassword}"
Foreground="{StaticResource Brush.Text.Secondary}"
FontSize="12" Margin="0,0,0,4" />
<TextBox Text="{Binding DbPassword, UpdateSourceTrigger=PropertyChanged}"
Background="#1A1A20" Foreground="{StaticResource Brush.Text.Primary}"
BorderBrush="{StaticResource Brush.Border}" BorderThickness="1" Padding="8"
FontFamily="Consolas" />
</StackPanel>
</Grid>
<TextBlock Text="{x:Static loc:Strings.SettingsDbName}"
Foreground="{StaticResource Brush.Text.Secondary}"
FontSize="12" Margin="0,0,0,4" />
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*" />
<ColumnDefinition Width="Auto" />
</Grid.ColumnDefinitions>
<TextBox Grid.Column="0"
Text="{Binding DbName, UpdateSourceTrigger=PropertyChanged}"
Background="#1A1A20" Foreground="{StaticResource Brush.Text.Primary}"
BorderBrush="{StaticResource Brush.Border}" BorderThickness="1" Padding="8"
FontFamily="Consolas" />
<Button Grid.Column="1" Margin="8,0,0,0"
Style="{StaticResource SecondaryButton}"
Content="{x:Static loc:Strings.SettingsTest}"
Command="{Binding TestDbCommand}"
IsEnabled="{Binding IsTestingDb, Converter={StaticResource InverseBoolToBool}}" />
</Grid>
<TextBlock Text="{Binding DbConnectionStatus}"
Foreground="{StaticResource Brush.Text.Secondary}"
FontSize="12" Margin="0,8,0,0" TextWrapping="Wrap" />
<CheckBox IsChecked="{Binding DbAutoApplyMigrations}"
Margin="0,12,0,0"
Foreground="{StaticResource Brush.Text.Primary}"
Content="{x:Static loc:Strings.SettingsDbAutoMigrate}" />
<Grid Margin="0,12,0,0">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*" />
<ColumnDefinition Width="Auto" />
</Grid.ColumnDefinitions>
<TextBlock Grid.Column="0"
Text="{Binding MigrationsReplayStatus}"
Foreground="{StaticResource Brush.Text.Secondary}"
FontSize="12" VerticalAlignment="Center"
TextWrapping="Wrap" />
<Button Grid.Column="1"
Style="{StaticResource SecondaryButton}"
Content="{x:Static loc:Strings.SettingsDbReplay}"
Command="{Binding ReplayMigrationsCommand}"
IsEnabled="{Binding IsReplayingMigrations, Converter={StaticResource InverseBoolToBool}}" />
</Grid>
<TextBlock Text="{x:Static loc:Strings.SettingsDbHint}"
Foreground="{StaticResource Brush.Text.Secondary}"
FontStyle="Italic" FontSize="11" Margin="0,8,0,0"
TextWrapping="Wrap" />
</StackPanel>
</Border>
<!-- Outil Report (XAMPP htdocs deploy) -->
<Border Background="{StaticResource Brush.Bg.Card}"
BorderBrush="{StaticResource Brush.Border}" BorderThickness="1"
CornerRadius="6" Padding="20" Margin="0,0,0,16">
<StackPanel>
<TextBlock Text="{x:Static loc:Strings.SettingsReportTool}"
FontSize="11" FontWeight="Bold"
Foreground="{StaticResource Brush.Text.Secondary}"
Margin="0,0,0,8" />
<TextBlock Text="{x:Static loc:Strings.SettingsReportHtdocs}"
Foreground="{StaticResource Brush.Text.Secondary}"
FontSize="12" Margin="0,0,0,4" />
<TextBox Text="{Binding ReportHtdocsRoot, UpdateSourceTrigger=PropertyChanged}"
Background="#1A1A20" Foreground="{StaticResource Brush.Text.Primary}"
BorderBrush="{StaticResource Brush.Border}" BorderThickness="1" Padding="8"
FontFamily="Consolas" />
<TextBlock Text="{x:Static loc:Strings.SettingsReportFolder}"
Foreground="{StaticResource Brush.Text.Secondary}"
FontSize="12" Margin="0,12,0,4" />
<TextBox Text="{Binding ReportFolderName, UpdateSourceTrigger=PropertyChanged}"
Background="#1A1A20" Foreground="{StaticResource Brush.Text.Primary}"
BorderBrush="{StaticResource Brush.Border}" BorderThickness="1" Padding="8"
FontFamily="Consolas" />
<CheckBox IsChecked="{Binding ReportAutoDeploy}"
Margin="0,12,0,0"
Foreground="{StaticResource Brush.Text.Primary}"
Content="{x:Static loc:Strings.SettingsReportAutoDeploy}" />
<!-- Conserver N backups -->
<Grid Margin="0,12,0,0">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*" />
<ColumnDefinition Width="80" />
</Grid.ColumnDefinitions>
<TextBlock Grid.Column="0"
Text="{x:Static loc:Strings.SettingsReportMaxBackups}"
Foreground="{StaticResource Brush.Text.Secondary}"
FontSize="12" VerticalAlignment="Center" />
<TextBox Grid.Column="1"
Text="{Binding ReportMaxBackups, UpdateSourceTrigger=PropertyChanged}"
Background="#1A1A20" Foreground="{StaticResource Brush.Text.Primary}"
BorderBrush="{StaticResource Brush.Border}" BorderThickness="1" Padding="8" />
</Grid>
<Grid Margin="0,12,0,0">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*" />
<ColumnDefinition Width="Auto" />
</Grid.ColumnDefinitions>
<TextBlock Grid.Column="0"
Text="{Binding ReportDeployStatus}"
Foreground="{StaticResource Brush.Text.Secondary}"
FontSize="12" VerticalAlignment="Center"
TextWrapping="Wrap" />
<Button Grid.Column="1"
Style="{StaticResource SecondaryButton}"
Content="{x:Static loc:Strings.SettingsReportRedeploy}"
Command="{Binding RedeployReportCommand}"
IsEnabled="{Binding IsDeployingReport, Converter={StaticResource InverseBoolToBool}}" />
</Grid>
<!-- Liste des backups disponibles avec bouton Revert par ligne -->
<TextBlock Text="{x:Static loc:Strings.SettingsReportBackups}"
FontWeight="Bold" FontSize="11"
Foreground="{StaticResource Brush.Text.Secondary}"
Margin="0,16,0,6" />
<TextBlock Text="{x:Static loc:Strings.SettingsReportNoBackups}"
Foreground="{StaticResource Brush.Text.Secondary}"
FontSize="12" FontStyle="Italic"
Visibility="{Binding HasReportBackups, Converter={StaticResource InverseBoolToVisibility}}" />
<ItemsControl ItemsSource="{Binding ReportBackups}"
Visibility="{Binding HasReportBackups, Converter={StaticResource BoolToVisibility}}">
<ItemsControl.ItemTemplate>
<DataTemplate>
<Border Background="#1A1A20"
BorderBrush="{StaticResource Brush.Border}" BorderThickness="1"
CornerRadius="4" Padding="10,8" Margin="0,0,0,4">
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*" />
<ColumnDefinition Width="Auto" />
<ColumnDefinition Width="Auto" />
</Grid.ColumnDefinitions>
<StackPanel Grid.Column="0" VerticalAlignment="Center">
<TextBlock Text="{Binding DateDisplay}"
Foreground="{StaticResource Brush.Text.Primary}"
FontSize="13" FontWeight="SemiBold" />
<TextBlock Text="{Binding SizeDisplay}"
Foreground="{StaticResource Brush.Text.Secondary}"
FontSize="11" />
</StackPanel>
<TextBlock Grid.Column="1"
Text="{Binding FolderName}"
FontFamily="Consolas" FontSize="10"
Foreground="{StaticResource Brush.Text.Secondary}"
VerticalAlignment="Center" Margin="0,0,12,0" />
<Button Grid.Column="2"
Style="{StaticResource SecondaryButton}"
Content="{x:Static loc:Strings.SettingsReportRevert}"
Command="{Binding RevertCommand}"
Padding="12,6" />
</Grid>
</Border>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</StackPanel>
</Border>
<!-- Logs (path + open button) -->
<Border Background="{StaticResource Brush.Bg.Card}" <Border Background="{StaticResource Brush.Bg.Card}"
BorderBrush="{StaticResource Brush.Border}" BorderThickness="1" BorderBrush="{StaticResource Brush.Border}" BorderThickness="1"
CornerRadius="6" Padding="20" Margin="0,0,0,16"> CornerRadius="6" Padding="20" Margin="0,0,0,16">
@@ -257,17 +534,7 @@
FontSize="11" FontWeight="Bold" FontSize="11" FontWeight="Bold"
Foreground="{StaticResource Brush.Text.Secondary}" Foreground="{StaticResource Brush.Text.Secondary}"
Margin="0,0,0,8" /> Margin="0,0,0,8" />
<TextBlock FontSize="13" <Grid>
Foreground="{StaticResource Brush.Text.Primary}">
<Run Text="{x:Static loc:Strings.SettingsLauncherVersion}" />
<Run Text=" " />
<Run Text="{Binding LauncherVersion, Mode=OneWay}" FontWeight="SemiBold" />
</TextBlock>
<TextBlock Text="{x:Static loc:Strings.Copyright}"
FontSize="12"
Foreground="{StaticResource Brush.Text.Secondary}"
Margin="0,4,0,0" />
<Grid Margin="0,8,0,0">
<Grid.ColumnDefinitions> <Grid.ColumnDefinitions>
<ColumnDefinition Width="*" /> <ColumnDefinition Width="*" />
<ColumnDefinition Width="Auto" /> <ColumnDefinition Width="Auto" />
@@ -290,6 +557,9 @@
</StackPanel> </StackPanel>
</Border> </Border>
</StackPanel>
</Expander>
</StackPanel> </StackPanel>
</ScrollViewer> </ScrollViewer>

View File

@@ -0,0 +1,72 @@
<Window x:Class="PSLauncher.App.Views.ThemedMessageBox"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:loc="clr-namespace:PSLauncher.Core.Localization;assembly=PSLauncher.Core"
Title=""
Icon="pack://application:,,,/Resources/favicon.ico"
SizeToContent="Height"
Width="480"
MinHeight="180"
MaxHeight="600"
ResizeMode="NoResize"
ShowInTaskbar="False"
WindowStartupLocation="CenterOwner"
Background="{StaticResource Brush.Bg.Window}">
<Border BorderBrush="{StaticResource Brush.Border}" BorderThickness="1">
<Grid Margin="24,20">
<Grid.RowDefinitions>
<RowDefinition Height="Auto" />
<RowDefinition Height="*" />
<RowDefinition Height="Auto" />
</Grid.RowDefinitions>
<!-- Title -->
<TextBlock Grid.Row="0"
x:Name="TitleText"
FontSize="16" FontWeight="SemiBold"
Foreground="{StaticResource Brush.Text.Primary}"
Margin="0,0,0,12" />
<!-- Icon + Message -->
<Grid Grid.Row="1">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="Auto" />
<ColumnDefinition Width="*" />
</Grid.ColumnDefinitions>
<TextBlock Grid.Column="0"
x:Name="IconText"
FontSize="36"
Margin="0,2,16,0"
VerticalAlignment="Top" />
<TextBlock Grid.Column="1"
x:Name="MessageText"
TextWrapping="Wrap"
FontSize="13" LineHeight="20"
Foreground="{StaticResource Brush.Text.Primary}"
VerticalAlignment="Center" />
</Grid>
<!-- Buttons -->
<StackPanel Grid.Row="2"
Orientation="Horizontal"
HorizontalAlignment="Right"
Margin="0,24,0,0">
<Button x:Name="Btn1"
Style="{StaticResource SecondaryButton}"
MinWidth="80" Padding="16,8" Margin="0,0,8,0"
Visibility="Collapsed"
Click="OnBtn1Click" />
<Button x:Name="Btn2"
Style="{StaticResource SecondaryButton}"
MinWidth="80" Padding="16,8" Margin="0,0,8,0"
Visibility="Collapsed"
Click="OnBtn2Click" />
<Button x:Name="Btn3"
Style="{StaticResource AccentButton}"
MinWidth="80" Padding="16,8"
Visibility="Collapsed"
Click="OnBtn3Click" />
</StackPanel>
</Grid>
</Border>
</Window>

View File

@@ -0,0 +1,187 @@
using System.Windows;
using System.Windows.Input;
using System.Windows.Media;
using PSLauncher.Core.Localization;
namespace PSLauncher.App.Views;
/// <summary>
/// Drop-in pour <see cref="System.Windows.MessageBox"/> avec un look qui colle
/// au reste de la UI (dark theme, accent ASTERION). API copiée :
/// <code>
/// ThemedMessageBox.Show("Texte", "Titre", MessageBoxButton.YesNo, MessageBoxImage.Question, MessageBoxResult.No);
/// </code>
///
/// Notes :
/// - <see cref="MessageBoxImage"/> est mappé vers une icône emoji + couleur.
/// - Les libellés des boutons (OK / Yes / No / Cancel) sont localisés via <see cref="Strings"/>.
/// - Owner = MainWindow par défaut (centrage), peut être passé explicitement.
/// - Esc renvoie Cancel ou No selon le bouton défini comme cancel.
/// </summary>
public partial class ThemedMessageBox : Window
{
public MessageBoxResult Result { get; private set; } = MessageBoxResult.None;
private MessageBoxResult _btn1Result = MessageBoxResult.None;
private MessageBoxResult _btn2Result = MessageBoxResult.None;
private MessageBoxResult _btn3Result = MessageBoxResult.None;
private ThemedMessageBox(string message, string title, MessageBoxButton button,
MessageBoxImage icon, MessageBoxResult defaultResult)
{
InitializeComponent();
Title = title;
TitleText.Text = title;
MessageText.Text = message;
SetIcon(icon);
SetupButtons(button, defaultResult);
// Esc = cancel/no
PreviewKeyDown += (_, e) =>
{
if (e.Key == Key.Escape)
{
Result = button switch
{
MessageBoxButton.OK => MessageBoxResult.OK,
MessageBoxButton.OKCancel => MessageBoxResult.Cancel,
MessageBoxButton.YesNo => MessageBoxResult.No,
MessageBoxButton.YesNoCancel => MessageBoxResult.Cancel,
_ => MessageBoxResult.None,
};
DialogResult = false;
Close();
}
};
}
private void SetIcon(MessageBoxImage icon)
{
// NOTE : MessageBoxImage.Stop ≡ Error (16) et Asterisk ≡ Information (64)
// côté enum .NET, donc on ne switche que sur les noms canoniques.
switch (icon)
{
case MessageBoxImage.Error: // = Stop = Hand
IconText.Text = "⛔";
IconText.Foreground = new SolidColorBrush(Color.FromRgb(0xEF, 0x44, 0x44));
break;
case MessageBoxImage.Warning: // = Exclamation
IconText.Text = "⚠";
IconText.Foreground = new SolidColorBrush(Color.FromRgb(0xF5, 0x9E, 0x0B));
break;
case MessageBoxImage.Question:
IconText.Text = "❓";
break;
case MessageBoxImage.Information: // = Asterisk
IconText.Text = "";
IconText.Foreground = new SolidColorBrush(Color.FromRgb(0x3B, 0x82, 0xF6));
break;
default:
IconText.Visibility = Visibility.Collapsed;
break;
}
}
/// <summary>
/// Configure jusqu'à 3 boutons. L'ordre visuel est gauche → droite, avec
/// le bouton "principal" (default) en accent à l'extrême droite. Les autres
/// en secondary à gauche.
/// </summary>
private void SetupButtons(MessageBoxButton button, MessageBoxResult defaultResult)
{
// Liste (label, result) dans l'ordre où on veut les afficher
var btns = button switch
{
MessageBoxButton.OK => new[] { (Strings.ActionOk, MessageBoxResult.OK) },
MessageBoxButton.OKCancel => new[] {
(Strings.ActionCancel, MessageBoxResult.Cancel),
(Strings.ActionOk, MessageBoxResult.OK),
},
MessageBoxButton.YesNo => new[] {
(Strings.ActionNo, MessageBoxResult.No),
(Strings.ActionYes, MessageBoxResult.Yes),
},
MessageBoxButton.YesNoCancel => new[] {
(Strings.ActionCancel, MessageBoxResult.Cancel),
(Strings.ActionNo, MessageBoxResult.No),
(Strings.ActionYes, MessageBoxResult.Yes),
},
_ => new[] { (Strings.ActionOk, MessageBoxResult.OK) },
};
// Btn3 = bouton accent (le plus à droite, principal). Toujours rempli.
// Btn2 = secondary à sa gauche, optionnel.
// Btn1 = secondary tout à gauche, optionnel.
// On remplit en partant de la fin pour que l'accent soit toujours sur Btn3.
var slots = new[] { Btn1, Btn2, Btn3 };
var resultSetters = new System.Action<MessageBoxResult>[]
{
r => _btn1Result = r,
r => _btn2Result = r,
r => _btn3Result = r,
};
int startSlot = 3 - btns.Length;
for (int i = 0; i < btns.Length; i++)
{
int slotIdx = startSlot + i;
slots[slotIdx].Content = btns[i].Item1;
slots[slotIdx].Visibility = Visibility.Visible;
resultSetters[slotIdx](btns[i].Item2);
// Default = celui dont le result match defaultResult
if (btns[i].Item2 == defaultResult)
slots[slotIdx].IsDefault = true;
}
// Si aucun match exact pour le default, on prend le bouton principal (Btn3)
if (Btn3.Visibility == Visibility.Visible && !Btn1.IsDefault && !Btn2.IsDefault && !Btn3.IsDefault)
Btn3.IsDefault = true;
}
private void OnBtn1Click(object sender, RoutedEventArgs e) => Finish(_btn1Result);
private void OnBtn2Click(object sender, RoutedEventArgs e) => Finish(_btn2Result);
private void OnBtn3Click(object sender, RoutedEventArgs e) => Finish(_btn3Result);
private void Finish(MessageBoxResult result)
{
Result = result;
DialogResult = true;
Close();
}
// ===================== STATIC SHOW OVERLOADS =====================
public static MessageBoxResult Show(string message)
=> Show(message, string.Empty, MessageBoxButton.OK, MessageBoxImage.None, MessageBoxResult.OK);
public static MessageBoxResult Show(string message, string title)
=> Show(message, title, MessageBoxButton.OK, MessageBoxImage.None, MessageBoxResult.OK);
public static MessageBoxResult Show(string message, string title, MessageBoxButton button)
=> Show(message, title, button, MessageBoxImage.None, DefaultFor(button));
public static MessageBoxResult Show(string message, string title, MessageBoxButton button, MessageBoxImage icon)
=> Show(message, title, button, icon, DefaultFor(button));
public static MessageBoxResult Show(string message, string title, MessageBoxButton button,
MessageBoxImage icon, MessageBoxResult defaultResult)
{
var owner = Application.Current?.Windows.OfType<Window>().FirstOrDefault(w => w.IsActive)
?? Application.Current?.MainWindow;
var dlg = new ThemedMessageBox(message, title, button, icon, defaultResult);
if (owner is not null && owner.IsVisible) dlg.Owner = owner;
else dlg.WindowStartupLocation = WindowStartupLocation.CenterScreen;
dlg.ShowDialog();
return dlg.Result;
}
private static MessageBoxResult DefaultFor(MessageBoxButton button) => button switch
{
MessageBoxButton.OK => MessageBoxResult.OK,
MessageBoxButton.OKCancel => MessageBoxResult.OK,
MessageBoxButton.YesNo => MessageBoxResult.Yes,
MessageBoxButton.YesNoCancel => MessageBoxResult.Yes,
_ => MessageBoxResult.None,
};
}

View File

@@ -102,6 +102,9 @@ public static class Strings
public static string ActionInstall => T("⬇ Installer", "⬇ Install", "⬇ 安装", "⬇ ติดตั้ง", "⬇ تثبيت"); public static string ActionInstall => T("⬇ Installer", "⬇ Install", "⬇ 安装", "⬇ ติดตั้ง", "⬇ تثبيت");
public static string ActionInstallBig => T("⬇ INSTALLER", "⬇ INSTALL", "⬇ 安装", "⬇ ติดตั้ง", "⬇ تثبيت"); public static string ActionInstallBig => T("⬇ INSTALLER", "⬇ INSTALL", "⬇ 安装", "⬇ ติดตั้ง", "⬇ تثبيت");
public static string ActionCancel => T("Annuler", "Cancel", "取消", "ยกเลิก", "إلغاء"); public static string ActionCancel => T("Annuler", "Cancel", "取消", "ยกเลิก", "إلغاء");
public static string ActionOk => T("OK", "OK", "确定", "ตกลง", "موافق");
public static string ActionYes => T("Oui", "Yes", "是", "ใช่", "نعم");
public static string ActionNo => T("Non", "No", "否", "ไม่", "لا");
public static string ActionLater => T("Plus tard", "Later", "稍后", "ภายหลัง", "لاحقاً"); public static string ActionLater => T("Plus tard", "Later", "稍后", "ภายหลัง", "لاحقاً");
public static string ActionSave => T("Enregistrer", "Save", "保存", "บันทึก", "حفظ"); public static string ActionSave => T("Enregistrer", "Save", "保存", "บันทึก", "حفظ");
public static string ActionClose => T("Fermer", "Close", "关闭", "ปิด", "إغلاق"); public static string ActionClose => T("Fermer", "Close", "关闭", "ปิด", "إغلاق");
@@ -138,6 +141,57 @@ public static class Strings
public static string SettingsInstallation => T("INSTALLATION", "INSTALLATION", "安装", "การติดตั้ง", "التثبيت"); public static string SettingsInstallation => T("INSTALLATION", "INSTALLATION", "安装", "การติดตั้ง", "التثبيت");
public static string SettingsCache => T("CACHE DE TÉLÉCHARGEMENTS", "DOWNLOAD CACHE", "下载缓存", "แคชดาวน์โหลด", "ذاكرة التخزين المؤقت للتنزيلات"); public static string SettingsCache => T("CACHE DE TÉLÉCHARGEMENTS", "DOWNLOAD CACHE", "下载缓存", "แคชดาวน์โหลด", "ذاكرة التخزين المؤقت للتنزيلات");
public static string SettingsLogs => T("LOGS & APPLICATION", "LOGS & APP", "日志与应用", "บันทึกและแอป", "السجلات والتطبيق"); public static string SettingsLogs => T("LOGS & APPLICATION", "LOGS & APP", "日志与应用", "บันทึกและแอป", "السجلات والتطبيق");
public static string SettingsDatabase => T("BASE DE DONNÉES (XAMPP / MySQL)", "DATABASE (XAMPP / MySQL)", "数据库XAMPP / MySQL", "ฐานข้อมูล (XAMPP / MySQL)", "قاعدة البيانات (XAMPP / MySQL)");
public static string SettingsDbHost => T("Hôte", "Host", "主机", "โฮสต์", "المضيف");
public static string SettingsDbPort => T("Port", "Port", "端口", "พอร์ต", "المنفذ");
public static string SettingsDbUser => T("Utilisateur", "User", "用户", "ผู้ใช้", "المستخدم");
public static string SettingsDbPassword => T("Mot de passe", "Password", "密码", "รหัสผ่าน", "كلمة المرور");
public static string SettingsDbName => T("Nom de la base", "Database name", "数据库名称", "ชื่อฐานข้อมูล", "اسم قاعدة البيانات");
public static string SettingsDbAutoMigrate => T(
"Appliquer automatiquement les migrations à l'install d'une nouvelle version",
"Automatically apply migrations when installing a new version",
"安装新版本时自动应用迁移",
"ปรับใช้การโยกย้ายโดยอัตโนมัติเมื่อติดตั้งเวอร์ชันใหม่",
"تطبيق الترقيات تلقائياً عند تثبيت نسخة جديدة"
);
public static string SettingsDbReplay => T("🔁 Rejouer les migrations", "🔁 Replay migrations", "🔁 重新执行迁移", "🔁 เรียกใช้การโยกย้ายอีกครั้ง", "🔁 إعادة تشغيل الترقيات");
// ==================== RIGHT NAV SIDEBAR ====================
public static string NavLibrary => T("Bibliothèque", "Library", "库", "ห้องสมุด", "المكتبة");
public static string NavReport => T("Statistiques", "Reports", "统计", "รายงาน", "التقارير");
public static string NavDocumentation => T("Documentation", "Documentation","文档", "เอกสาร", "التوثيق");
public static string SettingsReportUrl => T("URL de l'outil de reporting local", "Local reporting tool URL", "本地报告工具 URL", "URL ของเครื่องมือรายงานในเครื่อง", "عنوان أداة التقارير المحلية");
public static string SettingsDocsUrl => T("URL de la documentation", "Documentation URL", "文档 URL", "URL ของเอกสาร", "عنوان التوثيق");
public static string DocsPlaceholder => T(
"Aucune URL de documentation configurée.\n\nVa dans Paramètres → Avancés → Base de données pour pointer vers la documentation locale ou en ligne.",
"No documentation URL configured.\n\nGo to Settings → Advanced → Database to point to local or online documentation.",
"未配置文档 URL。\n\n前往 设置 → 高级 → 数据库 以指向本地或在线文档。",
"ยังไม่ได้กำหนด URL เอกสาร\n\nไปที่ การตั้งค่า → ขั้นสูง → ฐานข้อมูล เพื่อชี้ไปยังเอกสารในเครื่องหรือออนไลน์",
"لم يتم تكوين عنوان التوثيق.\n\nاذهب إلى الإعدادات → متقدم → قاعدة البيانات للإشارة إلى التوثيق المحلي أو عبر الإنترنت."
);
public static string WebViewLoadError(string url, string detail) => T(
$"Impossible de charger {url}\n\n{detail}\n\nVérifie que XAMPP est démarré et que l'URL est correcte (Paramètres → Avancés).",
$"Could not load {url}\n\n{detail}\n\nCheck that XAMPP is running and the URL is correct (Settings → Advanced).",
$"无法加载 {url}\n\n{detail}\n\n请检查 XAMPP 是否正在运行以及 URL 是否正确(设置 → 高级)。",
$"ไม่สามารถโหลด {url}\n\n{detail}\n\nตรวจสอบว่า XAMPP กำลังทำงานและ URL ถูกต้อง (ตั้งค่า → ขั้นสูง)",
$"تعذر تحميل {url}\n\n{detail}\n\nتأكد من تشغيل XAMPP وأن العنوان صحيح (الإعدادات → متقدم)."
);
public static string SettingsAdvanced => T(
"▸ PARAMÈTRES AVANCÉS (serveur, installation, base de données, logs)",
"▸ ADVANCED SETTINGS (server, installation, database, logs)",
"▸ 高级设置(服务器、安装、数据库、日志)",
"▸ ตั้งค่าขั้นสูง (เซิร์ฟเวอร์ การติดตั้ง ฐานข้อมูล บันทึก)",
"▸ الإعدادات المتقدمة (الخادم، التثبيت، قاعدة البيانات، السجلات)"
);
public static string SettingsAbout => T("À PROPOS", "ABOUT", "关于", "เกี่ยวกับ", "حول");
public static string SettingsDbHint => T(
"Le launcher applique automatiquement les scripts SQL bundled dans _migrations/ de chaque ZIP PROSERVE. Mot de passe stocké chiffré DPAPI.",
"The launcher automatically applies SQL scripts bundled in each PROSERVE ZIP's _migrations/ folder. Password stored DPAPI-encrypted.",
"启动器自动应用每个 PROSERVE ZIP 的 _migrations/ 文件夹中捆绑的 SQL 脚本。密码使用 DPAPI 加密存储。",
"Launcher จะปรับใช้สคริปต์ SQL ที่บรรจุใน _migrations/ ของ ZIP PROSERVE แต่ละชุดโดยอัตโนมัติ รหัสผ่านถูกเก็บแบบเข้ารหัส DPAPI",
"يطبق المُشغِّل تلقائياً نصوص SQL المرفقة في _migrations/ لكل ZIP من PROSERVE. كلمة المرور مُخزَّنة بتشفير DPAPI."
);
public static string SettingsLanguage => T("LANGUE", "LANGUAGE", "语言", "ภาษา", "اللغة"); public static string SettingsLanguage => T("LANGUE", "LANGUAGE", "语言", "ภาษา", "اللغة");
public static string SettingsLanguageHint => T( public static string SettingsLanguageHint => T(
"Le launcher redémarrera pour appliquer le changement.", "Le launcher redémarrera pour appliquer le changement.",
@@ -401,6 +455,103 @@ public static class Strings
$"🔍 التحقق من SHA-256 v{version}: {percent}%" $"🔍 التحقق من SHA-256 v{version}: {percent}%"
); );
// ==================== DB MIGRATIONS ====================
public static string StatusApplyingMigrations(string version) => T(
$"Application des migrations base de données v{version}…",
$"Applying database migrations for v{version}…",
$"正在应用 v{version} 的数据库迁移…",
$"กำลังปรับใช้การโยกย้ายฐานข้อมูล v{version}…",
$"جارٍ تطبيق ترقيات قاعدة البيانات v{version}…"
);
public static string ProgressMigration(int done, int total, string filename) => T(
$"🗄 Migration {done}/{total} : {filename}",
$"🗄 Migration {done}/{total}: {filename}",
$"🗄 迁移 {done}/{total}{filename}",
$"🗄 การโยกย้าย {done}/{total}: {filename}",
$"🗄 الترقية {done}/{total}: {filename}"
);
public static string MsgBoxMigrationFailed => T("Migration DB échouée", "DB migration failed", "数据库迁移失败", "การโยกย้าย DB ล้มเหลว", "فشل ترقية قاعدة البيانات");
// ==================== REPORT TOOL DEPLOY ====================
public static string StatusDeployingReport(string version) => T(
$"Déploiement de l'outil Report v{version}…",
$"Deploying Report tool for v{version}…",
$"正在部署 v{version} 的报告工具…",
$"กำลังปรับใช้เครื่องมือรายงาน v{version}…",
$"جارٍ نشر أداة التقارير v{version}…"
);
public static string ProgressReportDeploy(int done, int total, string filename) => T(
$"📂 Déploiement Report : {done}/{total} — {filename}",
$"📂 Deploying Report: {done}/{total} — {filename}",
$"📂 部署报告:{done}/{total} — {filename}",
$"📂 ปรับใช้รายงาน: {done}/{total} — {filename}",
$"📂 نشر التقرير: {done}/{total} — {filename}"
);
public static string MsgBoxReportDeployFailed => T("Déploiement Report échoué", "Report deploy failed", "报告部署失败", "การปรับใช้รายงานล้มเหลว", "فشل نشر التقرير");
public static string MsgReportXamppNotFound(string path) => T(
$"L'outil Report n'a pas pu être déployé : le dossier XAMPP {path} est introuvable.\n\nVérifie que XAMPP est installé à cet emplacement (par défaut C:\\xampp) ou ajuste le chemin dans Paramètres → Avancés → Outil Report.",
$"The Report tool could not be deployed: XAMPP folder {path} not found.\n\nMake sure XAMPP is installed there (default C:\\xampp) or adjust the path in Settings → Advanced → Report Tool.",
$"无法部署报告工具:找不到 XAMPP 文件夹 {path}。\n\n请确保 XAMPP 安装在该位置(默认 C:\\xampp或在 设置 → 高级 → 报告工具 中调整路径。",
$"ไม่สามารถปรับใช้เครื่องมือรายงานได้: ไม่พบโฟลเดอร์ XAMPP {path}\n\nตรวจสอบว่า XAMPP ติดตั้งที่ตำแหน่งนั้น (ค่าเริ่มต้น C:\\xampp) หรือปรับเส้นทางใน ตั้งค่า → ขั้นสูง → เครื่องมือรายงาน",
$"تعذر نشر أداة التقارير: مجلد XAMPP {path} غير موجود.\n\nتأكد من تثبيت XAMPP في هذا الموقع (الافتراضي C:\\xampp) أو اضبط المسار في الإعدادات → متقدم → أداة التقارير."
);
public static string MsgReportDeployFailed(string detail) => T(
$"Le déploiement de l'outil Report a échoué :\n\n{detail}\n\nLa version PROSERVE est installée mais l'onglet Statistiques pourrait être désynchronisé. Tu peux retenter via Paramètres → Avancés → Re-déployer le Report.",
$"Report tool deploy failed:\n\n{detail}\n\nThe PROSERVE version is installed but the Reports tab might be out of sync. You can retry via Settings → Advanced → Re-deploy Report.",
$"报告工具部署失败:\n\n{detail}\n\nPROSERVE 版本已安装,但报告选项卡可能不同步。您可以通过 设置 → 高级 → 重新部署报告 重试。",
$"การปรับใช้เครื่องมือรายงานล้มเหลว:\n\n{detail}\n\nเวอร์ชัน PROSERVE ติดตั้งแล้วแต่แท็บรายงานอาจไม่ซิงค์ คุณสามารถลองใหม่ผ่าน ตั้งค่า → ขั้นสูง → ปรับใช้รายงานใหม่",
$"فشل نشر أداة التقارير:\n\n{detail}\n\nنسخة PROSERVE مثبتة لكن علامة التبويب التقارير قد لا تكون متزامنة. يمكنك إعادة المحاولة عبر الإعدادات → متقدم → إعادة نشر التقرير."
);
public static string SettingsReportTool => T("OUTIL REPORT (XAMPP htdocs)", "REPORT TOOL (XAMPP htdocs)", "报告工具 (XAMPP htdocs)", "เครื่องมือรายงาน (XAMPP htdocs)", "أداة التقارير (XAMPP htdocs)");
public static string SettingsReportHtdocs => T("Racine XAMPP htdocs", "XAMPP htdocs root", "XAMPP htdocs 根目录", "รากของ XAMPP htdocs", "جذر XAMPP htdocs");
public static string SettingsReportFolder => T("Nom du dossier de déploiement", "Deploy folder name", "部署文件夹名称", "ชื่อโฟลเดอร์ปรับใช้", "اسم مجلد النشر");
public static string SettingsReportAutoDeploy => T(
"Déployer automatiquement l'outil Report à l'install d'une nouvelle version",
"Automatically deploy the Report tool when installing a new version",
"安装新版本时自动部署报告工具",
"ปรับใช้เครื่องมือรายงานโดยอัตโนมัติเมื่อติดตั้งเวอร์ชันใหม่",
"نشر أداة التقارير تلقائياً عند تثبيت نسخة جديدة"
);
public static string SettingsReportRedeploy => T("📂 Re-déployer le Report maintenant", "📂 Re-deploy Report now", "📂 立即重新部署报告", "📂 ปรับใช้รายงานใหม่เดี๋ยวนี้", "📂 إعادة نشر التقرير الآن");
// ---- Backups Report tool ----
public static string SettingsReportBackups => T("Backups disponibles", "Available backups", "可用备份", "สำรองข้อมูลที่มี", "النسخ الاحتياطية المتاحة");
public static string SettingsReportMaxBackups => T("Conserver N backups", "Keep N backups", "保留 N 个备份", "เก็บสำรอง N ชุด", "الاحتفاظ بـ N نسخة احتياطية");
public static string SettingsReportRevert => T("↶ Revert", "↶ Revert", "↶ 还原", "↶ ย้อนกลับ", "↶ استرجاع");
public static string SettingsReportNoBackups => T(
"Aucun backup pour l'instant. Le premier sera créé au prochain deploy.",
"No backups yet. The first one will be created on the next deploy.",
"暂无备份。下次部署时将创建第一个。",
"ยังไม่มีสำรองข้อมูล จะสร้างครั้งแรกในการปรับใช้ครั้งถัดไป",
"لا توجد نسخ احتياطية بعد. سيتم إنشاء أول نسخة عند النشر التالي."
);
public static string SettingsReportBackupSize(string size) => T(
$"taille : {size}", $"size: {size}", $"大小:{size}", $"ขนาด: {size}", $"الحجم: {size}"
);
public static string MsgBoxRevertReport => T("Revert Outil Report", "Revert Report tool", "还原报告工具", "ย้อนกลับเครื่องมือรายงาน", "استرجاع أداة التقارير");
public static string MsgRevertReportConfirm(string date) => T(
$"Revenir à la version du {date} ?\n\nLa version actuellement déployée sera elle-même sauvegardée comme nouveau backup, donc tu pourras y revenir si besoin.",
$"Revert to the version from {date}?\n\nThe currently deployed version will itself be saved as a new backup, so you can switch back if needed.",
$"还原到 {date} 的版本?\n\n当前已部署的版本将作为新备份保存因此您可以在需要时切换回去。",
$"ย้อนกลับไปยังเวอร์ชันของ {date}?\n\nเวอร์ชันที่ปรับใช้อยู่ในปัจจุบันจะถูกบันทึกเป็นสำรองใหม่ ดังนั้นคุณสามารถสลับกลับได้หากต้องการ",
$"العودة إلى نسخة {date}؟\n\nسيتم حفظ النسخة المنشورة حالياً كنسخة احتياطية جديدة، لذا يمكنك التبديل إليها إذا لزم الأمر."
);
public static string MsgRevertReportSuccess(string date) => T(
$"✓ Reverted vers la version du {date}",
$"✓ Reverted to version from {date}",
$"✓ 已还原到 {date} 的版本",
$"✓ ย้อนกลับเป็นเวอร์ชันของ {date} แล้ว",
$"✓ تم الاسترجاع إلى نسخة {date}"
);
public static string MsgMigrationFailed(string detail) => T(
$"Une migration de la base de données a échoué :\n\n{detail}\n\nLa version a été extraite mais la base n'a PAS été modifiée (rollback automatique).\nVérifie que XAMPP est démarré et que les paramètres de connexion sont corrects (Settings → Base de données), puis clique « Rejouer les migrations » dans Settings.",
$"A database migration failed:\n\n{detail}\n\nThe version was extracted but the database was NOT modified (automatic rollback).\nMake sure XAMPP is running and the connection settings are correct (Settings → Database), then click « Replay migrations » in Settings.",
$"数据库迁移失败:\n\n{detail}\n\n版本已解压但数据库未被修改自动回滚。\n请确保 XAMPP 正在运行并且连接设置正确(设置 → 数据库),然后点击设置中的「重新执行迁移」。",
$"การโยกย้ายฐานข้อมูลล้มเหลว:\n\n{detail}\n\nเวอร์ชันถูกแตกแล้วแต่ฐานข้อมูลไม่ถูกแก้ไข (rollback อัตโนมัติ)\nตรวจสอบว่า XAMPP กำลังทำงานและการตั้งค่าการเชื่อมต่อถูกต้อง (ตั้งค่า → ฐานข้อมูล) จากนั้นคลิก « เรียกใช้การโยกย้ายอีกครั้ง » ในการตั้งค่า",
$"فشلت ترقية قاعدة البيانات:\n\n{detail}\n\nتم استخراج النسخة لكن قاعدة البيانات لم تُعدَّل (rollback تلقائي).\nتأكد من تشغيل XAMPP وأن إعدادات الاتصال صحيحة (الإعدادات → قاعدة البيانات)، ثم انقر « إعادة تشغيل الترقيات » في الإعدادات."
);
public static string StatusPreparingDownload(string version) => T( public static string StatusPreparingDownload(string version) => T(
$"Préparation du téléchargement v{version}…", $"Préparation du téléchargement v{version}…",
$"Preparing download for v{version}…", $"Preparing download for v{version}…",

View File

@@ -0,0 +1,313 @@
using System.Diagnostics;
using System.Security.Cryptography;
using System.Text;
using Microsoft.Extensions.Logging;
using MySqlConnector;
using PSLauncher.Models;
namespace PSLauncher.Core.Migrations;
/// <summary>
/// Implémentation MySQL/MariaDB du service de migration.
/// Workflow :
/// <code>
/// 1. CREATE TABLE IF NOT EXISTS _launcher_migrations (filename, applied_at, checksum, duration_ms)
/// 2. SELECT déjà-appliquées
/// 3. Liste {installFolder}/_migrations/*.sql triées alphabétiquement
/// 4. Pour chaque non-appliquée :
/// START TRANSACTION
/// Exécute le script (peut contenir plusieurs statements séparés par ;)
/// INSERT INTO _launcher_migrations
/// COMMIT
/// Si exception → ROLLBACK + abort + reporte l'erreur
/// 5. Pour chaque appliquée dont le checksum a changé : log warning (script modifié après publication)
/// </code>
/// </summary>
public sealed class DatabaseMigrationService : IDatabaseMigrationService
{
private const string TrackingTable = "_launcher_migrations";
private readonly Func<DatabaseConfig> _configProvider;
private readonly Func<string?> _passwordProvider;
private readonly ILogger<DatabaseMigrationService> _logger;
public DatabaseMigrationService(
Func<DatabaseConfig> configProvider,
Func<string?> passwordProvider,
ILogger<DatabaseMigrationService> logger)
{
_configProvider = configProvider;
_passwordProvider = passwordProvider;
_logger = logger;
}
public async Task<string?> TestConnectionAsync(CancellationToken ct)
{
try
{
await using var conn = await OpenAsync(ct).ConfigureAwait(false);
await using var cmd = new MySqlCommand("SELECT 1", conn);
await cmd.ExecuteScalarAsync(ct).ConfigureAwait(false);
return null;
}
catch (Exception ex)
{
return ex.Message;
}
}
public async Task<MigrationResult> ApplyMigrationsAsync(
string installFolder,
IProgress<MigrationProgress>? progress,
CancellationToken ct)
{
var migrationsDir = Path.Combine(installFolder, "_migrations");
if (!Directory.Exists(migrationsDir))
{
_logger.LogInformation("No _migrations/ directory in {Folder}, skipping DB migration step", installFolder);
return new MigrationResult(Array.Empty<MigrationOutcome>(), AllSucceeded: true, FailureMessage: null);
}
// Liste les .sql, ignore les autres extensions (README, etc.)
var files = Directory.EnumerateFiles(migrationsDir, "*.sql", SearchOption.TopDirectoryOnly)
.OrderBy(f => Path.GetFileName(f), StringComparer.Ordinal)
.ToList();
if (files.Count == 0)
{
_logger.LogInformation("_migrations/ exists but empty in {Folder}", installFolder);
return new MigrationResult(Array.Empty<MigrationOutcome>(), AllSucceeded: true, FailureMessage: null);
}
await using var conn = await OpenAsync(ct).ConfigureAwait(false);
await EnsureTrackingTableAsync(conn, ct).ConfigureAwait(false);
var applied = await LoadAppliedAsync(conn, ct).ConfigureAwait(false);
var outcomes = new List<MigrationOutcome>();
for (int i = 0; i < files.Count; i++)
{
ct.ThrowIfCancellationRequested();
var filepath = files[i];
var filename = Path.GetFileName(filepath);
progress?.Report(new MigrationProgress(i, files.Count, filename));
var sql = await File.ReadAllTextAsync(filepath, Encoding.UTF8, ct).ConfigureAwait(false);
var checksum = ComputeSha256(sql);
if (applied.TryGetValue(filename, out var prevChecksum))
{
if (!string.Equals(prevChecksum, checksum, StringComparison.OrdinalIgnoreCase))
{
// Détection d'une modif après-publication : on skip mais on log fort.
_logger.LogWarning(
"Migration {File} was modified after being applied (stored sha256={Stored}, current={Now}). " +
"Ignored to preserve historical truth — write a NEW migration file instead.",
filename, prevChecksum[..12], checksum[..12]);
}
outcomes.Add(new MigrationOutcome(filename, MigrationStatus.Skipped, 0, null));
continue;
}
var sw = Stopwatch.StartNew();
await using var tx = await conn.BeginTransactionAsync(ct).ConfigureAwait(false);
try
{
await ExecuteScriptAsync(conn, tx, sql, ct).ConfigureAwait(false);
await using var insertCmd = new MySqlCommand(
$"INSERT INTO {TrackingTable} (filename, applied_at, checksum, duration_ms) VALUES (@f, NOW(), @c, @d)",
conn, tx);
insertCmd.Parameters.AddWithValue("@f", filename);
insertCmd.Parameters.AddWithValue("@c", checksum);
insertCmd.Parameters.AddWithValue("@d", sw.ElapsedMilliseconds);
await insertCmd.ExecuteNonQueryAsync(ct).ConfigureAwait(false);
await tx.CommitAsync(ct).ConfigureAwait(false);
sw.Stop();
outcomes.Add(new MigrationOutcome(filename, MigrationStatus.Applied, sw.ElapsedMilliseconds, null));
_logger.LogInformation("Migration {File} applied in {Ms} ms", filename, sw.ElapsedMilliseconds);
}
catch (Exception ex)
{
sw.Stop();
try { await tx.RollbackAsync(ct).ConfigureAwait(false); } catch { /* swallow */ }
outcomes.Add(new MigrationOutcome(filename, MigrationStatus.Failed, sw.ElapsedMilliseconds, ex.Message));
_logger.LogError(ex, "Migration {File} FAILED after {Ms} ms — DB state rolled back", filename, sw.ElapsedMilliseconds);
progress?.Report(new MigrationProgress(i + 1, files.Count, filename));
return new MigrationResult(outcomes, AllSucceeded: false, FailureMessage: $"{filename} : {ex.Message}");
}
}
progress?.Report(new MigrationProgress(files.Count, files.Count, string.Empty));
return new MigrationResult(outcomes, AllSucceeded: true, FailureMessage: null);
}
public async Task<IReadOnlyList<AppliedMigration>> ListAppliedAsync(CancellationToken ct)
{
await using var conn = await OpenAsync(ct).ConfigureAwait(false);
await EnsureTrackingTableAsync(conn, ct).ConfigureAwait(false);
var list = new List<AppliedMigration>();
await using var cmd = new MySqlCommand(
$"SELECT filename, applied_at, checksum, duration_ms FROM {TrackingTable} ORDER BY id",
conn);
await using var reader = await cmd.ExecuteReaderAsync(ct).ConfigureAwait(false);
while (await reader.ReadAsync(ct).ConfigureAwait(false))
{
list.Add(new AppliedMigration(
reader.GetString(0),
DateTime.SpecifyKind(reader.GetDateTime(1), DateTimeKind.Utc),
reader.GetString(2),
reader.GetInt64(3)));
}
return list;
}
// ----- helpers -----
private async Task<MySqlConnection> OpenAsync(CancellationToken ct)
{
var cfg = _configProvider();
var pwd = _passwordProvider() ?? string.Empty;
var csb = new MySqlConnectionStringBuilder
{
Server = cfg.Host,
Port = cfg.Port,
UserID = cfg.User,
Password = pwd,
Database = cfg.Database,
// Désactive le pooling : on ouvre une connexion par opération courte,
// pas besoin de garder un pool ouvert sur la machine du user.
Pooling = false,
ConnectionTimeout = 10,
DefaultCommandTimeout = 600, // 10 min : laisse de la marge pour les grosses migrations
AllowUserVariables = true,
};
var conn = new MySqlConnection(csb.ConnectionString);
await conn.OpenAsync(ct).ConfigureAwait(false);
return conn;
}
private static async Task EnsureTrackingTableAsync(MySqlConnection conn, CancellationToken ct)
{
const string ddl = $"""
CREATE TABLE IF NOT EXISTS {TrackingTable} (
id INT PRIMARY KEY AUTO_INCREMENT,
filename VARCHAR(255) NOT NULL UNIQUE,
applied_at DATETIME NOT NULL,
checksum CHAR(64) NOT NULL,
duration_ms BIGINT NOT NULL DEFAULT 0
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
""";
await using var cmd = new MySqlCommand(ddl, conn);
await cmd.ExecuteNonQueryAsync(ct).ConfigureAwait(false);
}
private static async Task<Dictionary<string, string>> LoadAppliedAsync(MySqlConnection conn, CancellationToken ct)
{
var map = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
await using var cmd = new MySqlCommand($"SELECT filename, checksum FROM {TrackingTable}", conn);
await using var reader = await cmd.ExecuteReaderAsync(ct).ConfigureAwait(false);
while (await reader.ReadAsync(ct).ConfigureAwait(false))
map[reader.GetString(0)] = reader.GetString(1);
return map;
}
/// <summary>
/// MySqlCommand exécute UNE commande à la fois. Pour un fichier .sql contenant
/// plusieurs statements séparés par <c>;</c>, on parse manuellement (en respectant
/// les chaînes / commentaires) et on exécute statement-par-statement dans la
/// même transaction. Évite la dépendance à <c>MySqlScript</c> et permet de
/// supporter <see cref="CancellationToken"/> proprement.
/// </summary>
private static async Task ExecuteScriptAsync(MySqlConnection conn, MySqlTransaction tx, string sql, CancellationToken ct)
{
foreach (var stmt in SplitStatements(sql))
{
ct.ThrowIfCancellationRequested();
var trimmed = stmt.Trim();
if (string.IsNullOrEmpty(trimmed)) continue;
// Skip si juste un commentaire ou une ligne blanche
if (IsAllCommentsOrWhitespace(trimmed)) continue;
await using var cmd = new MySqlCommand(trimmed, conn, tx);
await cmd.ExecuteNonQueryAsync(ct).ConfigureAwait(false);
}
}
private static bool IsAllCommentsOrWhitespace(string s)
{
// Heuristique simple : si après strip des commentaires il reste des trucs non-blancs.
var stripped = System.Text.RegularExpressions.Regex.Replace(s, @"(--[^\n]*)|(/\*[\s\S]*?\*/)", "");
return string.IsNullOrWhiteSpace(stripped);
}
/// <summary>
/// Splitter SQL minimaliste : sépare par <c>;</c> en respectant les chaînes
/// (' et "), les identifiants (`...`) et les commentaires (-- ... et /* ... */).
/// Suffisant pour la plupart des fichiers de migration courants ; pour des
/// triggers/procs avec DELIMITER, écrire chaque statement dans un fichier séparé.
/// </summary>
private static IEnumerable<string> SplitStatements(string sql)
{
var sb = new StringBuilder();
int i = 0;
char? quote = null;
bool inLineComment = false;
bool inBlockComment = false;
while (i < sql.Length)
{
char c = sql[i];
char next = i + 1 < sql.Length ? sql[i + 1] : '\0';
if (inLineComment)
{
sb.Append(c);
if (c == '\n') inLineComment = false;
i++;
continue;
}
if (inBlockComment)
{
sb.Append(c);
if (c == '*' && next == '/') { sb.Append(next); i += 2; inBlockComment = false; continue; }
i++;
continue;
}
if (quote is not null)
{
sb.Append(c);
if (c == '\\' && i + 1 < sql.Length) { sb.Append(sql[i + 1]); i += 2; continue; }
if (c == quote) quote = null;
i++;
continue;
}
// hors-quote, hors-commentaire
if (c == '-' && next == '-') { sb.Append(c).Append(next); i += 2; inLineComment = true; continue; }
if (c == '/' && next == '*') { sb.Append(c).Append(next); i += 2; inBlockComment = true; continue; }
if (c == '\'' || c == '"' || c == '`') { quote = c; sb.Append(c); i++; continue; }
if (c == ';')
{
yield return sb.ToString();
sb.Clear();
i++;
continue;
}
sb.Append(c);
i++;
}
if (sb.Length > 0)
{
var tail = sb.ToString().Trim();
if (tail.Length > 0) yield return tail;
}
}
private static string ComputeSha256(string text)
{
var bytes = Encoding.UTF8.GetBytes(text);
var hash = SHA256.HashData(bytes);
return Convert.ToHexString(hash).ToLowerInvariant();
}
}

View File

@@ -0,0 +1,36 @@
using System.Security.Cryptography;
using System.Text;
namespace PSLauncher.Core.Migrations;
/// <summary>
/// Chiffre / déchiffre le mot de passe MySQL stocké dans <see cref="PSLauncher.Models.DatabaseConfig.EncryptedPassword"/>
/// via DPAPI scope CurrentUser. Même mécanisme que pour la clé de license : un user
/// qui copierait le config.json sur une autre machine ne pourrait pas déchiffrer.
/// </summary>
public static class DatabasePasswordProtector
{
public static string? Protect(string? clearPassword)
{
if (string.IsNullOrEmpty(clearPassword)) return null;
var data = Encoding.UTF8.GetBytes(clearPassword);
var protectedBytes = ProtectedData.Protect(data, optionalEntropy: null, scope: DataProtectionScope.CurrentUser);
return Convert.ToBase64String(protectedBytes);
}
public static string? Unprotect(string? base64)
{
if (string.IsNullOrEmpty(base64)) return null;
try
{
var protectedBytes = Convert.FromBase64String(base64);
var data = ProtectedData.Unprotect(protectedBytes, optionalEntropy: null, scope: DataProtectionScope.CurrentUser);
return Encoding.UTF8.GetString(data);
}
catch
{
// Cache déplacé sur autre machine / autre user → non déchiffrable
return null;
}
}
}

View File

@@ -0,0 +1,57 @@
namespace PSLauncher.Core.Migrations;
/// <summary>
/// Applique les scripts SQL livrés dans le sous-répertoire <c>_migrations/</c>
/// d'un build PROSERVE installé. Chaque script tournant en transaction, le
/// résultat est tracké dans une table <c>_launcher_migrations</c> pour qu'un
/// même script ne soit pas rejoué deux fois (mêmes garanties que Flyway/Doctrine).
/// </summary>
public interface IDatabaseMigrationService
{
/// <summary>
/// Cherche <c>{installFolder}/_migrations/*.sql</c>, applique en ordre alphabétique
/// les scripts pas encore présents dans <c>_launcher_migrations</c>, et reporte la
/// progression. Retourne la liste détaillée des opérations (appliquées + skippées).
/// </summary>
Task<MigrationResult> ApplyMigrationsAsync(
string installFolder,
IProgress<MigrationProgress>? progress,
CancellationToken ct);
/// <summary>
/// Liste les migrations déjà appliquées en base. Utile pour la vue Settings.
/// </summary>
Task<IReadOnlyList<AppliedMigration>> ListAppliedAsync(CancellationToken ct);
/// <summary>
/// Test de connexion. Retourne null si OK, sinon le message d'erreur formaté.
/// </summary>
Task<string?> TestConnectionAsync(CancellationToken ct);
}
public sealed record MigrationProgress(int Done, int Total, string CurrentFilename);
public sealed record MigrationResult(
IReadOnlyList<MigrationOutcome> Outcomes,
bool AllSucceeded,
string? FailureMessage)
{
public int AppliedCount => Outcomes.Count(o => o.Status == MigrationStatus.Applied);
public int SkippedCount => Outcomes.Count(o => o.Status == MigrationStatus.Skipped);
public int FailedCount => Outcomes.Count(o => o.Status == MigrationStatus.Failed);
public bool HasNoMigrationsFolder => !Outcomes.Any() && AllSucceeded;
}
public sealed record MigrationOutcome(
string Filename,
MigrationStatus Status,
long DurationMs,
string? Error);
public enum MigrationStatus { Skipped, Applied, Failed }
public sealed record AppliedMigration(
string Filename,
DateTime AppliedAt,
string Checksum,
long DurationMs);

View File

@@ -12,6 +12,8 @@
<PackageReference Include="Polly" Version="8.4.2" /> <PackageReference Include="Polly" Version="8.4.2" />
<PackageReference Include="NSec.Cryptography" Version="24.4.0" /> <PackageReference Include="NSec.Cryptography" Version="24.4.0" />
<PackageReference Include="System.Security.Cryptography.ProtectedData" Version="8.0.0" /> <PackageReference Include="System.Security.Cryptography.ProtectedData" Version="8.0.0" />
<!-- MySQL client : MySqlConnector (BSD-3-Clause), pas Oracle MySql.Data (GPL) -->
<PackageReference Include="MySqlConnector" Version="2.3.7" />
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>

View File

@@ -0,0 +1,68 @@
namespace PSLauncher.Core.ReportTool;
/// <summary>
/// Déploie l'outil Report (page web hébergée dans XAMPP) depuis le sous-dossier
/// <c>_report/</c> bundled dans chaque ZIP PROSERVE vers le htdocs local.
/// Conserve N backups horodatés permettant un revert manuel via la UI Settings.
/// </summary>
public interface IReportToolDeployer
{
/// <summary>
/// Si <c>{installFolder}/_report/</c> existe, le copie vers
/// <c>{HtdocsRoot}/{FolderName}/</c> via un rename atomique :
/// <list type="number">
/// <item>copie récursive vers <c>{target}.new/</c></item>
/// <item>rename de l'existant vers <c>{target}.backup-{yyyyMMdd-HHmmss}/</c></item>
/// <item>rename <c>.new</c> → final</item>
/// <item>prune des vieux backups au-delà de <c>MaxBackups</c></item>
/// </list>
/// </summary>
Task<DeployResult> DeployAsync(
string installFolder,
IProgress<DeployProgress>? progress,
CancellationToken ct);
/// <summary>
/// Liste les backups horodatés disponibles dans <c>{HtdocsRoot}</c>, du plus
/// récent au plus ancien. Vide si aucun backup ou si htdocs introuvable.
/// </summary>
Task<IReadOnlyList<BackupInfo>> ListBackupsAsync(CancellationToken ct);
/// <summary>
/// Revient à un backup horodaté précis. Swap atomique : la version actuelle
/// est elle-même renommée en backup-{now} (donc le revert est lui-même
/// reversible), puis le backup choisi est renommé en version active.
/// </summary>
Task<DeployResult> RevertAsync(string backupFolderName, CancellationToken ct);
}
public enum DeployStatus
{
/// <summary>Le ZIP n'avait pas de sous-dossier <c>_report/</c>, rien à faire.</summary>
SkippedNoSourceFolder,
/// <summary>HtdocsRoot configuré n'existe pas (XAMPP pas installé là).</summary>
XamppNotFound,
/// <summary>Erreur I/O pendant la copie ou le rename.</summary>
Failed,
/// <summary>Déploiement (ou revert) réussi.</summary>
Deployed,
/// <summary>Revert demandé sur un backup qui n'existe pas / plus.</summary>
BackupNotFound,
}
public sealed record DeployResult(
DeployStatus Status,
int FilesDeployed,
long BytesDeployed,
string TargetPath,
string? ErrorMessage = null);
public sealed record DeployProgress(int FilesDone, int FilesTotal, string CurrentFilename);
/// <summary>
/// Métadonnées d'un backup horodaté présent dans <c>{HtdocsRoot}</c>.
/// </summary>
public sealed record BackupInfo(
string FolderName, // ProserveReport.backup-20260503-143022
DateTime TimestampUtc, // parsé depuis le suffixe du nom de dossier
long SizeBytes);

View File

@@ -0,0 +1,233 @@
using System.Globalization;
using Microsoft.Extensions.Logging;
using PSLauncher.Models;
namespace PSLauncher.Core.ReportTool;
/// <summary>
/// Implémentation : copy récursif + atomic rename + backups horodatés.
/// L'atomicité du rename garantit qu'Apache (qui sert les fichiers en parallèle)
/// ne tombera jamais sur un état mi-déployé. Au pire il sert l'ancienne version
/// pendant 1ms le temps du rename, puis sert la nouvelle.
///
/// Backups : à chaque deploy, l'ancien dossier est conservé sous le nom
/// <c>{FolderName}.backup-yyyyMMdd-HHmmss</c>. On garde les <c>MaxBackups</c>
/// plus récents et on supprime les autres en best-effort. Permet le revert
/// manuel via Settings → Avancés → Outil Report.
/// </summary>
public sealed class ReportToolDeployer : IReportToolDeployer
{
private const string SourceSubdir = "_report";
private const string BackupSuffix = ".backup-";
// Format trié-par-nom = trié-par-date (lexicographique == chronologique)
private const string TimestampFormat = "yyyyMMdd-HHmmss";
private readonly Func<ReportToolConfig> _configProvider;
private readonly ILogger<ReportToolDeployer> _logger;
public ReportToolDeployer(
Func<ReportToolConfig> configProvider,
ILogger<ReportToolDeployer> logger)
{
_configProvider = configProvider;
_logger = logger;
}
public async Task<DeployResult> DeployAsync(
string installFolder,
IProgress<DeployProgress>? progress,
CancellationToken ct)
{
var cfg = _configProvider();
var source = Path.Combine(installFolder, SourceSubdir);
var target = Path.Combine(cfg.HtdocsRoot, cfg.FolderName);
if (!Directory.Exists(source))
{
_logger.LogInformation("No {Sub}/ directory in {Folder}, skipping report deploy", SourceSubdir, installFolder);
return new DeployResult(DeployStatus.SkippedNoSourceFolder, 0, 0, target);
}
if (!Directory.Exists(cfg.HtdocsRoot))
{
_logger.LogWarning("HtdocsRoot {Root} not found — XAMPP not installed at expected location", cfg.HtdocsRoot);
return new DeployResult(DeployStatus.XamppNotFound, 0, 0, target,
$"Le dossier {cfg.HtdocsRoot} est introuvable. XAMPP est-il installé à cet emplacement ? (Settings → Avancés)");
}
var stagingTarget = target + ".new";
var backupTarget = target + BackupSuffix + DateTime.UtcNow.ToString(TimestampFormat, CultureInfo.InvariantCulture);
try
{
// Nettoyage d'un éventuel résidu de tentative précédente (le .new orphelin)
if (Directory.Exists(stagingTarget)) Directory.Delete(stagingTarget, recursive: true);
// Copy avec progress
var allFiles = Directory.EnumerateFiles(source, "*", SearchOption.AllDirectories).ToList();
int total = allFiles.Count;
long bytesTotal = 0;
await Task.Run(() =>
{
Directory.CreateDirectory(stagingTarget);
int done = 0;
foreach (var srcFile in allFiles)
{
ct.ThrowIfCancellationRequested();
var rel = Path.GetRelativePath(source, srcFile);
var dstFile = Path.Combine(stagingTarget, rel);
var dstDir = Path.GetDirectoryName(dstFile);
if (!string.IsNullOrEmpty(dstDir) && !Directory.Exists(dstDir))
Directory.CreateDirectory(dstDir);
File.Copy(srcFile, dstFile, overwrite: true);
bytesTotal += new FileInfo(srcFile).Length;
done++;
progress?.Report(new DeployProgress(done, total, rel));
}
}, ct).ConfigureAwait(false);
// Atomic swap : current → backup-{ts}, .new → current
if (Directory.Exists(target))
{
Directory.Move(target, backupTarget);
}
Directory.Move(stagingTarget, target);
// Prune des vieux backups (best-effort, ne bloque pas le retour)
PruneOldBackups(cfg);
_logger.LogInformation("Report tool deployed to {Target} ({Files} files, {Bytes} bytes), backup at {Backup}",
target, total, bytesTotal, backupTarget);
return new DeployResult(DeployStatus.Deployed, total, bytesTotal, target);
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to deploy report tool to {Target}", target);
try { if (Directory.Exists(stagingTarget)) Directory.Delete(stagingTarget, recursive: true); }
catch { /* ignore */ }
return new DeployResult(DeployStatus.Failed, 0, 0, target, ex.Message);
}
}
public Task<IReadOnlyList<BackupInfo>> ListBackupsAsync(CancellationToken ct)
{
var cfg = _configProvider();
var prefix = cfg.FolderName + BackupSuffix;
if (!Directory.Exists(cfg.HtdocsRoot))
return Task.FromResult<IReadOnlyList<BackupInfo>>(Array.Empty<BackupInfo>());
var list = new List<BackupInfo>();
foreach (var dir in Directory.EnumerateDirectories(cfg.HtdocsRoot, prefix + "*", SearchOption.TopDirectoryOnly))
{
ct.ThrowIfCancellationRequested();
var name = Path.GetFileName(dir);
var tsPart = name.Substring(prefix.Length);
if (!DateTime.TryParseExact(tsPart, TimestampFormat, CultureInfo.InvariantCulture,
DateTimeStyles.AssumeUniversal | DateTimeStyles.AdjustToUniversal, out var ts))
{
continue; // dossier non conforme, on ignore
}
long size = 0;
try
{
foreach (var f in Directory.EnumerateFiles(dir, "*", SearchOption.AllDirectories))
size += new FileInfo(f).Length;
}
catch { /* size best-effort */ }
list.Add(new BackupInfo(name, ts, size));
}
// Plus récent en premier
list.Sort((a, b) => b.TimestampUtc.CompareTo(a.TimestampUtc));
return Task.FromResult<IReadOnlyList<BackupInfo>>(list);
}
public async Task<DeployResult> RevertAsync(string backupFolderName, CancellationToken ct)
{
var cfg = _configProvider();
var backupPath = Path.Combine(cfg.HtdocsRoot, backupFolderName);
var target = Path.Combine(cfg.HtdocsRoot, cfg.FolderName);
if (!Directory.Exists(backupPath))
{
return new DeployResult(DeployStatus.BackupNotFound, 0, 0, target,
$"Le backup {backupFolderName} n'existe plus.");
}
// Le revert lui-même crée un backup de l'état actuel, pour qu'on puisse
// revert le revert si besoin.
var newBackupForCurrent = target + BackupSuffix + DateTime.UtcNow.ToString(TimestampFormat, CultureInfo.InvariantCulture);
try
{
await Task.Run(() =>
{
ct.ThrowIfCancellationRequested();
if (Directory.Exists(target))
Directory.Move(target, newBackupForCurrent);
Directory.Move(backupPath, target);
}, ct).ConfigureAwait(false);
// Compte les fichiers du backup restauré pour le retour
int files = 0;
long bytes = 0;
try
{
foreach (var f in Directory.EnumerateFiles(target, "*", SearchOption.AllDirectories))
{
files++;
bytes += new FileInfo(f).Length;
}
}
catch { /* best-effort */ }
PruneOldBackups(cfg);
_logger.LogInformation("Reverted to backup {Backup} ({Files} files), previous current saved as {New}",
backupFolderName, files, newBackupForCurrent);
return new DeployResult(DeployStatus.Deployed, files, bytes, target);
}
catch (Exception ex)
{
_logger.LogError(ex, "Revert to {Backup} failed", backupFolderName);
return new DeployResult(DeployStatus.Failed, 0, 0, target, ex.Message);
}
}
/// <summary>
/// Garde les <c>MaxBackups</c> plus récents, supprime les autres en best-effort.
/// Si <c>MaxBackups = 0</c>, supprime tous les backups (mode "no history").
/// </summary>
private void PruneOldBackups(ReportToolConfig cfg)
{
try
{
var prefix = cfg.FolderName + BackupSuffix;
var dirs = Directory.EnumerateDirectories(cfg.HtdocsRoot, prefix + "*", SearchOption.TopDirectoryOnly)
.Select(d => new { Path = d, Name = Path.GetFileName(d) })
// Tri descending par nom = descending par date (yyyyMMdd-HHmmss est lexicographique)
.OrderByDescending(d => d.Name, StringComparer.Ordinal)
.ToList();
int keep = Math.Max(0, cfg.MaxBackups);
for (int i = keep; i < dirs.Count; i++)
{
try
{
Directory.Delete(dirs[i].Path, recursive: true);
_logger.LogDebug("Pruned old report backup {Dir}", dirs[i].Name);
}
catch (Exception ex)
{
// Apache peut tenir un handle sur un fichier (logs ouverts, etc.).
// On retentera au prochain deploy. Pas bloquant.
_logger.LogDebug(ex, "Could not prune backup {Dir} (will retry next deploy)", dirs[i].Name);
}
}
}
catch (Exception ex)
{
_logger.LogDebug(ex, "Backup pruning skipped");
}
}
}

View File

@@ -44,7 +44,7 @@ public sealed class LauncherSelfUpdater : ILauncherSelfUpdater
IProgress<DownloadProgress>? progress, IProgress<DownloadProgress>? progress,
CancellationToken ct) CancellationToken ct)
{ {
// 1. Localise le PSLauncher.exe en cours d'exécution // 1. Localise le PS_Launcher.exe en cours d'exécution
var processModule = SysProcess.GetCurrentProcess().MainModule var processModule = SysProcess.GetCurrentProcess().MainModule
?? throw new InvalidOperationException("Cannot locate current process module"); ?? throw new InvalidOperationException("Cannot locate current process module");
var targetExe = processModule.FileName!; var targetExe = processModule.FileName!;
@@ -64,20 +64,18 @@ public sealed class LauncherSelfUpdater : ILauncherSelfUpdater
var downloadedPath = await _downloadManager.DownloadAsync(downloadJob, progress, ct).ConfigureAwait(false); var downloadedPath = await _downloadManager.DownloadAsync(downloadJob, progress, ct).ConfigureAwait(false);
_logger.LogInformation("New launcher downloaded to {Path}", downloadedPath); _logger.LogInformation("New launcher downloaded to {Path}", downloadedPath);
// 3. Localise PSLauncher.Updater.exe (à côté du target) // 3. Localise PS_Launcher.Updater.exe (à côté du target)
var updaterPath = Path.Combine(installDir, "PSLauncher.Updater.exe"); var updaterPath = Path.Combine(installDir, "PS_Launcher.Updater.exe");
if (!File.Exists(updaterPath)) if (!File.Exists(updaterPath))
{ {
// Fallback : tente de copier l'updater depuis le dossier d'install vers un staging
// si jamais il a été supprimé. Pour la v1, on demande à l'utilisateur de réinstaller.
throw new FileNotFoundException( throw new FileNotFoundException(
"PSLauncher.Updater.exe est manquant à côté de PSLauncher.exe. " + "PS_Launcher.Updater.exe est manquant à côté de PS_Launcher.exe. " +
"Réinstalle le launcher depuis l'installeur officiel.", "Réinstalle le launcher depuis l'installeur officiel.",
updaterPath); updaterPath);
} }
// 4. Copie le .new dans le staging dir si downloaded est ailleurs // 4. Copie le .new dans le staging dir si downloaded est ailleurs
var newPath = Path.Combine(stagingDir, $"PSLauncher-{launcher.Version}.exe"); var newPath = Path.Combine(stagingDir, $"PS_Launcher-{launcher.Version}.exe");
if (!string.Equals(downloadedPath, newPath, StringComparison.OrdinalIgnoreCase)) if (!string.Equals(downloadedPath, newPath, StringComparison.OrdinalIgnoreCase))
{ {
if (File.Exists(newPath)) File.Delete(newPath); if (File.Exists(newPath)) File.Delete(newPath);

View File

@@ -28,7 +28,94 @@ public sealed class LocalConfig
/// </summary> /// </summary>
public bool VerifyDownloadHash { get; set; } = true; public bool VerifyDownloadHash { get; set; } = true;
/// <summary>
/// URL de l'outil de reporting local (page web embarquée dans le launcher,
/// onglet "Report"). Par défaut pointe vers l'install standard ASTERION
/// dans XAMPP. Surchargeable dans Settings → Avancés.
/// </summary>
public string ReportUrl { get; set; } = "http://localhost/ProserveReport/";
/// <summary>
/// URL de la documentation locale (onglet "Documentation"). Vide par défaut :
/// l'utilisateur peut pointer vers une URL locale ou web. Si vide, l'onglet
/// affiche un placeholder avec les liens vers les fichiers .pptx du dossier
/// d'install du launcher.
/// </summary>
public string DocumentationUrl { get; set; } = string.Empty;
public LicenseConfig License { get; set; } = new(); public LicenseConfig License { get; set; } = new();
/// <summary>
/// Configuration du déploiement de l'outil Report (page web locale embarquée
/// dans l'onglet "Statistiques") vers la racine XAMPP htdocs.
/// Le launcher copie le dossier <c>_report/</c> bundled dans chaque ZIP
/// PROSERVE vers <c>{HtdocsRoot}\{FolderName}</c> à chaque install.
/// </summary>
public ReportToolConfig ReportTool { get; set; } = new();
/// <summary>
/// Connexion à la base MySQL locale (XAMPP) utilisée par PROSERVE pour ses
/// statistiques. Le launcher s'en sert pour appliquer les migrations bundled
/// dans <c>_migrations/</c> au moment de l'install d'une nouvelle version.
/// </summary>
public DatabaseConfig Database { get; set; } = new();
}
/// <summary>
/// Paramètres de connexion à la base MySQL locale. Les valeurs par défaut
/// correspondent à un XAMPP standard fraîchement installé. Override possible
/// via Settings → Base de données.
/// </summary>
public sealed class DatabaseConfig
{
public string Host { get; set; } = "localhost";
public uint Port { get; set; } = 3306;
public string User { get; set; } = "root";
/// <summary>
/// Mot de passe MySQL stocké chiffré DPAPI (CurrentUser scope) en base64.
/// Vide = pas de password (config XAMPP par défaut). NE PAS écrire en clair.
/// </summary>
public string? EncryptedPassword { get; set; }
/// <summary>
/// Nom de la base de données qui contient les tables de PROSERVE.
/// Default "proserveapi" — c'est le nom utilisé par le script d'install ASTERION
/// qui provisionne XAMPP sur les machines clients. À surcharger seulement si la DB
/// a été créée sous un autre nom.
/// </summary>
public string Database { get; set; } = "proserveapi";
/// <summary>
/// Si true, le launcher applique automatiquement les migrations SQL bundled
/// dans <c>_migrations/</c> de chaque ZIP, juste après l'extraction.
/// Sécurité : on n'applique JAMAIS sans cette opt-in (au cas où certains users
/// auraient une config DB exotique non gérée par le defaut).
/// </summary>
public bool AutoApplyMigrations { get; set; } = true;
}
/// <summary>
/// Paramètres de déploiement de l'outil Report local.
/// Le launcher cherche un sous-dossier <c>_report/</c> dans chaque ZIP PROSERVE
/// installé et le copie vers <c>{HtdocsRoot}\{FolderName}</c> via un rename
/// atomique (deploy en .new puis swap). L'URL de l'onglet Statistiques pointe
/// vers <c>http://localhost/{FolderName}/</c> ; les deux doivent matcher.
/// </summary>
public sealed class ReportToolConfig
{
public string HtdocsRoot { get; set; } = @"C:\xampp\htdocs";
public string FolderName { get; set; } = "ProserveReport";
public bool AutoDeploy { get; set; } = true;
/// <summary>
/// Nombre de backups horodatés à conserver dans htdocs après chaque deploy.
/// Format des dossiers : <c>{FolderName}.backup-yyyyMMdd-HHmmss</c>.
/// Permet le revert via Settings si le nouveau deploy a un bug visible.
/// 3 par défaut = couvre les dernières releases sans grossir trop le disque.
/// 0 = pas de backup (retourne au comportement v0.12.0).
/// </summary>
public int MaxBackups { get; set; } = 3;
} }
public sealed class LicenseConfig public sealed class LicenseConfig

View File

@@ -6,9 +6,9 @@
<Nullable>enable</Nullable> <Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings> <ImplicitUsings>enable</ImplicitUsings>
<LangVersion>latest</LangVersion> <LangVersion>latest</LangVersion>
<AssemblyName>PSLauncher.Updater</AssemblyName> <AssemblyName>PS_Launcher.Updater</AssemblyName>
<RootNamespace>PSLauncher.Updater</RootNamespace> <RootNamespace>PSLauncher.Updater</RootNamespace>
<Version>0.8.0</Version> <Version>0.16.0</Version>
<ApplicationIcon>..\PSLauncher.App\Resources\favicon.ico</ApplicationIcon> <ApplicationIcon>..\PSLauncher.App\Resources\favicon.ico</ApplicationIcon>
<Company>ASTERION VR</Company> <Company>ASTERION VR</Company>
<Copyright>© 2026 ASTERION VR — All rights reserved</Copyright> <Copyright>© 2026 ASTERION VR — All rights reserved</Copyright>

View File

@@ -0,0 +1,355 @@
-- =============================================================================
-- 0001_Init.sql — schéma initial PROSERVE (proserveapi).
--
-- Idempotent : tout est en IF NOT EXISTS / INSERT IGNORE / ADD CONSTRAINT IF
-- NOT EXISTS, donc rejouable sur une DB déjà peuplée sans générer d'erreur.
-- Cible MariaDB 10.0.2+ (XAMPP standard depuis longtemps).
--
-- Note : pas de START TRANSACTION / COMMIT à l'intérieur du fichier — le
-- launcher (PSLauncher.Core.Migrations.DatabaseMigrationService) wrappe déjà
-- chaque .sql dans sa propre transaction pour pouvoir rollback proprement.
--
-- Source d'origine : dump phpMyAdmin 5.2.1 / MariaDB 10.4.32 (5 fév 2026).
-- =============================================================================
SET SQL_MODE = "NO_AUTO_VALUE_ON_ZERO";
SET time_zone = "+00:00";
SET NAMES utf8mb4;
CREATE DATABASE IF NOT EXISTS `proserveapi`
DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci;
USE `proserveapi`;
-- =============================================================================
-- TABLES
-- =============================================================================
CREATE TABLE IF NOT EXISTS `participates` (
`userId` int(11) NOT NULL COMMENT 'user id',
`sessionId` int(11) NOT NULL COMMENT 'session id',
`score` int(11) NOT NULL DEFAULT 0 COMMENT 'user score in the session',
`firePrecision` float NOT NULL DEFAULT 0 COMMENT 'user precision during the session',
`reactionTime` float NOT NULL DEFAULT 0 COMMENT 'average reaction time of the user in this session',
`nbEnemyHit` int(11) NOT NULL DEFAULT 0 COMMENT 'nb of enemies hit',
`nbCivilsHit` int(11) NOT NULL DEFAULT 0 COMMENT 'nb of civils hit',
`damageTaken` int(11) NOT NULL DEFAULT 0 COMMENT 'nb hits received',
`endStatus` int(1) NOT NULL DEFAULT 1 COMMENT 'user status at the end of the session (0=>dead, 1=>alive, add 2 for injured ?)',
`avatar` varchar(255) NOT NULL COMMENT 'avatar of user for this session',
`weapon` varchar(255) NOT NULL COMMENT 'weapon of user for this session',
`role` int(11) NOT NULL DEFAULT 0 COMMENT 'user role in the session',
`results` text NOT NULL DEFAULT '\'\'' COMMENT 'user''s results (objectives) in session (JSON format)'
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci;
CREATE TABLE IF NOT EXISTS `reacteventmodes` (
`id` int(11) NOT NULL COMMENT 'id',
`displayName` varchar(255) NOT NULL COMMENT 'name',
`description` varchar(255) NOT NULL COMMENT 'description'
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci;
CREATE TABLE IF NOT EXISTS `reactevents` (
`id` int(11) NOT NULL COMMENT 'event id',
`srcEventSessionId` int(11) NOT NULL COMMENT 'session id',
`srcEventIndex` int(11) NOT NULL COMMENT 'id of the triggering event that initiated this reaction event',
`reactType` int(11) NOT NULL COMMENT 'react event type',
`reactMode` int(11) NOT NULL DEFAULT 0 COMMENT 'react event mode (penetration, ricochet, termination)',
`hitUserId` int(11) NOT NULL DEFAULT 0 COMMENT 'id of the user that has been hit (0 if enemy/civil/env/game)',
`hitTargetName` varchar(255) NOT NULL COMMENT 'target hit by the event',
`hitBoneName` varchar(255) NOT NULL COMMENT 'bone hit on the target',
`damage` float NOT NULL DEFAULT 0 COMMENT 'damage points for the event',
`targetKilled` tinyint(1) NOT NULL DEFAULT 0 COMMENT 'did target got killed by the event ?',
`objectHitLocationX` float NOT NULL DEFAULT 0 COMMENT 'when hitting a target (object) record X position on texture',
`objectHitLocationY` float NOT NULL DEFAULT 0 COMMENT 'when hitting a target (object) record Y position on texture',
`objectHitTagLocation` varchar(255) NOT NULL COMMENT 'tag on the hit object',
`hitPrecision` float NOT NULL DEFAULT 1 COMMENT 'precision of the hit',
`distance` float NOT NULL DEFAULT 0 COMMENT 'distance from source to target hit',
`reactTime` float NOT NULL DEFAULT 0 COMMENT 'reaction time (for challenges)',
`timeStamp` float NOT NULL DEFAULT 0 COMMENT 'timeStamp react shot was registered'
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci;
CREATE TABLE IF NOT EXISTS `reacteventtypes` (
`id` int(11) NOT NULL COMMENT 'type id',
`displayName` varchar(255) NOT NULL COMMENT 'name',
`description` varchar(255) NOT NULL COMMENT 'description'
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci;
CREATE TABLE IF NOT EXISTS `sessions` (
`id` int(11) NOT NULL COMMENT 'session id',
`sessionType` int(11) NOT NULL COMMENT 'session type',
`sessionName` varchar(255) NOT NULL COMMENT 'session name',
`sessionDate` datetime NOT NULL DEFAULT current_timestamp() COMMENT 'date/time of the session',
`mapName` varchar(255) NOT NULL COMMENT 'map of the session',
`scenarioName` varchar(255) NOT NULL COMMENT 'scenario name that was played',
`success` tinyint(1) NOT NULL DEFAULT 0 COMMENT 'was the session successful (scenario objective reached) ?',
`timeToFinish` float NOT NULL DEFAULT 0 COMMENT 'session duration',
`score` int(11) NOT NULL DEFAULT 0 COMMENT 'user score',
`nbEnemyHit` int(11) NOT NULL DEFAULT 0 COMMENT 'nb of enemies hit',
`nbCivilsHit` int(11) NOT NULL DEFAULT 0 COMMENT 'nb of civils saved',
`damageTaken` float NOT NULL DEFAULT 0 COMMENT 'total damage taken',
`replayFileName` varchar(255) NOT NULL COMMENT 'filename of the session replay'
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci;
CREATE TABLE IF NOT EXISTS `sessiontypes` (
`id` int(11) NOT NULL COMMENT 'session type id',
`displayName` varchar(255) NOT NULL COMMENT 'displayed name of the session type',
`description` varchar(255) NOT NULL COMMENT 'description of the session type'
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci;
CREATE TABLE IF NOT EXISTS `triggerevents` (
`sessionId` int(11) NOT NULL COMMENT 'id of the user session during which the event was triggered',
`indexCount` int(11) NOT NULL COMMENT 'index of event in session',
`srcUserId` int(11) NOT NULL COMMENT 'id of the user that triggered the shot',
`type` int(11) NOT NULL COMMENT 'type of event',
`successful` tinyint(1) NOT NULL COMMENT 'was the shot (or triggered event) successful (hit a target) ?',
`timeStamp` float NOT NULL COMMENT 'time the event occurred at'
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci;
CREATE TABLE IF NOT EXISTS `triggereventtypes` (
`id` int(11) NOT NULL COMMENT 'event type id',
`displayName` varchar(255) NOT NULL COMMENT 'displayed name of the event type',
`description` varchar(255) NOT NULL COMMENT 'description of the event type'
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci;
CREATE TABLE IF NOT EXISTS `userroles` (
`id` int(11) NOT NULL COMMENT 'session type id',
`displayName` varchar(255) NOT NULL COMMENT 'displayed name of the session type',
`description` varchar(255) NOT NULL COMMENT 'description of the session type'
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci;
CREATE TABLE IF NOT EXISTS `users` (
`id` int(11) NOT NULL COMMENT 'user id',
`username` varchar(255) CHARACTER SET latin1 COLLATE latin1_bin NOT NULL COMMENT 'username',
`password` varchar(255) NOT NULL COMMENT 'password',
`firstName` varchar(255) NOT NULL COMMENT 'first name',
`lastName` varchar(255) NOT NULL COMMENT 'last name',
`created` timestamp NOT NULL DEFAULT current_timestamp() COMMENT 'date/time of the creation of the user account',
`leftHanded` tinyint(1) NOT NULL DEFAULT 0 COMMENT 'is user left handed?',
`maleGender` tinyint(1) NOT NULL DEFAULT 1 COMMENT 'is user a man (true) or a woman?',
`charSkinAssetName` varchar(255) NOT NULL COMMENT 'name of the asset for the character skin',
`weaponAssetName` varchar(255) NOT NULL COMMENT 'name of the asset for the weapon',
`lastConnection` datetime NOT NULL DEFAULT current_timestamp() COMMENT 'date/time of the last connection',
`avgPrecision` float NOT NULL COMMENT 'average precision when firing',
`avgReaction` float NOT NULL COMMENT 'average reaction time',
`avgFault` float NOT NULL COMMENT 'average error rate',
`avgRapidity` float NOT NULL COMMENT 'average rapidity time',
`size` int(6) NOT NULL DEFAULT 175
) ENGINE=InnoDB DEFAULT CHARSET=latin1 COLLATE=latin1_swedish_ci;
CREATE TABLE IF NOT EXISTS `userstatus` (
`id` int(11) NOT NULL COMMENT 'status id',
`displayName` varchar(255) NOT NULL COMMENT 'display name for status (alive, dead)',
`description` varchar(255) NOT NULL COMMENT 'description'
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci;
-- =============================================================================
-- DONNÉES DE RÉFÉRENCE (INSERT IGNORE = skip silencieux si PK déjà présente)
-- =============================================================================
INSERT IGNORE INTO `reacteventmodes` (`id`, `displayName`, `description`) VALUES
(0, 'Penetration', ''),
(1, 'Ricochet', ''),
(2, 'Termination', '');
INSERT IGNORE INTO `reacteventtypes` (`id`, `displayName`, `description`) VALUES
(0, 'EnemyHit', 'An enemy has been hit'),
(1, 'CivilianHit', 'A civilian has been hit'),
(2, 'PoliceHit', 'Another player has been hit by himself'),
(3, 'ObjectHit', 'A target (object) has been hit'),
(4, 'PaperTargetHit', ''),
(5, 'TargetHit', ''),
(6, 'DeadBodyHit', 'An already-dead body has been hit');
INSERT IGNORE INTO `sessiontypes` (`id`, `displayName`, `description`) VALUES
(0, 'FireRange', ''),
(1, 'Challenge', ''),
(2, 'Protect', ''),
(3, 'De-Escalation', ''),
(4, 'Terrorism', ''),
(5, 'FireExtinction', ''),
(6, 'Rescue', ''),
(7, 'LongRange', '');
INSERT IGNORE INTO `triggereventtypes` (`id`, `displayName`, `description`) VALUES
(0, 'Fire', 'Player has fired');
INSERT IGNORE INTO `userroles` (`id`, `displayName`, `description`) VALUES
(0, 'Police', 'Player is Police/SWAT'),
(1, 'Enemy', 'Player is an Enemy'),
(2, 'Civil', 'Player is a Civilian'),
(3, 'IA', 'Role for IA and NPC');
INSERT IGNORE INTO `userstatus` (`id`, `displayName`, `description`) VALUES
(0, 'Alive', 'avatar is alive'),
(1, 'Lightly Injured', 'avatar has been slightly injured'),
(2, 'Seriously Injured', 'avatar has been seriously injured'),
(3, 'Dead', 'avatar is dead');
-- Comptes utilisateurs initiaux (system + 2 démos). INSERT IGNORE évite
-- d'écraser ce que le client aurait déjà créé si la table users contient
-- des id=0/1/2 différents.
INSERT IGNORE INTO `users` (`id`, `username`, `password`, `firstName`, `lastName`, `created`, `leftHanded`, `maleGender`, `charSkinAssetName`, `weaponAssetName`, `lastConnection`, `avgPrecision`, `avgReaction`, `avgFault`, `avgRapidity`, `size`) VALUES
(0, 'game', '', '', '', '2023-01-24 10:52:35', 0, 0, '', '', '2023-01-24 11:52:35', 0, 0, 0, 0, 0),
(1, 'Jerome', 'Q29udHJvbDM1', 'Jerome', '', '2026-02-05 18:06:06', 0, 1, 'PS_PAWN_VR_V2_POLICE_ARABIC_C', 'Weapon_G17_C', '2026-02-05 12:31:59', 0, 0, 0, 0, 171),
(2, 'Jerome2', 'Q29udHJvbDM1', 'Jerome2', '', '2026-02-05 18:06:24', 0, 1, 'PS_PAWN_VR_V2_POLICE_BLACK_C', 'Weapon_G17_C', '2026-02-05 12:32:23', 0, 0, 0, 0, 175);
-- =============================================================================
-- INDEX & CONTRAINTES (toutes en IF NOT EXISTS pour idempotence)
-- =============================================================================
-- participates : PK composite (userId, sessionId) + FKs
ALTER TABLE `participates`
ADD PRIMARY KEY IF NOT EXISTS (`userId`,`sessionId`),
ADD KEY IF NOT EXISTS `FK_Participates_SessionId` (`sessionId`),
ADD KEY IF NOT EXISTS `FK_Participates_UserStatus` (`endStatus`),
ADD KEY IF NOT EXISTS `FK_Participates_UserRole` (`role`);
ALTER TABLE `reacteventmodes`
ADD PRIMARY KEY IF NOT EXISTS (`id`);
ALTER TABLE `reactevents`
ADD PRIMARY KEY IF NOT EXISTS (`id`),
ADD KEY IF NOT EXISTS `FK_React_TypeId` (`reactType`),
ADD KEY IF NOT EXISTS `FK_React_HitUserId` (`hitUserId`),
ADD KEY IF NOT EXISTS `FK_React_EventIndex` (`srcEventIndex`),
ADD KEY IF NOT EXISTS `FK_React_SessionId` (`srcEventSessionId`),
ADD KEY IF NOT EXISTS `FK_React_Mode` (`reactMode`);
ALTER TABLE `reacteventtypes`
ADD PRIMARY KEY IF NOT EXISTS (`id`);
ALTER TABLE `sessions`
ADD PRIMARY KEY IF NOT EXISTS (`id`),
ADD KEY IF NOT EXISTS `FK_sessionType` (`sessionType`) USING BTREE;
ALTER TABLE `sessiontypes`
ADD PRIMARY KEY IF NOT EXISTS (`id`);
ALTER TABLE `triggerevents`
ADD PRIMARY KEY IF NOT EXISTS (`indexCount`,`sessionId`),
ADD KEY IF NOT EXISTS `FK_TriggerEvent_SrcUserId` (`srcUserId`),
ADD KEY IF NOT EXISTS `FK_TriggerEvent_Type` (`type`),
ADD KEY IF NOT EXISTS `FK_TriggerEvent_UserId` (`sessionId`);
ALTER TABLE `triggereventtypes`
ADD PRIMARY KEY IF NOT EXISTS (`id`);
ALTER TABLE `userroles`
ADD PRIMARY KEY IF NOT EXISTS (`id`);
ALTER TABLE `users`
ADD PRIMARY KEY IF NOT EXISTS (`id`),
ADD UNIQUE KEY IF NOT EXISTS `username` (`username`);
ALTER TABLE `userstatus`
ADD PRIMARY KEY IF NOT EXISTS (`id`);
-- =============================================================================
-- AUTO_INCREMENT (idempotent : MariaDB ignore la valeur si MAX(id) la dépasse)
-- =============================================================================
ALTER TABLE `reactevents`
MODIFY `id` int(11) NOT NULL AUTO_INCREMENT COMMENT 'event id';
ALTER TABLE `sessions`
MODIFY `id` int(11) NOT NULL AUTO_INCREMENT COMMENT 'session id';
ALTER TABLE `users`
MODIFY `id` int(11) NOT NULL AUTO_INCREMENT COMMENT 'user id';
-- =============================================================================
-- FOREIGN KEYS
-- Syntaxe : ADD FOREIGN KEY IF NOT EXISTS `name` (...) REFERENCES ...
-- (NOTE : en MariaDB, le `CONSTRAINT name FOREIGN KEY` n'accepte PAS de clause
-- `IF NOT EXISTS` ; en revanche `FOREIGN KEY IF NOT EXISTS name` est supporté
-- depuis 10.0.2 et est strictement équivalent côté résultat.)
-- =============================================================================
ALTER TABLE `participates`
ADD FOREIGN KEY IF NOT EXISTS `FK_Participates_SessionId` (`sessionId`) REFERENCES `sessions` (`id`) ON DELETE CASCADE,
ADD FOREIGN KEY IF NOT EXISTS `FK_Participates_UserId` (`userId`) REFERENCES `users` (`id`) ON DELETE CASCADE,
ADD FOREIGN KEY IF NOT EXISTS `FK_Participates_UserRole` (`role`) REFERENCES `userroles` (`id`),
ADD FOREIGN KEY IF NOT EXISTS `FK_Participates_UserStatus` (`endStatus`) REFERENCES `userstatus` (`id`);
ALTER TABLE `reactevents`
ADD FOREIGN KEY IF NOT EXISTS `FK_React_HitUserId` (`hitUserId`) REFERENCES `users` (`id`),
ADD FOREIGN KEY IF NOT EXISTS `FK_React_Mode` (`reactMode`) REFERENCES `reacteventmodes` (`id`),
ADD FOREIGN KEY IF NOT EXISTS `FK_React_SessionId` (`srcEventSessionId`) REFERENCES `sessions` (`id`) ON DELETE CASCADE,
ADD FOREIGN KEY IF NOT EXISTS `FK_React_TypeId` (`reactType`) REFERENCES `reacteventtypes` (`id`);
ALTER TABLE `sessions`
ADD FOREIGN KEY IF NOT EXISTS `FK_SessionType` (`sessionType`) REFERENCES `sessiontypes` (`id`);
ALTER TABLE `triggerevents`
ADD FOREIGN KEY IF NOT EXISTS `FK_TriggerEvent_SrcUserId` (`srcUserId`) REFERENCES `users` (`id`) ON DELETE CASCADE,
ADD FOREIGN KEY IF NOT EXISTS `FK_TriggerEvent_Type` (`type`) REFERENCES `triggereventtypes` (`id`),
ADD FOREIGN KEY IF NOT EXISTS `FK_TriggerEvent_UserId` (`sessionId`) REFERENCES `sessions` (`id`) ON DELETE CASCADE;
-- =============================================================================
-- VUE sessiondebriefs (CREATE OR REPLACE = idempotent par définition)
-- =============================================================================
CREATE OR REPLACE
ALGORITHM=UNDEFINED
SQL SECURITY DEFINER
VIEW `sessiondebriefs` AS
SELECT
`s`.`id` AS `SessionId`,
`s`.`sessionType` AS `SessionTypeId`,
`st`.`displayName` AS `SessionType`,
`s`.`sessionName` AS `SessionName`,
`s`.`sessionDate` AS `SessionDate`,
`s`.`mapName` AS `MapName`,
`s`.`scenarioName` AS `ScenarioName`,
`s`.`success` AS `SessionSuccessful`,
`s`.`timeToFinish` AS `SessionDuration`,
coalesce(`t`.`type`, -1) AS `TriggerTypeId`,
coalesce(`tt`.`displayName`, '') AS `TriggerType`,
(SELECT ifnull(`t`.`srcUserId`, `ps`.`userId`)) AS `ShooterId`,
`us`.`username` AS `ShooterName`,
(SELECT ifnull(`ps`.`role`, 3)) AS `ShooterRoleId`,
(SELECT `r`.`displayName` FROM `userroles` `r` WHERE `r`.`id` = `ShooterRoleId`) AS `ShooterRole`,
coalesce(`t`.`indexCount`, -1) AS `ShotIndex`,
coalesce(`r`.`id`, -1) AS `ReactId`,
coalesce(`r`.`reactMode`, 2) AS `ReactModeId`,
coalesce(`rm`.`displayName`, '') AS `ReactMode`,
coalesce(`r`.`reactType`, -1) AS `ReactTypeId`,
coalesce(`rt`.`displayName`, '') AS `ReactType`,
coalesce(`r`.`hitUserId`, -1) AS `TargetUserId`,
coalesce(`uh`.`username`, '') AS `TargetUserName`,
(SELECT ifnull(`ph`.`role`, 3)) AS `TargetRoleId`,
(SELECT `r`.`displayName` FROM `userroles` `r` WHERE `r`.`id` = `TargetRoleId`) AS `TargetRole`,
coalesce(`r`.`hitTargetName`, '') AS `TargetName`,
coalesce(`r`.`hitBoneName`, '') AS `TargetBoneName`,
coalesce(`r`.`targetKilled`, 0) AS `TargetKilled`,
coalesce(`r`.`objectHitLocationX`, 0) AS `HitLocationX`,
coalesce(`r`.`objectHitLocationY`, 0) AS `HitLocationY`,
coalesce(`r`.`objectHitTagLocation`, '') AS `HitLocationTag`,
coalesce(`r`.`hitPrecision`, 0) AS `HitPrecision`,
coalesce(`r`.`distance`, 0) AS `HitTargetDistance`,
coalesce(`r`.`reactTime`, 0) AS `ReactionTime`,
coalesce(`r`.`timeStamp`, 0) AS `TimeStamp`,
count(distinct `r`.`id`) AS `NbHit`,
count(distinct `rk`.`srcEventIndex`, `rk`.`hitTargetName`) AS `NbKilled`
FROM `sessions` `s`
LEFT JOIN `participates` `ps` ON `s`.`id` = `ps`.`sessionId`
LEFT JOIN `participates` `ph` ON `s`.`id` = `ph`.`sessionId`
LEFT JOIN `triggerevents` `t` ON `s`.`id` = `t`.`sessionId`
LEFT JOIN `triggereventtypes` `tt` ON `tt`.`id` = `t`.`type`
LEFT JOIN `sessiontypes` `st` ON `st`.`id` = `s`.`sessionType`
LEFT JOIN `reactevents` `r` ON `t`.`indexCount` = `r`.`srcEventIndex`
AND `t`.`sessionId` = `r`.`srcEventSessionId`
LEFT JOIN `users` `uh` ON `uh`.`id` = `r`.`hitUserId`
LEFT JOIN `users` `us` ON `us`.`id` = `t`.`srcUserId`
OR `us`.`id` = `ps`.`userId`
LEFT JOIN `reacteventtypes` `rt` ON `rt`.`id` = `r`.`reactType`
LEFT JOIN `reacteventmodes` `rm` ON `rm`.`id` = coalesce(`r`.`reactMode`, 2)
LEFT JOIN `reactevents` `rk` ON `r`.`id` = `rk`.`id` AND `rk`.`targetKilled` = 1
GROUP BY
`s`.`id`,
(SELECT ifnull(`t`.`srcUserId`, `ps`.`userId`)),
`t`.`indexCount`,
`r`.`hitTargetName`
ORDER BY
`s`.`id` ASC,
(SELECT ifnull(`t`.`srcUserId`, `ps`.`userId`)) ASC,
`t`.`indexCount` ASC,
`r`.`id` ASC;

View File

@@ -0,0 +1,11 @@
-- v1.4.5 : ajoute le SessionType id=7 "LongRange" et renomme id=6 en "Rescue".
-- Idempotent :
-- - INSERT IGNORE skip si la PK id=7 existe déjà (cas où 0001_Init.sql récent
-- contient déjà LongRange dans son INSERT initial)
-- - UPDATE ne génère pas d'erreur si aucune ligne ne match (no-op silencieux)
USE proserveapi;
INSERT IGNORE INTO `sessiontypes` (`id`, `displayName`, `description`)
VALUES (7, 'LongRange', '');
UPDATE `sessiontypes` SET `displayName` = 'Rescue' WHERE `id` = 6;

View File

@@ -0,0 +1,7 @@
-- v1.5.0 : ajoute la colonne `size` à la table `users` (taille en cm).
-- Idempotent : `IF NOT EXISTS` skip silencieusement si la colonne existe déjà
-- (cas d'une install fresh où 0001_Init.sql l'a déjà créée).
USE proserveapi;
ALTER TABLE `users`
ADD COLUMN IF NOT EXISTS `size` INT NOT NULL DEFAULT 175;