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 <noreply@anthropic.com>
This commit is contained in:
Le-King-Fu 2026-02-11 11:47:25 +00:00
parent 118a37e761
commit 0adfa5fe5e
14 changed files with 397 additions and 4 deletions

View file

@ -45,6 +45,8 @@ jobs:
uses: tauri-apps/tauri-action@v0 uses: tauri-apps/tauri-action@v0
env: env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 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: with:
tagName: ${{ github.ref_name }} tagName: ${{ github.ref_name }}
releaseName: "Simpl'Résultat ${{ 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 »**. > Cliquez sur **« Informations complémentaires »** puis **« Exécuter quand même »**.
releaseDraft: false releaseDraft: false
prerelease: false prerelease: false
updaterJsonPreferNsis: true

18
package-lock.json generated
View file

@ -10,7 +10,9 @@
"dependencies": { "dependencies": {
"@tauri-apps/api": "^2", "@tauri-apps/api": "^2",
"@tauri-apps/plugin-opener": "^2", "@tauri-apps/plugin-opener": "^2",
"@tauri-apps/plugin-process": "^2.3.1",
"@tauri-apps/plugin-sql": "^2.3.2", "@tauri-apps/plugin-sql": "^2.3.2",
"@tauri-apps/plugin-updater": "^2.10.0",
"i18next": "^25.8.4", "i18next": "^25.8.4",
"lucide-react": "^0.563.0", "lucide-react": "^0.563.0",
"papaparse": "^5.5.3", "papaparse": "^5.5.3",
@ -1618,6 +1620,14 @@
"@tauri-apps/api": "^2.8.0" "@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": { "node_modules/@tauri-apps/plugin-sql": {
"version": "2.3.2", "version": "2.3.2",
"resolved": "https://registry.npmjs.org/@tauri-apps/plugin-sql/-/plugin-sql-2.3.2.tgz", "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" "@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": { "node_modules/@types/babel__core": {
"version": "7.20.5", "version": "7.20.5",
"resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz",

View file

@ -12,7 +12,9 @@
"dependencies": { "dependencies": {
"@tauri-apps/api": "^2", "@tauri-apps/api": "^2",
"@tauri-apps/plugin-opener": "^2", "@tauri-apps/plugin-opener": "^2",
"@tauri-apps/plugin-process": "^2.3.1",
"@tauri-apps/plugin-sql": "^2.3.2", "@tauri-apps/plugin-sql": "^2.3.2",
"@tauri-apps/plugin-updater": "^2.10.0",
"i18next": "^25.8.4", "i18next": "^25.8.4",
"lucide-react": "^0.563.0", "lucide-react": "^0.563.0",
"papaparse": "^5.5.3", "papaparse": "^5.5.3",

View file

@ -22,6 +22,8 @@ tauri = { version = "2", features = [] }
tauri-plugin-opener = "2" tauri-plugin-opener = "2"
tauri-plugin-sql = { version = "2", features = ["sqlite"] } tauri-plugin-sql = { version = "2", features = ["sqlite"] }
tauri-plugin-dialog = "2" tauri-plugin-dialog = "2"
tauri-plugin-updater = "2"
tauri-plugin-process = "2"
serde = { version = "1", features = ["derive"] } serde = { version = "1", features = ["derive"] }
serde_json = "1" serde_json = "1"
sha2 = "0.10" sha2 = "0.10"

View file

@ -9,6 +9,8 @@
"sql:default", "sql:default",
"sql:allow-execute", "sql:allow-execute",
"sql:allow-select", "sql:allow-select",
"dialog:default" "dialog:default",
"updater:default",
"process:allow-restart"
] ]
} }

View file

@ -23,6 +23,12 @@ pub fn run() {
tauri::Builder::default() tauri::Builder::default()
.plugin(tauri_plugin_opener::init()) .plugin(tauri_plugin_opener::init())
.plugin(tauri_plugin_dialog::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( .plugin(
tauri_plugin_sql::Builder::default() tauri_plugin_sql::Builder::default()
.add_migrations("sqlite:simpl_resultat.db", migrations) .add_migrations("sqlite:simpl_resultat.db", migrations)

View file

@ -30,6 +30,18 @@
"icons/128x128@2x.png", "icons/128x128@2x.png",
"icons/icon.icns", "icons/icon.icns",
"icons/icon.ico" "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"
}
}
} }
} }

View file

@ -7,6 +7,7 @@ import CategoriesPage from "./pages/CategoriesPage";
import AdjustmentsPage from "./pages/AdjustmentsPage"; import AdjustmentsPage from "./pages/AdjustmentsPage";
import BudgetPage from "./pages/BudgetPage"; import BudgetPage from "./pages/BudgetPage";
import ReportsPage from "./pages/ReportsPage"; import ReportsPage from "./pages/ReportsPage";
import SettingsPage from "./pages/SettingsPage";
export default function App() { export default function App() {
return ( return (
@ -20,6 +21,7 @@ export default function App() {
<Route path="/adjustments" element={<AdjustmentsPage />} /> <Route path="/adjustments" element={<AdjustmentsPage />} />
<Route path="/budget" element={<BudgetPage />} /> <Route path="/budget" element={<BudgetPage />} />
<Route path="/reports" element={<ReportsPage />} /> <Route path="/reports" element={<ReportsPage />} />
<Route path="/settings" element={<SettingsPage />} />
</Route> </Route>
</Routes> </Routes>
</BrowserRouter> </BrowserRouter>

View file

@ -8,6 +8,7 @@ import {
SlidersHorizontal, SlidersHorizontal,
PiggyBank, PiggyBank,
BarChart3, BarChart3,
Settings,
Languages, Languages,
} from "lucide-react"; } from "lucide-react";
import { NAV_ITEMS, APP_NAME } from "../../shared/constants"; import { NAV_ITEMS, APP_NAME } from "../../shared/constants";
@ -20,6 +21,7 @@ const iconMap: Record<string, React.ComponentType<{ size?: number }>> = {
SlidersHorizontal, SlidersHorizontal,
PiggyBank, PiggyBank,
BarChart3, BarChart3,
Settings,
}; };
export default function Sidebar() { export default function Sidebar() {

122
src/hooks/useUpdater.ts Normal file
View file

@ -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<Update | null>(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 };
}

View file

@ -9,7 +9,8 @@
"categories": "Categories", "categories": "Categories",
"adjustments": "Adjustments", "adjustments": "Adjustments",
"budget": "Budget", "budget": "Budget",
"reports": "Reports" "reports": "Reports",
"settings": "Settings"
}, },
"dashboard": { "dashboard": {
"title": "Dashboard", "title": "Dashboard",
@ -233,6 +234,25 @@
"trends": "Monthly Trends", "trends": "Monthly Trends",
"export": "Export" "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": { "common": {
"save": "Save", "save": "Save",
"cancel": "Cancel", "cancel": "Cancel",

View file

@ -9,7 +9,8 @@
"categories": "Catégories", "categories": "Catégories",
"adjustments": "Ajustements", "adjustments": "Ajustements",
"budget": "Budget", "budget": "Budget",
"reports": "Rapports" "reports": "Rapports",
"settings": "Paramètres"
}, },
"dashboard": { "dashboard": {
"title": "Tableau de bord", "title": "Tableau de bord",
@ -233,6 +234,25 @@
"trends": "Tendances mensuelles", "trends": "Tendances mensuelles",
"export": "Exporter" "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": { "common": {
"save": "Enregistrer", "save": "Enregistrer",
"cancel": "Annuler", "cancel": "Annuler",

176
src/pages/SettingsPage.tsx Normal file
View file

@ -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 (
<div className="p-6 max-w-2xl mx-auto space-y-6">
<h1 className="text-2xl font-bold">{t("settings.title")}</h1>
{/* About card */}
<div className="bg-[var(--card)] border border-[var(--border)] rounded-xl p-6">
<div className="flex items-center gap-4">
<div className="w-12 h-12 rounded-xl bg-[var(--primary)] flex items-center justify-center text-white font-bold text-lg">
S
</div>
<div>
<h2 className="text-lg font-semibold">{APP_NAME}</h2>
<p className="text-sm text-[var(--muted)]">
{t("settings.version", { version })}
</p>
</div>
</div>
</div>
{/* Update card */}
<div className="bg-[var(--card)] border border-[var(--border)] rounded-xl p-6 space-y-4">
<h2 className="text-lg font-semibold flex items-center gap-2">
<Info size={18} />
{t("settings.updates.title")}
</h2>
{/* idle */}
{state.status === "idle" && (
<button
onClick={checkForUpdate}
className="flex items-center gap-2 px-4 py-2 bg-[var(--primary)] text-white rounded-lg hover:opacity-90 transition-opacity"
>
<RefreshCw size={16} />
{t("settings.updates.checkButton")}
</button>
)}
{/* checking */}
{state.status === "checking" && (
<div className="flex items-center gap-2 text-[var(--muted)]">
<Loader2 size={16} className="animate-spin" />
{t("settings.updates.checking")}
</div>
)}
{/* up to date */}
{state.status === "upToDate" && (
<div className="flex items-center justify-between">
<div className="flex items-center gap-2 text-[var(--positive)]">
<CheckCircle size={16} />
{t("settings.updates.upToDate")}
</div>
<button
onClick={checkForUpdate}
className="text-sm text-[var(--muted)] hover:text-[var(--foreground)] transition-colors"
>
<RefreshCw size={14} />
</button>
</div>
)}
{/* available */}
{state.status === "available" && (
<div className="space-y-3">
<p>
{t("settings.updates.available", { version: state.version })}
</p>
<button
onClick={downloadAndInstall}
className="flex items-center gap-2 px-4 py-2 bg-[var(--primary)] text-white rounded-lg hover:opacity-90 transition-opacity"
>
<Download size={16} />
{t("settings.updates.downloadButton")}
</button>
</div>
)}
{/* downloading */}
{state.status === "downloading" && (
<div className="space-y-2">
<div className="flex items-center gap-2 text-[var(--muted)]">
<Loader2 size={16} className="animate-spin" />
{t("settings.updates.downloading")}
{progressPercent !== null && <span>{progressPercent}%</span>}
</div>
<div className="w-full bg-[var(--border)] rounded-full h-2">
<div
className="bg-[var(--primary)] h-2 rounded-full transition-all duration-300"
style={{ width: `${progressPercent ?? 0}%` }}
/>
</div>
</div>
)}
{/* ready to install */}
{state.status === "readyToInstall" && (
<div className="space-y-3">
<p className="text-[var(--positive)]">
{t("settings.updates.readyToInstall")}
</p>
<button
onClick={installAndRestart}
className="flex items-center gap-2 px-4 py-2 bg-[var(--positive)] text-white rounded-lg hover:opacity-90 transition-opacity"
>
<RotateCcw size={16} />
{t("settings.updates.installButton")}
</button>
</div>
)}
{/* installing */}
{state.status === "installing" && (
<div className="flex items-center gap-2 text-[var(--muted)]">
<Loader2 size={16} className="animate-spin" />
{t("settings.updates.installing")}
</div>
)}
{/* error */}
{state.status === "error" && (
<div className="space-y-3">
<div className="flex items-center gap-2 text-[var(--negative)]">
<AlertCircle size={16} />
{t("settings.updates.error")}
</div>
<p className="text-sm text-[var(--muted)]">{state.error}</p>
<button
onClick={checkForUpdate}
className="flex items-center gap-2 px-4 py-2 border border-[var(--border)] rounded-lg hover:bg-[var(--border)] transition-colors"
>
<RotateCcw size={16} />
{t("settings.updates.retryButton")}
</button>
</div>
)}
</div>
{/* Data safety notice */}
<div className="flex items-start gap-2 text-sm text-[var(--muted)]">
<ShieldCheck size={16} className="mt-0.5 shrink-0" />
<p>{t("settings.dataSafeNotice")}</p>
</div>
</div>
);
}

View file

@ -46,4 +46,10 @@ export const NAV_ITEMS: NavItem[] = [
icon: "BarChart3", icon: "BarChart3",
labelKey: "nav.reports", labelKey: "nav.reports",
}, },
{
key: "settings",
path: "/settings",
icon: "Settings",
labelKey: "nav.settings",
},
]; ];