feat(tauri): implement Steam-style launcher redesign per design handoff
Replace the previous monolithic 900-line `App.tsx` launcher UI with the
Steam-inspired dark redesign specified in `design/README.md` (handoff
committed in the previous commit). The new UI is split across small,
single-responsibility React modules instead of one file.
What changes from the user's perspective
----------------------------------------
- Dark, gradient-tinted background with sticky 64px top bar (glass blur
+ saturate). Single-row chrome (handoff variant A).
- Pill-style filter toggle (`All Games` / `Local` / `Installed`) with an
animated thumb that slides between options.
- Search field with magnifying-glass icon and a `/` keyboard shortcut to
focus it from anywhere outside an input.
- Sort menu (Name A–Z / Size / Status) as a dropdown.
- Game directory button shows the current path with leading-ellipsis
truncation; clicking it opens the native folder picker.
- Kebab menu hosts Settings, Refresh library, and Unpack logs (existing
companion window). The standalone Unpack-Logs button is removed from
the chrome.
- Game grid uses CSS `auto-fill` minmax with three density presets
(compact / normal / large) and three cover aspects (box / square /
banner), persisted via the Settings dialog.
- Game cards render with the real thumbnail when the backend has one
(via `get_game_thumbnail`) and fall back to a procedurally-generated
gradient + accent-blob placeholder with a Bebas Neue title burned in.
Each card carries a color-coded state chip (Installed = green,
Downloaded = amber, busy = pulsing accent), a peers chip when at
least one peer holds the game, the title, size · genre meta line, a
status line (errors in red), and a single color-coded primary action
button: Play (green gradient), Update / Install (accent), Download
(neutral), animated "busy" spinner during in-flight operations, or a
disabled "Unavailable" state when no peer has the game.
- Clicking anywhere on a card except the action button opens a detail
modal: 16:7 hero (uses the thumbnail), state chip, tag pills derived
from genre/publisher/release_year, large title, 4-cell meta grid
(size, players from `max_players`, version from `local_version` or
`eti_game_version` formatted YYYY.MM.DD, status), description, and an
action row with the primary action plus an Uninstall ghost-danger
button when the game is installed. Esc, scrim click, and the close
button all dismiss the modal.
- Settings dialog (opened from the kebab menu) lets the user change the
accent color (six swatches), background style (flat / gradient /
animated), grid density, and cover aspect. Changes apply live and
persist immediately to the Tauri store under `launcher-settings.json`
(key `ui-settings`); the existing `game-directory` key in the same
file is unchanged.
- Empty state when no directory is chosen offers a centered prompt with
a single CTA. Empty state when filters/search match nothing shows a
distinct "Nothing matches" message.
Why this approach
-----------------
The handoff selected variant A (single-row chrome) explicitly, so only
that variant is implemented; variant B underlined tabs and the
storage-meter widget are intentionally omitted (no free-space data
available from the backend yet).
Real cover art from `get_game_thumbnail` is preferred over the
placeholder generator. When a thumbnail is present, the Bebas Neue
title overlay is suppressed because shipped cover art already has its
own title. When the thumbnail is absent, the placeholder gradient (with
per-id stable hue/blob/angle) plus the burned-in title takes over —
this is the same procedural look as the design reference.
Architecture / file layout
--------------------------
The previous single-file design is decomposed top-down:
```
src/
main.tsx entry; loads tokens + launcher CSS
App.tsx thin router (main vs. unpack-logs view)
styles/
tokens.css CSS custom props + body reset
launcher.css port of the design reference styles.css
(single-row chrome only)
windows/
MainWindow.tsx composition root: top bar + grid + modals
lib/
types.ts Game, InstallStatus, GameAvailability,
ActiveOperationKind, GameFilter / GameSort,
DerivedState
gameState.ts derive() + isUnavailable + needsUpdate +
primaryActionFor + actionLabel +
mergeGameUpdate (event reconciliation) +
countByFilter + applyFilterAndSort
format.ts formatBytes, formatEtiVersion (YYYYMMDD),
truncatePath, formatPlayers
cover.ts coverColorsFor(id) — stable palette pick +
gradient angle + blob position from id
hash; titleFontSize
store.ts file + key constants for plugin-store
hooks/
useSettings.ts UISettings + accent/bg/density/aspect/
sort/filter, persisted via plugin-store
useGameDirectory.ts loads + persists the chosen directory and
pushes it to update_game_directory
useGames.ts owns the games list; listens to every
backend event (games-list-updated,
game-download-begin/finished/failed/
peers-gone, game-no-peers, game-install-
begin/finished/failed, game-uninstall-
begin/finished/failed, peer-count-updated);
exposes markChecking with a 5s fallback to
clear "Checking peers…" when nothing comes
back from the backend
useGameActions.ts play / install / update / uninstall
wrappers around the corresponding invoke
commands
useThumbnails.ts lazy per-id cache for get_game_thumbnail
components/
Icon.tsx inline SVG icon set (currentColor)
Brand.tsx brand mark + name + peer-count chip
Modal.tsx generic scrim + panel + Esc handler
StateChip.tsx corner pill with state-coded dot
ActionButton.tsx color-coded primary action; disabled when
unavailable; spinner when busy
SegmentedRadio.tsx generic 3-way segmented control
ColorSwatchPicker.tsx 6-swatch picker with check overlay
topbar/
TopBar.tsx chrome composition
SegmentedFilters.tsx All / Local / Installed with sliding thumb
SearchField.tsx input + `/` shortcut
SortMenu.tsx dropdown sort selector
DirectoryButton.tsx folder picker trigger
KebabMenu.tsx generic dropdown menu
grid/
ResultsBar.tsx "Showing N of M games"
GameGrid.tsx CSS-grid wrapper
GameCard.tsx full card composition
GameCover.tsx thumbnail OR placeholder cover art
modals/
GameDetailModal.tsx hero + meta grid + actions
SettingsDialog.tsx appearance + library preferences
empty/
NoDirectoryState.tsx onboarding CTA
EmptyResultsState.tsx "scanning" / "nothing matches"
```
`UnpackLogsWindow.tsx` and its CSS are untouched — the unpack-logs
companion window is rendered as before via the existing `?view=unpack-
logs` route in `App.tsx`.
The previous `App.css` is removed entirely (its styles are superseded
by `styles/launcher.css`).
Bebas Neue is loaded via Google Fonts in `index.html` (preconnect +
swap), used for the brand mark and the placeholder cover-art titles.
Tradeoffs and intentional omissions
-----------------------------------
- Storage meter: omitted. The handoff specifies installed/local/free
bytes, but no Tauri command currently provides free-space data.
- Variant B (two-row chrome with underline tabs): omitted; the handoff
picked variant A.
- "View files" action in the detail modal: omitted. The backend doesn't
expose per-game install paths and `shell.open` of the user-chosen
root directory would be misleading.
- "Delete from disk" ghost-danger action for `local` games: omitted.
No backend command currently distinguishes "delete downloaded
archive" from `uninstall_game`. Only installed games get an Uninstall
button.
- "Recently Played" sort: omitted (no play-time tracking yet). The sort
menu offers Name / Size / Status instead.
- Keyboard arrow grid navigation: not yet implemented (out of scope per
the handoff).
- Per-game progress bar during downloads/installs: not implemented; the
action button shows a spinner + "Downloading…" / "Installing…" label
instead, matching the existing event-driven status text.
Persistence
-----------
UI preferences (accent, bg, density, aspect, sort, filter) live in
`launcher-settings.json` under a new `ui-settings` key. The existing
`game-directory` key in the same file is preserved untouched, so users
keep their previously selected directory.
Test plan
---------
Frontend build verified locally:
cd crates/lanspread-tauri-deno-ts && deno task build
→ `tsc && vite build` completes with no diagnostics; bundle ~228 kB.
Manual verification (recommended once the app boots end-to-end):
- [ ] Launch with no directory set: only the "Pick a game directory"
empty state is visible; clicking the button opens the native
folder picker.
- [ ] Pick a directory: top bar appears, grid populates as games arrive.
- [ ] Click the All / Local / Installed pills: the thumb slides; the
count chips reflect the right subset.
- [ ] Press `/`: focus moves to the search input; type a substring and
confirm the grid filters live.
- [ ] Open the Sort menu, switch between sorts; the grid reorders.
- [ ] Open the Settings dialog from the kebab: change accent → the
thumb, brand mark, search-focus ring, and Install button all
switch color live. Change density → grid card size changes.
Change cover aspect → cards re-shape (2/3, 1/1, 16/9). Close and
reopen: choices are remembered.
- [ ] Click anywhere on a card except the action button → detail modal
opens with the right metadata; Esc / scrim click / close button
all dismiss it.
- [ ] Click the action button on an `installed` card → game launches.
- [ ] Click the action button on a `local` card → install starts;
button shows the spinner + "Installing…".
- [ ] Click on a `none` card with peer_count > 0 → download starts; the
lifecycle events update the button label correctly.
- [ ] Card for a game with peer_count == 0 and not downloaded → button
reads "Unavailable" and is disabled.
- [ ] Trigger a `game-download-failed` from the backend: the error
status line appears under the card title in red.
- [ ] Open Unpack Logs from the kebab: the companion window opens
exactly as before.
Trailer
-------
Refs: design/README.md (canonical handoff), design/design_reference/
This commit is contained in:
@@ -1,911 +1,11 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
import { listen } from '@tauri-apps/api/event';
|
||||
import { WebviewWindow } from '@tauri-apps/api/webviewWindow';
|
||||
import { open } from '@tauri-apps/plugin-dialog';
|
||||
import { load } from '@tauri-apps/plugin-store';
|
||||
|
||||
import "./App.css";
|
||||
import { MainWindow } from './windows/MainWindow';
|
||||
import { UnpackLogsWindow, isUnpackLogsView } from './UnpackLogsWindow';
|
||||
|
||||
const FILE_STORAGE = 'launcher-settings.json';
|
||||
const GAME_DIR_KEY = 'game-directory';
|
||||
const CHECKING_PEERS_TIMEOUT_MS = 5000;
|
||||
const FALLBACK_THUMBNAIL =
|
||||
'data:image/jpeg;base64,/9j/4AAQSkZJRgABAQEAYABgAAD/2wBDAAEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQH/2wBDAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQH/wAARCAABAAEDASIAAhEBAxEB/8QAFQABAQAAAAAAAAAAAAAAAAAAAAv/xAAUEAEAAAAAAAAAAAAAAAAAAAAA/8QAFQEBAQAAAAAAAAAAAAAAAAAAAAX/xAAUEQEAAAAAAAAAAAAAAAAAAAAA/9oADAMBAAIRAxEAPwA/8A8A';
|
||||
const STORE_OPTIONS = {
|
||||
autoSave: true,
|
||||
defaults: {
|
||||
[GAME_DIR_KEY]: '',
|
||||
},
|
||||
};
|
||||
|
||||
// enum with install status
|
||||
enum InstallStatus {
|
||||
NotInstalled = 'NotInstalled',
|
||||
CheckingPeers = 'CheckingPeers',
|
||||
Downloading = 'Downloading',
|
||||
Installing = 'Installing',
|
||||
Uninstalling = 'Uninstalling',
|
||||
Installed = 'Installed',
|
||||
}
|
||||
|
||||
type StatusLevel = 'info' | 'error';
|
||||
|
||||
type GameFilter = 'all' | 'local' | 'installed';
|
||||
|
||||
interface Game {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
size: number;
|
||||
thumbnail: Uint8Array | number[];
|
||||
downloaded: boolean;
|
||||
installed: boolean;
|
||||
availability: GameAvailability;
|
||||
install_status: InstallStatus;
|
||||
eti_game_version?: string;
|
||||
local_version?: string;
|
||||
status_message?: string;
|
||||
status_level?: StatusLevel;
|
||||
peer_count: number;
|
||||
}
|
||||
|
||||
enum GameAvailability {
|
||||
Ready = 'Ready',
|
||||
LocalOnly = 'LocalOnly',
|
||||
}
|
||||
|
||||
enum ActiveOperationKind {
|
||||
Downloading = 'Downloading',
|
||||
Installing = 'Installing',
|
||||
Updating = 'Updating',
|
||||
Uninstalling = 'Uninstalling',
|
||||
}
|
||||
|
||||
interface ActiveOperation {
|
||||
id: string;
|
||||
operation: ActiveOperationKind;
|
||||
}
|
||||
|
||||
interface GamesListPayload {
|
||||
games: Game[];
|
||||
active_operations?: ActiveOperation[];
|
||||
}
|
||||
|
||||
interface GameThumbnailProps {
|
||||
gameId: string;
|
||||
alt: string;
|
||||
getThumbnailUrl: (gameId: string) => Promise<string>;
|
||||
}
|
||||
|
||||
const GameThumbnail = ({ gameId, alt, getThumbnailUrl }: GameThumbnailProps) => {
|
||||
const [thumbnailUrl, setThumbnailUrl] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
let isMounted = true;
|
||||
|
||||
const loadThumbnail = async () => {
|
||||
const url = await getThumbnailUrl(gameId);
|
||||
if (isMounted) {
|
||||
setThumbnailUrl(url);
|
||||
}
|
||||
};
|
||||
|
||||
void loadThumbnail();
|
||||
|
||||
return () => {
|
||||
isMounted = false;
|
||||
};
|
||||
}, [gameId, getThumbnailUrl]);
|
||||
|
||||
if (!thumbnailUrl) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return <img src={thumbnailUrl} alt={alt} />;
|
||||
};
|
||||
|
||||
const IN_PROGRESS_INSTALL_STATUSES = new Set<InstallStatus>([
|
||||
InstallStatus.CheckingPeers,
|
||||
InstallStatus.Downloading,
|
||||
InstallStatus.Installing,
|
||||
InstallStatus.Uninstalling,
|
||||
]);
|
||||
|
||||
const RECONCILED_OPERATION_STATUSES = new Set<InstallStatus>([
|
||||
InstallStatus.Downloading,
|
||||
InstallStatus.Installing,
|
||||
InstallStatus.Uninstalling,
|
||||
]);
|
||||
|
||||
const isInProgressInstallStatus = (status: InstallStatus): boolean => {
|
||||
return IN_PROGRESS_INSTALL_STATUSES.has(status);
|
||||
};
|
||||
|
||||
const isReconciledOperationStatus = (status: InstallStatus): boolean => {
|
||||
return RECONCILED_OPERATION_STATUSES.has(status);
|
||||
};
|
||||
|
||||
const installStatusFromActiveOperation = (operation: ActiveOperationKind): InstallStatus => {
|
||||
switch (operation) {
|
||||
case ActiveOperationKind.Downloading:
|
||||
return InstallStatus.Downloading;
|
||||
case ActiveOperationKind.Installing:
|
||||
case ActiveOperationKind.Updating:
|
||||
return InstallStatus.Installing;
|
||||
case ActiveOperationKind.Uninstalling:
|
||||
return InstallStatus.Uninstalling;
|
||||
}
|
||||
};
|
||||
|
||||
const activeStatusById = (activeOperations: ActiveOperation[] = []): Map<string, InstallStatus> => {
|
||||
return new Map(activeOperations.map(operation => [
|
||||
operation.id,
|
||||
installStatusFromActiveOperation(operation.operation),
|
||||
]));
|
||||
};
|
||||
|
||||
const normalizeGamesListPayload = (payload: GamesListPayload | Game[]): GamesListPayload => {
|
||||
if (Array.isArray(payload)) {
|
||||
return { games: payload };
|
||||
}
|
||||
return payload;
|
||||
};
|
||||
|
||||
const mergeGameUpdate = (
|
||||
game: Game,
|
||||
previous?: Game,
|
||||
activeStatus?: InstallStatus,
|
||||
hasAuthoritativeSnapshot = false,
|
||||
): Game => {
|
||||
let installStatus = InstallStatus.NotInstalled;
|
||||
if (activeStatus !== undefined) {
|
||||
installStatus = activeStatus;
|
||||
} else if (game.installed) {
|
||||
installStatus = InstallStatus.Installed;
|
||||
} else if (
|
||||
previous
|
||||
&& isInProgressInstallStatus(previous.install_status)
|
||||
&& (!hasAuthoritativeSnapshot || previous.install_status === InstallStatus.CheckingPeers)
|
||||
) {
|
||||
installStatus = previous.install_status;
|
||||
}
|
||||
|
||||
const localStateChanged = previous !== undefined
|
||||
&& (previous.installed !== game.installed || previous.downloaded !== game.downloaded);
|
||||
const activeStateReconciled = hasAuthoritativeSnapshot
|
||||
&& (activeStatus !== undefined
|
||||
|| (previous !== undefined && isReconciledOperationStatus(previous.install_status)));
|
||||
const clearStatus = localStateChanged || activeStateReconciled;
|
||||
|
||||
return {
|
||||
...game,
|
||||
availability: game.availability ?? (game.downloaded ? GameAvailability.Ready : GameAvailability.LocalOnly),
|
||||
install_status: installStatus,
|
||||
status_message: clearStatus ? undefined : previous?.status_message,
|
||||
status_level: clearStatus ? undefined : previous?.status_level,
|
||||
peer_count: game.peer_count ?? 0,
|
||||
};
|
||||
};
|
||||
|
||||
const MainWindow = () => {
|
||||
const [gameItems, setGameItems] = useState<Game[]>([]);
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const [gameDir, setGameDir] = useState('');
|
||||
const [currentFilter, setCurrentFilter] = useState<GameFilter>('local');
|
||||
const [totalPeerCount, setTotalPeerCount] = useState(0);
|
||||
const checkingPeersTimeouts = useRef<Record<string, ReturnType<typeof setTimeout>>>({});
|
||||
const [thumbnails, setThumbnails] = useState<Map<string, string>>(new Map());
|
||||
|
||||
const getThumbnailUrl = useCallback(async (gameId: string): Promise<string> => {
|
||||
// Check cache first
|
||||
if (thumbnails.has(gameId)) {
|
||||
return thumbnails.get(gameId)!;
|
||||
}
|
||||
|
||||
try {
|
||||
const thumbnailUrl = await invoke<string>('get_game_thumbnail', { gameId });
|
||||
setThumbnails(prev => new Map(prev).set(gameId, thumbnailUrl));
|
||||
return thumbnailUrl;
|
||||
} catch {
|
||||
// Return a small placeholder for missing images
|
||||
setThumbnails(prev => new Map(prev).set(gameId, FALLBACK_THUMBNAIL));
|
||||
return FALLBACK_THUMBNAIL;
|
||||
}
|
||||
}, [thumbnails]);
|
||||
|
||||
const getFilteredGames = (games: Game[], filter: GameFilter): Game[] => {
|
||||
switch (filter) {
|
||||
case 'local':
|
||||
// Games present on this machine, whether the archive is downloaded or already installed.
|
||||
return games.filter(game => game.installed || game.downloaded);
|
||||
case 'installed':
|
||||
return games.filter(game => game.installed);
|
||||
case 'all':
|
||||
default:
|
||||
// Games reachable on the LAN: held on this machine or advertised by another peer.
|
||||
return games.filter(game => game.installed || game.downloaded || game.peer_count > 0);
|
||||
}
|
||||
};
|
||||
|
||||
const filteredAndSearchedGames = getFilteredGames(gameItems, currentFilter).filter(item =>
|
||||
item.name.toLowerCase().includes(searchTerm.toLowerCase())
|
||||
);
|
||||
|
||||
const clearCheckingPeersTimeout = (gameId: string) => {
|
||||
const timeoutId = checkingPeersTimeouts.current[gameId];
|
||||
if (timeoutId !== undefined) {
|
||||
clearTimeout(timeoutId);
|
||||
delete checkingPeersTimeouts.current[gameId];
|
||||
}
|
||||
};
|
||||
|
||||
const scheduleCheckingPeersFallback = (gameId: string, fallbackMessage?: string, fallbackLevel?: StatusLevel) => {
|
||||
clearCheckingPeersTimeout(gameId);
|
||||
checkingPeersTimeouts.current[gameId] = setTimeout(() => {
|
||||
setGameItems(prev => prev.map(item => {
|
||||
if (item.id !== gameId || item.install_status !== InstallStatus.CheckingPeers) {
|
||||
return item;
|
||||
}
|
||||
return {
|
||||
...item,
|
||||
install_status: item.installed ? InstallStatus.Installed : InstallStatus.NotInstalled,
|
||||
status_message: fallbackMessage ?? 'No peers currently have this game.',
|
||||
status_level: fallbackLevel ?? 'error',
|
||||
};
|
||||
}));
|
||||
delete checkingPeersTimeouts.current[gameId];
|
||||
}, CHECKING_PEERS_TIMEOUT_MS);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
Object.values(checkingPeersTimeouts.current).forEach(clearTimeout);
|
||||
checkingPeersTimeouts.current = {};
|
||||
};
|
||||
}, []);
|
||||
|
||||
const getInitialGameDir = useCallback(async () => {
|
||||
await new Promise(resolve => setTimeout(resolve, 1000));
|
||||
const store = await load(FILE_STORAGE, STORE_OPTIONS);
|
||||
const savedGameDir = await store.get<string>(GAME_DIR_KEY);
|
||||
if (savedGameDir) {
|
||||
setGameDir(savedGameDir);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
void getInitialGameDir();
|
||||
}, [getInitialGameDir]);
|
||||
|
||||
useEffect(() => {
|
||||
// Listen for game-download-failed events specifically
|
||||
const setupDownloadFailedListener = async () => {
|
||||
const unlisten = await listen('game-download-failed', (event) => {
|
||||
const game_id = event.payload as string;
|
||||
console.log(`❌ game-download-failed ${game_id} event received`);
|
||||
clearCheckingPeersTimeout(game_id);
|
||||
setGameItems(prev => prev.map(item => item.id === game_id
|
||||
? {
|
||||
...item,
|
||||
install_status: item.installed ? InstallStatus.Installed : InstallStatus.NotInstalled,
|
||||
status_message: 'Download failed. Please try again.',
|
||||
status_level: 'error',
|
||||
}
|
||||
: item));
|
||||
|
||||
// Convert to string explicitly and verify it's not empty
|
||||
const pathString = String(gameDir);
|
||||
if (!pathString) {
|
||||
console.error('gameDir is empty before invoke!');
|
||||
return;
|
||||
}
|
||||
invoke('update_game_directory', { path: pathString })
|
||||
.catch(error => console.error('❌ Error updating game directory:', error));
|
||||
});
|
||||
return unlisten;
|
||||
};
|
||||
|
||||
const setupPeersGoneListener = async () => {
|
||||
const unlisten = await listen('game-download-peers-gone', (event) => {
|
||||
const game_id = event.payload as string;
|
||||
console.log(`❌ game-download-peers-gone ${game_id} event received`);
|
||||
clearCheckingPeersTimeout(game_id);
|
||||
setGameItems(prev => prev.map(item => item.id === game_id
|
||||
? {
|
||||
...item,
|
||||
install_status: item.installed ? InstallStatus.Installed : InstallStatus.NotInstalled,
|
||||
status_message: 'Failed: All Peers gone',
|
||||
status_level: 'error',
|
||||
}
|
||||
: item));
|
||||
|
||||
const pathString = String(gameDir);
|
||||
if (!pathString) {
|
||||
console.error('gameDir is empty before invoke!');
|
||||
return;
|
||||
}
|
||||
invoke('update_game_directory', { path: pathString })
|
||||
.catch(error => console.error('❌ Error updating game directory:', error));
|
||||
});
|
||||
return unlisten;
|
||||
};
|
||||
|
||||
const setupNoPeersListener = async () => {
|
||||
const unlisten = await listen('game-no-peers', (event) => {
|
||||
const game_id = event.payload as string;
|
||||
console.log(`⚠️ game-no-peers ${game_id} event received`);
|
||||
clearCheckingPeersTimeout(game_id);
|
||||
setGameItems(prev => prev.map(item => item.id === game_id
|
||||
? {
|
||||
...item,
|
||||
install_status: item.installed ? InstallStatus.Installed : InstallStatus.NotInstalled,
|
||||
status_message: 'No peers currently have this game.',
|
||||
status_level: 'error',
|
||||
}
|
||||
: item));
|
||||
});
|
||||
return unlisten;
|
||||
};
|
||||
|
||||
setupDownloadFailedListener();
|
||||
setupPeersGoneListener();
|
||||
setupNoPeersListener();
|
||||
|
||||
const setupPeerCountListener = async () => {
|
||||
const unlisten = await listen('peer-count-updated', (event) => {
|
||||
const count = event.payload as number;
|
||||
console.log(`🗲 peer-count-updated ${count} event received`);
|
||||
setTotalPeerCount(count);
|
||||
});
|
||||
return unlisten;
|
||||
};
|
||||
|
||||
setupPeerCountListener();
|
||||
}, [gameDir]);
|
||||
|
||||
useEffect(() => {
|
||||
// Listen for game-install-finished events specifically
|
||||
const setupInstallFinishedListener = async () => {
|
||||
const unlisten = await listen('game-install-finished', (event) => {
|
||||
const game_id = event.payload as string;
|
||||
console.log(`🗲 game-install-finished ${game_id} event received`);
|
||||
clearCheckingPeersTimeout(game_id);
|
||||
setGameItems(prev => prev.map(item => item.id === game_id
|
||||
? {
|
||||
...item,
|
||||
install_status: InstallStatus.Installed,
|
||||
status_message: undefined,
|
||||
status_level: undefined,
|
||||
}
|
||||
: item));
|
||||
|
||||
// Convert to string explicitly and verify it's not empty
|
||||
const pathString = String(gameDir);
|
||||
if (!pathString) {
|
||||
console.error('gameDir is empty before invoke!');
|
||||
return;
|
||||
}
|
||||
invoke('update_game_directory', { path: pathString })
|
||||
.catch(error => console.error('❌ Error updating game directory:', error));
|
||||
});
|
||||
return unlisten;
|
||||
};
|
||||
|
||||
setupInstallFinishedListener();
|
||||
}, [gameDir]);
|
||||
|
||||
useEffect(() => {
|
||||
if (gameDir) {
|
||||
// store game directory in persistent storage
|
||||
const updateStorage = async (game_dir: string) => {
|
||||
try {
|
||||
const store = await load(FILE_STORAGE, STORE_OPTIONS);
|
||||
await store.set(GAME_DIR_KEY, game_dir);
|
||||
console.info(`📦 Storage updated with game directory: ${game_dir}`);
|
||||
} catch (error) {
|
||||
console.error('❌ Error updating storage:', error);
|
||||
}
|
||||
};
|
||||
|
||||
updateStorage(gameDir);
|
||||
|
||||
console.log(`📂 Game directory changed to: ${gameDir}`);
|
||||
invoke('update_game_directory', { path: gameDir })
|
||||
.catch(error => console.error('❌ Error updating game directory:', error));
|
||||
}
|
||||
}, [gameDir]);
|
||||
|
||||
useEffect(() => {
|
||||
console.log('🔵 Effect starting - setting up listener and requesting games');
|
||||
|
||||
const setupEventListener = async () => {
|
||||
try {
|
||||
// Listen for games-list-updated events
|
||||
const unlisten_games = await listen('games-list-updated', (event) => {
|
||||
console.log('🗲 Received games-list-updated event');
|
||||
const payload = normalizeGamesListPayload(event.payload as GamesListPayload | Game[]);
|
||||
const games = payload.games;
|
||||
const activeStatuses = activeStatusById(payload.active_operations);
|
||||
const hasAuthoritativeSnapshot = payload.active_operations !== undefined;
|
||||
console.log(`🎮 ${games.length} Games received`);
|
||||
setGameItems(prev => {
|
||||
const previousById = new Map(prev.map(item => [item.id, item]));
|
||||
return games.map(game => mergeGameUpdate(
|
||||
game,
|
||||
previousById.get(game.id),
|
||||
activeStatuses.get(game.id),
|
||||
hasAuthoritativeSnapshot,
|
||||
));
|
||||
});
|
||||
void getInitialGameDir();
|
||||
});
|
||||
|
||||
// Listen for game-download-begin events
|
||||
const unlisten_game_download_begin = await listen('game-download-begin', (event) => {
|
||||
const game_id = event.payload as string;
|
||||
console.log(`🗲 game-download-begin ${game_id} event received`);
|
||||
clearCheckingPeersTimeout(game_id);
|
||||
setGameItems(prev => prev.map(item => item.id === game_id
|
||||
? {
|
||||
...item,
|
||||
install_status: InstallStatus.Downloading,
|
||||
status_message: undefined,
|
||||
status_level: undefined,
|
||||
}
|
||||
: item));
|
||||
});
|
||||
|
||||
// Listen for game-download-finished events
|
||||
const unlisten_game_download_finished = await listen('game-download-finished', (event) => {
|
||||
const game_id = event.payload as string;
|
||||
console.log(`🗲 game-download-finished ${game_id} event received`);
|
||||
clearCheckingPeersTimeout(game_id);
|
||||
setGameItems(prev => prev.map(item => item.id === game_id
|
||||
? {
|
||||
...item,
|
||||
install_status: InstallStatus.Installing,
|
||||
status_message: undefined,
|
||||
status_level: undefined,
|
||||
}
|
||||
: item));
|
||||
});
|
||||
|
||||
const unlisten_game_install_begin = await listen('game-install-begin', (event) => {
|
||||
const game_id = event.payload as string;
|
||||
console.log(`🗲 game-install-begin ${game_id} event received`);
|
||||
clearCheckingPeersTimeout(game_id);
|
||||
setGameItems(prev => prev.map(item => item.id === game_id
|
||||
? {
|
||||
...item,
|
||||
install_status: InstallStatus.Installing,
|
||||
status_message: undefined,
|
||||
status_level: undefined,
|
||||
}
|
||||
: item));
|
||||
});
|
||||
|
||||
const unlisten_game_install_failed = await listen('game-install-failed', (event) => {
|
||||
const game_id = event.payload as string;
|
||||
console.log(`❌ game-install-failed ${game_id} event received`);
|
||||
clearCheckingPeersTimeout(game_id);
|
||||
setGameItems(prev => prev.map(item => item.id === game_id
|
||||
? {
|
||||
...item,
|
||||
install_status: item.installed ? InstallStatus.Installed : InstallStatus.NotInstalled,
|
||||
status_message: 'Install failed. Please try again.',
|
||||
status_level: 'error',
|
||||
}
|
||||
: item));
|
||||
});
|
||||
|
||||
const unlisten_game_uninstall_begin = await listen('game-uninstall-begin', (event) => {
|
||||
const game_id = event.payload as string;
|
||||
console.log(`🗲 game-uninstall-begin ${game_id} event received`);
|
||||
setGameItems(prev => prev.map(item => item.id === game_id
|
||||
? {
|
||||
...item,
|
||||
install_status: InstallStatus.Uninstalling,
|
||||
status_message: undefined,
|
||||
status_level: undefined,
|
||||
}
|
||||
: item));
|
||||
});
|
||||
|
||||
const unlisten_game_uninstall_finished = await listen('game-uninstall-finished', (event) => {
|
||||
const game_id = event.payload as string;
|
||||
console.log(`🗲 game-uninstall-finished ${game_id} event received`);
|
||||
setGameItems(prev => prev.map(item => item.id === game_id
|
||||
? {
|
||||
...item,
|
||||
installed: false,
|
||||
install_status: InstallStatus.NotInstalled,
|
||||
status_message: undefined,
|
||||
status_level: undefined,
|
||||
}
|
||||
: item));
|
||||
|
||||
});
|
||||
|
||||
const unlisten_game_uninstall_failed = await listen('game-uninstall-failed', (event) => {
|
||||
const game_id = event.payload as string;
|
||||
console.log(`❌ game-uninstall-failed ${game_id} event received`);
|
||||
setGameItems(prev => prev.map(item => item.id === game_id
|
||||
? {
|
||||
...item,
|
||||
install_status: item.installed ? InstallStatus.Installed : InstallStatus.NotInstalled,
|
||||
status_message: 'Uninstall failed. Please try again.',
|
||||
status_level: 'error',
|
||||
}
|
||||
: item));
|
||||
});
|
||||
|
||||
// Initial request for games
|
||||
console.log('📤 Requesting initial games list');
|
||||
await invoke('request_games');
|
||||
|
||||
// Cleanup function
|
||||
return () => {
|
||||
console.log('🧹 Cleaning up - removing listener');
|
||||
unlisten_games();
|
||||
unlisten_game_download_begin();
|
||||
unlisten_game_download_finished();
|
||||
unlisten_game_install_begin();
|
||||
unlisten_game_install_failed();
|
||||
unlisten_game_uninstall_begin();
|
||||
unlisten_game_uninstall_finished();
|
||||
unlisten_game_uninstall_failed();
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('❌ Error in setup:', error);
|
||||
}
|
||||
};
|
||||
|
||||
setupEventListener();
|
||||
|
||||
// Cleanup
|
||||
return () => {
|
||||
console.log('🚫 Effect cleanup - component unmounting');
|
||||
};
|
||||
}, []); // Empty dependency array means this runs once on mount
|
||||
|
||||
const runGame = async (id: string) => {
|
||||
console.log(`🎯 Running game with id=${id}`);
|
||||
try {
|
||||
const result = await invoke('run_game', { id });
|
||||
console.log(`✅ Game started, result=${result}`);
|
||||
} catch (error) {
|
||||
console.error('❌ Error running game:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const installGame = async (id: string) => {
|
||||
console.log(`🎯 Installing game with id=${id}`);
|
||||
try {
|
||||
const success = await invoke('install_game', { id });
|
||||
if (success) {
|
||||
console.log(`✅ Game install for id=${id} started...`);
|
||||
let fallbackMessage: string | undefined;
|
||||
let fallbackLevel: StatusLevel | undefined;
|
||||
// update install status in gameItems for this game
|
||||
setGameItems(prev => prev.map(item => {
|
||||
if (item.id === id) {
|
||||
fallbackMessage = item.status_message;
|
||||
fallbackLevel = item.status_level;
|
||||
return {
|
||||
...item,
|
||||
install_status: InstallStatus.CheckingPeers,
|
||||
};
|
||||
}
|
||||
return item;
|
||||
}));
|
||||
scheduleCheckingPeersFallback(id, fallbackMessage, fallbackLevel);
|
||||
} else {
|
||||
// game is already being installed
|
||||
console.warn(`🚧 Game with id=${id} is already being installed`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('❌ Error installing game:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const updateGame = async (id: string) => {
|
||||
console.log(`🎯 Updating game with id=${id}`);
|
||||
try {
|
||||
const success = await invoke('update_game', { id });
|
||||
if (success) {
|
||||
console.log(`✅ Game update for id=${id} started...`);
|
||||
let fallbackMessage: string | undefined;
|
||||
let fallbackLevel: StatusLevel | undefined;
|
||||
// update install status in gameItems for this game
|
||||
setGameItems(prev => prev.map(item => {
|
||||
if (item.id === id) {
|
||||
fallbackMessage = item.status_message;
|
||||
fallbackLevel = item.status_level;
|
||||
return {
|
||||
...item,
|
||||
install_status: InstallStatus.CheckingPeers,
|
||||
};
|
||||
}
|
||||
return item;
|
||||
}));
|
||||
scheduleCheckingPeersFallback(id, fallbackMessage, fallbackLevel);
|
||||
} else {
|
||||
// game is already being installed/updated
|
||||
console.warn(`🚧 Game with id=${id} is already being updated`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('❌ Error updating game:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const uninstallGame = async (id: string) => {
|
||||
console.log(`🎯 Uninstalling game with id=${id}`);
|
||||
try {
|
||||
const success = await invoke('uninstall_game', { id });
|
||||
if (success) {
|
||||
setGameItems(prev => prev.map(item => item.id === id
|
||||
? {
|
||||
...item,
|
||||
install_status: InstallStatus.Uninstalling,
|
||||
status_message: undefined,
|
||||
status_level: undefined,
|
||||
}
|
||||
: item));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('❌ Error uninstalling game:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const needsUpdate = (game: Game): boolean => {
|
||||
if (!game.installed) return false;
|
||||
|
||||
// Check if peers have a version and we have a local version
|
||||
const peerVersion = game.eti_game_version;
|
||||
const localVersion = game.local_version;
|
||||
|
||||
// If we don't have local version but peers have one, we need update
|
||||
if (!localVersion && peerVersion) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// If we have both versions, compare them numerically
|
||||
if (localVersion && peerVersion) {
|
||||
const localNum = parseInt(localVersion, 10);
|
||||
const peerNum = parseInt(peerVersion, 10);
|
||||
return peerNum > localNum;
|
||||
}
|
||||
|
||||
return false;
|
||||
};
|
||||
|
||||
const getInProgressLabel = (game: Game): string | undefined => {
|
||||
switch (game.install_status) {
|
||||
case InstallStatus.CheckingPeers:
|
||||
return 'Checking peers...';
|
||||
case InstallStatus.Downloading:
|
||||
return 'Downloading...';
|
||||
case InstallStatus.Installing:
|
||||
return 'Installing...';
|
||||
case InstallStatus.Uninstalling:
|
||||
return 'Uninstalling...';
|
||||
default:
|
||||
return undefined;
|
||||
}
|
||||
};
|
||||
|
||||
const isUnavailable = (game: Game): boolean => {
|
||||
return !game.installed
|
||||
&& !game.downloaded
|
||||
&& game.peer_count === 0
|
||||
&& game.install_status === InstallStatus.NotInstalled;
|
||||
};
|
||||
|
||||
const getActionLabel = (game: Game): string => {
|
||||
const inProgress = getInProgressLabel(game);
|
||||
if (inProgress) {
|
||||
return inProgress;
|
||||
}
|
||||
|
||||
if (isUnavailable(game)) {
|
||||
return 'Unavailable';
|
||||
}
|
||||
|
||||
if (!game.installed) {
|
||||
return game.downloaded ? 'Install' : 'Download';
|
||||
}
|
||||
|
||||
if (needsUpdate(game)) {
|
||||
return 'Update';
|
||||
}
|
||||
|
||||
return 'Play';
|
||||
};
|
||||
|
||||
const dialogGameDir = async () => {
|
||||
const file = await open({
|
||||
multiple: false,
|
||||
directory: true,
|
||||
});
|
||||
|
||||
if (file) {
|
||||
setGameDir(file);
|
||||
}
|
||||
};
|
||||
|
||||
const openUnpackLogsWindow = async () => {
|
||||
try {
|
||||
const existing = await WebviewWindow.getByLabel('unpack-logs');
|
||||
if (existing) {
|
||||
await existing.setFocus();
|
||||
return;
|
||||
}
|
||||
|
||||
const logWindow = new WebviewWindow('unpack-logs', {
|
||||
url: '/?view=unpack-logs',
|
||||
title: 'Unpack Logs',
|
||||
width: 900,
|
||||
height: 700,
|
||||
resizable: true,
|
||||
});
|
||||
await logWindow.once<unknown>('tauri://error', (event) => {
|
||||
console.error('Error opening unpack logs window:', event.payload);
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error opening unpack logs window:', error);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<main className="container">
|
||||
<div className="fixed-header">
|
||||
<div className="top-left-peer-count">
|
||||
{totalPeerCount > 0 && (
|
||||
<span className="peer-count">
|
||||
👥 {totalPeerCount}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<h1 className="align-center">SoftLAN Launcher</h1>
|
||||
<div className="main-header">
|
||||
{gameDir ? (
|
||||
<div>
|
||||
<div className="filter-container">
|
||||
<button
|
||||
className={`filter-button ${currentFilter === 'all' ? 'active' : ''}`}
|
||||
onClick={() => setCurrentFilter('all')}
|
||||
title="Show all games available on the LAN"
|
||||
>
|
||||
All Games
|
||||
</button>
|
||||
<button
|
||||
className={`filter-button ${currentFilter === 'local' ? 'active' : ''}`}
|
||||
onClick={() => setCurrentFilter('local')}
|
||||
title="Show games downloaded or installed on your system"
|
||||
>
|
||||
Local
|
||||
</button>
|
||||
<button
|
||||
className={`filter-button ${currentFilter === 'installed' ? 'active' : ''}`}
|
||||
onClick={() => setCurrentFilter('installed')}
|
||||
title="Show games installed on your system"
|
||||
>
|
||||
Installed
|
||||
</button>
|
||||
</div>
|
||||
<div className="search-settings-wrapper">
|
||||
<div></div>
|
||||
<div className="search-container">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search games..."
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
className="search-input"
|
||||
/>
|
||||
</div>
|
||||
<div className="settings-container">
|
||||
<button onClick={() => void openUnpackLogsWindow()} className="settings-button">Unpack Logs</button>
|
||||
<button onClick={dialogGameDir} className="settings-button">Set Game Directory</button>
|
||||
<span className="settings-text">{gameDir}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="no-directory-container">
|
||||
<div className="no-directory-message">
|
||||
Please set a game directory to start scanning for games...
|
||||
</div>
|
||||
<div className="no-directory-button">
|
||||
<button onClick={dialogGameDir} className="settings-button">Set Game Directory</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid-container">
|
||||
{gameDir && filteredAndSearchedGames.length === 0 && gameItems.length === 0 ? (
|
||||
<div className="no-games-message">
|
||||
Scanning for games in your directory...
|
||||
</div>
|
||||
) : gameDir && filteredAndSearchedGames.length === 0 && gameItems.length > 0 ? (
|
||||
<div className="no-games-message">
|
||||
No games found matching your search and filters.
|
||||
</div>
|
||||
) : null}
|
||||
{filteredAndSearchedGames.map((item) => (
|
||||
<div key={item.id} className="item">
|
||||
<GameThumbnail
|
||||
gameId={item.id}
|
||||
alt={`${item.name} thumbnail`}
|
||||
getThumbnailUrl={getThumbnailUrl}
|
||||
/>
|
||||
<div className="item-name">{item.name}</div>
|
||||
<div className="description">
|
||||
<span className="desc-text">{item.description.slice(0, 10)}</span>
|
||||
<span className="size-text">{(item.size / 1024 / 1024 / 1024).toFixed(1)} GB</span>
|
||||
</div>
|
||||
<div className="badges">
|
||||
{item.installed && item.availability === GameAvailability.LocalOnly && (
|
||||
<span className="badge local-only">LocalOnly</span>
|
||||
)}
|
||||
{!item.installed && item.downloaded && item.local_version && (
|
||||
<span className="badge">v{item.local_version}</span>
|
||||
)}
|
||||
</div>
|
||||
<div
|
||||
className={`play-button${isUnavailable(item) ? ' unavailable' : ''}`}
|
||||
onClick={() => {
|
||||
if (isUnavailable(item)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!item.installed) {
|
||||
installGame(item.id);
|
||||
} else if (needsUpdate(item)) {
|
||||
updateGame(item.id);
|
||||
} else {
|
||||
runGame(item.id);
|
||||
}
|
||||
}}>
|
||||
{getActionLabel(item)}
|
||||
</div>
|
||||
{item.installed && !isInProgressInstallStatus(item.install_status) && (
|
||||
<button
|
||||
className="uninstall-button"
|
||||
aria-label={`Uninstall ${item.name}`}
|
||||
title="Uninstall"
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
uninstallGame(item.id);
|
||||
}}
|
||||
>
|
||||
X
|
||||
</button>
|
||||
)}
|
||||
<div className={`item-info${item.status_level ? ` ${item.status_level}` : ''}`}>
|
||||
<div className="status-left">
|
||||
{item.status_message && item.peer_count === 0 && !item.installed && !item.downloaded ? item.status_message : ''}
|
||||
</div>
|
||||
<div className="status-right">
|
||||
{item.peer_count > 0 && (
|
||||
<span className="peer-count">
|
||||
👥 {item.peer_count}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
};
|
||||
|
||||
const App = () => {
|
||||
return isUnpackLogsView() ? <UnpackLogsWindow /> : <MainWindow />;
|
||||
};
|
||||
/**
|
||||
* Tauri can spawn this bundle in either the main launcher window or the
|
||||
* unpack-logs companion window. The URL query string disambiguates the two so
|
||||
* a single Vite build serves both.
|
||||
*/
|
||||
const App = () => (isUnpackLogsView() ? <UnpackLogsWindow /> : <MainWindow />);
|
||||
|
||||
export default App;
|
||||
|
||||
Reference in New Issue
Block a user