Skip to content

Commit 7063842

Browse files
committed
feat: implement update management with relaunch functionality and localization support
1 parent d4480d4 commit 7063842

22 files changed

Lines changed: 300 additions & 61 deletions

src-tauri/src/commands/app_cmd.rs

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -100,6 +100,11 @@ pub fn get_install_channel_info() -> InstallChannelInfo {
100100
}
101101
}
102102

103+
#[tauri::command]
104+
pub fn relaunch_app(app: AppHandle) {
105+
app.restart();
106+
}
107+
103108
fn default_shortcuts() -> ShortcutSettings {
104109
ShortcutSettings {
105110
open_widget_center: "Alt+W".to_string(),
@@ -110,10 +115,11 @@ fn default_shortcuts() -> ShortcutSettings {
110115
}
111116

112117
#[tauri::command]
113-
pub fn get_app_settings(app: AppHandle, db: State<DbState>) -> Result<AppSettingsPayload, String> {
118+
pub fn get_app_settings(db: State<DbState>) -> Result<AppSettingsPayload, String> {
114119
let conn = db.lock().map_err(|e| e.to_string())?;
115120

116-
let launch_at_startup = app.autolaunch().is_enabled().unwrap_or(false);
121+
let launch_at_startup =
122+
crate::db::get_bool_setting(&conn, "launch_at_startup", false).unwrap_or(false);
117123
let silent_startup =
118124
crate::db::get_bool_setting(&conn, "silent_startup", true).map_err(|e| e.to_string())?;
119125
let auto_open_widgets =

src-tauri/src/lib.rs

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ use tauri::{
1616
tray::{TrayIconBuilder, TrayIconEvent},
1717
AppHandle, Emitter, Listener, Manager,
1818
};
19+
use tauri_plugin_autostart::ManagerExt;
1920

2021
use commands::storage_cmd::DbState;
2122
use monitor::{MonitorStatus, SharedMonitorStatus};
@@ -609,6 +610,25 @@ pub fn run() {
609610
// Widget/main windows can start invoking commands immediately.
610611
app.manage(db_state.clone());
611612

613+
// Sync OS-level autostart with the stored user preference. Re-installing the
614+
// app often clears the registry/launch-agent entry, so re-apply it here.
615+
{
616+
let conn = db_state.lock().unwrap();
617+
match crate::db::get_bool_setting(&conn, "launch_at_startup", false) {
618+
Ok(true) => {
619+
if let Err(e) = app.autolaunch().enable() {
620+
log::warn!("Failed to enable launch at startup: {}", e);
621+
}
622+
}
623+
Ok(false) => {
624+
if let Err(e) = app.autolaunch().disable() {
625+
log::warn!("Failed to disable launch at startup: {}", e);
626+
}
627+
}
628+
Err(e) => log::warn!("Failed to read launch_at_startup setting: {}", e),
629+
}
630+
}
631+
612632
// Widget runtime v2.2.0 rate limiters
613633
app.manage(commands::WidgetCallRateLimiter::new());
614634
app.manage(commands::WidgetEventRateLimiter::new());
@@ -1123,6 +1143,7 @@ pub fn run() {
11231143
commands::get_browser_extension_status,
11241144
commands::get_local_api_base_url,
11251145
commands::get_install_channel_info,
1146+
commands::relaunch_app,
11261147
commands::set_launch_at_startup,
11271148
commands::get_tray_icon_style,
11281149
commands::set_tray_icon_style,

src/MainApp.tsx

Lines changed: 152 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import { register as registerGlobalShortcut, unregisterAll as unregisterAllGloba
66
import { isPermissionGranted, requestPermission, sendNotification } from "@tauri-apps/plugin-notification";
77
import { open as openExternal } from "@tauri-apps/plugin-shell";
88
import { check } from "@tauri-apps/plugin-updater";
9+
import type { Update, DownloadEvent } from "@tauri-apps/plugin-updater";
910
import MainLayout from "./components/layout/MainLayout";
1011
import Loading from "./components/Loading";
1112

@@ -86,15 +87,17 @@ export default function MainApp() {
8687
} = useStatsStore();
8788
const {
8889
setMonitoringActive,
89-
autoCheckUpdates,
90+
updateMode,
9091
notificationQuietHoursEnabled,
9192
notificationQuietStart,
9293
notificationQuietEnd,
9394
notificationCooldownMin,
9495
} = useSettingsStore();
9596
const { t } = useTranslation(["common", "limits", "browserUsage"]);
9697

97-
const [updateInfo, setUpdateInfo] = useState<{ version: string; notes: string; url: string } | null>(null);
98+
const [updateInfo, setUpdateInfo] = useState<{ version: string; notes: string; url: string; update: Update | null } | null>(null);
99+
const [updatePhase, setUpdatePhase] = useState<"available" | "downloading" | "downloaded" | "installing">("available");
100+
const [downloadProgress, setDownloadProgress] = useState(0);
98101
const updateCloseButtonRef = useRef<HTMLButtonElement>(null);
99102

100103
useEffect(() => {
@@ -318,7 +321,7 @@ export default function MainApp() {
318321

319322
// Update check – once after 4 s
320323
useEffect(() => {
321-
if (!autoCheckUpdates) return;
324+
if (updateMode === "off") return;
322325

323326
const timer = setTimeout(async () => {
324327
try {
@@ -329,7 +332,6 @@ export default function MainApp() {
329332
if (!(latest && compareVersions(latest, CURRENT_VERSION) > 0)) return;
330333

331334
const channel = await api.getInstallChannelInfo();
332-
setUpdateInfo({ version: latest, notes: data.body ?? "", url: data.html_url ?? "" });
333335

334336
if (!channel.should_trigger_update) {
335337
const storeUpdateUrl = channel.update_url ?? "ms-windows-store://downloadsandupdates";
@@ -347,32 +349,46 @@ export default function MainApp() {
347349
return;
348350
}
349351

352+
if (updateMode === "auto") {
353+
try {
354+
const update = await check();
355+
if (update) {
356+
await update.downloadAndInstall();
357+
await api.relaunchApp();
358+
return;
359+
}
360+
} catch {
361+
// fallback to release page
362+
}
363+
if (data.html_url) window.open(data.html_url, "_blank", "noopener,noreferrer");
364+
await notifyWithNavigate(
365+
t("common:updateAvailableTitle"),
366+
t("common:updateAvailableBody", { version: latest, current: CURRENT_VERSION }),
367+
"#/settings"
368+
);
369+
return;
370+
}
371+
372+
// notify mode: show the manual download/install dialog
350373
try {
351374
const update = await check();
352375
if (update) {
353-
await update.downloadAndInstall();
354-
await notifyWithNavigate(
355-
t("common:updateAvailableTitle"),
356-
t("common:updateInstallReady", { version: latest }),
357-
"#/settings"
358-
);
376+
setUpdateInfo({ version: latest, notes: data.body ?? "", url: data.html_url ?? "", update });
377+
setUpdatePhase("available");
378+
setDownloadProgress(0);
359379
return;
360380
}
361381
} catch {
362-
// fallback to release page
382+
// fallback to release page modal
363383
}
364384

365-
if (data.html_url) window.open(data.html_url, "_blank", "noopener,noreferrer");
366-
367-
await notifyWithNavigate(
368-
t("common:updateAvailableTitle"),
369-
t("common:updateAvailableBody", { version: latest, current: CURRENT_VERSION }),
370-
"#/settings"
371-
);
385+
setUpdateInfo({ version: latest, notes: data.body ?? "", url: data.html_url ?? "", update: null });
386+
setUpdatePhase("available");
387+
setDownloadProgress(0);
372388
} catch { /* offline */ }
373389
}, 4000);
374390
return () => clearTimeout(timer);
375-
}, [autoCheckUpdates, notifyWithNavigate, t]);
391+
}, [updateMode, notifyWithNavigate, t]);
376392

377393
useEffect(() => {
378394
if (updateInfo) {
@@ -447,6 +463,54 @@ export default function MainApp() {
447463
};
448464
}, [focusMainAndNavigate, setMonitorActive, setMonitoringActive, toggleWidgetsVisibility]);
449465

466+
const closeUpdateModal = useCallback(() => {
467+
if (updateInfo?.update && updatePhase === "available") {
468+
updateInfo.update.close().catch(() => {});
469+
}
470+
setUpdateInfo(null);
471+
setUpdatePhase("available");
472+
setDownloadProgress(0);
473+
}, [updateInfo, updatePhase]);
474+
475+
const handleDownloadUpdate = useCallback(async () => {
476+
if (!updateInfo?.update) return;
477+
setUpdatePhase("downloading");
478+
setDownloadProgress(0);
479+
let downloaded = 0;
480+
let contentLength = 0;
481+
try {
482+
await updateInfo.update.download((event: DownloadEvent) => {
483+
if (event.event === "Started") {
484+
contentLength = event.data.contentLength ?? 0;
485+
} else if (event.event === "Progress") {
486+
downloaded += event.data.chunkLength;
487+
if (contentLength > 0) {
488+
setDownloadProgress(Math.min(100, Math.round((downloaded / contentLength) * 100)));
489+
}
490+
}
491+
});
492+
setUpdatePhase("downloaded");
493+
} catch (err) {
494+
setUpdatePhase("available");
495+
setDownloadProgress(0);
496+
const message = err instanceof Error ? err.message : "";
497+
await notifyWithNavigate(t("common:downloadUpdateFailed"), message, "#/settings");
498+
}
499+
}, [updateInfo, notifyWithNavigate, t]);
500+
501+
const handleInstallUpdate = useCallback(async () => {
502+
if (!updateInfo?.update) return;
503+
setUpdatePhase("installing");
504+
try {
505+
await updateInfo.update.install();
506+
await api.relaunchApp();
507+
} catch (err) {
508+
setUpdatePhase("downloaded");
509+
const message = err instanceof Error ? err.message : "";
510+
await notifyWithNavigate(t("common:error"), message, "#/settings");
511+
}
512+
}, [updateInfo, notifyWithNavigate, t]);
513+
450514
return (
451515
<HashRouter future={{ v7_startTransition: true, v7_relativeSplatPath: true }}>
452516
<MainLayout>
@@ -486,27 +550,88 @@ export default function MainApp() {
486550
</div>
487551
<button
488552
ref={updateCloseButtonRef}
489-
onClick={() => setUpdateInfo(null)}
553+
onClick={closeUpdateModal}
490554
aria-label={t("common:close")}
491555
title={t("common:close")}
492556
className="text-text-muted hover:text-text-primary flex-shrink-0"
493557
>
494558
495559
</button>
496560
</div>
497-
{updateInfo.notes && (
561+
562+
{updateInfo.notes && updatePhase === "available" && (
498563
<div className="bg-surface-light rounded-xl p-3 max-h-52 overflow-y-auto">
499564
<p className="text-xs font-semibold text-text-secondary mb-1">{t("common:whatsNew")}</p>
500565
<pre className="text-xs text-text-muted whitespace-pre-wrap font-sans leading-relaxed">{updateInfo.notes}</pre>
501566
</div>
502567
)}
568+
569+
{updatePhase === "downloading" && (
570+
<div className="space-y-2">
571+
<div className="w-full bg-surface-hover rounded-full h-2 overflow-hidden">
572+
<div
573+
className="bg-accent-blue h-full transition-all duration-150"
574+
style={{ width: `${downloadProgress}%` }}
575+
/>
576+
</div>
577+
<p className="text-xs text-text-muted">
578+
{t("common:downloadingUpdate")}
579+
{downloadProgress > 0 ? ` ${downloadProgress}%` : ""}
580+
</p>
581+
</div>
582+
)}
583+
584+
{updatePhase === "downloaded" && (
585+
<p className="text-sm text-text-secondary">{t("common:updateDownloadReady")}</p>
586+
)}
587+
588+
{updatePhase === "installing" && (
589+
<p className="text-sm text-text-secondary">{t("common:installingUpdate")}</p>
590+
)}
591+
503592
<div className="flex gap-3">
504-
<a href={updateInfo.url} target="_blank" rel="noopener noreferrer" className="flex-1 py-2.5 rounded-xl text-sm font-medium text-center bg-accent-blue/20 text-accent-blue hover:bg-accent-blue/30 transition-colors border border-accent-blue/30">
505-
{t("common:viewOnGitHub")}
506-
</a>
507-
<button onClick={() => setUpdateInfo(null)} className="flex-1 py-2.5 rounded-xl text-sm font-medium border border-surface-border text-text-muted hover:text-text-secondary hover:bg-surface-hover transition-colors">
508-
{t("common:remindLater")}
509-
</button>
593+
{updatePhase === "available" && updateInfo.update && (
594+
<button
595+
onClick={handleDownloadUpdate}
596+
className="flex-1 py-2.5 rounded-xl text-sm font-medium text-center bg-accent-blue/20 text-accent-blue hover:bg-accent-blue/30 transition-colors border border-accent-blue/30"
597+
>
598+
{t("common:downloadUpdate")}
599+
</button>
600+
)}
601+
{updatePhase === "available" && !updateInfo.update && updateInfo.url && (
602+
<a
603+
href={updateInfo.url}
604+
target="_blank"
605+
rel="noopener noreferrer"
606+
className="flex-1 py-2.5 rounded-xl text-sm font-medium text-center bg-accent-blue/20 text-accent-blue hover:bg-accent-blue/30 transition-colors border border-accent-blue/30"
607+
>
608+
{t("common:viewOnGitHub")}
609+
</a>
610+
)}
611+
{updatePhase === "downloaded" && updateInfo.update && (
612+
<button
613+
onClick={handleInstallUpdate}
614+
className="flex-1 py-2.5 rounded-xl text-sm font-medium text-center bg-accent-green/20 text-accent-green hover:bg-accent-green/30 transition-colors border border-accent-green/30"
615+
>
616+
{t("common:installUpdate")}
617+
</button>
618+
)}
619+
{(updatePhase === "available" || updatePhase === "downloaded") && (
620+
<button
621+
onClick={closeUpdateModal}
622+
className="flex-1 py-2.5 rounded-xl text-sm font-medium border border-surface-border text-text-muted hover:text-text-secondary hover:bg-surface-hover transition-colors"
623+
>
624+
{updatePhase === "downloaded" ? t("common:installLater") : t("common:remindLater")}
625+
</button>
626+
)}
627+
{(updatePhase === "downloading" || updatePhase === "installing") && (
628+
<button
629+
disabled
630+
className="flex-1 py-2.5 rounded-xl text-sm font-medium border border-surface-border text-text-muted opacity-50 cursor-not-allowed"
631+
>
632+
{updatePhase === "downloading" ? t("common:downloadingUpdate") : t("common:installingUpdate")}
633+
</button>
634+
)}
510635
</div>
511636
</div>
512637
</div>

src/i18n/locales/de/common.json

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,13 @@
3434
"updateAvailableBody": "TimeLens v{{version}} ist verfügbar. Sie verwenden v{{current}}.",
3535
"updateAvailableStoreBody": "TimeLens v{{version}} ist verfügbar. Sie verwenden v{{current}}. TimeLens hat die Microsoft Store-Updates geöffnet.",
3636
"updateInstallReady": "TimeLens v{{version}} wurde installiert. Starten Sie die App neu, um Updates anzuwenden.",
37+
"downloadUpdate": "Download update",
38+
"downloadingUpdate": "Downloading update…",
39+
"installUpdate": "Install update",
40+
"installingUpdate": "Installing update…",
41+
"installLater": "Install later",
42+
"updateDownloadReady": "The update package is ready. Install now?",
43+
"downloadUpdateFailed": "Failed to download update",
3744
"vscodeExtensionOfflineTitle": "VS Code-Erweiterung nicht verbunden",
3845
"vscodeExtensionOfflineBody": "VS Code-Statistiken wurden leicht reduziert, da die Erweiterung nicht läuft.",
3946
"viewOnGitHub": "Auf GitHub anzeigen",

src/i18n/locales/de/settings.json

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -348,7 +348,10 @@
348348
"version": "Version",
349349
"logs": "Logs",
350350
"openLogFolder": "Log-Ordner öffnen",
351-
"autoCheckUpdates": "Updates automatisch prüfen",
351+
"updateMode": "Update checks",
352+
"updateModeOff": "Off",
353+
"updateModeNotify": "Notify only",
354+
"updateModeAuto": "Download and install automatically",
352355
"checkUpdate": "Nach Updates suchen",
353356
"checking": "Prüfe…",
354357
"checkFailed": "Update-Prüfung fehlgeschlagen",

src/i18n/locales/en/common.json

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,13 @@
3434
"updateAvailableBody": "TimeLens v{{version}} is now available. You are on v{{current}}.",
3535
"updateAvailableStoreBody": "TimeLens v{{version}} is now available. You are on v{{current}}. TimeLens has opened Microsoft Store updates.",
3636
"updateInstallReady": "TimeLens v{{version}} has been installed. Restart the app to apply updates.",
37+
"downloadUpdate": "Download update",
38+
"downloadingUpdate": "Downloading update…",
39+
"installUpdate": "Install update",
40+
"installingUpdate": "Installing update…",
41+
"installLater": "Install later",
42+
"updateDownloadReady": "The update package is ready. Install now?",
43+
"downloadUpdateFailed": "Failed to download update",
3744
"vscodeExtensionOfflineTitle": "VS Code extension is not connected",
3845
"vscodeExtensionOfflineBody": "VS Code stats have been soft-degraded because the extension is not running.",
3946
"viewOnGitHub": "View on GitHub",

src/i18n/locales/en/settings.json

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -348,7 +348,10 @@
348348
"version": "Version",
349349
"logs": "Logs",
350350
"openLogFolder": "Open Log Folder",
351-
"autoCheckUpdates": "Auto-check updates",
351+
"updateMode": "Update checks",
352+
"updateModeOff": "Off",
353+
"updateModeNotify": "Notify only",
354+
"updateModeAuto": "Download and install automatically",
352355
"checkUpdate": "Check for Updates",
353356
"checking": "Checking…",
354357
"checkFailed": "Update check failed",

src/i18n/locales/es/common.json

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,13 @@
3434
"updateAvailableBody": "TimeLens v{{version}} está disponible. Tienes v{{current}}.",
3535
"updateAvailableStoreBody": "TimeLens v{{version}} está disponible. Tienes v{{current}}. TimeLens abrió las actualizaciones de Microsoft Store.",
3636
"updateInstallReady": "TimeLens v{{version}} se ha instalado. Reinicia la app para aplicar las actualizaciones.",
37+
"downloadUpdate": "Download update",
38+
"downloadingUpdate": "Downloading update…",
39+
"installUpdate": "Install update",
40+
"installingUpdate": "Installing update…",
41+
"installLater": "Install later",
42+
"updateDownloadReady": "The update package is ready. Install now?",
43+
"downloadUpdateFailed": "Failed to download update",
3744
"vscodeExtensionOfflineTitle": "La extensión de VS Code no está conectada",
3845
"vscodeExtensionOfflineBody": "Las estadísticas de VS Code se han degradado levemente porque la extensión no se está ejecutando.",
3946
"viewOnGitHub": "Ver en GitHub",

0 commit comments

Comments
 (0)