Feature/streamed install prototype #27

Merged
ddidderr merged 15 commits from feature/streamed-install-prototype into main 2026-06-11 08:52:33 +02:00
5 changed files with 79 additions and 26 deletions
Showing only changes of commit f62515451b - Show all commits
+5 -4
View File
@@ -54,11 +54,12 @@ product-ready.
The peer-cli harness now exposes `cancel-download` so cancellation scenarios
exercise the same runtime path as the GUI.
7. **Clean product semantics**
7. **Done — Clean product semantics**
Decide how the UI labels this state. It is installed but not downloaded, so
“Local only” is technically correct, but users may need a clear affordance
like “Installed, not shareable”.
The UI now keeps streamed installs in the installed visual state while making
the sharing limitation explicit: cards show `Not shareable`, and the detail
modal status shows `Installed, not shareable`. Downloaded-and-installed games
keep the normal `Installed` label.
My recommended next slice: make the provider abstraction final-ish, then
implement a real one-pass provider. Everything else builds cleanly on that.
@@ -1,13 +1,5 @@
import { Game } from '../lib/types';
import { deriveState } from '../lib/gameState';
const LABELS: Record<string, string> = {
installed: 'Installed',
local: 'Local',
downloading: 'Downloading',
busy: 'Working',
none: '',
};
import { deriveState, stateChipLabel } from '../lib/gameState';
interface Props {
game: Game;
@@ -17,7 +9,7 @@ interface Props {
export const StateChip = ({ game, showNone = false }: Props) => {
const state = deriveState(game);
const label = LABELS[state] ?? '';
const label = stateChipLabel(game);
if (!label && !showNone) return null;
return (
<div className="state-chip" data-state={state}>
@@ -5,7 +5,7 @@ import { StateChip } from '../StateChip';
import { ActionButton } from '../ActionButton';
import { Game, InstallStatus } from '../../lib/types';
import { canStreamInstall, deriveState, hasNewerLocalVersion, isInProgress } from '../../lib/gameState';
import { canStreamInstall, gameStatusLabel, hasNewerLocalVersion, isInProgress } from '../../lib/gameState';
import { formatBytes, formatEtiVersion, formatPlayers } from '../../lib/format';
interface Props {
@@ -29,16 +29,6 @@ const tagsFromGame = (game: Game): string[] => {
return tags;
};
const statusLabelFor = (game: Game): string => {
switch (deriveState(game)) {
case 'installed': return 'Installed';
case 'local': return 'Downloaded';
case 'downloading': return 'Downloading';
case 'busy': return 'Working…';
case 'none': return 'Not downloaded';
}
};
export const GameDetailModal = ({
game,
thumbnailUrl,
@@ -115,7 +105,7 @@ export const GameDetailModal = ({
</div>
<div className="meta-cell">
<div className="meta-label">Status</div>
<div className="meta-value">{statusLabelFor(game)}</div>
<div className="meta-value">{gameStatusLabel(game)}</div>
</div>
</div>
@@ -82,6 +82,35 @@ export const deriveState = (game: Game): DerivedState => {
return 'none';
};
export const isInstalledNotShareable = (game: Game): boolean =>
game.installed && !game.downloaded;
export const stateChipLabel = (game: Game): string => {
const state = deriveState(game);
if (state === 'installed' && isInstalledNotShareable(game)) return 'Not shareable';
switch (state) {
case 'installed': return 'Installed';
case 'local': return 'Local';
case 'downloading': return 'Downloading';
case 'busy': return 'Working';
case 'none': return '';
}
};
export const gameStatusLabel = (game: Game): string => {
const state = deriveState(game);
if (state === 'installed' && isInstalledNotShareable(game)) {
return 'Installed, not shareable';
}
switch (state) {
case 'installed': return 'Installed';
case 'local': return 'Downloaded';
case 'downloading': return 'Downloading';
case 'busy': return 'Working…';
case 'none': return 'Not downloaded';
}
};
export const isUnavailable = (game: Game): boolean =>
!game.installed
&& !game.downloaded
@@ -11,7 +11,9 @@ import {
formatDownloadEta,
formatDownloadSpeed,
formatDownloadSpeedShort,
gameStatusLabel,
mergeGameUpdate,
stateChipLabel,
} from '../src/lib/gameState.ts';
import {
ActiveOperationKind,
@@ -243,3 +245,42 @@ Deno.test('stream install is available only for idle remote games', () => {
'busy games should not expose streamed install',
);
});
Deno.test('streamed local installs are labeled installed but not shareable', () => {
const streamed = game({
downloaded: false,
installed: true,
install_status: InstallStatus.Installed,
});
const downloadedInstall = game({
downloaded: true,
installed: true,
install_status: InstallStatus.Installed,
});
assertEquals(
deriveState(streamed),
'installed',
'streamed local installs should keep installed visual state',
);
assertEquals(
stateChipLabel(streamed),
'Not shareable',
'card chip should make the non-shareable state visible',
);
assertEquals(
gameStatusLabel(streamed),
'Installed, not shareable',
'detail status should spell out installed plus non-shareable',
);
assertEquals(
stateChipLabel(downloadedInstall),
'Installed',
'normal downloaded installs should keep the installed chip label',
);
assertEquals(
gameStatusLabel(downloadedInstall),
'Installed',
'normal downloaded installs should keep the installed detail label',
);
});