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
4 changes: 2 additions & 2 deletions apps/client/src/features/i18n/locales/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -154,10 +154,10 @@
"radiusHint": "How big the predicted-landing circle is on the map. Two-shot triangulation isn't perfectly precise — the circle just makes the uncertainty honest. Adjust to taste."
},
"tray": {
"lock": "Lock (click-through)",
"lock": "Lock overlay (clicks pass to game)",
"showWindow": "Show window",
"alwaysOnTop": "Always on top",
"playerFollow": "Player follow",
"updateAvailable": "Update available: v{version}",
"pairPhone": "Pair phone",
"copyUrl": "Copy LAN URL",
"quit": "Quit",
Expand Down
4 changes: 2 additions & 2 deletions apps/client/src/features/i18n/locales/ru.json
Original file line number Diff line number Diff line change
Expand Up @@ -154,10 +154,10 @@
"radiusHint": "Размер круга предполагаемого места падения на карте. Триангуляция по двум снимкам не идеально точная — круг просто честно показывает погрешность. Подбери на свой вкус."
},
"tray": {
"lock": "Блокировка (клики сквозь)",
"lock": "Блокировка оверлея (клики идут в игру)",
"showWindow": "Показать окно",
"alwaysOnTop": "Поверх всех окон",
"playerFollow": "Авто-следование",
"updateAvailable": "Доступно обновление: v{version}",
"pairPhone": "Подключить телефон",
"copyUrl": "Копировать LAN-ссылку",
"quit": "Выход",
Expand Down
115 changes: 79 additions & 36 deletions apps/client/src/features/overlay/composables/useTrayIcon.ts
Original file line number Diff line number Diff line change
@@ -1,21 +1,12 @@
import type { TrayIcon, TrayIconEvent } from '@tauri-apps/api/tray';
import type { Menu, CheckMenuItem } from '@tauri-apps/api/menu';
import { useI18nStore } from '@/features/i18n/store';
import { useOverlayStore } from '@/features/overlay/store';
import { useMapSettingsStore } from '@/features/map/store';
import { useUpdaterStore } from '@/features/updater/store';

type TrayHandle = Awaited<ReturnType<typeof TrayIcon.new>>;

const TRAY_ID = 'tarkov-checker-tray';

async function showWindow(): Promise<void> {
const { getCurrentWindow } = await import('@tauri-apps/api/window');
const win = getCurrentWindow();
await win.show();
await win.unminimize();
await win.setFocus();
}

/**
* Owns the Tauri system-tray icon lifecycle: creates the icon on mount,
* rebuilds the menu when the UI language changes, keeps the checkmark items in
Expand All @@ -24,27 +15,49 @@ async function showWindow(): Promise<void> {
*
* The tray is the only control surface reachable while the game is fullscreen
* (the overlay can be hidden or click-through-locked behind it), so the menu
* leans on actions that matter mid-game: unlock, restore, the always-on-top /
* player-follow toggles, and the LAN-share shortcuts. Left-click restores the
* holds ONLY window/session-level rescues and lifecycle: unlock, restore,
* always-on-top, the LAN-share shortcuts, quit. Map-layer settings (player
* follow, labels, ...) deliberately stay out — they belong to the LayerRail,
* which is reachable whenever you'd actually want to flip them. Left-click restores the
* window (Windows convention); right-click opens the menu. Since ✕ now parks
* the overlay in the tray (see overlay store `minimizeToTray`), "Quit" here is
* the canonical way to actually exit.
*/
export function useTrayIcon(isTauri: boolean, overlayClickThrough: Ref<boolean>): void {
if (!isTauri) return;

const { t } = useI18n();
const { apiLang } = storeToRefs(useI18nStore());
const { t, locale } = useI18n();
const { alwaysOnTop, pairingModalOpen } = storeToRefs(useOverlayStore());
const { playerFollow } = storeToRefs(useMapSettingsStore());
const updaterStore = useUpdaterStore();
const { info: updateInfo, bannerDismissed } = storeToRefs(updaterStore);

let trayRef: TrayHandle | null = null;
// Live references to the check items so external state changes (hotkey,
// settings drawer, quick menu) can re-sync their checkmarks without
// rebuilding the whole menu.
let lockItem: CheckMenuItem | null = null;
let aotItem: CheckMenuItem | null = null;
let followItem: CheckMenuItem | null = null;

async function showWindow(): Promise<void> {
try {
const { getCurrentWindow } = await import('@tauri-apps/api/window');
const win = getCurrentWindow();
await win.show();
await win.unminimize();
// A visible-but-covered window won't raise on setFocus() alone:
// Windows' foreground lock lets SetForegroundWindow fail silently for
// background processes. Pulse always-on-top to force the raise, then
// restore the user's setting.
await win.setAlwaysOnTop(true);
await win.setFocus();
if (!alwaysOnTop.value) await win.setAlwaysOnTop(false);
} catch (err) {
// Most likely a missing core:window:allow-* capability — surface it,
// these rejections are otherwise invisible (see CLAUDE.md).
// eslint-disable-next-line no-console
console.error('[tray] showWindow failed:', err);
}
}

async function copyLanUrl(): Promise<void> {
try {
Expand All @@ -57,70 +70,94 @@ export function useTrayIcon(isTauri: boolean, overlayClickThrough: Ref<boolean>)
}

async function buildTrayMenu(): Promise<Menu> {
// Snapshot every label BEFORE the first await: the locale can flip
// mid-build (it's set asynchronously by the i18n store), and a build
// interleaved with that flip produces a mixed-language menu.
const labels = {
lock: t('tray.lock'),
show: t('tray.showWindow'),
aot: t('tray.alwaysOnTop'),
update: updateInfo.value
? t('tray.updateAvailable', { version: updateInfo.value.latest })
: null,
pair: t('tray.pairPhone'),
copy: t('tray.copyUrl'),
quit: t('tray.quit'),
tooltip: t('tray.tooltip'),
};

const { Menu, MenuItem, CheckMenuItem, PredefinedMenuItem } =
await import('@tauri-apps/api/menu');
const separator = () => PredefinedMenuItem.new({ item: 'Separator' });

lockItem = await CheckMenuItem.new({
id: 'toggle-lock',
text: t('tray.lock'),
text: labels.lock,
checked: overlayClickThrough.value,
action: () => {
overlayClickThrough.value = !overlayClickThrough.value;
},
});
const showItem = await MenuItem.new({
id: 'show',
text: t('tray.showWindow'),
text: labels.show,
action: () => void showWindow(),
});
aotItem = await CheckMenuItem.new({
id: 'always-on-top',
text: t('tray.alwaysOnTop'),
text: labels.aot,
checked: alwaysOnTop.value,
action: () => {
alwaysOnTop.value = !alwaysOnTop.value;
},
});
followItem = await CheckMenuItem.new({
id: 'player-follow',
text: t('tray.playerFollow'),
checked: playerFollow.value === 'on',
action: () => {
playerFollow.value = playerFollow.value === 'on' ? 'off' : 'on';
},
});
const pairItem = await MenuItem.new({
id: 'pair-phone',
text: t('tray.pairPhone'),
text: labels.pair,
action: () => {
void showWindow();
pairingModalOpen.value = true;
},
});
const copyItem = await MenuItem.new({
id: 'copy-url',
text: t('tray.copyUrl'),
text: labels.copy,
action: () => void copyLanUrl(),
});
const quitItem = await MenuItem.new({
id: 'quit',
text: t('tray.quit'),
text: labels.quit,
action: () =>
void (async () => {
const { getCurrentWindow } = await import('@tauri-apps/api/window');
await getCurrentWindow().close();
})(),
});

// Conditional: only when an update is already known. Deliberately does
// NOT install from the tray — install = respawn = session killed, too
// destructive for an impulsive menu click. It restores the window with
// the banner visible; the install decision happens window-in-focus.
const updateItem = labels.update
? await MenuItem.new({
id: 'update-available',
text: labels.update,
action: () => {
bannerDismissed.value = false;
void showWindow();
},
})
: null;

// Three groups: overlay-mode toggles / window presence / LAN sharing,
// plus Quit at arm's length (irreversible among recoverables).
return Menu.new({
items: [
lockItem,
aotItem,
await separator(),
showItem,
await separator(),
aotItem,
followItem,
...(updateItem ? [updateItem] : []),
await separator(),
pairItem,
copyItem,
Expand Down Expand Up @@ -162,16 +199,22 @@ export function useTrayIcon(isTauri: boolean, overlayClickThrough: Ref<boolean>)
// Keep checkmarks in sync when state is changed from anywhere else.
watch(overlayClickThrough, (v) => void lockItem?.setChecked(v));
watch(alwaysOnTop, (v) => void aotItem?.setChecked(v));
watch(playerFollow, (v) => void followItem?.setChecked(v === 'on'));

watch(apiLang, async () => {
// Full rebuild on language change AND when an update appears/clears —
// the conditional "update available" item can't be toggled in place.
// Watches the actual vue-i18n `locale` (what t() reads), NOT the store's
// apiLang: the store applies apiLang to the locale asynchronously, so an
// apiLang watcher races the flip — it both misses the startup apply (tray
// builds before the persisted language lands, screenshot: RU app / EN tray)
// and rebuilds too early on a switch.
watch([locale, updateInfo], async () => {
if (!trayRef) return;
try {
await trayRef.setMenu(await buildTrayMenu());
await trayRef.setTooltip(t('tray.tooltip'));
} catch (err) {
// eslint-disable-next-line no-console
console.error('[tray] i18n refresh failed:', err);
console.error('[tray] menu refresh failed:', err);
}
});

Expand Down
8 changes: 3 additions & 5 deletions apps/client/src/features/updater/components/UpdateBanner.vue
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,7 @@ const { t } = useI18n();
const { clickThrough } = storeToRefs(useOverlayStore());

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

const dismissed = ref(false);
const { info, installing, installFailed, autoCheck, bannerDismissed } = storeToRefs(updater);

onMounted(() => {
if (isTauri && autoCheck.value) void updater.check();
Expand All @@ -19,7 +17,7 @@ onMounted(() => {
<template>
<!-- Hidden while click-through-locked: the banner is interactive chrome. -->
<div
v-if="info && !dismissed && !clickThrough"
v-if="info && !bannerDismissed && !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="!installFailed">
Expand All @@ -35,7 +33,7 @@ onMounted(() => {
<button
class="pi pi-times text-surface-400 hover:text-surface-0 cursor-pointer text-[10px]"
:aria-label="t('close')"
@click="dismissed = true"
@click="bannerDismissed = true"
/>
</div>
</template>
15 changes: 14 additions & 1 deletion apps/client/src/features/updater/store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,9 @@ export const useUpdaterStore = defineStore('updater', () => {
const installing = ref(false);
const installFailed = ref(false);
const lastCheck = ref<CheckOutcome>('none');
// Lives in the store (not the banner) so the tray's "update available"
// item can un-dismiss the banner when restoring the window.
const bannerDismissed = ref(false);

async function check(): Promise<void> {
if (!isTauri || checking.value) return;
Expand Down Expand Up @@ -55,5 +58,15 @@ export const useUpdaterStore = defineStore('updater', () => {
}
}

return { autoCheck, info, checking, installing, installFailed, lastCheck, check, install };
return {
autoCheck,
info,
checking,
installing,
installFailed,
lastCheck,
bannerDismissed,
check,
install,
};
});
1 change: 1 addition & 0 deletions apps/desktop/src-tauri/capabilities/default.json
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
"core:window:allow-start-dragging",
"core:window:allow-hide",
"core:window:allow-show",
"core:window:allow-unminimize",
"core:window:allow-set-focus",
"core:webview:allow-set-webview-zoom",
"core:tray:default",
Expand Down