Skip to content

Commit 12c6d4b

Browse files
authored
Add files via upload
1 parent a4def4c commit 12c6d4b

17 files changed

Lines changed: 190 additions & 101 deletions

CHANGELOG.md

Lines changed: 21 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,26 @@
11
# Changelog
22

3+
## [1.2.0] — 2026-03-21
4+
5+
### Added
6+
7+
- **Game-already-running detection** — launching while another session is active shows a confirm dialog with "Stop & Launch" to switch games instantly
8+
- **Live "Playing" indicator** — play button shows a pulsing green "Playing" state while a game session is active
9+
- **Stop & Launch** — terminates the running game process (`TerminateProcess`) before launching the new one; all launch entry-points consolidated into a shared `useLaunchGame` hook
10+
11+
### Fixed
12+
13+
- **ZipSlip path traversal**`install_bepinex` and `install_melonloader` now validate each ZIP entry path component, rejecting any `..` or rooted segments; `canonicalize` double-check against base directory
14+
- **Scan button broken**`GameGrid` now calls `useScan()` at component level; the "Scan Steam / Epic / GOG" empty-state card correctly triggers the scan hook instead of a dead dynamic import
15+
- **Dual game state divergence**`game-session-ended` listener in `AppBehavior` now calls `qc.invalidateQueries` rather than manually replacing store state; TanStack Query is the single source of truth
16+
- **Soft-delete UNIQUE constraint**`steam_app_id` and `epic_app_name` UNIQUE column constraints replaced with partial unique indexes (`WHERE deleted_at IS NULL`); live migration rebuilds existing installs automatically
17+
- **IGDB token waste**`fetch_igdb_metadata` now caches the OAuth access token in `IgdbTokenState` with a 60-second expiry buffer; subsequent lookups reuse the cached token instead of requesting a new one per call
18+
- **Unrestricted filesystem write**`save_file` command validates the resolved canonical target path against an allowlist of safe directories (APPDATA, LOCALAPPDATA, Documents, Desktop, Downloads, OneDrive); writes outside these directories are rejected
19+
- **Missing transactions**`reorder_games` and `batch_update_games` now wrap their multi-statement loops in a single SQLite transaction for atomicity and a ~10× throughput improvement
20+
- **Game tracking fallback** — tracking state no longer resets instantly when a Steam/Epic game has no install directory or exe path; fallback tracker keeps the session active until manually stopped
21+
22+
---
23+
324
## [1.0.0] — 2026-03-20
425

526
### Added
@@ -69,22 +90,6 @@
6990

7091

7192

72-
73-
74-
75-
76-
77-
78-
79-
80-
81-
82-
83-
84-
85-
86-
87-
8893
---
8994

9095
## [0.9.0] — 2026-03-20
@@ -250,10 +255,6 @@
250255

251256

252257

253-
254-
255-
256-
257258
---
258259

259260
## [0.6.0] — 2026-03-17

README.md

Lines changed: 33 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -10,46 +10,48 @@ Track, organize, rate and launch every game you own — Steam, Epic, GOG, and cu
1010
<p>
1111
<a href="LICENSE"><img src="https://img.shields.io/badge/License-MIT-7c3aed?style=flat-square" alt="MIT License"/></a>
1212
<img src="https://img.shields.io/badge/Platform-Windows%2010%2F11-0078D4?style=flat-square&logo=windows" alt="Windows"/>
13-
<img src="https://img.shields.io/badge/Version-1.0.0-22c55e?style=flat-square" alt="v1.0.0"/>
13+
<img src="https://img.shields.io/badge/Version-1.2.0-22c55e?style=flat-square" alt="v1.2.0"/>
1414
<a href="https://tauri.app"><img src="https://img.shields.io/badge/Built%20with-Tauri%202-FFC131?style=flat-square" alt="Tauri 2"/></a>
1515
<img src="https://img.shields.io/badge/React-18-61DAFB?style=flat-square&logo=react" alt="React 18"/>
1616
<img src="https://img.shields.io/badge/Rust-backend-CE422B?style=flat-square&logo=rust" alt="Rust"/>
1717
</p>
1818

