diff --git a/apps/client/src/features/i18n/locales/en.json b/apps/client/src/features/i18n/locales/en.json index 9c4be7e..4a964ef 100644 --- a/apps/client/src/features/i18n/locales/en.json +++ b/apps/client/src/features/i18n/locales/en.json @@ -72,7 +72,12 @@ "available": "Update v{version} available", "install": "Update", "installing": "Updating…", - "error": "Update failed. Grab the new exe manually: GitHub → Releases." + "error": "Update failed. Grab the new exe manually: GitHub → Releases.", + "checkButton": "Check for updates", + "upToDate": "Up to date", + "checkFailed": "Check failed", + "updateTo": "Update to v{version}", + "autoCheckLabel": "Check for updates on startup" }, "overlay": { "heading": "Overlay", diff --git a/apps/client/src/features/i18n/locales/ru.json b/apps/client/src/features/i18n/locales/ru.json index 1a1df13..3883814 100644 --- a/apps/client/src/features/i18n/locales/ru.json +++ b/apps/client/src/features/i18n/locales/ru.json @@ -72,7 +72,12 @@ "available": "Доступно обновление v{version}", "install": "Обновить", "installing": "Обновляем…", - "error": "Не получилось обновиться. Скачай новый exe вручную: GitHub → Releases." + "error": "Не получилось обновиться. Скачай новый exe вручную: GitHub → Releases.", + "checkButton": "Проверить обновления", + "upToDate": "Актуальная версия", + "checkFailed": "Не удалось проверить", + "updateTo": "Обновить до v{version}", + "autoCheckLabel": "Проверять обновления при запуске" }, "overlay": { "heading": "Оверлей", diff --git a/apps/client/src/features/settings/SettingsPanel.vue b/apps/client/src/features/settings/SettingsPanel.vue index 4f3f065..ecb86a7 100644 --- a/apps/client/src/features/settings/SettingsPanel.vue +++ b/apps/client/src/features/settings/SettingsPanel.vue @@ -2,6 +2,7 @@ import { z } from 'zod'; import { useSettingsSections } from './registry'; import { persistedRef } from '@/shared/persisted-store'; +import AboutFooter from '@/features/updater/components/AboutFooter.vue'; const { t } = useI18n(); const systemSections = useSettingsSections(); @@ -67,5 +68,8 @@ const openSections = persistedRef( + + diff --git a/apps/client/src/features/updater/components/AboutFooter.vue b/apps/client/src/features/updater/components/AboutFooter.vue new file mode 100644 index 0000000..b71c17d --- /dev/null +++ b/apps/client/src/features/updater/components/AboutFooter.vue @@ -0,0 +1,87 @@ + + + + + + + + {{ version ? `v${version}` : '—' }} + + + + + + + {{ t('updater.autoCheckLabel') }} + + + + + diff --git a/apps/client/src/features/updater/components/UpdateBanner.vue b/apps/client/src/features/updater/components/UpdateBanner.vue index f35b2b4..b12d3c9 100644 --- a/apps/client/src/features/updater/components/UpdateBanner.vue +++ b/apps/client/src/features/updater/components/UpdateBanner.vue @@ -1,49 +1,19 @@ @@ -52,13 +22,13 @@ async function install(): Promise { v-if="info && !dismissed && !clickThrough" class="border-surface-700 bg-surface-900/95 fixed bottom-2 left-1/2 z-[1100] flex -translate-x-1/2 items-center gap-2 rounded-lg border px-3 py-1.5 text-xs shadow-lg" > - + {{ t('updater.available', { version: info.latest }) }} {{ t('updater.error') }} diff --git a/apps/client/src/features/updater/composables/useAppVersion.ts b/apps/client/src/features/updater/composables/useAppVersion.ts new file mode 100644 index 0000000..7d5071c --- /dev/null +++ b/apps/client/src/features/updater/composables/useAppVersion.ts @@ -0,0 +1,27 @@ +import { isTauri } from '@/shared/tauri'; + +/** + * Current app version for display. Tauri: the overlay's own version via the + * app API. Browser/phone: the helper's version from `GET /api/ping` — that's + * the version that matters there, the page itself has no version of its own. + * `null` while loading or when the helper is unreachable. + */ +export function useAppVersion(): Ref { + const version = ref(null); + + onMounted(async () => { + try { + if (isTauri) { + const { getVersion } = await import('@tauri-apps/api/app'); + version.value = await getVersion(); + } else { + const res = await fetch('/api/ping'); + if (res.ok) version.value = ((await res.json()) as { version?: string }).version ?? null; + } + } catch { + version.value = null; + } + }); + + return version; +} diff --git a/apps/client/src/features/updater/store.ts b/apps/client/src/features/updater/store.ts new file mode 100644 index 0000000..a03d888 --- /dev/null +++ b/apps/client/src/features/updater/store.ts @@ -0,0 +1,59 @@ +import { z } from 'zod'; +import { persistedRef } from '@/shared/persisted-store'; +import { isTauri } from '@/shared/tauri'; + +/** Mirrors `UpdateInfo` in src-tauri/src/updater.rs (downloadUrl stays Rust-side). */ +export interface UpdateInfo { + current: string; + latest: string; + releaseUrl: string; +} + +/** Outcome of the most recent check — drives the settings-footer button label. */ +export type CheckOutcome = 'none' | 'latest' | 'error'; + +export const useUpdaterStore = defineStore('updater', () => { + // When off, no check fires on startup; the manual button still works. + const autoCheck = persistedRef('tc.updater.autoCheck', z.boolean(), true); + + // Runtime state, deliberately not persisted: a pending update is only as + // fresh as the process that found it. + const info = ref(null); + const checking = ref(false); + const installing = ref(false); + const installFailed = ref(false); + const lastCheck = ref('none'); + + async function check(): Promise { + if (!isTauri || checking.value) return; + checking.value = true; + try { + const { invoke } = await import('@tauri-apps/api/core'); + info.value = await invoke('check_update'); + lastCheck.value = info.value ? 'none' : 'latest'; + } catch (e) { + console.warn('[updater] check failed:', e); + lastCheck.value = 'error'; + } finally { + checking.value = false; + } + } + + async function install(): Promise { + if (!isTauri || installing.value || !info.value) return; + installing.value = true; + installFailed.value = false; + try { + const { invoke } = await import('@tauri-apps/api/core'); + // On success the backend swaps the exe, respawns it and exits this + // process — there is no resolved state to handle. + await invoke('install_update'); + } catch (e) { + console.warn('[updater] install failed:', e); + installFailed.value = true; + installing.value = false; + } + } + + return { autoCheck, info, checking, installing, installFailed, lastCheck, check, install }; +}); diff --git a/apps/desktop/src-tauri/src/commands.rs b/apps/desktop/src-tauri/src/commands.rs index c99844f..2cfa596 100644 --- a/apps/desktop/src-tauri/src/commands.rs +++ b/apps/desktop/src-tauri/src/commands.rs @@ -182,6 +182,18 @@ pub async fn check_update() -> Result, String crate::updater::check().await.map_err(|e| format!("{e:#}")) } +/// Opens the GitHub releases page in the system default browser. The URL is +/// fixed server-side (never taken from the webview); `explorer.exe ` +/// hands it to the default browser without needing the opener plugin. +#[tauri::command] +pub async fn open_releases_page() -> Result<(), String> { + std::process::Command::new("explorer") + .arg(crate::updater::releases_url()) + .spawn() + .map_err(|e| e.to_string())?; + Ok(()) +} + /// Portable self-update: re-checks GitHub (the webview never supplies a URL, /// so it can't point the updater anywhere else), downloads the new exe, swaps /// it in via the rename dance, relaunches and exits. See `updater.rs`. diff --git a/apps/desktop/src-tauri/src/lib.rs b/apps/desktop/src-tauri/src/lib.rs index b189016..d00a84f 100644 --- a/apps/desktop/src-tauri/src/lib.rs +++ b/apps/desktop/src-tauri/src/lib.rs @@ -89,6 +89,7 @@ pub fn run() { commands::notify_tray_hint, commands::check_update, commands::install_update, + commands::open_releases_page, ]) .setup(move |app| { let app_handle = app.handle().clone(); diff --git a/apps/desktop/src-tauri/src/updater.rs b/apps/desktop/src-tauri/src/updater.rs index 2676693..2a6b377 100644 --- a/apps/desktop/src-tauri/src/updater.rs +++ b/apps/desktop/src-tauri/src/updater.rs @@ -21,6 +21,11 @@ pub fn current_version() -> &'static str { env!("CARGO_PKG_VERSION") } +/// Human-facing releases page (the settings footer links here). +pub fn releases_url() -> String { + format!("https://github.com/{REPO}/releases") +} + #[derive(Debug, Clone, Serialize)] #[serde(rename_all = "camelCase")] pub struct UpdateInfo {