Skip to content

Commit c2c1750

Browse files
committed
Auto-update flow, cloud installs, and collection sync
Auto-open the update flow from GameCard via a ?update=1 deep link and make the primary CTA trigger updates for installed outdated games. Remove the separate yellow "Update available" CTA and surface version diff, release date and notes in the UpdateBackupWarningModal (now accepts current/new version, releasedAt, notes, gameName). GameCard now navigates to the detail page to initiate updates from cards. Add a cloud "Install on this PC" carousel to LauncherPage that shows games from cloud play history not currently installed on this device, backed by an installedAppidSet to dedupe local installs. Adjusted imports/icons and always render the local "Recently installed" strip. Sync cloud collection memberships into local libraryGameMeta in use-user-collections so collection filters on the desktop reflect website additions/removals (best-effort). Small UI/copy improvements around update backup warning.
1 parent c06583c commit c2c1750

5 files changed

Lines changed: 262 additions & 59 deletions

File tree

renderer/src/app/pages/GameDetailPage.tsx

Lines changed: 43 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -684,6 +684,11 @@ export function GameDetailPage() {
684684
void launchInstalledGameRef.current()
685685
}, [game, loading, searchParams, setSearchParams])
686686

687+
// Forward-declared so the auto-open effect below can reference it; the
688+
// ref is wired to the real `hasUpdate` value further down the file.
689+
const hasUpdateRef = useRef(false)
690+
const deepLinkUpdateHandledRef = useRef(false)
691+
687692
const popularAppIds = useMemo(() => {
688693
const withStats = games.filter((g) => {
689694
const st = stats[g.appid]
@@ -864,6 +869,28 @@ export function GameDetailPage() {
864869
const isInstalled = hasInstalledVersions
865870
const isInstallReady = Boolean(installingManifest) && installingManifest?.installStatus === "downloaded" && !isInstalled
866871
const hasUpdate = isInstalled && Boolean(game?.version) && installedVersionLabels.length > 0 && !installedVersionLabels.includes(game.version ?? '')
872+
hasUpdateRef.current = hasUpdate
873+
874+
// Auto-open the update flow when GameCard hands us off via `?update=1`.
875+
// Sits after hasUpdate is declared so it can read it directly. Gated on
876+
// hasUpdate so a stale URL doesn't surprise the user with a modal for a
877+
// game that's no longer outdated; we also strip the param so refreshing
878+
// the page doesn't re-trigger it.
879+
useEffect(() => {
880+
if (searchParams.get("update") !== "1") return
881+
if (!game || loading) return
882+
// Wait until install state has resolved before deciding — otherwise we
883+
// could miss the chance because hasUpdate is still false on first render.
884+
if (installedVersionLabels.length === 0 && isInstalled) return
885+
if (deepLinkUpdateHandledRef.current) return
886+
deepLinkUpdateHandledRef.current = true
887+
const next = new URLSearchParams(searchParams)
888+
next.delete("update")
889+
setSearchParams(next, { replace: true })
890+
if (hasUpdate) {
891+
setUpdateWarningOpen(true)
892+
}
893+
}, [game, loading, isInstalled, hasUpdate, installedVersionLabels, searchParams, setSearchParams])
867894
const showActionMenu = isInstalled
868895
// Only treat as "installing" from manifest if there are corresponding download items.
869896
// If the manifest exists but no download items remain (e.g. items were lost), it's a stale
@@ -1566,6 +1593,12 @@ export function GameDetailPage() {
15661593
onClick={() => {
15671594
if (isGameRunning) {
15681595
void stopRunningGame()
1596+
} else if (hasUpdate) {
1597+
// White "Update" button now actually triggers the
1598+
// update flow (via the backup warning modal) instead
1599+
// of launching the installed game. The yellow
1600+
// duplicate CTA below has been removed.
1601+
setUpdateWarningOpen(true)
15691602
} else if (isInstalled) {
15701603
void launchInstalledGame()
15711604
} else if (isInstallReady) {
@@ -1582,6 +1615,8 @@ export function GameDetailPage() {
15821615
<Square className="mr-2 h-5 w-5" />
15831616
) : isCheckingLinks ? (
15841617
<Loader2 className="mr-2 h-5 w-5 animate-spin" />
1618+
) : hasUpdate ? (
1619+
<RefreshCw className="mr-2 h-5 w-5" />
15851620
) : isInstalled ? (
15861621
<Play className="mr-2 h-5 w-5" />
15871622
) : isInstallReady ? (
@@ -1654,16 +1689,10 @@ export function GameDetailPage() {
16541689
</Button>
16551690
)}
16561691

1657-
{hasUpdate && !isInstalling && !isGameRunning && (
1658-
<Button
1659-
variant="outline"
1660-
className="mt-2 w-full border-amber-500/40 bg-amber-500/10 text-amber-400 hover:bg-amber-500/20 hover:text-amber-300"
1661-
onClick={() => setUpdateWarningOpen(true)}
1662-
>
1663-
<RefreshCw className="mr-2 h-4 w-4" />
1664-
Update available - {game.version}
1665-
</Button>
1666-
)}
1692+
{/* The previous yellow "Update available - X.Y" CTA used to live here.
1693+
It's been removed: the white main button now drives the update flow
1694+
directly when hasUpdate is true, and the version diff has moved into
1695+
the UpdateBackupWarningModal. */}
16671696

16681697
{shortcutFeedback && (
16691698
<div className={`mt-2 text-xs ${shortcutFeedback.type === 'success' ? 'text-zinc-300' : 'text-destructive'}`}>
@@ -1998,6 +2027,10 @@ export function GameDetailPage() {
19982027
</div>{/* close relative z-10 */}
19992028
<UpdateBackupWarningModal
20002029
open={updateWarningOpen}
2030+
currentVersion={installedVersionLabels[0] ?? null}
2031+
newVersion={game?.version ?? null}
2032+
releasedAt={game?.update_time ?? null}
2033+
gameName={game?.name ?? null}
20012034
onProceed={async () => {
20022035
setUpdateWarningOpen(false)
20032036
setPendingForceDownload(true)

renderer/src/app/pages/LauncherPage.tsx

Lines changed: 86 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ import { PaginationBar } from "@/components/PaginationBar"
1515
import { formatNumber, generateErrorCode, ErrorTypes, getInstalledVersionLabel, hasInstalledVersionUpdate, proxyImageUrl } from "@/lib/utils"
1616
import { useConnectivityStatus } from "@/hooks/use-online-status"
1717
import { fetchCatalogGames, fetchCatalogStats, getCatalogCache, hydrateCatalogCache, isCatalogGamesStale, isCatalogStatsStale, mergeInstalledGames, persistCatalogCache, type CatalogGame } from "@/lib/catalog"
18-
import { ArrowRight, Layers3, PlayCircle } from "lucide-react"
18+
import { ArrowRight, Cloud, Download, Layers3 } from "lucide-react"
1919
import { usePlayHistory } from "@/hooks/use-play-history"
2020
import { useUserCollections } from "@/hooks/use-user-collections"
2121

@@ -60,6 +60,10 @@ export function LauncherPage() {
6060
const [emptyStateReady, setEmptyStateReady] = useState(false)
6161
const [currentPage, setCurrentPage] = useState(1)
6262
const [recentlyInstalledGames, setRecentlyInstalledGames] = useState<Game[]>([])
63+
// Full set of locally-installed appids so the cloud carousel can subtract
64+
// them — kept separate from `recentlyInstalledGames` (which is sliced to 10
65+
// for the carousel itself).
66+
const [installedAppidSet, setInstalledAppidSet] = useState<Set<string>>(() => new Set())
6367
const [installedVersionMap, setInstalledVersionMap] = useState<Record<string, string[]>>({})
6468
const itemsPerPage = 30
6569
const [statsCacheTime, setStatsCacheTime] = useState<number>(initialCatalog.statsUpdatedAt || 0)
@@ -151,6 +155,7 @@ export function LauncherPage() {
151155

152156
if (!ignore) {
153157
setRecentlyInstalledGames(resolved)
158+
setInstalledAppidSet(new Set(installedGames.map((g) => String(g.appid))))
154159
setInstalledVersionMap(nextInstalledVersions)
155160
}
156161
}
@@ -296,6 +301,17 @@ export function LauncherPage() {
296301
return games.slice(0, 8)
297302
}, [games])
298303

304+
// Games the user has installed or played on *another* device (from cloud
305+
// play history) that are NOT currently installed on this PC. Surface them
306+
// so the user can one-click install on this device. Prefers entries with
307+
// recent activity; falls back to install-only rows when those exist.
308+
const cloudUninstalled = useMemo(() => {
309+
if (!playHistory.items || playHistory.items.length === 0) return []
310+
return playHistory.items
311+
.filter((entry) => entry.game && !installedAppidSet.has(entry.appid))
312+
.slice(0, 12)
313+
}, [playHistory.items, installedAppidSet])
314+
299315
const popularReleases = useMemo(() => {
300316
if (Object.keys(gameStats).length === 0) return []
301317

@@ -464,48 +480,11 @@ export function LauncherPage() {
464480
</div>
465481
</section>
466482

467-
{/* Continue playing — cloud-synced when signed in, falls back to local
468-
recently-installed below for everyone else. */}
469-
{playHistory.authed && playHistory.items && playHistory.items.length > 0 && (
470-
<section className="overflow-visible">
471-
<SectionHeading
472-
eyebrow="From the cloud"
473-
title="Continue playing"
474-
icon={<PlayCircle className="h-4 w-4" />}
475-
actionLabel="Open library"
476-
onAction={() => navigate("/library")}
477-
/>
478-
<Carousel
479-
opts={{ align: "start", loop: false, skipSnaps: false, dragFree: true }}
480-
className="w-full"
481-
>
482-
<CarouselContent className="-ml-2 md:-ml-4">
483-
{playHistory.items
484-
.filter((entry) => Boolean(entry.game))
485-
.slice(0, 12)
486-
.map((entry) => (
487-
<CarouselItem
488-
key={entry.appid}
489-
className="pl-2 md:pl-4 basis-1/2 sm:basis-1/3 md:basis-1/4 lg:basis-1/5"
490-
>
491-
<GameCardCompact
492-
game={{
493-
appid: entry.appid,
494-
name: entry.game!.name,
495-
image: entry.game!.image,
496-
genres: Array.isArray(entry.game!.genres) ? entry.game!.genres : [],
497-
}}
498-
/>
499-
</CarouselItem>
500-
))}
501-
</CarouselContent>
502-
<CarouselPrevious className={cardCarouselNavClass} />
503-
<CarouselNext className={cardCarouselNavClass} />
504-
</Carousel>
505-
</section>
506-
)}
507-
508-
{recentlyInstalledGames.length > 0 && !(playHistory.authed && playHistory.items && playHistory.items.length > 0) && (
483+
{/* Recently installed — always rendered when we have any local installs,
484+
independent of the cloud carousel below. Previously this section was
485+
hidden whenever the cloud history had any rows, which made it fight
486+
for screen space with the cloud strip. */}
487+
{recentlyInstalledGames.length > 0 && (
509488
<section>
510489
<div>
511490
<SectionHeading
@@ -561,6 +540,70 @@ export function LauncherPage() {
561540
</section>
562541
)}
563542

543+
{/* From your cloud library — games this account has installed or played
544+
on *another* device that aren't on this PC yet. Filtered so it's
545+
actionable (one-click install on this device) rather than a noisy
546+
mirror of the local "Recently installed" strip. */}
547+
{playHistory.authed && cloudUninstalled.length > 0 && (
548+
<section className="overflow-visible">
549+
<SectionHeading
550+
eyebrow="From your cloud library"
551+
title="Install on this PC"
552+
icon={<Cloud className="h-4 w-4" />}
553+
actionLabel="See all"
554+
onAction={() => navigate("/library")}
555+
/>
556+
<p className="-mt-1 mb-3 text-xs text-zinc-500">
557+
On your account but not installed here. Click any game to install it on this device.
558+
</p>
559+
<Carousel
560+
opts={{ align: "start", loop: false, skipSnaps: false, dragFree: true }}
561+
className="w-full"
562+
>
563+
<CarouselContent className="-ml-2 md:-ml-4">
564+
{cloudUninstalled.map((entry) => {
565+
const playedHere = (entry.playCount ?? 0) > 0
566+
const installedElsewhere = Boolean(entry.installedAt)
567+
const subtitle = playedHere
568+
? "Played on another device"
569+
: installedElsewhere
570+
? "Installed on another device"
571+
: "On your account"
572+
return (
573+
<CarouselItem
574+
key={entry.appid}
575+
className="pl-2 md:pl-4 basis-1/2 sm:basis-1/3 md:basis-1/4 lg:basis-1/5"
576+
>
577+
<div className="relative">
578+
<GameCardCompact
579+
game={{
580+
appid: entry.appid,
581+
name: entry.game!.name,
582+
image: entry.game!.image,
583+
genres: Array.isArray(entry.game!.genres) ? entry.game!.genres : [],
584+
}}
585+
/>
586+
{/* Inline overlay so the carousel item still looks like a
587+
regular catalog card but signals "available to pull
588+
down from cloud". */}
589+
<div
590+
className="pointer-events-none absolute top-2 left-2 z-10 inline-flex items-center gap-1 rounded-full border border-sky-500/40 bg-sky-500/15 px-2 py-0.5 text-[10px] font-semibold text-sky-200 backdrop-blur-sm"
591+
title={subtitle}
592+
>
593+
<Download className="h-2.5 w-2.5" />
594+
<span>Not on this PC</span>
595+
</div>
596+
</div>
597+
</CarouselItem>
598+
)
599+
})}
600+
</CarouselContent>
601+
<CarouselPrevious className={cardCarouselNavClass} />
602+
<CarouselNext className={cardCarouselNavClass} />
603+
</Carousel>
604+
</section>
605+
)}
606+
564607
{!isOnline && games.length === 0 && !loading && (
565608
<OfflineBanner
566609
onRetry={() => {

renderer/src/components/GameCard.tsx

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { memo, useCallback, useEffect, useMemo, useRef, useState, type MouseEvent } from "react"
2-
import { Link } from "react-router-dom"
2+
import { Link, useNavigate } from "react-router-dom"
33
import { Badge } from "@/components/ui/badge"
44
import { Calendar, HardDrive, Download, Eye, Wifi, Flame, Play, Square, RefreshCw } from "lucide-react"
55
import { formatNumber, getCardImage, hasOnlineMode, isGameVersionUpdate, pickGameExecutable, proxyImageUrl, timeAgo } from "@/lib/utils"
@@ -75,6 +75,7 @@ export const GameCard = memo(function GameCard({
7575
const [sessionRevealed, setSessionRevealed] = useState(false)
7676
const displayStats = initialStats || hoveredStats || { downloads: 0, views: 0 }
7777

78+
const navigate = useNavigate()
7879
const { openPath } = useDownloads()
7980
const downloadState = useDownloadsSelector(
8081
useCallback(
@@ -403,6 +404,16 @@ export const GameCard = memo(function GameCard({
403404
event.preventDefault()
404405
event.stopPropagation()
405406

407+
// If an update is available, the white button must update the game — not
408+
// launch it. We don't have the host-selector / backup flow available in a
409+
// card, so hand off to the detail page with an auto-open update query
410+
// parameter. The detail page knows how to show the changelog, run the
411+
// backup, and queue the download.
412+
if (updateAvailable && isInstalled && !isRunning) {
413+
navigate(`/game/${encodeURIComponent(game.appid)}?update=1`)
414+
return
415+
}
416+
406417
// If game is running, stop it
407418
if (isRunning && window.ucDownloads?.quitGameExecutable) {
408419
try {

renderer/src/components/VersionConflictModal.tsx

Lines changed: 67 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,47 @@
11
import { Button } from "@/components/ui/button"
2-
import { AlertTriangle } from "lucide-react"
2+
import { AlertTriangle, ArrowRight, Calendar, Tag } from "lucide-react"
3+
import { timeAgoLong } from "@/lib/utils"
34

45
type Props = {
56
open: boolean
67
onProceed: () => void
78
onClose: () => void
9+
/** Currently-installed version label (first one if multiple). Falls back
10+
* to a generic "Installed" string when unknown. */
11+
currentVersion?: string | null
12+
/** Version about to be installed (from the catalog). */
13+
newVersion?: string | null
14+
/** Catalog update_time so we can show "released X ago". */
15+
releasedAt?: string | null
16+
/** Optional changelog / patch notes. Rendered as plain text — short. */
17+
notes?: string | null
18+
/** Game name for the headline. */
19+
gameName?: string | null
820
}
921

10-
export function UpdateBackupWarningModal({ open, onProceed, onClose }: Props) {
22+
/**
23+
* Modal shown the moment the user actually opts to update a game.
24+
*
25+
* The data shown here is what the user used to discover by hovering the now-
26+
* removed yellow "Update available - X.Y" button: it folds the version diff,
27+
* release date, and the "back up your saves" reminder into the single update
28+
* flow so there's no second yellow CTA below the main button.
29+
*/
30+
export function UpdateBackupWarningModal({
31+
open,
32+
onProceed,
33+
onClose,
34+
currentVersion,
35+
newVersion,
36+
releasedAt,
37+
notes,
38+
gameName,
39+
}: Props) {
1140
if (!open) return null
1241

42+
const releaseLabel = releasedAt ? timeAgoLong(releasedAt) : null
43+
const showVersionRow = Boolean(currentVersion || newVersion)
44+
1345
return (
1446
<div className="fixed inset-0 z-50 flex items-center justify-center px-4">
1547
<div
@@ -19,11 +51,41 @@ export function UpdateBackupWarningModal({ open, onProceed, onClose }: Props) {
1951
<div className="relative w-full max-w-md rounded-2xl border border-border/60 bg-card/95 p-6 text-foreground shadow-2xl">
2052
<div className="flex items-center gap-2 text-lg font-semibold">
2153
<AlertTriangle className="h-5 w-5 text-amber-400" />
22-
Backup your game data first
54+
Update {gameName ? `"${gameName}"` : "this game"}?
2355
</div>
2456

25-
<p className="mt-3 text-sm text-muted-foreground leading-relaxed">
26-
Please backup your game data before updating. As sometimes game saves are stored inside the game files. For help, join our Discord server.
57+
{showVersionRow && (
58+
<div className="mt-4 space-y-2 rounded-xl border border-white/[.07] bg-zinc-900/50 px-3 py-2.5 text-sm">
59+
<div className="flex items-center gap-2 text-xs uppercase tracking-wider text-zinc-500">
60+
<Tag className="h-3 w-3" />
61+
<span>Version</span>
62+
</div>
63+
<div className="flex flex-wrap items-center gap-2 text-sm">
64+
<span className="rounded-md bg-zinc-800/80 px-2 py-0.5 text-xs font-mono text-zinc-300">
65+
{currentVersion || "Installed"}
66+
</span>
67+
<ArrowRight className="h-3.5 w-3.5 text-zinc-500" />
68+
<span className="rounded-md bg-emerald-500/15 px-2 py-0.5 text-xs font-mono text-emerald-200">
69+
{newVersion || "Latest"}
70+
</span>
71+
{releaseLabel && (
72+
<span className="ml-auto inline-flex items-center gap-1 text-[11px] text-zinc-500">
73+
<Calendar className="h-3 w-3" />
74+
Released {releaseLabel}
75+
</span>
76+
)}
77+
</div>
78+
{notes && (
79+
<p className="pt-1.5 text-xs text-zinc-400 leading-relaxed border-t border-white/[.06] mt-2">
80+
{notes}
81+
</p>
82+
)}
83+
</div>
84+
)}
85+
86+
<p className="mt-4 text-sm text-muted-foreground leading-relaxed">
87+
Please backup your game data before updating &mdash; some games store saves
88+
inside the game folder. For help, join our Discord server.
2789
</p>
2890

2991
<div className="mt-5 flex flex-col gap-2">

0 commit comments

Comments
 (0)