V0.3
This commit is contained in:
@@ -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(®istered) == 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
@@ -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!())
|
||||
|
||||
Reference in New Issue
Block a user