This commit is contained in:
2026-08-10 22:06:49 +02:00
parent 2a5d9bb0fd
commit 8bff7982ff
83 changed files with 17975 additions and 4824 deletions
+310
View File
@@ -0,0 +1,310 @@
//! Installeur intégré à l'application (« self-installing portable »).
//!
//! Le `.exe` distribué EST l'application. Lancé depuis un dossier autre que
//! le dossier d'installation, il affiche son propre écran d'installation —
//! écrit en React, avec le design system de l'app — qui se recopie dans
//! `%LOCALAPPDATA%\GameDev Tracker`, crée les raccourcis, s'enregistre
//! auprès de Windows, puis se relance depuis l'emplacement installé.
//!
//! Pas de fenêtre NSIS pour la première installation.
//!
//! # Cohabitation avec les mises à jour automatiques
//!
//! Les mises à jour, elles, continuent de passer par l'installeur NSIS
//! silencieux (invisible pour l'utilisateur). Pour qu'il écrase la même
//! installation au lieu d'en créer une seconde, on reproduit ICI très
//! exactement ce que le template NSIS de Tauri écrit et relit :
//!
//! * dossier par défaut : `%LOCALAPPDATA%\{PRODUCT_NAME}`
//! (cf. `StrCpy $INSTDIR "$LOCALAPPDATA\${PRODUCTNAME}"`)
//! * clé de localisation : `HKCU\Software\{PUBLISHER}\{PRODUCT_NAME}`, valeur
//! par défaut (cf. `RestorePreviousInstallLocation`)
//! * clé de désinstallation : `HKCU\…\Uninstall\{PRODUCT_NAME}`
//!
//! Toute modification de ces trois constantes doit rester alignée sur
//! `src-tauri/installer/installer.nsi`, sinon une mise à jour installerait
//! une deuxième copie à côté de la première.
#![cfg(windows)]
use std::path::{Path, PathBuf};
use std::process::Command;
use serde::Serialize;
/// Doit correspondre à `productName` de tauri.conf.json (= `${PRODUCTNAME}`).
pub const PRODUCT_NAME: &str = "GameDev Tracker";
/// Doit correspondre à `bundle.publisher` (= `${MANUFACTURER}`).
pub const PUBLISHER: &str = "Mathew";
pub const VERSION: &str = env!("CARGO_PKG_VERSION");
/// Nom du binaire une fois installé (= `${MAINBINARYNAME}.exe`).
const BINARY_NAME: &str = "app.exe";
fn uninstall_key() -> String {
format!(
"Software\\Microsoft\\Windows\\CurrentVersion\\Uninstall\\{}",
PRODUCT_NAME
)
}
/// `Software\{PUBLISHER}\{PRODUCT_NAME}` — c'est là que NSIS va relire le
/// dossier d'installation existant.
fn manu_product_key() -> String {
format!("Software\\{}\\{}", PUBLISHER, PRODUCT_NAME)
}
/// `%LOCALAPPDATA%\GameDev Tracker` — le même défaut que NSIS en mode
/// « currentUser ».
pub fn default_install_dir() -> Option<PathBuf> {
dirs::data_local_dir().map(|d| d.join(PRODUCT_NAME))
}
fn installed_exe_in(dir: &Path) -> PathBuf {
dir.join(BINARY_NAME)
}
fn canon(p: &Path) -> PathBuf {
std::fs::canonicalize(p).unwrap_or_else(|_| p.to_path_buf())
}
/// Dossier d'installation enregistré, s'il existe.
pub fn registered_install_dir() -> Option<PathBuf> {
use winreg::enums::HKEY_CURRENT_USER;
use winreg::RegKey;
let hkcu = RegKey::predef(HKEY_CURRENT_USER);
// 1) La clé que NSIS relit en priorité.
if let Ok(key) = hkcu.open_subkey(manu_product_key()) {
if let Ok(dir) = key.get_value::<String, _>("") {
if !dir.is_empty() {
return Some(PathBuf::from(dir));
}
}
}
// 2) Repli : l'entrée « Applications installées » de Windows.
// NSIS y écrit le chemin ENTRE GUILLEMETS — sans ce nettoyage, on
// obtiendrait un dossier littéralement nommé `"C:\...\GameDev Tracker"`.
let key = hkcu.open_subkey(uninstall_key()).ok()?;
let dir: String = key.get_value("InstallLocation").ok()?;
let dir = dir.trim().trim_matches('"').to_string();
(!dir.is_empty()).then(|| PathBuf::from(dir))
}
/// Le binaire en cours d'exécution est-il celui qui est installé ?
///
/// C'est ce test qui décide, au démarrage, entre « afficher l'installeur »
/// et « démarrer l'application ».
pub fn is_running_installed() -> bool {
let Ok(exe) = std::env::current_exe() else {
return false;
};
let Some(dir) = exe.parent() else {
return false;
};
let dir = canon(dir);
if let Some(registered) = registered_install_dir() {
if canon(&registered) == dir {
return true;
}
}
// Même sans registre : si on tourne depuis le dossier d'installation
// par défaut, c'est qu'on est installé (cas d'une mise à jour NSIS qui
// aurait réécrit les clés autrement).
default_install_dir().map(|d| canon(&d) == dir).unwrap_or(false)
}
/// Recopie le binaire courant dans le dossier d'installation.
fn copy_exe(dst_dir: &Path) -> Result<PathBuf, String> {
let src = std::env::current_exe().map_err(|e| format!("Binaire courant introuvable : {e}"))?;
let dst = installed_exe_in(dst_dir);
std::fs::create_dir_all(dst_dir)
.map_err(|e| format!("Création de « {} » impossible : {e}", dst_dir.display()))?;
// Réinstallation par-dessus : on écrase.
if dst.exists() {
std::fs::remove_file(&dst).ok();
}
std::fs::copy(&src, &dst).map_err(|e| format!("Copie impossible : {e}"))?;
Ok(dst)
}
/// Écrit un raccourci `.lnk` via WScript.Shell — évite d'embarquer une
/// bibliothèque COM entière pour cette seule opération.
fn write_lnk(lnk: &Path, target: &Path) -> Result<(), String> {
let script = format!(
"$s=(New-Object -ComObject WScript.Shell).CreateShortcut('{}');\
$s.TargetPath='{}';$s.WorkingDirectory='{}';$s.Description='{}';$s.Save()",
lnk.display(),
target.display(),
target.parent().map(|p| p.display().to_string()).unwrap_or_default(),
PRODUCT_NAME
);
let mut cmd = Command::new("powershell");
cmd.args(["-NoProfile", "-NonInteractive", "-Command", &script]);
#[cfg(windows)]
{
use std::os::windows::process::CommandExt;
cmd.creation_flags(0x0800_0000); // CREATE_NO_WINDOW
}
let out = cmd.output().map_err(|e| format!("PowerShell indisponible : {e}"))?;
if !out.status.success() {
return Err(format!(
"Raccourci « {} » non créé : {}",
lnk.display(),
String::from_utf8_lossy(&out.stderr).trim()
));
}
Ok(())
}
fn create_start_menu_shortcut(target: &Path) -> Result<(), String> {
let dir = dirs::data_dir()
.ok_or("APPDATA introuvable")?
.join("Microsoft/Windows/Start Menu/Programs");
std::fs::create_dir_all(&dir).ok();
write_lnk(&dir.join(format!("{PRODUCT_NAME}.lnk")), target)
}
fn create_desktop_shortcut(target: &Path) -> Result<(), String> {
let dir = dirs::desktop_dir().ok_or("Bureau introuvable")?;
write_lnk(&dir.join(format!("{PRODUCT_NAME}.lnk")), target)
}
fn dir_size_kb(dir: &Path) -> u32 {
fn walk(p: &Path) -> u64 {
let mut total = 0;
if let Ok(entries) = std::fs::read_dir(p) {
for e in entries.flatten() {
match e.metadata() {
Ok(m) if m.is_dir() => total += walk(&e.path()),
Ok(m) => total += m.len(),
Err(_) => {}
}
}
}
total
}
((walk(dir) / 1024) as u32).max(1)
}
/// Enregistre l'application auprès de Windows : elle apparaît dans
/// « Paramètres → Applications », avec un bouton de désinstallation.
fn write_registry(exe: &Path, install_dir: &Path) -> Result<(), String> {
use winreg::enums::HKEY_CURRENT_USER;
use winreg::RegKey;
let hkcu = RegKey::predef(HKEY_CURRENT_USER);
let exe_s = exe.display().to_string();
let dir_s = install_dir.display().to_string();
// 1) Localisation — c'est CETTE clé que l'updater NSIS relit pour
// réinstaller au même endroit.
let (loc, _) = hkcu
.create_subkey(manu_product_key())
.map_err(|e| format!("Registre (localisation) : {e}"))?;
loc.set_value("", &dir_s).map_err(|e| format!("Registre : {e}"))?;
// 2) Entrée « Applications installées ».
let (key, _) = hkcu
.create_subkey(uninstall_key())
.map_err(|e| format!("Registre (désinstallation) : {e}"))?;
let set = |name: &str, value: &str| key.set_value(name, &value.to_string());
set("DisplayName", PRODUCT_NAME).ok();
set("DisplayVersion", VERSION).ok();
set("Publisher", PUBLISHER).ok();
set("InstallLocation", &dir_s).ok();
set("DisplayIcon", &exe_s).ok();
set("UninstallString", &format!("\"{exe_s}\" --uninstall")).ok();
set("QuietUninstallString", &format!("\"{exe_s}\" --uninstall --silent")).ok();
// MainBinaryName : lu par l'updater NSIS pour nettoyer l'ancien binaire.
set("MainBinaryName", BINARY_NAME).ok();
key.set_value("NoModify", &1u32).ok();
key.set_value("NoRepair", &1u32).ok();
key.set_value("EstimatedSize", &dir_size_kb(install_dir)).ok();
Ok(())
}
// =====================================================================
// Commandes exposées au front
// =====================================================================
#[derive(Serialize)]
pub struct InstallerStatus {
/// `true` = on tourne depuis le dossier installé → démarrer l'app.
pub running_installed: bool,
/// Une installation existe déjà (réinstallation / mise à jour manuelle).
pub already_installed: bool,
pub install_dir: String,
pub version: String,
pub product_name: String,
}
#[tauri::command]
pub fn installer_status() -> InstallerStatus {
let dir = registered_install_dir()
.or_else(default_install_dir)
.unwrap_or_default();
InstallerStatus {
running_installed: is_running_installed(),
already_installed: registered_install_dir()
.map(|d| installed_exe_in(&d).exists())
.unwrap_or(false),
install_dir: dir.display().to_string(),
version: VERSION.to_string(),
product_name: PRODUCT_NAME.to_string(),
}
}
/// Installe : copie, raccourcis, registre. Renvoie le dossier d'installation.
#[tauri::command]
pub fn installer_install(desktop_shortcut: bool) -> Result<String, String> {
let dir = default_install_dir().ok_or("LOCALAPPDATA introuvable")?;
let exe = copy_exe(&dir)?;
// Les raccourcis ne sont pas critiques : on n'annule pas une
// installation réussie parce qu'un .lnk n'a pas pu être écrit.
if let Err(e) = create_start_menu_shortcut(&exe) {
log::warn!("{e}");
}
if desktop_shortcut {
if let Err(e) = create_desktop_shortcut(&exe) {
log::warn!("{e}");
}
}
write_registry(&exe, &dir)?;
Ok(dir.display().to_string())
}
/// Démarre la version installée puis quitte le processus courant.
#[tauri::command]
pub fn installer_launch(app: tauri::AppHandle) -> Result<(), String> {
let dir = registered_install_dir()
.or_else(default_install_dir)
.ok_or("Aucune installation enregistrée")?;
let target = installed_exe_in(&dir);
let mut cmd = Command::new(&target);
#[cfg(windows)]
{
use std::os::windows::process::CommandExt;
cmd.creation_flags(0x0000_0008); // DETACHED_PROCESS
}
cmd.spawn()
.map_err(|e| format!("Démarrage de « {} » impossible : {e}", target.display()))?;
// On laisse au nouveau processus le temps de prendre la main : sans ce
// délai, la fenêtre disparaît avant que la suivante n'apparaisse.
let handle = app.clone();
std::thread::spawn(move || {
std::thread::sleep(std::time::Duration::from_millis(200));
handle.exit(0);
});
Ok(())
}
+77 -1
View File
@@ -1,6 +1,46 @@
use tauri::{WebviewUrl, WebviewWindowBuilder};
#[cfg(windows)]
mod installer;
/// L'application tourne-t-elle depuis son dossier d'installation ?
/// (Sur les autres plateformes il n'y a pas d'installeur intégré.)
fn running_installed() -> bool {
#[cfg(windows)]
{
installer::is_running_installed()
}
#[cfg(not(windows))]
{
true
}
}
#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
tauri::Builder::default()
let mut builder = tauri::Builder::default();
// Mises à jour automatiques : l'application va chercher elle-même la
// dernière version publiée. Plus besoin de renvoyer un .exe à l'équipe.
#[cfg(desktop)]
{
builder = builder
.plugin(tauri_plugin_updater::Builder::new().build())
.plugin(tauri_plugin_process::init());
}
// Installeur intégré : le front affiche l'écran d'installation ou
// l'application selon `installer_status()`.
#[cfg(windows)]
{
builder = builder.invoke_handler(tauri::generate_handler![
installer::installer_status,
installer::installer_install,
installer::installer_launch
]);
}
builder
.setup(|app| {
if cfg!(debug_assertions) {
app.handle().plugin(
@@ -9,6 +49,42 @@ pub fn run() {
.build(),
)?;
}
// La fenêtre est créée ICI, directement à sa forme définitive.
//
// Elle n'est volontairement PAS déclarée dans tauri.conf.json : la
// déclarer là-bas imposait de la corriger ensuite — la masquer, la
// redimensionner, la maximiser, puis l'afficher. Chacune de ces
// étapes était visible, d'où l'impression que « plusieurs petites
// fenêtres » s'ouvraient avant l'application.
//
// Créée d'un coup aux bonnes dimensions, il n'y a plus rien à
// rattraper après coup : une seule apparition, la bonne.
let window = WebviewWindowBuilder::new(app, "main", WebviewUrl::default())
// Le glisser-déposer doit rester géré par le webview (Kanban,
// dépôt de pièces jointes) et non capté par Tauri.
.disable_drag_drop_handler();
if running_installed() {
window
.title("GameDev Tracker")
.inner_size(1360.0, 860.0)
.min_inner_size(900.0, 620.0)
.resizable(true)
.maximized(true)
.build()?;
} else {
// Écran d'installation : petite fenêtre sans bordure, centrée.
window
.title("Installation de GameDev Tracker")
.inner_size(520.0, 560.0)
.resizable(false)
.maximizable(false)
.decorations(false)
.center()
.build()?;
}
Ok(())
})
.run(tauri::generate_context!())