From 0adfa5fe5e18513fe7c15b9e3e867fff8ca7b6d2 Mon Sep 17 00:00:00 2001 From: Le-King-Fu Date: Wed, 11 Feb 2026 11:47:25 +0000 Subject: [PATCH] feat: add Settings page with in-app updater support Add a Settings page with about card (app name + version) and an update section that uses the Tauri v2 updater plugin to check GitHub Releases, download signed installers, and relaunch. Includes full state machine (idle/checking/available/downloading/readyToInstall/installing/error) with progress bar and retry. Database in %APPDATA% is never touched. - Add tauri-plugin-updater and tauri-plugin-process (Rust + npm) - Configure updater endpoint, pubkey placeholder, and passive install mode - Add signing env vars and updaterJsonPreferNsis to release workflow - Add Settings nav item, route, and fr/en translations Co-Authored-By: Claude Opus 4.6 --- .github/workflows/release.yml | 3 + package-lock.json | 18 +++ package.json | 2 + src-tauri/Cargo.toml | 2 + src-tauri/capabilities/default.json | 4 +- src-tauri/src/lib.rs | 6 + src-tauri/tauri.conf.json | 14 ++- src/App.tsx | 2 + src/components/layout/Sidebar.tsx | 2 + src/hooks/useUpdater.ts | 122 +++++++++++++++++++ src/i18n/locales/en.json | 22 +++- src/i18n/locales/fr.json | 22 +++- src/pages/SettingsPage.tsx | 176 ++++++++++++++++++++++++++++ src/shared/constants/index.ts | 6 + 14 files changed, 397 insertions(+), 4 deletions(-) create mode 100644 src/hooks/useUpdater.ts create mode 100644 src/pages/SettingsPage.tsx diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 2133948..f049a36 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -45,6 +45,8 @@ jobs: uses: tauri-apps/tauri-action@v0 env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }} + TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }} with: tagName: ${{ github.ref_name }} releaseName: "Simpl'Résultat ${{ github.ref_name }}" @@ -57,3 +59,4 @@ jobs: > Cliquez sur **« Informations complémentaires »** puis **« Exécuter quand même »**. releaseDraft: false prerelease: false + updaterJsonPreferNsis: true diff --git a/package-lock.json b/package-lock.json index 34de047..caa0643 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,7 +10,9 @@ "dependencies": { "@tauri-apps/api": "^2", "@tauri-apps/plugin-opener": "^2", + "@tauri-apps/plugin-process": "^2.3.1", "@tauri-apps/plugin-sql": "^2.3.2", + "@tauri-apps/plugin-updater": "^2.10.0", "i18next": "^25.8.4", "lucide-react": "^0.563.0", "papaparse": "^5.5.3", @@ -1618,6 +1620,14 @@ "@tauri-apps/api": "^2.8.0" } }, + "node_modules/@tauri-apps/plugin-process": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/@tauri-apps/plugin-process/-/plugin-process-2.3.1.tgz", + "integrity": "sha512-nCa4fGVaDL/B9ai03VyPOjfAHRHSBz5v6F/ObsB73r/dA3MHHhZtldaDMIc0V/pnUw9ehzr2iEG+XkSEyC0JJA==", + "dependencies": { + "@tauri-apps/api": "^2.8.0" + } + }, "node_modules/@tauri-apps/plugin-sql": { "version": "2.3.2", "resolved": "https://registry.npmjs.org/@tauri-apps/plugin-sql/-/plugin-sql-2.3.2.tgz", @@ -1626,6 +1636,14 @@ "@tauri-apps/api": "^2.10.1" } }, + "node_modules/@tauri-apps/plugin-updater": { + "version": "2.10.0", + "resolved": "https://registry.npmjs.org/@tauri-apps/plugin-updater/-/plugin-updater-2.10.0.tgz", + "integrity": "sha512-ljN8jPlnT0aSn8ecYhuBib84alxfMx6Hc8vJSKMJyzGbTPFZAC44T2I1QNFZssgWKrAlofvJqCC6Rr472JWfkQ==", + "dependencies": { + "@tauri-apps/api": "^2.10.1" + } + }, "node_modules/@types/babel__core": { "version": "7.20.5", "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", diff --git a/package.json b/package.json index 03ac859..179ab0e 100644 --- a/package.json +++ b/package.json @@ -12,7 +12,9 @@ "dependencies": { "@tauri-apps/api": "^2", "@tauri-apps/plugin-opener": "^2", + "@tauri-apps/plugin-process": "^2.3.1", "@tauri-apps/plugin-sql": "^2.3.2", + "@tauri-apps/plugin-updater": "^2.10.0", "i18next": "^25.8.4", "lucide-react": "^0.563.0", "papaparse": "^5.5.3", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index ab65fad..3d3990c 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -22,6 +22,8 @@ tauri = { version = "2", features = [] } tauri-plugin-opener = "2" tauri-plugin-sql = { version = "2", features = ["sqlite"] } tauri-plugin-dialog = "2" +tauri-plugin-updater = "2" +tauri-plugin-process = "2" serde = { version = "1", features = ["derive"] } serde_json = "1" sha2 = "0.10" diff --git a/src-tauri/capabilities/default.json b/src-tauri/capabilities/default.json index 8aa143c..dc12aea 100644 --- a/src-tauri/capabilities/default.json +++ b/src-tauri/capabilities/default.json @@ -9,6 +9,8 @@ "sql:default", "sql:allow-execute", "sql:allow-select", - "dialog:default" + "dialog:default", + "updater:default", + "process:allow-restart" ] } diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 18351c6..2fbffd0 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -23,6 +23,12 @@ pub fn run() { tauri::Builder::default() .plugin(tauri_plugin_opener::init()) .plugin(tauri_plugin_dialog::init()) + .plugin(tauri_plugin_process::init()) + .setup(|app| { + #[cfg(desktop)] + app.handle().plugin(tauri_plugin_updater::Builder::new().build())?; + Ok(()) + }) .plugin( tauri_plugin_sql::Builder::default() .add_migrations("sqlite:simpl_resultat.db", migrations) diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index a70b53f..92f704d 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -30,6 +30,18 @@ "icons/128x128@2x.png", "icons/icon.icns", "icons/icon.ico" - ] + ], + "createUpdaterArtifacts": true + }, + "plugins": { + "updater": { + "pubkey": "REPLACE_WITH_PUBLIC_KEY", + "endpoints": [ + "https://github.com/Le-King-Fu/simpl-resultat/releases/latest/download/latest.json" + ], + "windows": { + "installMode": "passive" + } + } } } diff --git a/src/App.tsx b/src/App.tsx index 67d5f71..6055eee 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -7,6 +7,7 @@ import CategoriesPage from "./pages/CategoriesPage"; import AdjustmentsPage from "./pages/AdjustmentsPage"; import BudgetPage from "./pages/BudgetPage"; import ReportsPage from "./pages/ReportsPage"; +import SettingsPage from "./pages/SettingsPage"; export default function App() { return ( @@ -20,6 +21,7 @@ export default function App() { } /> } /> } /> + } /> diff --git a/src/components/layout/Sidebar.tsx b/src/components/layout/Sidebar.tsx index 25c4024..73d6d60 100644 --- a/src/components/layout/Sidebar.tsx +++ b/src/components/layout/Sidebar.tsx @@ -8,6 +8,7 @@ import { SlidersHorizontal, PiggyBank, BarChart3, + Settings, Languages, } from "lucide-react"; import { NAV_ITEMS, APP_NAME } from "../../shared/constants"; @@ -20,6 +21,7 @@ const iconMap: Record> = { SlidersHorizontal, PiggyBank, BarChart3, + Settings, }; export default function Sidebar() { diff --git a/src/hooks/useUpdater.ts b/src/hooks/useUpdater.ts new file mode 100644 index 0000000..8da8639 --- /dev/null +++ b/src/hooks/useUpdater.ts @@ -0,0 +1,122 @@ +import { useReducer, useCallback, useRef } from "react"; +import { check, type Update } from "@tauri-apps/plugin-updater"; +import { relaunch } from "@tauri-apps/plugin-process"; + +type UpdateStatus = + | "idle" + | "checking" + | "upToDate" + | "available" + | "downloading" + | "readyToInstall" + | "installing" + | "error"; + +interface UpdaterState { + status: UpdateStatus; + version: string | null; + progress: number; + contentLength: number | null; + error: string | null; +} + +type UpdaterAction = + | { type: "CHECK_START" } + | { type: "UP_TO_DATE" } + | { type: "AVAILABLE"; version: string } + | { type: "DOWNLOAD_START" } + | { type: "DOWNLOAD_PROGRESS"; downloaded: number; contentLength: number | null } + | { type: "READY_TO_INSTALL" } + | { type: "INSTALLING" } + | { type: "ERROR"; error: string }; + +const initialState: UpdaterState = { + status: "idle", + version: null, + progress: 0, + contentLength: null, + error: null, +}; + +function reducer(state: UpdaterState, action: UpdaterAction): UpdaterState { + switch (action.type) { + case "CHECK_START": + return { ...initialState, status: "checking" }; + case "UP_TO_DATE": + return { ...state, status: "upToDate", error: null }; + case "AVAILABLE": + return { ...state, status: "available", version: action.version, error: null }; + case "DOWNLOAD_START": + return { ...state, status: "downloading", progress: 0, contentLength: null, error: null }; + case "DOWNLOAD_PROGRESS": + return { ...state, progress: action.downloaded, contentLength: action.contentLength ?? state.contentLength }; + case "READY_TO_INSTALL": + return { ...state, status: "readyToInstall", error: null }; + case "INSTALLING": + return { ...state, status: "installing", error: null }; + case "ERROR": + return { ...state, status: "error", error: action.error }; + } +} + +export function useUpdater() { + const [state, dispatch] = useReducer(reducer, initialState); + const updateRef = useRef(null); + + const checkForUpdate = useCallback(async () => { + dispatch({ type: "CHECK_START" }); + try { + const update = await check(); + if (update) { + updateRef.current = update; + dispatch({ type: "AVAILABLE", version: update.version }); + } else { + dispatch({ type: "UP_TO_DATE" }); + } + } catch (e) { + dispatch({ type: "ERROR", error: e instanceof Error ? e.message : String(e) }); + } + }, []); + + const downloadAndInstall = useCallback(async () => { + const update = updateRef.current; + if (!update) return; + + dispatch({ type: "DOWNLOAD_START" }); + try { + let downloaded = 0; + await update.downloadAndInstall((event) => { + if (event.event === "Started") { + dispatch({ + type: "DOWNLOAD_PROGRESS", + downloaded: 0, + contentLength: event.data.contentLength ?? null, + }); + } else if (event.event === "Progress") { + downloaded += event.data.chunkLength; + dispatch({ + type: "DOWNLOAD_PROGRESS", + downloaded, + contentLength: null, + }); + } else if (event.event === "Finished") { + // handled below + } + }); + dispatch({ type: "READY_TO_INSTALL" }); + } catch (e) { + dispatch({ type: "ERROR", error: e instanceof Error ? e.message : String(e) }); + } + }, []); + + const installAndRestart = useCallback(async () => { + dispatch({ type: "INSTALLING" }); + try { + await relaunch(); + } catch (e) { + dispatch({ type: "ERROR", error: e instanceof Error ? e.message : String(e) }); + } + }, []); + + return { state, checkForUpdate, downloadAndInstall, installAndRestart }; +} diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index 15f0873..1460372 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -9,7 +9,8 @@ "categories": "Categories", "adjustments": "Adjustments", "budget": "Budget", - "reports": "Reports" + "reports": "Reports", + "settings": "Settings" }, "dashboard": { "title": "Dashboard", @@ -233,6 +234,25 @@ "trends": "Monthly Trends", "export": "Export" }, + "settings": { + "title": "Settings", + "version": "Version {{version}}", + "updates": { + "title": "Updates", + "checkButton": "Check for updates", + "checking": "Checking for updates...", + "upToDate": "App is up to date", + "available": "Version {{version}} available", + "downloadButton": "Download and install", + "downloading": "Downloading...", + "readyToInstall": "Update ready to install", + "installButton": "Install and restart", + "installing": "Installing...", + "error": "Update failed", + "retryButton": "Retry" + }, + "dataSafeNotice": "Your data is safe — only the app binary is replaced, your database is not modified." + }, "common": { "save": "Save", "cancel": "Cancel", diff --git a/src/i18n/locales/fr.json b/src/i18n/locales/fr.json index 23759b3..7c9f3b1 100644 --- a/src/i18n/locales/fr.json +++ b/src/i18n/locales/fr.json @@ -9,7 +9,8 @@ "categories": "Catégories", "adjustments": "Ajustements", "budget": "Budget", - "reports": "Rapports" + "reports": "Rapports", + "settings": "Paramètres" }, "dashboard": { "title": "Tableau de bord", @@ -233,6 +234,25 @@ "trends": "Tendances mensuelles", "export": "Exporter" }, + "settings": { + "title": "Paramètres", + "version": "Version {{version}}", + "updates": { + "title": "Mises à jour", + "checkButton": "Vérifier les mises à jour", + "checking": "Vérification en cours...", + "upToDate": "L'application est à jour", + "available": "Version {{version}} disponible", + "downloadButton": "Télécharger et installer", + "downloading": "Téléchargement en cours...", + "readyToInstall": "Mise à jour prête à installer", + "installButton": "Installer et redémarrer", + "installing": "Installation en cours...", + "error": "Erreur lors de la mise à jour", + "retryButton": "Réessayer" + }, + "dataSafeNotice": "Vos données sont en sécurité — seul le programme est remplacé, votre base de données n'est pas modifiée." + }, "common": { "save": "Enregistrer", "cancel": "Annuler", diff --git a/src/pages/SettingsPage.tsx b/src/pages/SettingsPage.tsx new file mode 100644 index 0000000..9c7c691 --- /dev/null +++ b/src/pages/SettingsPage.tsx @@ -0,0 +1,176 @@ +import { useEffect, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { + Info, + RefreshCw, + Download, + CheckCircle, + AlertCircle, + RotateCcw, + Loader2, + ShieldCheck, +} from "lucide-react"; +import { getVersion } from "@tauri-apps/api/app"; +import { useUpdater } from "../hooks/useUpdater"; +import { APP_NAME } from "../shared/constants"; + +export default function SettingsPage() { + const { t } = useTranslation(); + const { state, checkForUpdate, downloadAndInstall, installAndRestart } = + useUpdater(); + const [version, setVersion] = useState(""); + + useEffect(() => { + getVersion().then(setVersion); + }, []); + + const progressPercent = + state.contentLength && state.contentLength > 0 + ? Math.round((state.progress / state.contentLength) * 100) + : null; + + return ( +
+

