feat(core): Implement Steam library sync with metadata extraction and image processing (#278)

## Description
<!-- Briefly describe the purpose and scope of your changes -->


<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

- **New Features**
- Added AWS queue infrastructure and SQS handler for processing Steam
game libraries and images.
- Introduced event-driven handling for new credentials and game
additions, including image uploads to S3.
- Added client functions to fetch Steam user libraries, friends lists,
app info, and related images.
- Added new database columns and schema updates to track game
acquisition, playtime, and family sharing.
  - Added utility function for chunking arrays.
- Added new event notifications for library queue processing and game
creation.
  - Added new lookup functions for categories and teams by slug.
- Introduced a new Team API with endpoints to list and fetch teams by
slug.
  - Added a new Steam library page displaying game images.

- **Enhancements**
  - Improved game creation with event notifications and upsert logic.
  - Enhanced category and team retrieval with new lookup functions.
  - Renamed and refined image categories for clearer classification.
  - Expanded dependencies for image processing and AWS SDK integration.
- Improved image processing utilities with caching, ranking, and
metadata extraction.
  - Refined Steam client utilities for concurrency and error handling.

- **Bug Fixes**
- Fixed event publishing timing and removed deprecated credential
retrieval methods.

- **Chores**
- Updated infrastructure configurations with increased timeouts, memory,
and resource linking.
- Added new dependencies for image processing, caching, and AWS SDK
clients.
  - Refined internal code structure and imports for clarity.
  - Removed Steam provider and related UI components from the frontend.
- Disabled authentication providers and Steam-related routes in the
frontend.
  - Updated API fetch handler to accept environment bindings.

- **Refactor**
- Simplified query result handling and renamed functions for better
clarity.
- Removed outdated event handler in favor of consolidated event
subscriber.
- Consolidated and simplified database relationships and permission
queries.

- **Tests**
  - No explicit test changes included in this release.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
Wanjohi
2025-05-17 00:51:18 +03:00
committed by GitHub
parent cc2065299d
commit e1a903a7c9
82 changed files with 7819 additions and 1002 deletions

View File

@@ -92,23 +92,24 @@ export const App: Component = () => {
const storage = useStorage();
return (
<OpenAuthProvider
issuer={import.meta.env.VITE_AUTH_URL}
clientID="web"
>
// <OpenAuthProvider
// issuer={import.meta.env.VITE_AUTH_URL}
// clientID="web"
// >
<Root class={theme() === "light" ? lightClass : darkClass} id="styled">
<Router>
<Route
path="*"
component={(props) => (
<AccountProvider
loadingUI={
<FullScreen>
<Text weight='semibold' spacing='xs' size="3xl" font="heading" >Confirming your identity&hellip;</Text>
</FullScreen>
}>
{props.children}
</AccountProvider>
// <AccountProvider
// loadingUI={
// <FullScreen>
// <Text weight='semibold' spacing='xs' size="3xl" font="heading" >Confirming your identity&hellip;</Text>
// </FullScreen>
// }>
// {props.children}
props.children
// </AccountProvider>
)}
>
<Route path=":teamSlug">{TeamRoute}</Route>
@@ -141,6 +142,6 @@ export const App: Component = () => {
</Route>
</Router>
</Root>
</OpenAuthProvider>
// </OpenAuthProvider>
)
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 625 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 800 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 495 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 779 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 508 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 825 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 706 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 590 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.4 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 MiB

View File

@@ -1,5 +1,5 @@
import { animate, scroll } from "motion"
import { A } from "@solidjs/router";
import { A, useLocation } from "@solidjs/router";
import { Container } from "@nestri/www/ui";
import Avatar from "@nestri/www/ui/avatar";
import { styled } from "@macaron-css/solid";
@@ -200,6 +200,15 @@ const Nav = styled("nav", {
}
})
const capitalize = (name: string) => {
return name
.charAt(0) // first character
.toUpperCase() // make it uppercase
+ name
.slice(1) // rest of the string
.toLowerCase();
}
/**
* Displays the application's fixed top navigation bar with branding, team information, and navigation links.
*
@@ -230,7 +239,8 @@ export function Header(props: ParentProps) {
})
// const account = useAccount()
const location = useLocation()
return (
<PageWrapper>
<NavWrapper scrolled={hasScrolled()}>
@@ -313,6 +323,21 @@ export function Header(props: ParentProps) {
d="M72.61 83.06a8 8 0 0 1 1.73-8.72l48-48a8 8 0 0 1 11.32 0l48 48A8 8 0 0 1 176 88H80a8 8 0 0 1-7.39-4.94M176 168H80a8 8 0 0 0-5.66 13.66l48 48a8 8 0 0 0 11.32 0l48-48A8 8 0 0 0 176 168" />
</DropIcon>
</TeamRoot>
{/**Fixme, this does not work for us */}
<Show when={location.pathname.split("/").pop() !== "home"} >
<LineSvg
height="16"
stroke-linejoin="round"
viewBox="0 0 16 16"
width="16">
<path
fill-rule="evenodd"
clip-rule="evenodd"
d="M4.01526 15.3939L4.3107 14.7046L10.3107 0.704556L10.6061 0.0151978L11.9849 0.606077L11.6894 1.29544L5.68942 15.2954L5.39398 15.9848L4.01526 15.3939Z" fill="currentColor"></path>
</LineSvg>
<div>{capitalize(location.pathname.split("/").pop()!)}</div>
</Show>
</LogoRoot>
{/* </Show> */}
</Container>
@@ -332,17 +357,17 @@ export function Header(props: ParentProps) {
</NavLink>
</NavRoot>
</Show>
<div style={{ "margin-bottom": "2px" }} >
{/* <div style={{ "margin-bottom": "2px" }} >
<AvatarImg src={"https://avatars.githubusercontent.com/u/71614375?v=4"} alt={`Wanjohi's avatar`} />
{/* <Switch>
<Switch>
<Match when={account.current.avatarUrl} >
<AvatarImg src={account.current.avatarUrl} alt={`${account.current.name}'s avatar`} />
<AvatarImg src={account.current.avatarUrl} alt={`${account.current.name}'s avatar`} />
</Match>
<Match when={!account.current.avatarUrl}>
<Avatar size={32} name={`${account.current.name}#${account.current.discriminator}`} />
</Match>
</Switch> */}
</div>
</Switch>
</div> */}
</RightRoot>
</Nav>
</NavWrapper>

View File

@@ -1,7 +1,6 @@
import { FullScreen, theme } from "@nestri/www/ui";
import { styled } from "@macaron-css/solid";
import { Header } from "@nestri/www/pages/team/header";
import { useSteam } from "@nestri/www/providers/steam";
import { Modal } from "@nestri/www/ui/modal";
import { createEffect, createSignal, Match, onCleanup, Switch } from "solid-js";
import { Text } from "@nestri/www/ui/text"
@@ -411,6 +410,7 @@ export function HomeRoute() {
<Portal />
</PortalContainer>
</LastPlayedWrapper>
*/}
<GamesContainer>
<GamesWrapper>
<GameSquareImage draggable={false} alt="Assasin's Creed Shadows" src="https://assets-prd.ignimgs.com/2024/05/15/acshadows-1715789601294.jpg" />
@@ -508,7 +508,7 @@ export function HomeRoute() {
</SteamGameContainer>
</div>
</SteamLibrary>
</GamesContainer>*/}
</GamesContainer>
</FullScreen>
</Header>
</>

View File

@@ -1,69 +1,66 @@
import { HomeRoute } from "./home";
import { LibraryRoute } from "./library";
import { useOpenAuth } from "@openauthjs/solid";
import { Route, useParams } from "@solidjs/router";
import { ApiProvider } from "@nestri/www/providers/api";
import { SteamRoute } from "@nestri/www/pages/team/steam";
import { ZeroProvider } from "@nestri/www/providers/zero";
import { TeamContext } from "@nestri/www/providers/context";
import { SteamProvider } from "@nestri/www/providers/steam";
import { createEffect, createMemo, Match, Switch } from "solid-js";
import { NotAllowed, NotFound } from "@nestri/www/pages/not-found";
import { useAccount, useStorage } from "@nestri/www/providers/account";
export const TeamRoute = (
<Route
component={(props) => {
const params = useParams();
const account = useAccount();
const storage = useStorage();
const openauth = useOpenAuth();
// component={(props) => {
// const params = useParams();
// const account = useAccount();
// const storage = useStorage();
// const openauth = useOpenAuth();
const team = createMemo(() =>
account.current.teams.find(
(item) => item.slug === params.teamSlug,
),
);
// const team = createMemo(() =>
// account.current.teams.find(
// (item) => item.slug === params.teamSlug,
// ),
// );
createEffect(() => {
const t = team();
if (!t) return;
storage.set("team", t.id);
});
// createEffect(() => {
// const t = team();
// if (!t) return;
// storage.set("team", t.id);
// });
createEffect(() => {
const teamSlug = params.teamSlug;
for (const item of Object.values(account.all)) {
for (const team of item.teams) {
if (team.slug === teamSlug && item.id !== openauth.subject!.id) {
openauth.switch(item.email);
}
}
}
})
// createEffect(() => {
// const teamSlug = params.teamSlug;
// for (const item of Object.values(account.all)) {
// for (const team of item.teams) {
// if (team.slug === teamSlug && item.id !== openauth.subject!.id) {
// openauth.switch(item.email);
// }
// }
// }
// })
return (
<Switch>
<Match when={!team()}>
{/* TODO: Add a public page for (other) teams */}
<NotAllowed header />
</Match>
<Match when={team()}>
<TeamContext.Provider value={() => team()!}>
<ZeroProvider>
<ApiProvider>
<SteamProvider>
{props.children}
</SteamProvider>
</ApiProvider>
</ZeroProvider>
</TeamContext.Provider>
</Match>
</Switch>
)
}}
>
// return (
// <Switch>
// <Match when={!team()}>
// {/* TODO: Add a public page for (other) teams */}
// <NotAllowed header />
// </Match>
// <Match when={team()}>
// <TeamContext.Provider value={() => team()!}>
// <ZeroProvider>
// <ApiProvider>
// {props.children}
// </ApiProvider>
// </ZeroProvider>
// </TeamContext.Provider>
// </Match>
// </Switch>
// )
// }}
>
<Route path="" component={HomeRoute} />
<Route path="steam" component={SteamRoute} />
<Route path="library" component={LibraryRoute} />
<Route path="*" component={() => <NotFound header />} />
</Route>
)

View File

@@ -0,0 +1,179 @@
import { For } from "solid-js";
import { styled } from "@macaron-css/solid";
import { FullScreen, theme } from "@nestri/www/ui";
import { Header } from "@nestri/www/pages/team/header";
const Container = styled("div", {
base: {
width: "100%",
display: "flex",
alignItems: "center",
flexDirection: "column",
zIndex: 10,
isolation: "isolate",
marginTop: 30,
}
})
const Wrapper = styled("div", {
base: {
maxWidth: "70vw",
width: "100%",
gridTemplateColumns: "repeat(4, minmax(0, 1fr))",
margin: "0 auto",
display: "grid",
columnGap: 12,
rowGap: 10
}
})
const SquareImage = styled("img", {
base: {
width: "100%",
height: "100%",
userSelect: "none",
aspectRatio: "1/1",
borderRadius: 10,
transitionDuration: "0.4s",
transitionTimingFunction: "cubic-bezier(0.4,0,0.2,1)",
transitionProperty: "opacity",
cursor: "pointer",
border: `3px solid transparent`,
":hover": {
// transform: "scale(1.01)",
outline: `3px solid ${theme.color.brand}`
}
}
})
const TitleHeader = styled("header", {
base: {
borderBottom: `1px solid ${theme.color.gray.d400}`,
color: theme.color.d1000.gray
}
})
const TitleWrapper = styled("div", {
base: {
width: "calc(1000px + calc(2 * 24px))",
paddingLeft: "24px",
display: "flex",
justifyContent: "flex-start",
alignItems: "center",
paddingRight: "24px",
marginLeft: "auto",
marginRight: "auto",
maxWidth: "100%"
}
})
const TitleContainer = styled("div", {
base: {
margin: "40px 0",
display: "flex",
flexDirection: "column",
gap: 16,
width: "100%",
minWidth: 0
}
})
const Title = styled("h1", {
base: {
lineHeight: "2.5rem",
fontWeight: theme.font.weight.semibold,
letterSpacing: "-0.069375rem",
textAlign: "left",
fontSize: theme.font.size["4xl"],
textTransform: "capitalize"
}
})
const Description = styled("p", {
base: {
fontSize: theme.font.size.sm,
lineHeight: "1.25rem",
textAlign: "left",
fontWeight: theme.font.weight.regular,
letterSpacing: "initial",
color: theme.color.gray.d900
}
})
const LogoFooter = styled("section", {
base: {
position: "relative",
bottom: -1,
fontSize: "100%",
maxWidth: 1440,
width: "100%",
pointerEvents: "none",
display: "flex",
margin: "-80px 0",
alignItems: "center",
justifyContent: "center",
padding: "0 8px",
overflow: "hidden",
},
})
const Logo = styled("svg", {
base: {
width: "100%",
height: "100%",
transform: "translateY(40%)",
opacity: "70%",
}
})
//MaRt@6563
export function LibraryRoute() {
return (
<Header>
<FullScreen inset="header" >
<TitleHeader>
<TitleWrapper>
<TitleContainer>
<Title>
Your Steam Library
</Title>
<Description>
Install games directly from your Steam account to your Nestri Machine
</Description>
</TitleContainer>
</TitleWrapper>
</TitleHeader>
<Container>
<Wrapper>
<For each={new Array(30)} >
{(item, index) => (
<SquareImage
draggable={false}
alt="Assasin's Creed Shadows"
src={`/src/assets/games/${index() + 1}.png`} />
)}
</For>
</Wrapper>
</Container>
<LogoFooter >
<Logo viewBox="0 0 498.05 70.508" xmlns="http://www.w3.org/2000/svg" height={157} width={695} >
<g stroke-linecap="round" fill-rule="evenodd" font-size="9pt" stroke="currentColor" stroke-width="0.25mm" fill="currentColor" style="stroke:currentColor;stroke-width:0.25mm;fill:currentColor">
<path
fill="url(#paint1)"
pathLength="1"
stroke="url(#paint1)"
d="M 261.23 41.65 L 212.402 41.65 Q 195.313 41.65 195.313 27.002 L 195.313 14.795 A 17.814 17.814 0 0 1 196.311 8.57 Q 199.443 0.146 212.402 0.146 L 283.203 0.146 L 283.203 14.844 L 217.236 14.844 Q 215.337 14.844 214.945 16.383 A 3.67 3.67 0 0 0 214.844 17.285 L 214.844 24.561 Q 214.844 27.002 217.236 27.002 L 266.113 27.002 Q 283.203 27.002 283.203 41.65 L 283.203 53.857 A 17.814 17.814 0 0 1 282.205 60.083 Q 279.073 68.506 266.113 68.506 L 195.313 68.506 L 195.313 53.809 L 261.23 53.809 A 3.515 3.515 0 0 0 262.197 53.688 Q 263.672 53.265 263.672 51.367 L 263.672 44.092 A 3.515 3.515 0 0 0 263.551 43.126 Q 263.128 41.65 261.23 41.65 Z M 185.547 53.906 L 185.547 68.506 L 114.746 68.506 Q 97.656 68.506 97.656 53.857 L 97.656 14.795 A 17.814 17.814 0 0 1 98.655 8.57 Q 101.787 0.146 114.746 0.146 L 168.457 0.146 Q 185.547 0.146 185.547 14.795 L 185.547 31.885 A 17.827 17.827 0 0 1 184.544 38.124 Q 181.621 45.972 170.174 46.538 A 36.906 36.906 0 0 1 168.457 46.582 L 117.188 46.582 L 117.236 51.465 Q 117.236 53.906 119.629 53.955 L 185.547 53.906 Z M 19.531 14.795 L 19.531 68.506 L 0 68.506 L 0 0.146 L 70.801 0.146 Q 87.891 0.146 87.891 14.795 L 87.891 68.506 L 68.359 68.506 L 68.359 17.236 Q 68.359 14.795 65.967 14.795 L 19.531 14.795 Z M 449.219 68.506 L 430.176 46.533 L 400.391 46.533 L 400.391 68.506 L 380.859 68.506 L 380.859 0.146 L 451.66 0.146 A 24.602 24.602 0 0 1 458.423 0.994 Q 466.007 3.166 468.021 10.907 A 25.178 25.178 0 0 1 468.75 17.236 L 468.75 31.885 A 18.217 18.217 0 0 1 467.887 37.73 Q 465.954 43.444 459.698 45.455 A 23.245 23.245 0 0 1 454.492 46.436 L 473.633 68.506 L 449.219 68.506 Z M 292.969 0 L 371.094 0.098 L 371.094 14.795 L 341.846 14.795 L 341.846 68.506 L 322.266 68.506 L 322.217 14.795 L 292.969 14.844 L 292.969 0 Z M 478.516 0.146 L 498.047 0.146 L 498.047 68.506 L 478.516 68.506 L 478.516 0.146 Z M 400.391 14.844 L 400.391 31.885 L 446.826 31.885 Q 448.726 31.885 449.117 30.345 A 3.67 3.67 0 0 0 449.219 29.443 L 449.219 17.285 Q 449.219 14.844 446.826 14.844 L 400.391 14.844 Z M 117.188 31.836 L 163.574 31.934 Q 165.528 31.895 165.918 30.355 A 3.514 3.514 0 0 0 166.016 29.492 L 166.016 17.236 Q 166.016 15.337 164.476 14.945 A 3.67 3.67 0 0 0 163.574 14.844 L 119.629 14.795 Q 117.188 14.795 117.188 17.188 L 117.188 31.836 Z" />
</g>
<defs>
<linearGradient gradientUnits="userSpaceOnUse" id="paint1" x1="317.5" x2="314.007" y1="-51.5" y2="126">
<stop stop-color="white"></stop>
<stop offset="1" stop-opacity="0"></stop>
</linearGradient>
</defs>
</Logo>
</LogoFooter>
</FullScreen>
</Header>
)
}

View File

@@ -1,238 +0,0 @@
import { Header } from "./header"
import { theme } from "@nestri/www/ui";
import { Text } from "@nestri/www/ui";
import { styled } from "@macaron-css/solid";
import { useSteam } from "@nestri/www/providers/steam";
import { createEffect, onCleanup } from "solid-js";
// FIXME: Remove this route, or move it to machines
// The idea has changed, let the user login to Steam from the / route
// Let the machines route remain different from the main page
// Why? It becomes much simpler for routing and onboarding, plus how often will you move to the machines route?
// Now it will be the home page's problem with making sure the user can download and install games on whatever machine they need/want
const Root = styled("div", {
base: {
display: "grid",
gridAutoRows: "1fr",
position: "relative",
gridTemplateRows: "0 auto",
backgroundColor: theme.color.background.d200,
minHeight: `calc(100vh - ${theme.headerHeight.root})`,
gridTemplateColumns: "minmax(24px,1fr) minmax(0,1000px) minmax(24px,1fr)"
},
});
const Section = styled("section", {
base: {
gridColumn: "1/-1",
}
})
const TitleHeader = styled("header", {
base: {
borderBottom: `1px solid ${theme.color.gray.d400}`,
color: theme.color.d1000.gray
}
})
const TitleWrapper = styled("div", {
base: {
width: "calc(1000px + calc(2 * 24px))",
paddingLeft: "24px",
display: "flex",
paddingRight: "24px",
marginLeft: "auto",
marginRight: "auto",
maxWidth: "100%"
}
})
const TitleContainer = styled("div", {
base: {
margin: "40px 0",
display: "flex",
flexDirection: "column",
gap: 16,
width: "100%",
minWidth: 0
}
})
const ButtonContainer = styled("div", {
base: {
display: "flex",
flexDirection: "row",
gap: 16,
margin: "40px 0",
}
})
const Title = styled("h1", {
base: {
lineHeight: "2.5rem",
fontWeight: theme.font.weight.semibold,
letterSpacing: "-0.069375rem",
fontSize: theme.font.size["4xl"],
textTransform: "capitalize"
}
})
const Description = styled("p", {
base: {
fontSize: theme.font.size.sm,
lineHeight: "1.25rem",
fontWeight: theme.font.weight.regular,
letterSpacing: "initial",
color: theme.color.gray.d900
}
})
const QRButton = styled("button", {
base: {
height: 40,
borderRadius: theme.borderRadius,
backgroundColor: theme.color.d1000.gray,
color: theme.color.gray.d100,
fontSize: theme.font.size.sm,
textWrap: "nowrap",
border: "1px solid transparent",
padding: `${theme.space[2]} ${theme.space[4]}`,
letterSpacing: 0.1,
lineHeight: "1.25rem",
fontFamily: theme.font.family.body,
fontWeight: theme.font.weight.medium,
cursor: "pointer",
transitionDelay: "0s, 0s",
transitionDuration: "0.2s, 0.2s",
transitionProperty: "background-color, border",
transitionTimingFunction: "ease-out, ease-out",
display: "inline-flex",
gap: theme.space[2],
alignItems: "center",
justifyContent: "center",
":disabled": {
pointerEvents: "none",
},
":hover": {
background: theme.color.hoverColor
}
}
})
const ButtonText = styled("span", {
base: {
textOverflow: "ellipsis",
whiteSpace: "nowrap",
overflow: "hidden",
}
})
const Body = styled("div", {
base: {
padding: "0 24px",
width: "calc(1000px + calc(2 * 24px))",
minWidth: "calc(100vh - 273px)",
margin: "24px auto"
}
})
const GamesContainer = styled("div", {
base: {
background: theme.color.background.d200,
padding: "32px 16px",
borderRadius: 5,
border: `1px solid ${theme.color.gray.d400}`,
display: "flex",
flexDirection: "column",
alignItems: "center",
height: "calc(100vh - 300px)",
}
})
const EmptyState = styled("div", {
base: {
height: "100%",
width: "100%",
display: "flex",
justifyContent: "center",
alignItems: "center",
gap: theme.space[8],
flexDirection: "column"
}
})
const SteamLogoContainer = styled("div", {
base: {
height: 60,
width: 60,
padding: 4,
borderRadius: 8,
display: "flex",
justifyContent: "center",
alignItems: "center",
backgroundColor: theme.color.background.d200,
border: `1px solid ${theme.color.gray.d400}`,
}
})
export function SteamRoute() {
const steam = useSteam();
createEffect(() => {
// steam.client.loginStream.connect();
// Clean up on component unmount
// onCleanup(() => {
// steam.client.loginStream.disconnect();
// });
});
return (
<>
<Header />
<Root>
<Section>
<TitleHeader>
<TitleWrapper>
<TitleContainer>
<Title>
Steam Library
</Title>
<Description>
{/* Read and write directly to databases and stores from your projects. */}
Install games directly from your Steam account to your Nestri Machine
</Description>
</TitleContainer>
<ButtonContainer>
<QRButton>
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 32 32">
<path fill="currentColor" d="M15.974 0C7.573 0 .682 6.479.031 14.714l8.573 3.547a4.5 4.5 0 0 1 2.552-.786c.083 0 .167.005.25.005l3.813-5.521v-.078c0-3.328 2.703-6.031 6.031-6.031s6.036 2.708 6.036 6.036a6.04 6.04 0 0 1-6.036 6.031h-.135l-5.438 3.88c0 .073.005.141.005.214c0 2.5-2.021 4.526-4.521 4.526c-2.177 0-4.021-1.563-4.443-3.635L.583 20.36c1.901 6.719 8.063 11.641 15.391 11.641c8.833 0 15.995-7.161 15.995-16s-7.161-16-15.995-16zm-5.922 24.281l-1.964-.813a3.4 3.4 0 0 0 1.755 1.667a3.404 3.404 0 0 0 4.443-1.833a3.38 3.38 0 0 0 .005-2.599a3.36 3.36 0 0 0-1.839-1.844a3.38 3.38 0 0 0-2.5-.042l2.026.839c1.276.536 1.88 2 1.349 3.276s-2 1.88-3.276 1.349zm15.219-12.406a4.025 4.025 0 0 0-4.016-4.021a4.02 4.02 0 1 0 0 8.042a4.02 4.02 0 0 0 4.016-4.021m-7.026-.005c0-1.672 1.349-3.021 3.016-3.021s3.026 1.349 3.026 3.021c0 1.667-1.359 3.021-3.026 3.021s-3.016-1.354-3.016-3.021" />
</svg>
<ButtonText>
Connect Steam
</ButtonText>
</QRButton>
</ButtonContainer>
</TitleWrapper>
</TitleHeader>
<Body>
<GamesContainer>
<EmptyState>
<SteamLogoContainer>
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 32 32">
<path fill="currentColor" d="M15.974 0C7.573 0 .682 6.479.031 14.714l8.573 3.547a4.5 4.5 0 0 1 2.552-.786c.083 0 .167.005.25.005l3.813-5.521v-.078c0-3.328 2.703-6.031 6.031-6.031s6.036 2.708 6.036 6.036a6.04 6.04 0 0 1-6.036 6.031h-.135l-5.438 3.88c0 .073.005.141.005.214c0 2.5-2.021 4.526-4.521 4.526c-2.177 0-4.021-1.563-4.443-3.635L.583 20.36c1.901 6.719 8.063 11.641 15.391 11.641c8.833 0 15.995-7.161 15.995-16s-7.161-16-15.995-16zm-5.922 24.281l-1.964-.813a3.4 3.4 0 0 0 1.755 1.667a3.404 3.404 0 0 0 4.443-1.833a3.38 3.38 0 0 0 .005-2.599a3.36 3.36 0 0 0-1.839-1.844a3.38 3.38 0 0 0-2.5-.042l2.026.839c1.276.536 1.88 2 1.349 3.276s-2 1.88-3.276 1.349zm15.219-12.406a4.025 4.025 0 0 0-4.016-4.021a4.02 4.02 0 1 0 0 8.042a4.02 4.02 0 0 0 4.016-4.021m-7.026-.005c0-1.672 1.349-3.021 3.016-3.021s3.026 1.349 3.026 3.021c0 1.667-1.359 3.021-3.026 3.021s-3.016-1.354-3.016-3.021" />
</svg>
</SteamLogoContainer>
<Text align="center" style={{ "letter-spacing": "-0.3px" }} size="base" >
{/* After connecting your Steam account, your games will appear here */}
{/* URL: {steam.client.loginStream.loginUrl()} */}
</Text>
</EmptyState>
</GamesContainer>
</Body>
</Section>
</Root>
</>
)
}

View File

@@ -1,270 +0,0 @@
import { useTeam } from "./context";
import { EventSource } from 'eventsource'
import { useOpenAuth } from "@openauthjs/solid";
import { createSignal, onCleanup } from "solid-js";
import { createInitializedContext } from "../common/context";
// Global connection state to prevent multiple instances
let globalEventSource: EventSource | null = null;
let globalReconnectAttempts = 0;
const MAX_RECONNECT_ATTEMPTS = 1;
let isConnecting = false;
let activeConnection: SteamConnection | null = null;
// FIXME: The redo button is not working as expected... it does not reinitialise the connection
// Type definitions for the events
interface SteamEventTypes {
'connected': { sessionID: string };
'challenge': { sessionID: string; url: string };
'error': { message: string };
'completed': { sessionID: string };
}
// Type for the connection
type SteamConnection = {
addEventListener: <T extends keyof SteamEventTypes>(
event: T,
callback: (data: SteamEventTypes[T]) => void
) => () => void;
removeEventListener: <T extends keyof SteamEventTypes>(
event: T,
callback: (data: SteamEventTypes[T]) => void
) => void;
disconnect: () => void;
isConnected: () => boolean;
}
interface SteamContext {
ready: boolean;
client: {
// SSE connection for login
login: {
connect: () => Promise<SteamConnection>;
};
};
}
// Create the initialized context
export const { use: useSteam, provider: SteamProvider } = createInitializedContext(
"SteamContext",
() => {
const team = useTeam();
const auth = useOpenAuth();
// Create the HTTP client for regular endpoints
const client = {
// SSE connection factory for login
login: {
connect: async (): Promise<SteamConnection> => {
// Return existing connection if active
if (activeConnection && globalEventSource && globalEventSource.readyState !== 2) {
return activeConnection;
}
// Prevent multiple simultaneous connection attempts
if (isConnecting) {
console.log("Connection attempt already in progress, waiting...");
// Wait for existing connection attempt to finish
return new Promise((resolve) => {
const checkInterval = setInterval(() => {
if (!isConnecting && activeConnection) {
clearInterval(checkInterval);
resolve(activeConnection);
}
}, 100);
});
}
isConnecting = true;
const [isConnected, setIsConnected] = createSignal(false);
// Store event listeners
const listeners: Record<string, Array<(data: any) => void>> = {
'connected': [],
'challenge': [],
'error': [],
'completed': []
};
// Method to add event listeners
const addEventListener = <T extends keyof SteamEventTypes>(
event: T,
callback: (data: SteamEventTypes[T]) => void
) => {
if (!listeners[event]) {
listeners[event] = [];
}
listeners[event].push(callback as any);
// Return a function to remove this specific listener
return () => {
removeEventListener(event, callback);
};
};
// Method to remove event listeners
const removeEventListener = <T extends keyof SteamEventTypes>(
event: T,
callback: (data: SteamEventTypes[T]) => void
) => {
if (listeners[event]) {
const index = listeners[event].indexOf(callback as any);
if (index !== -1) {
listeners[event].splice(index, 1);
}
}
};
// Handle notifying listeners safely
const notifyListeners = (eventType: string, data: any) => {
if (listeners[eventType]) {
listeners[eventType].forEach(callback => {
try {
callback(data);
} catch (error) {
console.error(`Error in ${eventType} event handler:`, error);
}
});
}
};
// Initialize connection
const initConnection = async () => {
if (globalReconnectAttempts >= MAX_RECONNECT_ATTEMPTS) {
console.log(`Maximum reconnection attempts (${MAX_RECONNECT_ATTEMPTS}) reached. Giving up.`);
notifyListeners('error', { message: 'Connection to Steam authentication failed after multiple attempts' });
isConnecting = false;
disconnect()
return;
}
if (globalEventSource) {
globalEventSource.close();
globalEventSource = null;
}
try {
const token = await auth.access();
// Create new EventSource connection
globalEventSource = new EventSource(`${import.meta.env.VITE_API_URL}/steam/login`, {
fetch: (input, init) =>
fetch(input, {
...init,
headers: {
...init?.headers,
'Authorization': `Bearer ${token}`,
'x-nestri-team': team().id
},
}),
});
globalEventSource.onopen = () => {
console.log('Connected to Steam login stream');
setIsConnected(true);
globalReconnectAttempts = 0; // Reset reconnect counter on successful connection
isConnecting = false;
};
// Set up event handlers for all specific events
['connected', 'challenge', 'completed'].forEach((eventType) => {
globalEventSource!.addEventListener(eventType, (event) => {
try {
const data = JSON.parse(event.data);
console.log(`Received ${eventType} event:`, data);
notifyListeners(eventType, data);
} catch (error) {
console.error(`Error parsing ${eventType} event data:`, error);
}
});
});
// Handle connection errors (this is different from server-sent 'error' events)
globalEventSource.onerror = (error) => {
console.error('Steam login stream connection error:', error);
setIsConnected(false);
// Close the connection to prevent automatic browser reconnect
if (globalEventSource) {
globalEventSource.close();
}
// Check if we should attempt to reconnect
if (globalReconnectAttempts <= MAX_RECONNECT_ATTEMPTS) {
const currentAttempt = globalReconnectAttempts + 1;
console.log(`Reconnecting (attempt ${currentAttempt}/${MAX_RECONNECT_ATTEMPTS})...`);
globalReconnectAttempts = currentAttempt;
// Exponential backoff for reconnection
const delay = Math.min(1000 * Math.pow(2, globalReconnectAttempts), 30000);
setTimeout(initConnection, delay);
} else {
console.error(`Maximum reconnection attempts (${MAX_RECONNECT_ATTEMPTS}) reached`);
// Notify listeners about connection failure
notifyListeners('error', { message: 'Connection to Steam authentication failed after multiple attempts' });
disconnect();
isConnecting = false;
}
};
} catch (error) {
console.error('Failed to connect to Steam login stream:', error);
setIsConnected(false);
isConnecting = false;
}
};
// Disconnection function
const disconnect = () => {
if (globalEventSource) {
globalEventSource.close();
globalEventSource = null;
setIsConnected(false);
console.log('Disconnected from Steam login stream');
// Clear all listeners
Object.keys(listeners).forEach(key => {
listeners[key] = [];
});
activeConnection = null;
}
};
// Start the connection immediately
await initConnection();
// Create the connection interface
const connection: SteamConnection = {
addEventListener,
removeEventListener,
disconnect,
isConnected: () => isConnected()
};
// Store the active connection
activeConnection = connection;
// Clean up on context destruction
onCleanup(() => {
// Instead of disconnecting on cleanup, we'll leave the connection
// active for other components to use
// Only disconnect if no components are using it
if (!isConnected()) {
disconnect();
}
});
return connection;
}
}
};
return {
client,
ready: true
};
}
);

View File

@@ -1,22 +1,22 @@
import { useTeam } from "./context"
import { createEffect } from "solid-js"
// import { createEffect } from "solid-js"
import { schema } from "@nestri/zero/schema"
import { useQuery } from "@rocicorp/zero/solid"
// import { useQuery } from "@rocicorp/zero/solid"
import { useOpenAuth } from "@openauthjs/solid"
import { Query, Schema, Zero } from "@rocicorp/zero"
import { Zero } from "@rocicorp/zero"
import { useAccount } from "@nestri/www/providers/account"
import { createInitializedContext } from "@nestri/www/common/context"
export const { use: useZero, provider: ZeroProvider } =
createInitializedContext("ZeroContext", () => {
const team = useTeam()
const auth = useOpenAuth()
const account = useAccount()
const team = useTeam()
const zero = new Zero({
schema: schema,
auth: () => auth.access(),
userID: account.current.email,
schema,
storageKey: team().id,
auth: () => auth.access(),
userID: account.current.id,
server: import.meta.env.VITE_ZERO_URL,
})
@@ -28,12 +28,12 @@ export const { use: useZero, provider: ZeroProvider } =
};
});
export function usePersistentQuery<TSchema extends Schema, TTable extends keyof TSchema['tables'] & string, TReturn>(querySignal: () => Query<TSchema, TTable, TReturn>) {
const team = useTeam()
//@ts-ignore
const q = () => querySignal().where("team_id", "=", team().id).where("time_deleted", "IS", null)
createEffect(() => {
q().preload()
})
return useQuery<TSchema, TTable, TReturn>(q)
}
// export function usePersistentQuery<TSchema extends Schema, TTable extends keyof TSchema['tables'] & string, TReturn>(querySignal: () => Query<TSchema, TTable, TReturn>) {
// const team = useTeam()
// //@ts-ignore
// const q = () => querySignal().where("team_id", "=", team().id).where("time_deleted", "IS", null)
// createEffect(() => {
// q().preload()
// })
// return useQuery<TSchema, TTable, TReturn>(q)
// }

View File

@@ -6,6 +6,7 @@ export const FullScreen = styled("div", {
display: "flex",
flexDirection: "column",
alignItems: "center",
position:"relative",
textAlign: "center",
width: "100%",
justifyContent: "center"
@@ -15,7 +16,7 @@ export const FullScreen = styled("div", {
none: {},
header: {
paddingTop: `calc(1px + ${theme.headerHeight.root})`,
minHeight: `calc(100dvh - ${theme.headerHeight.root})`,
// minHeight: `calc(100dvh - ${theme.headerHeight.root})`,
},
},
},