1919
<p>
2020
<a href="https://zsync.eu/zgamelib/"><strong>🌐 Website</strong></a> &nbsp;·&nbsp;
21-
<a href="https://zsync.eu/zgamelib/app/ZGameLib_1.0.0_x64_en-US.msi"><strong>⬇ Download MSI</strong></a> &nbsp;·&nbsp;
22-
<a href="https://zsync.eu/zgamelib/app/ZGameLib_1.0.0_x64-setup.exe"><strong>⬇ Download EXE</strong></a> &nbsp;·&nbsp;
21+
<a href="https://zsync.eu/zgamelib/app/ZGameLib_1.2.0_x64_en-US.msi"><strong>⬇ Download MSI</strong></a> &nbsp;·&nbsp;
22+
<a href="https://zsync.eu/zgamelib/app/ZGameLib_1.2.0_x64-setup.exe"><strong>⬇ Download EXE</strong></a> &nbsp;·&nbsp;
2323
<a href="https://github.com/TheHolyOneZ/ZGameLib"><strong>GitHub</strong></a>
2424
</p>
2525

2626
</div>
2727

2828
---
2929

30-
## What's New in v1.0.0
30+
## What's New in v1.2.0
3131

32-
The milestone release. v1.0.0 is focused on **Polish & Discovery** — making ZGameLib feel like a finished, welcoming product from the very first launch.
32+
v1.2.0 brings **game session management**, **security hardening**, and **8 targeted bug fixes**.
3333

3434
| Feature | Description |
3535
|---------|-------------|
36-
| **Interactive Onboarding Tour** | Three-mode cinematic tour (Quick / Standard / Deep Dive) with animated spotlight overlay, live UI demonstrations, and a custom ending animation |
37-
| **Year in Review** | Annual gaming recap at `/wrapped` — total hours, most played, top rated, busiest month, platform split, 9 animated stat cards |
38-
| **Smart Play Recommendations** | "Play Next" strip on the library page — surfaces your best backlog picks using tag and genre matching against your highest-rated games |
39-
| **What's New Modal** | In-app release notes shown automatically on first launch after an update |
40-
| **Contextual Empty State** | Three action cards (Scan, Add, Browse Steam) replace the blank state once onboarding completes |
41-
| **New Keyboard Shortcuts** | `S` scan, `W` wrapped, `H` toggle hidden, `1``9`/`0` quick-rate, documented `Ctrl+Z` undo deletion |
42-
| **Default 6-Column Grid** | Out-of-the-box grid now shows 6 columns instead of 4 |
36+
| **Game-Already-Running Detection** | Launching while another session is active shows a confirm dialog with "Stop & Launch" to switch games instantly |
37+
| **Live "Playing" Indicator** | Play button shows a pulsing green "Playing" state while a game session is active |
38+
| **Stop & Launch** | Terminates the running game process (`TerminateProcess`) before launching the new one |
39+
| **ZipSlip Patch** | Path traversal vulnerability patched in BepInEx/MelonLoader installer |
40+
| **Filesystem Hardening** | `save_file` restricted to safe directories (AppData, Documents, Desktop) |
41+
| **IGDB Token Cache** | OAuth tokens cached with 60s expiry buffer — no redundant round-trips |
42+
| **Soft-Delete Fix** | Partial UNIQUE indexes prevent duplicate blocking on soft-deleted records |
43+
| **Transaction Safety** | `reorder_games` and `batch_update_games` wrapped in SQLite transactions |
44+
| **Game Tracking Fallback** | Steam/Epic games without install directory stay tracked instead of resetting instantly |
4345

4446
---
4547

4648
## Preview
4749