{t("settings.title")}

+ + {/* About card */} +
+
+
+ S +
+
+

{APP_NAME}

+

+ {t("settings.version", { version })} +

+
+
+
+ + {/* Update card */} +
+

+ + {t("settings.updates.title")} +

+ + {/* idle */} + {state.status === "idle" && ( + + )} + + {/* checking */} + {state.status === "checking" && ( +
+ + {t("settings.updates.checking")} +
+ )} + + {/* up to date */} + {state.status === "upToDate" && ( +
+
+ + {t("settings.updates.upToDate")} +
+ +
+ )} + + {/* available */} + {state.status === "available" && ( +
+

+ {t("settings.updates.available", { version: state.version })} +

+ +
+ )} + + {/* downloading */} + {state.status === "downloading" && ( +
+
+ + {t("settings.updates.downloading")} + {progressPercent !== null && {progressPercent}%} +
+
+
+
+
+ )} + + {/* ready to install */} + {state.status === "readyToInstall" && ( +
+

+ {t("settings.updates.readyToInstall")} +

+ +
+ )} + + {/* installing */} + {state.status === "installing" && ( +
+ + {t("settings.updates.installing")} +
+ )} + + {/* error */} + {state.status === "error" && ( +
+
+ + {t("settings.updates.error")} +
+

{state.error}

+ +
+ )} +
+ + {/* Data safety notice */} +
+ +

{t("settings.dataSafeNotice")}

+
+
+ ); +} diff --git a/src/shared/constants/index.ts b/src/shared/constants/index.ts index 493ca3e..c770dbf 100644 --- a/src/shared/constants/index.ts +++ b/src/shared/constants/index.ts @@ -46,4 +46,10 @@ export const NAV_ITEMS: NavItem[] = [ icon: "BarChart3", labelKey: "nav.reports", }, + { + key: "settings", + path: "/settings", + icon: "Settings", + labelKey: "nav.settings", + }, ];