Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion apps/client/src/features/i18n/locales/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
7 changes: 6 additions & 1 deletion apps/client/src/features/i18n/locales/ru.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": "Оверлей",
Expand Down
4 changes: 4 additions & 0 deletions apps/client/src/features/settings/SettingsPanel.vue
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down Expand Up @@ -67,5 +68,8 @@ const openSections = persistedRef(
</AccordionContent>
</AccordionPanel>
</Accordion>
<!-- Not a section on purpose: version + update controls are periphery,
not a peer of Hotkeys/Overlay (see the about-footer design review). -->
<AboutFooter />
</Drawer>
</template>
87 changes: 87 additions & 0 deletions apps/client/src/features/updater/components/AboutFooter.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
<script setup lang="ts">
import { isTauri } from '@/shared/tauri';
import { useUpdaterStore } from '@/features/updater/store';
import { useAppVersion } from '@/features/updater/composables/useAppVersion';

const RELEASES_URL = 'https://github.com/Mosmain/tarkov-checker/releases';

const { t } = useI18n();
const version = useAppVersion();

const updater = useUpdaterStore();
const { autoCheck, info, checking, installing, lastCheck } = storeToRefs(updater);

// "Up to date" settles for a moment, then the button returns to its idle
// label; errors stay until the next click (per the design review). Driven by
// the click completing — NOT by watching lastCheck, whose value doesn't
// change on a repeat "still up to date" outcome.
const showLatest = ref(false);
let latestTimer: ReturnType<typeof setTimeout> | undefined;
function flashUpToDate(): void {
clearTimeout(latestTimer);
showLatest.value = true;
latestTimer = setTimeout(() => {
showLatest.value = false;
}, 3000);
}

const buttonLabel = computed(() => {
if (info.value) return t('updater.updateTo', { version: info.value.latest });
if (showLatest.value) return t('updater.upToDate');
if (lastCheck.value === 'error') return t('updater.checkFailed');
return t('updater.checkButton');
});

async function onButtonClick(): Promise<void> {
// An update is already known (banner is up) — the button becomes the same
// install action, not a re-fetch.
if (info.value) {
void updater.install();
return;
}
showLatest.value = false;
await updater.check();
if (!info.value && lastCheck.value === 'latest') flashUpToDate();
}

async function openReleases(event: MouseEvent): Promise<void> {
if (!isTauri) return; // plain anchor navigation handles the browser case
event.preventDefault();
const { invoke } = await import('@tauri-apps/api/core');
await invoke('open_releases_page').catch(() => undefined);
}
</script>

<template>
<!-- No border of its own: the accordion's last panel already draws one. -->
<div class="mt-3">
<div class="text-surface-400 flex items-center justify-between gap-2 text-xs">
<a
:href="RELEASES_URL"
target="_blank"
rel="noreferrer"
class="text-surface-300 hover:underline"
@click="openReleases"
>
{{ version ? `v${version}` : '—' }}
<i class="pi pi-external-link ml-0.5 text-[9px]" aria-hidden="true" />
</a>
<Button
v-if="isTauri"
size="small"
:variant="info ? 'outlined' : 'text'"
:severity="info ? undefined : 'secondary'"
:class="showLatest ? '!text-green-400' : ''"
:label="buttonLabel"
:loading="checking || installing"
@click="onButtonClick"
/>
</div>
<div v-if="isTauri" class="mt-1.5 flex items-center justify-between gap-2">
<label for="updater-auto-check" class="text-surface-400 text-xs">
{{ t('updater.autoCheckLabel') }}
</label>
<ToggleSwitch v-model="autoCheck" input-id="updater-auto-check" class="shrink-0 scale-90" />
</div>
</div>
</template>
46 changes: 8 additions & 38 deletions apps/client/src/features/updater/components/UpdateBanner.vue
Original file line number Diff line number Diff line change
@@ -1,49 +1,19 @@
<script setup lang="ts">
import { isTauri } from '@/shared/tauri';
import { useOverlayStore } from '@/features/overlay/store';

/** Mirrors `UpdateInfo` in src-tauri/src/updater.rs (downloadUrl stays Rust-side). */
interface UpdateInfo {
current: string;
latest: string;
releaseUrl: string;
}
import { useUpdaterStore } from '@/features/updater/store';

const { t } = useI18n();
const { clickThrough } = storeToRefs(useOverlayStore());

const info = ref<UpdateInfo | null>(null);
const installing = ref(false);
const failed = ref(false);
const updater = useUpdaterStore();
const { info, installing, installFailed, autoCheck } = storeToRefs(updater);

const dismissed = ref(false);

onMounted(async () => {
if (!isTauri) return;
try {
const { invoke } = await import('@tauri-apps/api/core');
info.value = await invoke<UpdateInfo | null>('check_update');
} catch (e) {
// Convenience check only — offline / rate-limited GitHub is not an error
// worth surfacing, the app works fine on the current version.
console.warn('[updater] check failed:', e);
}
onMounted(() => {
if (isTauri && autoCheck.value) void updater.check();
});

async function install(): Promise<void> {
if (installing.value) return;
installing.value = true;
failed.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);
failed.value = true;
installing.value = false;
}
}
</script>

<template>
Expand All @@ -52,13 +22,13 @@ async function install(): Promise<void> {
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"
>
<template v-if="!failed">
<template v-if="!installFailed">
<span>{{ t('updater.available', { version: info.latest }) }}</span>
<Button
size="small"
:label="installing ? t('updater.installing') : t('updater.install')"
:loading="installing"
@click="install"
@click="updater.install"
/>
</template>
<span v-else class="text-amber-400">{{ t('updater.error') }}</span>
Expand Down
27 changes: 27 additions & 0 deletions apps/client/src/features/updater/composables/useAppVersion.ts
Original file line number Diff line number Diff line change
@@ -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<string | null> {
const version = ref<string | null>(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;
}
59 changes: 59 additions & 0 deletions apps/client/src/features/updater/store.ts
Original file line number Diff line number Diff line change
@@ -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<UpdateInfo | null>(null);
const checking = ref(false);
const installing = ref(false);
const installFailed = ref(false);
const lastCheck = ref<CheckOutcome>('none');

async function check(): Promise<void> {
if (!isTauri || checking.value) return;
checking.value = true;
try {
const { invoke } = await import('@tauri-apps/api/core');
info.value = await invoke<UpdateInfo | null>('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<void> {
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 };
});
12 changes: 12 additions & 0 deletions apps/desktop/src-tauri/src/commands.rs
Original file line number Diff line number Diff line change
Expand Up @@ -182,6 +182,18 @@ pub async fn check_update() -> Result<Option<crate::updater::UpdateInfo>, 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 <url>`
/// 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`.
Expand Down
1 change: 1 addition & 0 deletions apps/desktop/src-tauri/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
5 changes: 5 additions & 0 deletions apps/desktop/src-tauri/src/updater.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down