diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 00000000..2ed28c2e --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,102 @@ +name: CI & Cache + +on: + push: + branches: + - main + paths: + - "apps/desktop/**" + - ".github/workflows/ci.yml" + - ".github/workflows/release.yml" + - "package.json" + - "bun.lock" + pull_request: + branches: + - main + paths: + - "apps/desktop/**" + - ".github/workflows/ci.yml" + - ".github/workflows/release.yml" + - "package.json" + - "bun.lock" + workflow_dispatch: + +permissions: + contents: read + +env: + FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true + +jobs: + typecheck-frontend: + name: Frontend Typecheck + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Bun + uses: oven-sh/setup-bun@v2 + with: + bun-version: latest + + - name: Install dependencies + run: bun install --frozen-lockfile + working-directory: apps/desktop + + - name: Typecheck + run: bun run tsc + working-directory: apps/desktop + + rust-check-cache: + name: Rust Check & Cache (${{ matrix.platform }}) + strategy: + fail-fast: false + matrix: + include: + - platform: windows-latest + - platform: ubuntu-22.04 + - platform: macos-latest + rustTargets: "aarch64-apple-darwin x86_64-apple-darwin" + runs-on: ${{ matrix.platform }} + + env: + CMAKE_POLICY_VERSION_MINIMUM: "3.5" + CMAKEFLAGS: "-DCMAKE_POLICY_VERSION_MINIMUM=3.5" + + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + submodules: recursive + + - name: Setup Bun + uses: oven-sh/setup-bun@v2 + with: + bun-version: latest + + - name: Install Rust stable + uses: dtolnay/rust-toolchain@stable + with: + targets: ${{ matrix.rustTargets || '' }} + + - name: Rust cache + uses: Swatinem/rust-cache@v2 + with: + workspaces: "apps/desktop/src-tauri -> apps/desktop/src-tauri/target" + cache-all-crates: "true" + prefix-key: "v1-rust-${{ matrix.platform }}" + shared-key: "desktop-cache-${{ matrix.platform }}" + + - name: Install dependencies (macOS) + if: matrix.platform == 'macos-latest' + run: brew install opus pkg-config + + - name: Install dependencies (Ubuntu) + if: matrix.platform == 'ubuntu-22.04' + run: | + sudo apt-get update + sudo apt-get install -y pkg-config libasound2-dev libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev patchelf libudev-dev build-essential curl wget file libxdo-dev libssl-dev + + - name: Cargo Check + run: cargo check --manifest-path apps/desktop/src-tauri/Cargo.toml diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 84ff82e7..4d302727 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -4,6 +4,7 @@ on: push: tags: - "v*" + workflow_dispatch: permissions: contents: write @@ -134,8 +135,10 @@ jobs: - name: Rust cache uses: Swatinem/rust-cache@v2 with: - workspaces: "apps/desktop/src-tauri" - shared-key: "desktop-release-cache" + workspaces: "apps/desktop/src-tauri -> apps/desktop/src-tauri/target" + cache-all-crates: "true" + prefix-key: "v1-rust-${{ matrix.platform }}" + shared-key: "desktop-cache-${{ matrix.platform }}" - name: Setup Python uses: actions/setup-python@v5 diff --git a/apps/desktop/bun.lock b/apps/desktop/bun.lock index 198caa7f..690bb312 100644 --- a/apps/desktop/bun.lock +++ b/apps/desktop/bun.lock @@ -8,7 +8,6 @@ "@heroui/react": "^2.8.9", "@tailwindcss/vite": "^4.2.1", "@tanstack/react-query": "^5", - "@tanstack/react-virtual": "^3.14.9", "@tauri-apps/api": "^2.11.0", "@tauri-apps/plugin-autostart": "^2.5.1", "@tauri-apps/plugin-dialog": "~2", @@ -698,9 +697,9 @@ "@tanstack/react-query": ["@tanstack/react-query@5.99.0", "", { "dependencies": { "@tanstack/query-core": "5.99.0" }, "peerDependencies": { "react": "^18 || ^19" } }, "sha512-OY2bCqPemT1LlqJ8Y2CUau4KELnIhhG9Ol3ZndPbdnB095pRbPo1cHuXTndg8iIwtoHTgwZjyaDnQ0xD0mYwAw=="], - "@tanstack/react-virtual": ["@tanstack/react-virtual@3.14.9", "", { "dependencies": { "@tanstack/virtual-core": "3.17.7" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-qZyr0FZDP8rDC4WBhsryIZmAd9bveJvFGUJJtskWaew6/0dTRS6wZxnR6VQ5bY2KwL3LjerrHqQLk3a0GKcPXQ=="], + "@tanstack/react-virtual": ["@tanstack/react-virtual@3.11.3", "", { "dependencies": { "@tanstack/virtual-core": "3.11.3" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-vCU+OTylXN3hdC8RKg68tPlBPjjxtzon7Ys46MgrSLE+JhSjSTPvoQifV6DQJeJmA8Q3KT6CphJbejupx85vFw=="], - "@tanstack/virtual-core": ["@tanstack/virtual-core@3.17.7", "", {}, "sha512-bp+v10y65sp2H7WpWfIMyxTNfl8ZVfxFTLRjPIFRryi6FV/J33z4IS53WO4pTk36KlvJ4iLiQz+oaydDC1xbcA=="], + "@tanstack/virtual-core": ["@tanstack/virtual-core@3.11.3", "", {}, "sha512-v2mrNSnMwnPJtcVqNvV0c5roGCBqeogN8jDtgtuHCphdwBasOZ17x8UV8qpHUh+u0MLfX43c0uUHKje0s+Zb0w=="], "@tauri-apps/api": ["@tauri-apps/api@2.11.0", "", {}, "sha512-7CinYODhky9lmO23xHnUFv0Xt43fbtWMyxZcLcRBlFkcgXKuEirBvHpmtJ89YMhyeGcq20Wuc47Fa4XjyniywA=="], @@ -1428,10 +1427,6 @@ "@eslint-community/eslint-utils/eslint-visitor-keys": ["eslint-visitor-keys@3.4.3", "", {}, "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag=="], - "@heroui/listbox/@tanstack/react-virtual": ["@tanstack/react-virtual@3.11.3", "", { "dependencies": { "@tanstack/virtual-core": "3.11.3" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-vCU+OTylXN3hdC8RKg68tPlBPjjxtzon7Ys46MgrSLE+JhSjSTPvoQifV6DQJeJmA8Q3KT6CphJbejupx85vFw=="], - - "@heroui/table/@tanstack/react-virtual": ["@tanstack/react-virtual@3.11.3", "", { "dependencies": { "@tanstack/virtual-core": "3.11.3" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-vCU+OTylXN3hdC8RKg68tPlBPjjxtzon7Ys46MgrSLE+JhSjSTPvoQifV6DQJeJmA8Q3KT6CphJbejupx85vFw=="], - "@heroui/use-aria-multiselect/@react-aria/label": ["@react-aria/label@3.7.25", "", { "dependencies": { "@react-aria/utils": "^3.33.1", "@react-types/shared": "^3.33.1", "@swc/helpers": "^0.5.0" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1", "react-dom": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" } }, "sha512-oNK3Pqj4LDPwEbQaoM/uCip4QvQmmwGOh08VeW+vzSi6TAwf+KoWTyH/tiAeB0CHWNDK0k3e1iTygTAt4wzBmg=="], "@heroui/use-is-mobile/@react-aria/ssr": ["@react-aria/ssr@3.9.10", "", { "dependencies": { "@swc/helpers": "^0.5.0" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" } }, "sha512-hvTm77Pf+pMBhuBm760Li0BVIO38jv1IBws1xFm1NoL26PU+fe+FMW5+VZWyANR6nYL65joaJKZqOdTQMkO9IQ=="], @@ -1528,10 +1523,6 @@ "react-stately/@react-types/shared": ["@react-types/shared@3.34.0", "", { "peerDependencies": { "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" } }, "sha512-gp6xo/s2lX54AlTjOiqwDnxA7UW79BNvI9dB9pr3LZTzRKCd1ZA+ZbgKw/ReIiWuvvVw/8QFJpnqeeFyLocMcQ=="], - "@heroui/listbox/@tanstack/react-virtual/@tanstack/virtual-core": ["@tanstack/virtual-core@3.11.3", "", {}, "sha512-v2mrNSnMwnPJtcVqNvV0c5roGCBqeogN8jDtgtuHCphdwBasOZ17x8UV8qpHUh+u0MLfX43c0uUHKje0s+Zb0w=="], - - "@heroui/table/@tanstack/react-virtual/@tanstack/virtual-core": ["@tanstack/virtual-core@3.11.3", "", {}, "sha512-v2mrNSnMwnPJtcVqNvV0c5roGCBqeogN8jDtgtuHCphdwBasOZ17x8UV8qpHUh+u0MLfX43c0uUHKje0s+Zb0w=="], - "@react-types/slider/@react-aria/slider/@react-types/shared": ["@react-types/shared@3.34.0", "", { "peerDependencies": { "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" } }, "sha512-gp6xo/s2lX54AlTjOiqwDnxA7UW79BNvI9dB9pr3LZTzRKCd1ZA+ZbgKw/ReIiWuvvVw/8QFJpnqeeFyLocMcQ=="], "@react-types/tabs/@react-aria/tabs/@react-types/shared": ["@react-types/shared@3.34.0", "", { "peerDependencies": { "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" } }, "sha512-gp6xo/s2lX54AlTjOiqwDnxA7UW79BNvI9dB9pr3LZTzRKCd1ZA+ZbgKw/ReIiWuvvVw/8QFJpnqeeFyLocMcQ=="], diff --git a/apps/desktop/package.json b/apps/desktop/package.json index 1c45f56e..1999f904 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -18,7 +18,6 @@ "@heroui/react": "^2.8.9", "@tailwindcss/vite": "^4.2.1", "@tanstack/react-query": "^5", - "@tanstack/react-virtual": "^3.14.9", "@tauri-apps/api": "^2.11.0", "@tauri-apps/plugin-autostart": "^2.5.1", "@tauri-apps/plugin-dialog": "~2", diff --git a/apps/desktop/src/features/games/GameCard.tsx b/apps/desktop/src/features/games/GameCard.tsx index 123ac97c..54905dea 100644 --- a/apps/desktop/src/features/games/GameCard.tsx +++ b/apps/desktop/src/features/games/GameCard.tsx @@ -201,7 +201,7 @@ export const GameCard = memo(function GameCard(props: GameCardProps) { {!isCatalog && syncProgress && } - +
{(isEffectivelyLoading || (displayImageUrl && !imgLoaded && !imgError)) && ( diff --git a/apps/desktop/src/features/steam-catalog/components/SteamCatalogGridItem.tsx b/apps/desktop/src/features/steam-catalog/components/SteamCatalogGridItem.tsx index 4c36b6db..0df59edf 100644 --- a/apps/desktop/src/features/steam-catalog/components/SteamCatalogGridItem.tsx +++ b/apps/desktop/src/features/steam-catalog/components/SteamCatalogGridItem.tsx @@ -1,5 +1,4 @@ import { memo, startTransition } from "react"; -import { motion } from "framer-motion"; import { useTranslation } from "react-i18next"; import { useLocation, useNavigate } from "react-router-dom"; import { addTransitionType } from "react"; @@ -10,6 +9,7 @@ import { GameCard } from "@features/games/GameCard"; import { catalogListItemToConfiguredGame } from "@features/steam-catalog/model/catalogConfiguredGame"; import { useLowPerformanceMode } from "@hooks/useLowPerformanceMode"; import { sourceCandidateKey } from "@utils/sourceMatch"; +import { useShellUiStore } from "@store/ShellUiStore"; export type CatalogGridItemProps = { item: CatalogListItem; @@ -56,7 +56,7 @@ export const CatalogGridItem = memo(function CatalogGridItem({ const best = candidates.length > 0 ? candidates[0] : undefined; return ( - +
{ + const currentY = window.scrollY || document.documentElement.scrollTop; + if (currentY > 0) { + useShellUiStore.getState().setCatalogScrollPosition(currentY); + } if (isLowPerf) { navigate(`/games/${toRouteGameId(item)}`, { state: { @@ -140,7 +145,7 @@ export function SteamCatalogTrendingHero({ pauseOnMouseEnter: true, }} speed={520} - className="sg-trending-swiper min-h-[380px] overflow-hidden rounded-2xl border border-default-200/70 shadow-sm dark:border-default-100/15"> + className="sg-trending-swiper min-h-95 overflow-hidden rounded-2xl border border-default-200/70 shadow-sm dark:border-default-100/15"> {slides.map((featured, slideIndex) => ( (null); - const [columnCount, setColumnCount] = useState(4); - - useEffect(() => { - if (!containerRef.current) return; - const minWidth = consoleMode ? 320 : 280; - const gap = 20; - - const updateColumns = () => { - if (!containerRef.current) return; - const width = containerRef.current.clientWidth; - const cols = Math.max(1, Math.floor((width + gap) / (minWidth + gap))); - setColumnCount(cols); - }; - - updateColumns(); - const observer = new ResizeObserver(updateColumns); - observer.observe(containerRef.current); - return () => observer.disconnect(); - }, [consoleMode]); - - const rowCount = Math.ceil(items.length / columnCount); - - const virtualizer = useWindowVirtualizer({ - count: rowCount, - estimateSize: () => (consoleMode ? 320 : 266), - getItemKey: (index) => index, - overscan: 6, - scrollMargin: containerRef.current?.offsetTop ?? 0, - }); - - useLayoutEffect(() => { - virtualizer.measure(); - }, [columnCount, consoleMode]); - return ( -
-
- {virtualizer.getVirtualItems().map((virtualRow) => { - const rowStartIndex = virtualRow.index * columnCount; - const rowItems = items.slice(rowStartIndex, rowStartIndex + columnCount); - - return ( -
-
- {rowItems.map((item, colIdx) => { - const idx = rowStartIndex + colIdx; - const libraryGame = - (item.steamAppId ? libraryGamesMap.get(String(item.steamAppId)) : undefined) ?? - libraryGamesMap.get(item.name.toLowerCase()); +
+ {items.map((item, idx) => { + const libraryGame = + (item.steamAppId ? libraryGamesMap.get(String(item.steamAppId)) : undefined) ?? + libraryGamesMap.get(item.name.toLowerCase()); - const uniqueKey = item.steamAppId ? `steam-${item.steamAppId}` : `${item.name}-${idx}`; + const uniqueKey = item.steamAppId ? `steam-${item.steamAppId}` : `${item.name}-${idx}`; - return ( - - ); - })} -
-
- ); - })} -
+ return ( +
+ +
+ ); + })}
); }); diff --git a/apps/desktop/src/features/steam-catalog/hooks/useCatalogScrollRestoration.ts b/apps/desktop/src/features/steam-catalog/hooks/useCatalogScrollRestoration.ts deleted file mode 100644 index df54dbde..00000000 --- a/apps/desktop/src/features/steam-catalog/hooks/useCatalogScrollRestoration.ts +++ /dev/null @@ -1,42 +0,0 @@ -import { useEffect, useLayoutEffect, useRef } from "react"; - -const SCROLL_STORAGE_KEY = "savecloud_catalog_scroll_y"; - -export function useCatalogScrollRestoration(itemsLength: number, isReady: boolean) { - const restoredRef = useRef(false); - - useEffect(() => { - const handleScroll = () => { - if (window.scrollY > 0) { - sessionStorage.setItem(SCROLL_STORAGE_KEY, String(window.scrollY)); - } - }; - - window.addEventListener("scroll", handleScroll, { passive: true }); - return () => window.removeEventListener("scroll", handleScroll); - }, []); - - useLayoutEffect(() => { - if (restoredRef.current || !isReady || itemsLength === 0) return; - - const savedY = sessionStorage.getItem(SCROLL_STORAGE_KEY); - if (savedY) { - const scrollY = parseInt(savedY, 10); - if (!isNaN(scrollY) && scrollY > 0) { - window.scrollTo({ top: scrollY, behavior: "instant" }); - - requestAnimationFrame(() => { - window.scrollTo({ top: scrollY, behavior: "instant" }); - }); - } - } - restoredRef.current = true; - }, [itemsLength, isReady]); - - const clearSavedScroll = () => { - sessionStorage.removeItem(SCROLL_STORAGE_KEY); - restoredRef.current = true; - }; - - return { clearSavedScroll }; -} diff --git a/apps/desktop/src/features/steam-catalog/pages/SteamCatalogPage.tsx b/apps/desktop/src/features/steam-catalog/pages/SteamCatalogPage.tsx index e330b8e7..2b37faef 100644 --- a/apps/desktop/src/features/steam-catalog/pages/SteamCatalogPage.tsx +++ b/apps/desktop/src/features/steam-catalog/pages/SteamCatalogPage.tsx @@ -28,7 +28,10 @@ export function SteamCatalogPage() { const catalogScrollPosition = useShellUiStore((state) => state.catalogScrollPosition); const setCatalogScrollPosition = useShellUiStore((state) => state.setCatalogScrollPosition); + const initialScrollTargetRef = useRef(catalogScrollPosition); + const currentScrollYRef = useRef(catalogScrollPosition); const hasRestored = useRef(false); + const isRestoringRef = useRef(false); const [isFilterDrawerOpen, setIsFilterDrawerOpen] = useState(false); const bigPictureConsole = useMemo( @@ -162,25 +165,25 @@ export function SteamCatalogPage() { error: heroError, } = useSteamCatalogTrendingHero(showTrendingHero); - const isRestoringRef = useRef(false); const prevFiltersRef = useRef({ debouncedSearch, filterSignature, sortOption }); useLayoutEffect(() => { - if (catalogScrollPosition > 0 && isReady && !hasRestored.current) { - void document.body.offsetHeight; + if (hasRestored.current || !isReady) return; + const targetY = initialScrollTargetRef.current; + if (targetY > 0) { isRestoringRef.current = true; - window.scrollTo({ top: catalogScrollPosition, behavior: "instant" }); + window.scrollTo({ top: targetY, behavior: "instant" }); + hasRestored.current = true; const timer = setTimeout(() => { - void document.body.offsetHeight; - window.scrollTo({ top: catalogScrollPosition, behavior: "instant" }); - hasRestored.current = true; isRestoringRef.current = false; - }, 100); + }, 200); return () => clearTimeout(timer); + } else { + hasRestored.current = true; } - }, [isReady, catalogScrollPosition, activeItems.length]); + }, [isReady]); useEffect(() => { const prev = prevFiltersRef.current; @@ -191,8 +194,11 @@ export function SteamCatalogPage() { prevFiltersRef.current = { debouncedSearch, filterSignature, sortOption }; - if (hasChanged && hasRestored.current) { + if (hasChanged) { setCatalogScrollPosition(0); + initialScrollTargetRef.current = 0; + currentScrollYRef.current = 0; + window.scrollTo({ top: 0, behavior: "instant" }); } }, [debouncedSearch, filterSignature, sortOption, setCatalogScrollPosition]); @@ -200,23 +206,25 @@ export function SteamCatalogPage() { let scrollTimeout: ReturnType; const handleScroll = () => { - if (isRestoringRef.current) return; + if (!hasRestored.current || isRestoringRef.current) return; + const currentY = window.scrollY || document.documentElement.scrollTop; + if (currentY > 0) { + currentScrollYRef.current = currentY; + } clearTimeout(scrollTimeout); scrollTimeout = setTimeout(() => { - const currentY = window.scrollY || document.documentElement.scrollTop; - if (currentY > 0) { - setCatalogScrollPosition(currentY); + if (currentScrollYRef.current > 0) { + setCatalogScrollPosition(currentScrollYRef.current); } - }, 200); + }, 150); }; window.addEventListener("scroll", handleScroll, { passive: true }); return () => { window.removeEventListener("scroll", handleScroll); clearTimeout(scrollTimeout); - const currentY = window.scrollY || document.documentElement.scrollTop; - if (currentY > 0 && !isRestoringRef.current) { - setCatalogScrollPosition(currentY); + if (hasRestored.current && !isRestoringRef.current && currentScrollYRef.current > 0) { + setCatalogScrollPosition(currentScrollYRef.current); } }; }, [setCatalogScrollPosition]); @@ -364,9 +372,6 @@ export function SteamCatalogPage() {
) : ( <> - {activeIsMatchingPending ? ( -

{t("steamCatalog.validatingSources")}

- ) : null}
{isListRefetching ? (