feat(ui): delegate install lifecycle to the peer

Remove the Tauri-side whole-game backup and unpack flow. The Tauri shell now
provides an injected unrar sidecar implementation and lets the peer own
install, update, uninstall, rollback, and recovery decisions.

Route install commands by local state: missing version.ini fetches from peers,
downloaded archives without local/ send InstallGame directly, and already
installed games are left to the Play action. Updates request a fresh download
and uninstalls forward UninstallGame. The UI mirrors peer operation events for
downloading, installing, updating, and uninstalling.

Render installed-but-not-downloaded games as LocalOnly and surface the local
version for downloaded-but-not-installed games. Add a secondary uninstall
affordance that does not change the main Install/Open action.

Test Plan:
- just fmt
- just clippy
- just test
- just build

Refs: PLAN.md
This commit is contained in:
2026-05-15 18:20:45 +02:00
parent 6c8a2bb9f0
commit c5dfbf99a0
3 changed files with 399 additions and 256 deletions
@@ -74,6 +74,29 @@ h1.align-center {
font-size: 0.9em;
}
.badges {
display: flex;
min-height: 24px;
gap: 6px;
justify-content: center;
align-items: center;
padding: 0 10px 8px;
}
.badge {
border: 1px solid #4866b9;
border-radius: 4px;
color: #D5DBFE;
font-size: 12px;
line-height: 1;
padding: 5px 7px;
}
.badge.local-only {
border-color: #8b6f2a;
color: #f1d58a;
}
.desc-text {
text-align: left;
}
@@ -127,6 +150,24 @@ h1.align-center {
transform: none;
}
.uninstall-button {
align-self: center;
width: 34px;
height: 34px;
margin: 6px 0 0;
border-radius: 50%;
border: 1px solid #6c2942;
background: #2a0714;
color: #ffb4c8;
font-weight: bold;
cursor: pointer;
}
.uninstall-button:hover {
border-color: #ff6d9d;
background: #4d1025;
}
@keyframes flicker {
0% { opacity: 1; }
50% { opacity: 0.8; }
+123 -5
View File
@@ -23,7 +23,8 @@ enum InstallStatus {
NotInstalled = 'NotInstalled',
CheckingPeers = 'CheckingPeers',
Downloading = 'Downloading',
Unpacking = 'Unpacking',
Installing = 'Installing',
Uninstalling = 'Uninstalling',
Installed = 'Installed',
}
@@ -83,7 +84,8 @@ const GameThumbnail = ({ gameId, alt, getThumbnailUrl }: GameThumbnailProps) =>
const IN_PROGRESS_INSTALL_STATUSES = new Set<InstallStatus>([
InstallStatus.CheckingPeers,
InstallStatus.Downloading,
InstallStatus.Unpacking,
InstallStatus.Installing,
InstallStatus.Uninstalling,
]);
const isInProgressInstallStatus = (status: InstallStatus): boolean => {
@@ -378,13 +380,82 @@ const App = () => {
setGameItems(prev => prev.map(item => item.id === game_id
? {
...item,
install_status: InstallStatus.Unpacking,
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');
@@ -395,6 +466,11 @@ const App = () => {
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);
@@ -479,6 +555,25 @@ const App = () => {
}
};
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;
@@ -507,8 +602,10 @@ const App = () => {
return 'Checking peers...';
case InstallStatus.Downloading:
return 'Downloading...';
case InstallStatus.Unpacking:
return 'Unpacking...';
case InstallStatus.Installing:
return 'Installing...';
case InstallStatus.Uninstalling:
return 'Uninstalling...';
default:
return undefined;
}
@@ -641,6 +738,14 @@ const App = () => {
<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.downloaded && (
<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={() => {
@@ -658,6 +763,19 @@ const App = () => {
}}>
{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 : ''}