520 lines
22 KiB
TypeScript
520 lines
22 KiB
TypeScript
import { useEffect, useRef, useState } from 'react';
|
|
import { invoke } from '@tauri-apps/api/core';
|
|
import { listen } from '@tauri-apps/api/event';
|
|
import { open } from '@tauri-apps/plugin-dialog';
|
|
import { load } from '@tauri-apps/plugin-store';
|
|
|
|
import "./App.css";
|
|
|
|
const FILE_STORAGE = 'launcher-settings.json';
|
|
const GAME_DIR_KEY = 'game-directory';
|
|
const CHECKING_PEERS_TIMEOUT_MS = 5000;
|
|
|
|
// enum with install status
|
|
enum InstallStatus {
|
|
NotInstalled = 'NotInstalled',
|
|
CheckingPeers = 'CheckingPeers',
|
|
Downloading = 'Downloading',
|
|
Unpacking = 'Unpacking',
|
|
Installed = 'Installed',
|
|
}
|
|
|
|
type StatusLevel = 'info' | 'error';
|
|
|
|
type GameFilter = 'all' | 'available' | 'installed';
|
|
|
|
interface Game {
|
|
id: string;
|
|
name: string;
|
|
description: string;
|
|
size: number;
|
|
thumbnail: Uint8Array | number[];
|
|
installed: boolean;
|
|
install_status: InstallStatus;
|
|
eti_game_version?: string;
|
|
local_version?: string;
|
|
status_message?: string;
|
|
status_level?: StatusLevel;
|
|
peer_count: number;
|
|
}
|
|
|
|
const App = () => {
|
|
const [gameItems, setGameItems] = useState<Game[]>([]);
|
|
const [searchTerm, setSearchTerm] = useState('');
|
|
const [gameDir, setGameDir] = useState('');
|
|
const [currentFilter, setCurrentFilter] = useState<GameFilter>('available');
|
|
const checkingPeersTimeouts = useRef<Record<string, ReturnType<typeof setTimeout>>>({});
|
|
|
|
const getFilteredGames = (games: Game[], filter: GameFilter): Game[] => {
|
|
switch (filter) {
|
|
case 'available':
|
|
// Show union of installed games and games with peers
|
|
return games.filter(game => game.installed || game.peer_count > 0);
|
|
case 'installed':
|
|
return games.filter(game => game.installed);
|
|
case 'all':
|
|
default:
|
|
return games;
|
|
}
|
|
};
|
|
|
|
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 = async () => {
|
|
// update game directory from storage (if exists)
|
|
// only if it's not already set
|
|
await new Promise(resolve => setTimeout(resolve, 1000));
|
|
const store = await load(FILE_STORAGE, { autoSave: true });
|
|
const savedGameDir = await store.get<string>(GAME_DIR_KEY);
|
|
if (savedGameDir) {
|
|
setGameDir(savedGameDir);
|
|
}
|
|
};
|
|
|
|
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 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();
|
|
setupNoPeersListener();
|
|
}, [gameDir]);
|
|
|
|
useEffect(() => {
|
|
// Listen for game-unpack-finished events specifically
|
|
const setupUnpackListener = async () => {
|
|
const unlisten = await listen('game-unpack-finished', (event) => {
|
|
const game_id = event.payload as string;
|
|
console.log(`🗲 game-unpack-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;
|
|
};
|
|
|
|
setupUnpackListener();
|
|
}, [gameDir]);
|
|
|
|
useEffect(() => {
|
|
if (gameDir) {
|
|
// store game directory in persistent storage
|
|
const updateStorage = async (game_dir: string) => {
|
|
try {
|
|
const store = await load(FILE_STORAGE, { autoSave: true });
|
|
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 games = event.payload as Game[];
|
|
console.log(`🎮 ${games.length} Games received`);
|
|
setGameItems(prev => {
|
|
const previousById = new Map(prev.map(item => [item.id, item]));
|
|
return games.map(game => {
|
|
const previous = previousById.get(game.id);
|
|
const installStatus = previous?.install_status
|
|
?? (game.installed ? InstallStatus.Installed : InstallStatus.NotInstalled);
|
|
return {
|
|
...game,
|
|
install_status: installStatus,
|
|
status_message: previous?.status_message,
|
|
status_level: previous?.status_level,
|
|
peer_count: game.peer_count ?? 0, // Ensure peer_count is always set
|
|
};
|
|
});
|
|
});
|
|
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.Unpacking,
|
|
status_message: undefined,
|
|
status_level: undefined,
|
|
}
|
|
: 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();
|
|
};
|
|
} 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 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 dialogGameDir = async () => {
|
|
const file = await open({
|
|
multiple: false,
|
|
directory: true,
|
|
});
|
|
|
|
if (file) {
|
|
setGameDir(file);
|
|
}
|
|
};
|
|
|
|
// Rest of your component remains the same
|
|
return (
|
|
<main className="container">
|
|
<div className="fixed-header">
|
|
<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, regardless of installation status or peer availability"
|
|
>
|
|
All Games
|
|
</button>
|
|
<button
|
|
className={`filter-button ${currentFilter === 'available' ? 'active' : ''}`}
|
|
onClick={() => setCurrentFilter('available')}
|
|
title="Show games that are either installed or have peers available for download"
|
|
>
|
|
Available
|
|
</button>
|
|
<button
|
|
className={`filter-button ${currentFilter === 'installed' ? 'active' : ''}`}
|
|
onClick={() => setCurrentFilter('installed')}
|
|
title="Show only games that are currently 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={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) => {
|
|
const uint8Array = new Uint8Array(item.thumbnail);
|
|
const binaryString = uint8Array.reduce((acc, byte) => acc + String.fromCharCode(byte), '');
|
|
const thumbnailUrl = `data:image/jpeg;base64,${btoa(binaryString)}`;
|
|
return (
|
|
<div key={item.id} className="item">
|
|
<img src={thumbnailUrl} alt={`${item.name} thumbnail`} />
|
|
<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="play-button"
|
|
onClick={() => {
|
|
if (!item.installed) {
|
|
installGame(item.id);
|
|
} else if (needsUpdate(item)) {
|
|
updateGame(item.id);
|
|
} else {
|
|
runGame(item.id);
|
|
}
|
|
}}>
|
|
{!item.installed
|
|
? item.install_status === InstallStatus.CheckingPeers ? 'Checking peers...'
|
|
: item.install_status === InstallStatus.Downloading ? 'Downloading...'
|
|
: item.install_status === InstallStatus.Unpacking ? 'Unpacking...'
|
|
: 'Install'
|
|
: needsUpdate(item)
|
|
? item.install_status === InstallStatus.CheckingPeers ? 'Checking peers...'
|
|
: item.install_status === InstallStatus.Downloading ? 'Downloading...'
|
|
: item.install_status === InstallStatus.Unpacking ? 'Unpacking...'
|
|
: 'Update'
|
|
: 'Play'}
|
|
</div>
|
|
<div className={`item-info${item.status_level ? ` ${item.status_level}` : ''}`}>
|
|
<div className="status-left">
|
|
{item.status_message && item.peer_count === 0 && !item.installed ? 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>
|
|
);
|
|
};
|
|
|
|
export default App;
|