48-
> Recorded on v0.7.0. v1.0.0 includes onboarding tour, Year in Review, smart recommendations, and many more improvements.
50+
> Recorded on v0.3.0. Latest version includes game session management, onboarding tour, Year in Review, smart recommendations, and many more improvements.
4951
5052
<div align="center">
5153

52-
[![ZGameLib Preview](https://img.youtube.com/vi/4L1U4SOJrQg/maxresdefault.jpg)](https://www.youtube.com/watch?v=4L1U4SOJrQg)
54+
[![ZGameLib Preview](https://img.youtube.com/vi/rlqUUqAPOxU/maxresdefault.jpg)](https://www.youtube.com/watch?v=rlqUUqAPOxU)
5355

5456
</div>
5557

@@ -59,16 +61,16 @@ The milestone release. v1.0.0 is focused on **Polish & Discovery** — making ZG
5961

6062
| Installer | Format | Notes |
6163
|-----------|--------|-------|
62-
| [ZGameLib_1.0.0_x64_en-US.msi](https://zsync.eu/zgamelib/app/ZGameLib_1.0.0_x64_en-US.msi) | `.msi` | **Recommended** — Windows Installer |
63-
| [ZGameLib_1.0.0_x64-setup.exe](https://zsync.eu/zgamelib/app/ZGameLib_1.0.0_x64-setup.exe) | `.exe` | NSIS alternative installer |
64+
| [ZGameLib_1.2.0_x64_en-US.msi](https://zsync.eu/zgamelib/app/ZGameLib_1.2.0_x64_en-US.msi) | `.msi` | **Recommended** — Windows Installer |
65+
| [ZGameLib_1.2.0_x64-setup.exe](https://zsync.eu/zgamelib/app/ZGameLib_1.2.0_x64-setup.exe) | `.exe` | NSIS alternative installer |
6466

6567
> **Windows SmartScreen:** On first launch you may see *"Windows protected your PC"* — click **More info → Run anyway**. This is expected for unsigned indie apps.
6668
6769
---
6870

6971
## Table of Contents
7072

71-
- [What's New in v1.0.0](#whats-new-in-v100)
73+
- [What's New in v1.2.0](#whats-new-in-v120)
7274
- [Features](#features)
7375
- [Onboarding Tour](#-interactive-onboarding-tour)
7476
- [Library & Scanning](#-library--scanning)
@@ -264,7 +266,9 @@ The signature 1.0 feature. On first launch, users pick a tour mode — ZGameLib
264266
- Multi-process games (launcher stub → real exe) are handled with an adaptive grace window: 300 s for the initial launcher handoff, then 30 s once the real game is confirmed running
265267
- Records elapsed minutes and saves a session row when the game exits; fires a `game-session-ended` event to the frontend so playtime updates instantly without a manual refresh
266268
- Updates `last_played` timestamp on launch
267-
- Falls back to single-PID tracking when no install directory is resolvable
269+
- Falls back to timeout-based tracking when no install directory is resolvable — session stays active until manually stopped
270+
- **Game-already-running detection** — if you try to launch a game while another is tracked, an in-app confirm dialog offers "Stop & Launch" to kill the running game (`TerminateProcess`) and start the new one
271+
- **Live "Playing" indicator** — the play button shows a pulsing green "Playing" state while a game session is active
268272
- **Idle detection** — polls `GetForegroundWindow` every 30 s; if the game window loses focus for 5+ consecutive minutes, that idle period is excluded from the session total; brief alt-tabs are ignored; can be toggled off in Settings → Behavior
269273
- **Steam Playtime Sync** — enter your Steam API Key and SteamID64 in Settings → Integrations; sync only increases local values, never decreases
270274
- **Minimize on launch** — ZGameLib hides to tray with a 400 ms delay (for window focus handoff), then auto-restores when the game exits
@@ -1070,6 +1074,17 @@ npx tauri dev
10701074

10711075
Rust source changes trigger a full backend recompile. Frontend changes hot-reload instantly.
10721076

1077+
### Production Build
1078+
1079+
```powershell
1080+
.\build-release.ps1
1081+
```
1082+
1083+
Outputs installers to `src-tauri/target/release/bundle/`:
1084+
- `msi/ZGameLib_1.2.0_x64_en-US.msi`
1085+
- `nsis/ZGameLib_1.2.0_x64-setup.exe`
1086+
1087+
---
10731088

10741089
## License
10751090

src/components/game/GameDetail.tsx

Lines changed: 21 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import { useGames } from "@/hooks/useGames";
88
import { useCover, setCoverCache, clearCoverCache } from "@/hooks/useCover";
99
import { useQuery, useQueryClient } from "@tanstack/react-query";
1010
import { api } from "@/lib/tauri";
11+
import { useLaunchGame } from "@/lib/useLaunchGame";
1112
import { cn, formatPlaytime, formatDate, COVER_PLACEHOLDER } from "@/lib/utils";
1213
import StarRating from "@/components/ui/StarRating";
1314
import GameNotes from "./GameNotes";
@@ -153,6 +154,8 @@ export default function GameDetail() {
153154
const { update, remove, toggleFavorite } = useGames();
154155
const openConfirm = useUIStore((s) => s.openConfirm);
155156
const addToast = useUIStore((s) => s.addToast);
157+
const activeGameId = useUIStore((s) => s.activeGameId);
158+
const { launch } = useLaunchGame();
156159

157160
const [editingName, setEditingName] = useState(false);
158161
const [nameVal, setNameVal] = useState("");
@@ -272,14 +275,11 @@ export default function GameDetail() {
272275
}
273276
};
274277

275-
const handlePlay = async () => {
276-
try {
277-
if (game.platform === "steam" && game.steam_app_id) await api.launchSteamGame(game.steam_app_id, game.id);
278-
else if (game.platform === "epic" && game.epic_app_name) await api.launchEpicGame(game.epic_app_name, game.id);
279-
else await api.launchGame(game.id);
278+
const handlePlay = () => {
279+
launch(game, () => {
280280
setGameStarted(true);
281281
setTimeout(() => setGameStarted(false), 3000);
282-
} catch (e) { addToast(String(e), "error"); }
282+
});
283283
};
284284

285285
const loadSessions = async () => {
@@ -786,14 +786,23 @@ export default function GameDetail() {
786786
<div className="flex gap-2">
787787
<motion.button
788788
data-tour="play-btn"
789-
whileHover={{ scale: 1.02 }}
790-
whileTap={{ scale: 0.98 }}
791-
onClick={handlePlay}
792-
className={cn("btn-primary flex-1 justify-center py-3 transition-all", gameStarted && "bg-green-600 hover:bg-green-500")}
793-
style={{ boxShadow: gameStarted ? "0 0 25px rgba(34,197,94,0.3)" : "0 0 25px rgb(var(--accent-500) /0.2)" }}
789+
whileHover={{ scale: activeGameId === game.id ? 1 : 1.02 }}
790+
whileTap={{ scale: activeGameId === game.id ? 1 : 0.98 }}
791+
onClick={activeGameId === game.id ? undefined : handlePlay}
792+
className={cn(
793+
"btn-primary flex-1 justify-center py-3 transition-all",
794+
activeGameId === game.id && "bg-green-600 hover:bg-green-600 cursor-default",
795+
gameStarted && activeGameId !== game.id && "bg-green-600 hover:bg-green-500",
796+
)}
797+
style={{ boxShadow: activeGameId === game.id ? "0 0 25px rgba(34,197,94,0.3)" : gameStarted ? "0 0 25px rgba(34,197,94,0.3)" : "0 0 25px rgb(var(--accent-500) /0.2)" }}
794798
>
795799
<AnimatePresence mode="wait" initial={false}>
796-
{gameStarted ? (
800+
{activeGameId === game.id ? (
801+
<motion.span key="playing" initial={{ opacity: 0, y: 4 }} animate={{ opacity: 1, y: 0 }} exit={{ opacity: 0, y: -4 }} className="flex items-center gap-1.5">
802+
<span className="relative flex h-2.5 w-2.5"><span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-green-300 opacity-75" /><span className="relative inline-flex rounded-full h-2.5 w-2.5 bg-green-400" /></span>
803+
Playing
804+
</motion.span>
805+
) : gameStarted ? (
797806
<motion.span key="started" initial={{ opacity: 0, y: 4 }} animate={{ opacity: 1, y: 0 }} exit={{ opacity: 0, y: -4 }} className="flex items-center gap-1.5">
798807
<CheckIcon size={14} />
799808
Game Started

src/components/layout/Layout.tsx

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { Outlet, useNavigate } from "react-router-dom";
22
import { useEffect, useRef, useState } from "react";
3-
import { useQuery } from "@tanstack/react-query";
3+
import { useQuery, useQueryClient } from "@tanstack/react-query";
44
import { motion, AnimatePresence } from "framer-motion";
55
import { listen } from "@tauri-apps/api/event";
66
import { api } from "@/lib/tauri";
@@ -183,6 +183,7 @@ function AppBehavior() {
183183
queryFn: () => api.getSettings(),
184184
staleTime: 5 * 60 * 1000,
185185
});
186+
const qc = useQueryClient();
186187
const { scan } = useScan();
187188
const { pullUninstalled } = usePullUninstalled();
188189
const hasAutoScanned = useRef(false);
@@ -212,12 +213,12 @@ function AppBehavior() {
212213
}, []);
213214

214215
useEffect(() => {
215-
const promise = listen<string>("game-session-ended", async () => {
216-
const games = await api.getAllGames();
217-
if (games) useGameStore.getState().setGames(games);
216+
const promise = listen<string>("game-session-ended", () => {
217+
qc.invalidateQueries({ queryKey: ["games"] });
218+
useUIStore.getState().setActiveGameId(null);
218219
});
219220
return () => { promise.then((f) => f()); };
220-
}, []);
221+
}, [qc]);
221222

222223
useEffect(() => {
223224
const promise = listen<string>("playtime-reminder", (event) => {

src/components/library/GameCard.tsx

Lines changed: 4 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import { useUIStore } from "@/store/useUIStore";
88
import { useGames } from "@/hooks/useGames";
99
import { useCover } from "@/hooks/useCover";
1010
import { api } from "@/lib/tauri";
11+
import { useLaunchGame } from "@/lib/useLaunchGame";
1112
import GameContextMenu from "@/components/ui/GameContextMenu";
1213
import PlatformBadge from "@/components/ui/PlatformBadge";
1314
import { HeartIcon, PlayIcon, FolderIcon, StarIcon, FireIcon, SettingsIcon, AlertIcon, CheckIcon } from "@/components/ui/Icons";
@@ -17,6 +18,7 @@ function GameCard({ game }: { game: Game }) {
1718
const setDetailOpen = useUIStore((s) => s.setDetailOpen);
1819
const addToast = useUIStore((s) => s.addToast);
1920
const { toggleFavorite, update } = useGames();
21+
const { launch } = useLaunchGame();
2022
const coverUrl = useCover(game);
2123
const [imgFailed, setImgFailed] = useState(false);
2224
const [hovered, setHovered] = useState(false);
@@ -34,19 +36,9 @@ function GameCard({ game }: { game: Game }) {
3436
toggleFavorite(game.id);
3537
};
3638

37-
const handlePlay = async (e: React.MouseEvent) => {
39+
const handlePlay = (e: React.MouseEvent) => {
3840
e.stopPropagation();
39-
try {
40-
if (game.platform === "steam" && game.steam_app_id) {
41-
await api.launchSteamGame(game.steam_app_id, game.id);
42-
} else if (game.platform === "epic" && game.epic_app_name) {
43-
await api.launchEpicGame(game.epic_app_name, game.id);
44-
} else {
45-
await api.launchGame(game.id);
46-
}
47-
} catch (err) {
48-
addToast(String(err), "error");
49-
}
41+
launch(game);
5042
};
5143

5244
const handleFolder = async (e: React.MouseEvent) => {

src/components/library/GameGrid.tsx

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import { AnimatePresence, motion, Reorder } from "framer-motion";
22
import { useQuery } from "@tanstack/react-query";
33
import { useState, useEffect } from "react";
44
import { useGameStore } from "@/store/useGameStore";
5-
import { useFilteredGames } from "@/hooks/useGames";
5+
import { useFilteredGames, useScan } from "@/hooks/useGames";
66
import { api } from "@/lib/tauri";
77
import GameCard from "./GameCard";
88
import GameListRow from "./GameListRow";
@@ -101,6 +101,7 @@ function Pagination({ page, totalPages, onPage }: { page: number; totalPages: nu
101101

102102
export default function GameGrid({ isLoading = false }: { isLoading?: boolean }) {
103103
const games = useFilteredGames();
104+
const { scan } = useScan();
104105
const allGamesRaw = useGameStore((s) => s.games);
105106
const viewMode = useGameStore((s) => s.viewMode);
106107
const sortKey = useGameStore((s) => s.sortKey);
@@ -184,7 +185,7 @@ export default function GameGrid({ isLoading = false }: { isLoading?: boolean })
184185
</div>
185186
<div className="grid grid-cols-3 gap-3 w-full max-w-md">
186187
{[
187-
{ label: "Scan Steam / Epic / GOG", desc: "Auto-detect installed games", onClick: () => import("@/hooks/useGames").then(m => m.useScan) },
188+
{ label: "Scan Steam / Epic / GOG", desc: "Auto-detect installed games", onClick: () => scan() },
188189
{ label: "Add a game manually", desc: "Pick an exe or folder", onClick: () => setAddGameOpen(true) },
189190
{ label: "Browse Steam library", desc: "Import owned games", onClick: () => setAddGameOpen(true) },
190191
].map((card, i) => (

src/components/library/GameListRow.tsx

Lines changed: 3 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import type { Game } from "@/lib/types";
66
import { useGameStore } from "@/store/useGameStore";
77
import { useGames } from "@/hooks/useGames";
88
import { api } from "@/lib/tauri";
9+
import { useLaunchGame } from "@/lib/useLaunchGame";
910
import Badge from "@/components/ui/Badge";
1011
import PlatformBadge from "@/components/ui/PlatformBadge";
1112
import GameContextMenu from "@/components/ui/GameContextMenu";
@@ -17,6 +18,7 @@ export default function GameListRow({ game }: { game: Game }) {
1718
const addToast = useUIStore((s) => s.addToast);
1819
const customStatuses = useUIStore((s) => s.customStatuses);
1920
const { toggleFavorite } = useGames();
21+
const { launch } = useLaunchGame();
2022
const coverUrl = useCover(game);
2123
const selectedIds = useGameStore((s) => s.selectedIds);
2224
const toggleSelected = useGameStore((s) => s.toggleSelected);
@@ -113,13 +115,7 @@ export default function GameListRow({ game }: { game: Game }) {
113115
</motion.button>
114116
<motion.button
115117
whileTap={{ scale: 0.85 }}
116-
onClick={async (e) => {
117-
e.stopPropagation();
118-
try {
119-
if (game.platform === "steam" && game.steam_app_id) await api.launchSteamGame(game.steam_app_id, game.id);
120-
else await api.launchGame(game.id);
121-
} catch (e: any) { addToast(e?.message ?? "Failed to launch game", "error"); }
122-
}}
118+
onClick={(e) => { e.stopPropagation(); launch(game); }}
123119
className="btn-icon w-7 h-7 hover:text-cyan-400"
124120
>
125121
<PlayIcon size={11} />

0 commit comments

Comments
 (0)