⭐ feat: Update website, API, and infra (#164)
>Adds `maitred` in charge of handling automated game installs, updates,
and even execution.
>Not only that, we have the hosted stuff here
>- [x] AWS Task on ECS GPUs
>- [ ] Add a service to listen for game starts and stops
(docker-compose.yml)
>- [x] Add a queue for requesting a game to start
>- [x] Fix up the play/watch UI
>TODO:
>- Add a README
>- Add an SST docs
Edit:
- This adds a new landing page, updates the homepage etc etc
>I forgot what the rest of the updated stuff are 😅
57
apps/docs/sst-env.d.ts
vendored
@@ -2,57 +2,8 @@
|
||||
/* tslint:disable */
|
||||
/* eslint-disable */
|
||||
/* deno-fmt-ignore-file */
|
||||
|
||||
/// <reference path="../../sst-env.d.ts" />
|
||||
|
||||
import "sst"
|
||||
export {}
|
||||
declare module "sst" {
|
||||
export interface Resource {
|
||||
"Api": {
|
||||
"type": "sst.cloudflare.Worker"
|
||||
"url": string
|
||||
}
|
||||
"Auth": {
|
||||
"type": "sst.cloudflare.Worker"
|
||||
"url": string
|
||||
}
|
||||
"AuthFingerprintKey": {
|
||||
"type": "random.index/randomString.RandomString"
|
||||
"value": string
|
||||
}
|
||||
"CloudflareAuthKV": {
|
||||
"type": "sst.cloudflare.Kv"
|
||||
}
|
||||
"DiscordClientID": {
|
||||
"type": "sst.sst.Secret"
|
||||
"value": string
|
||||
}
|
||||
"DiscordClientSecret": {
|
||||
"type": "sst.sst.Secret"
|
||||
"value": string
|
||||
}
|
||||
"GithubClientID": {
|
||||
"type": "sst.sst.Secret"
|
||||
"value": string
|
||||
}
|
||||
"GithubClientSecret": {
|
||||
"type": "sst.sst.Secret"
|
||||
"value": string
|
||||
}
|
||||
"InstantAdminToken": {
|
||||
"type": "sst.sst.Secret"
|
||||
"value": string
|
||||
}
|
||||
"InstantAppId": {
|
||||
"type": "sst.sst.Secret"
|
||||
"value": string
|
||||
}
|
||||
"LoopsApiKey": {
|
||||
"type": "sst.sst.Secret"
|
||||
"value": string
|
||||
}
|
||||
"Urls": {
|
||||
"api": string
|
||||
"auth": string
|
||||
"type": "sst.sst.Linkable"
|
||||
}
|
||||
}
|
||||
}
|
||||
export {}
|
||||
@@ -35,10 +35,12 @@
|
||||
"@builder.io/qwik": "^1.8.0",
|
||||
"@builder.io/qwik-city": "^1.8.0",
|
||||
"@builder.io/qwik-react": "0.5.0",
|
||||
"@fontsource-variable/bricolage-grotesque": "^5.1.1",
|
||||
"@fontsource-variable/mona-sans": "^5.0.1",
|
||||
"@modular-forms/qwik": "^0.29.0",
|
||||
"@nestri/input": "*",
|
||||
"@nestri/libmoq": "*",
|
||||
"@nestri/sdk": "0.1.0-alpha.11",
|
||||
"@nestri/sdk": "0.1.0-alpha.14",
|
||||
"@nestri/ui": "*",
|
||||
"@openauthjs/openauth": "^0.2.6",
|
||||
"@polar-sh/checkout": "^0.1.8",
|
||||
|
||||
BIN
apps/www/public/fonts/BasementGrotesque-Black.otf
Normal file
BIN
apps/www/public/fonts/BasementGrotesque-Black.woff
Normal file
BIN
apps/www/public/fonts/BasementGrotesque-Black.woff2
Normal file
BIN
apps/www/public/images/avatars/dathorse.png
Normal file
|
After Width: | Height: | Size: 44 KiB |
BIN
apps/www/public/images/avatars/janried.png
Normal file
|
After Width: | Height: | Size: 137 KiB |
BIN
apps/www/public/images/avatars/victor.png
Normal file
|
After Width: | Height: | Size: 490 KiB |
BIN
apps/www/public/images/avatars/wanjohi.png
Normal file
|
After Width: | Height: | Size: 106 KiB |
BIN
apps/www/public/images/screenshots/doom.png
Normal file
|
After Width: | Height: | Size: 867 KiB |
BIN
apps/www/public/images/screenshots/main-dark.png
Normal file
|
After Width: | Height: | Size: 620 KiB |
BIN
apps/www/public/images/screenshots/main-light.png
Normal file
|
After Width: | Height: | Size: 199 KiB |
BIN
apps/www/public/images/screenshots/movie.avifs
Normal file
|
After Width: | Height: | Size: 2.3 MiB |
BIN
apps/www/public/images/screenshots/multiplayer.png
Normal file
|
After Width: | Height: | Size: 1.1 MiB |
@@ -27,6 +27,18 @@ export default component$(() => {
|
||||
<meta name="theme-color" media="(prefers-color-scheme: light)" content="#f5f5f5" />
|
||||
<meta name="theme-color" media="(prefers-color-scheme: dark)" content="#171717" />
|
||||
<meta charset="utf-8" />
|
||||
<link
|
||||
rel="preload"
|
||||
href="/fonts/BasementGrotesque-Black.woff2"
|
||||
as="font"
|
||||
crossOrigin=""
|
||||
/>
|
||||
<link
|
||||
rel="preload"
|
||||
href="/fonts/BasementGrotesque-Black.woff"
|
||||
as="font"
|
||||
crossOrigin=""
|
||||
/>
|
||||
{!isDev && (
|
||||
<link
|
||||
rel="manifest"
|
||||
|
||||
98
apps/www/src/routes/(play)/(user)/home/index.tsx
Normal file
@@ -0,0 +1,98 @@
|
||||
import Nestri from "@nestri/sdk";
|
||||
import { server$ } from "@builder.io/qwik-city";
|
||||
import { $, component$ } from "@builder.io/qwik";
|
||||
import { HomeFriendsSection, HomeGamesSection, HomeMachineSection } from "@nestri/ui";
|
||||
|
||||
export const getUserSubscriptions = server$(
|
||||
async function () {
|
||||
|
||||
const access = this.cookie.get("access_token")
|
||||
if (access) {
|
||||
const bearerToken = access.value
|
||||
|
||||
const nestriClient = new Nestri({ bearerToken, maxRetries: 5 })
|
||||
const subscriptions = await nestriClient.subscriptions.list().then(t => t.data.length > 0 ? "Pro" : "Free").catch(async (err) => {
|
||||
if (err instanceof Nestri.APIError) {
|
||||
if (err.status == 404) {
|
||||
return "Free"
|
||||
} else {
|
||||
throw err
|
||||
}
|
||||
} else {
|
||||
throw err;
|
||||
}
|
||||
})
|
||||
|
||||
return subscriptions as "Free" | "Pro"
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
export const getActiveUsers = server$(
|
||||
async function () {
|
||||
|
||||
const access = this.cookie.get("access_token")
|
||||
if (access) {
|
||||
const bearerToken = access.value
|
||||
|
||||
try {
|
||||
const nestriClient = new Nestri({ bearerToken, maxRetries: 5 })
|
||||
const users = await nestriClient.users.list().then(t => t.data) as any
|
||||
return users as Nestri.Users.UserListResponse.Data[]
|
||||
} catch (error) {
|
||||
console.log("error", error)
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
export const getSession = server$(
|
||||
async function (profileID: string) {
|
||||
|
||||
const access = this.cookie.get("access_token")
|
||||
if (access) {
|
||||
const bearerToken = access.value
|
||||
try {
|
||||
const nestriClient = new Nestri({ bearerToken, maxRetries: 5 })
|
||||
const session = await nestriClient.users.session(profileID)
|
||||
return session
|
||||
} catch (error) {
|
||||
console.log("error", error)
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
export const createSession = server$(
|
||||
async function () {
|
||||
const access = this.cookie.get("access_token")
|
||||
if (access) {
|
||||
const bearerToken = access.value
|
||||
|
||||
try {
|
||||
const nestriClient = new Nestri({ bearerToken, maxRetries: 5 })
|
||||
const taskID = await nestriClient.tasks.create()
|
||||
const sessionID = await nestriClient.tasks.session(taskID.data)
|
||||
return sessionID.data
|
||||
} catch (error) {
|
||||
console.log("error", error)
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
export default component$(() => {
|
||||
|
||||
return (
|
||||
<main class="flex w-screen h-full flex-col relative">
|
||||
<section class="max-w-[750px] w-full mx-auto flex flex-col gap-3 px-5 pt-20 pb-14 min-h-screen">
|
||||
<HomeMachineSection getUserSubscription$={$(async () => { return await getUserSubscriptions() })} />
|
||||
<HomeFriendsSection getActiveUsers$={$(async () => { return await getActiveUsers() })} getSession$={$(async (profileID: string) => { return await getSession(profileID) })} />
|
||||
<HomeGamesSection getUserSubscription$={$(async () => { return await getUserSubscriptions() })} createSession$={$(async () => { return await createSession() })} />
|
||||
</section >
|
||||
</main >
|
||||
)
|
||||
})
|
||||
42
apps/www/src/routes/(play)/(user)/layout.tsx
Normal file
@@ -0,0 +1,42 @@
|
||||
import Nestri from "@nestri/sdk";
|
||||
import { HomeNavBar } from "@nestri/ui";
|
||||
import { server$ } from "@builder.io/qwik-city";
|
||||
import { $, component$, Slot } from "@builder.io/qwik"
|
||||
|
||||
const cookieOptions = {
|
||||
path: "/",
|
||||
sameSite: "lax" as const,
|
||||
secure: false, // Only send cookies over HTTPS
|
||||
//FIXME: This causes weird issues in Qwik
|
||||
httpOnly: true, // Prevent JavaScript access to cookies
|
||||
expires: new Date(Date.now() + 24 * 10 * 60 * 60 * 1000), // expires in like 10 days
|
||||
}
|
||||
export const getUserProfile = server$(
|
||||
async function () {
|
||||
const userData = this.cookie.get("profile_data")
|
||||
if (userData) {
|
||||
const user = userData.json() as Nestri.Users.UserRetrieveResponse.Data
|
||||
return user
|
||||
} else {
|
||||
const access = this.cookie.get("access_token")
|
||||
if (access) {
|
||||
const bearerToken = access.value
|
||||
|
||||
const nestriClient = new Nestri({ bearerToken, maxRetries: 5 })
|
||||
const currentProfile = await nestriClient.users.retrieve()
|
||||
this.cookie.set("profile_data", JSON.stringify(currentProfile.data), cookieOptions)
|
||||
return currentProfile.data;
|
||||
}
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
export default component$(() => {
|
||||
|
||||
return (
|
||||
<>
|
||||
<HomeNavBar getUserProfile$={$(async () => { return await getUserProfile() })} />
|
||||
<Slot />
|
||||
</>
|
||||
)
|
||||
})
|
||||
98
apps/www/src/routes/(play)/(user)/machine/index.tsx
Normal file
@@ -0,0 +1,98 @@
|
||||
import { component$ } from "@builder.io/qwik"
|
||||
|
||||
export default component$(() => {
|
||||
|
||||
return (
|
||||
<main class="w-full">
|
||||
<div class="relative pt-32 pb-10 max-w-5xl mx-auto px-6" >
|
||||
<div class="container flex flex-col gap-6 md:flex-row" >
|
||||
<div class="flex min-w-[209px] flex-col gap-2" >
|
||||
<span class="text-xs font-medium text-gray-800 dark:text-gray-200 uppercase" >
|
||||
General
|
||||
</span>
|
||||
<div class="flex divide-x flex-col overflow-hidden" >
|
||||
<div class="[&>svg]:size-5 flex flex-row gap-2 items-center px-3 py-2.5 text-sm font-medium no-underline rounded border-none bg-gray-200 dark:bg-gray-800 text-gray-700 dark:text-gray-300">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 24 24"><path fill="currentColor" d="M14.06 9.94L12 9l2.06-.94L15 6l.94 2.06L18 9l-2.06.94L15 12zM4 14l.94-2.06L7 11l-2.06-.94L4 8l-.94 2.06L1 11l2.06.94zm4.5-5l1.09-2.41L12 5.5L9.59 4.41L8.5 2L7.41 4.41L5 5.5l2.41 1.09zm-4 11.5l6-6.01l4 4L23 8.93l-1.41-1.41l-7.09 7.97l-4-4L3 19z" /></svg>
|
||||
Performance
|
||||
</div>
|
||||
<div class="[&>svg]:size-5 flex flex-row gap-2 items-center px-3 py-2.5 text-sm no-underline rounded border-none text-gray-700 dark:text-gray-300">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 24 24"><path fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 7a4 4 0 1 0 8 0a4 4 0 1 0-8 0M3 21v-2a4 4 0 0 1 4-4h4a4 4 0 0 1 4 4v2m1-17.87a4 4 0 0 1 0 7.75M21 21v-2a4 4 0 0 0-3-3.85" /></svg>
|
||||
Users
|
||||
</div>
|
||||
<div class="[&>svg]:size-5 flex flex-row gap-2 items-center px-3 py-2.5 text-sm no-underline rounded border-none text-gray-700 dark:text-gray-300">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 24 24"><path fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 3a12 12 0 0 0 8.5 3A12 12 0 0 1 12 21A12 12 0 0 1 3.5 6A12 12 0 0 0 12 3" /></svg>
|
||||
Security
|
||||
</div>
|
||||
</div>
|
||||
<span class="text-xs font-medium text-gray-800 dark:text-gray-200 uppercase mt-6" >
|
||||
Network
|
||||
</span>
|
||||
<div class="flex divide-x flex-col overflow-hidden" >
|
||||
<div class="[&>svg]:size-5 flex flex-row gap-2 items-center px-3 py-2.5 text-sm no-underline rounded border-none text-gray-700 dark:text-gray-300">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 24 24"><g fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2"><path d="M20.5 9.035a9.004 9.004 0 0 0-17 0m17 0c.324.928.5 1.926.5 2.965a9 9 0 0 1-.5 2.966m0-5.931h-17m0 0A9 9 0 0 0 3 12a9 9 0 0 0 .5 2.966m0 0a9.004 9.004 0 0 0 17 0m-17 0h17" /><path d="M12 21c4.97-4.97 4.97-13.03 0-18c-4.97 4.97-4.97 13.03 0 18" /></g></svg>
|
||||
API requests
|
||||
</div>
|
||||
<div class="[&>svg]:size-5 flex flex-row gap-2 items-center px-3 py-2.5 text-sm no-underline rounded border-none text-gray-700 dark:text-gray-300">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 24 24"><g fill="none"><path d="M24 0v24H0V0zM12.593 23.258l-.011.002l-.071.035l-.02.004l-.014-.004l-.071-.035q-.016-.005-.024.005l-.004.01l-.017.428l.005.02l.01.013l.104.074l.015.004l.012-.004l.104-.074l.012-.016l.004-.017l-.017-.427q-.004-.016-.017-.018m.265-.113l-.013.002l-.185.093l-.01.01l-.003.011l.018.43l.005.012l.008.007l.201.093q.019.005.029-.008l.004-.014l-.034-.614q-.005-.019-.02-.022m-.715.002a.02.02 0 0 0-.027.006l-.006.014l-.034.614q.001.018.017.024l.015-.002l.201-.093l.01-.008l.004-.011l.017-.43l-.003-.012l-.01-.01z" /><path fill="currentColor" d="M8.207 11.757a1 1 0 0 1 0 1.415L6.38 15H16a1 1 0 1 1 0 2H6.38l1.828 1.828a1 1 0 1 1-1.414 1.415l-3.536-3.536a1 1 0 0 1 0-1.414l3.536-3.536a1 1 0 0 1 1.414 0Zm7.586-8a1 1 0 0 1 1.32-.083l.094.083l3.536 3.536a1 1 0 0 1 .083 1.32l-.083.094l-3.536 3.535a1 1 0 0 1-1.497-1.32l.083-.094L17.62 9H8a1 1 0 0 1-.117-1.993L8 7h9.621l-1.828-1.83a1 1 0 0 1 0-1.414Z" /></g></svg>
|
||||
Data transfer
|
||||
</div>
|
||||
<div class="[&>svg]:size-5 flex flex-row gap-2 items-center px-3 py-2.5 text-sm no-underline rounded border-none text-gray-700 dark:text-gray-300">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 16 16"><path fill="currentColor" d="M2.5 5.724V8c0 .248.238.7 1.169 1.159c.874.43 2.144.745 3.62.822a.75.75 0 1 1-.078 1.498c-1.622-.085-3.102-.432-4.204-.975a6 6 0 0 1-.507-.28V12.5c0 .133.058.318.282.551c.227.237.591.483 1.101.707c1.015.447 2.47.742 4.117.742q.61 0 1.183-.052a.751.751 0 1 1 .134 1.494Q8.676 15.999 8 16c-1.805 0-3.475-.32-4.721-.869c-.623-.274-1.173-.619-1.579-1.041c-.408-.425-.7-.964-.7-1.59v-9c0-.626.292-1.165.7-1.591c.406-.42.956-.766 1.579-1.04C4.525.32 6.195 0 8 0s3.476.32 4.721.869c.623.274 1.173.619 1.579 1.041c.408.425.7.964.7 1.59s-.292 1.165-.7 1.591c-.406.42-.956.766-1.578 1.04C11.475 6.68 9.805 7 8 7s-3.475-.32-4.721-.869a6 6 0 0 1-.779-.407m0-2.224c0 .133.058.318.282.551c.227.237.591.483 1.101.707C4.898 5.205 6.353 5.5 8 5.5s3.101-.295 4.118-.742c.508-.224.873-.471 1.1-.708c.224-.232.282-.417.282-.55s-.058-.318-.282-.551c-.227-.237-.591-.483-1.101-.707C11.102 1.795 9.647 1.5 8 1.5s-3.101.295-4.118.742c-.508.224-.873.471-1.1.708c-.224.232-.282.417-.282.55" /><path fill="currentColor" d="M14.49 7.582a.375.375 0 0 0-.66-.313l-3.625 4.625a.375.375 0 0 0 .295.606h2.127l-.619 2.922a.375.375 0 0 0 .666.304l3.125-4.125A.375.375 0 0 0 15.5 11h-1.778z" /></svg>
|
||||
WAN cache
|
||||
</div>
|
||||
</div>
|
||||
<span class="text-xs font-medium text-gray-800 dark:text-gray-200 uppercase mt-6" >
|
||||
Admin
|
||||
</span>
|
||||
<div class="flex divide-x flex-col overflow-hidden" >
|
||||
<div class="[&>svg]:size-5 flex flex-row gap-2 items-center px-3 py-2.5 text-sm no-underline rounded border-none text-gray-700 dark:text-gray-300">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 24 24"><g fill="none" stroke="currentColor" stroke-width="1.5"><circle cx="12" cy="12" r="3" /><path d="M13.765 2.152C13.398 2 12.932 2 12 2s-1.398 0-1.765.152a2 2 0 0 0-1.083 1.083c-.092.223-.129.484-.143.863a1.62 1.62 0 0 1-.79 1.353a1.62 1.62 0 0 1-1.567.008c-.336-.178-.579-.276-.82-.308a2 2 0 0 0-1.478.396C4.04 5.79 3.806 6.193 3.34 7s-.7 1.21-.751 1.605a2 2 0 0 0 .396 1.479c.148.192.355.353.676.555c.473.297.777.803.777 1.361s-.304 1.064-.777 1.36c-.321.203-.529.364-.676.556a2 2 0 0 0-.396 1.479c.052.394.285.798.75 1.605c.467.807.7 1.21 1.015 1.453a2 2 0 0 0 1.479.396c.24-.032.483-.13.819-.308a1.62 1.62 0 0 1 1.567.008c.483.28.77.795.79 1.353c.014.38.05.64.143.863a2 2 0 0 0 1.083 1.083C10.602 22 11.068 22 12 22s1.398 0 1.765-.152a2 2 0 0 0 1.083-1.083c.092-.223.129-.483.143-.863c.02-.558.307-1.074.79-1.353a1.62 1.62 0 0 1 1.567-.008c.336.178.579.276.819.308a2 2 0 0 0 1.479-.396c.315-.242.548-.646 1.014-1.453s.7-1.21.751-1.605a2 2 0 0 0-.396-1.479c-.148-.192-.355-.353-.676-.555A1.62 1.62 0 0 1 19.562 12c0-.558.304-1.064.777-1.36c.321-.203.529-.364.676-.556a2 2 0 0 0 .396-1.479c-.052-.394-.285-.798-.75-1.605c-.467-.807-.7-1.21-1.015-1.453a2 2 0 0 0-1.479-.396c-.24.032-.483.13-.82.308a1.62 1.62 0 0 1-1.566-.008a1.62 1.62 0 0 1-.79-1.353c-.014-.38-.05-.64-.143-.863a2 2 0 0 0-1.083-1.083Z" /></g></svg>
|
||||
Settings
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex-1" >
|
||||
<div class="flex flex-col gap-y-4" >
|
||||
<div class="w-full bg-white dark:bg-black ring-gray-200 ring-2 dark:ring-gray-800 rounded-[10px]">
|
||||
<div class="size-full ring-1 ring-gray-200 dark:ring-gray-800 rounded-md flex justify-center items-center h-[300px] gap-2 flex-col">
|
||||
<div class="border-2 border-gray-100 dark:border-gray-900 size-[60px] text-gray-600 dark:text-gray-400 rounded-lg justify-center flex items-center [&>svg]:size-8">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 1024 1024"><path fill="currentColor" d="m665.216 768l110.848 192h-73.856L591.36 768H433.024L322.176 960H248.32l110.848-192H160a32 32 0 0 1-32-32V192H64a32 32 0 0 1 0-64h896a32 32 0 1 1 0 64h-64v544a32 32 0 0 1-32 32zM832 192H192v512h640zM352 448a32 32 0 0 1 32 32v64a32 32 0 0 1-64 0v-64a32 32 0 0 1 32-32m160-64a32 32 0 0 1 32 32v128a32 32 0 0 1-64 0V416a32 32 0 0 1 32-32m160-64a32 32 0 0 1 32 32v192a32 32 0 1 1-64 0V352a32 32 0 0 1 32-32" /></svg>
|
||||
</div>
|
||||
<div class="max-w-[340px] w-full text-black dark:text-white text-lg text-center font-semibold font-title flex gap-1 justify-center h-max items-center">
|
||||
No data yet
|
||||
<div class="select-none text-[#ff990a] bg-[#ff990a]/30 h-max uppercase overflow-hidden rounded-md px-2 py-1 text-xs transition-colors duration-200 ease-out font-semibold font-title">
|
||||
<span>Soon</span>
|
||||
</div>
|
||||
</div>
|
||||
<span class="text-sm text-gray-700 dark:text-gray-300 max-w-[340px] text-center" >
|
||||
Once you have installed a game and started playing data should start flowing into Nestri
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="grid gap-3 lg:grid-cols-2">
|
||||
<div class="bg-white p-8 dark:bg-black ring-gray-200 ring-2 dark:ring-gray-800 relative w-full rounded-[10px] h-[240px] min-h-[240px]">
|
||||
<div class="relative w-full h-full flex flex-col gap-2">
|
||||
<p class="font-medium font-title" >GPU usage</p>
|
||||
<div class="animate-pulse bg-gray-200 dark:bg-gray-800 rounded-md h-6 w-full"/>
|
||||
<div class="animate-pulse bg-gray-200 dark:bg-gray-800 rounded-md h-6 w-full"/>
|
||||
<div class="animate-pulse bg-gray-200 dark:bg-gray-800 rounded-md h-6 w-full"/>
|
||||
<div class="animate-pulse bg-gray-200 dark:bg-gray-800 rounded-md h-6 w-full"/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="bg-white p-8 dark:bg-black ring-gray-200 ring-2 dark:ring-gray-800 relative w-full rounded-[10px] h-[240px] min-h-[240px]">
|
||||
<div class="relative w-full h-full flex flex-col gap-2">
|
||||
<p class="font-medium font-title" >CPU usage</p>
|
||||
<div class="animate-pulse bg-gray-200 dark:bg-gray-800 rounded-md h-6 w-full"/>
|
||||
<div class="animate-pulse bg-gray-200 dark:bg-gray-800 rounded-md h-6 w-full"/>
|
||||
<div class="animate-pulse bg-gray-200 dark:bg-gray-800 rounded-md h-6 w-full"/>
|
||||
<div class="animate-pulse bg-gray-200 dark:bg-gray-800 rounded-md h-6 w-full"/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
)
|
||||
})
|
||||
@@ -1,490 +0,0 @@
|
||||
import { cn } from "@nestri/ui/design";
|
||||
import type Nestri from "@nestri/sdk";
|
||||
import { Modal } from "@qwik-ui/headless";
|
||||
import { Avatar, Icons } from "@nestri/ui";
|
||||
import { Link, routeLoader$ } from "@builder.io/qwik-city";
|
||||
import { HomeNavBar, SimpleFooter, GameStoreButton } from "@nestri/ui";
|
||||
import { component$, useSignal, useStore, useVisibleTask$ } from "@builder.io/qwik";
|
||||
|
||||
const games = [
|
||||
{
|
||||
id: 2507950,
|
||||
name: "Delta Force",
|
||||
image: "https://assets-prd.ignimgs.com/2024/08/28/delta-force-button-replacement-1724855313566.jpg"
|
||||
},
|
||||
{
|
||||
id: 870780,
|
||||
name: "Control Ultimate Edition",
|
||||
image: "https://assets-prd.ignimgs.com/2023/04/08/sq-nswitchds-controlultimateeditioncloudversion-image500w-1680973421643.jpg"
|
||||
},
|
||||
{
|
||||
id: 1172470,
|
||||
name: "Apex Legends",
|
||||
image: "https://assets-prd.ignimgs.com/2023/02/16/apexrevelry-1676588335122.jpg"
|
||||
},
|
||||
{
|
||||
id: 914800,
|
||||
name: "Coffee Talk",
|
||||
image: "https://assets-prd.ignimgs.com/2022/11/09/coffee-talk-episode-1-button-fin-1668033710468.jpg"
|
||||
}, {
|
||||
id: 1085220,
|
||||
name: "Figment 2: Creed Valley",
|
||||
image: "https://assets-prd.ignimgs.com/2021/12/15/figment-2-button-1639602944843.jpg"
|
||||
}, {
|
||||
id: 1568400,
|
||||
name: "Sheepy: A Short Adventure",
|
||||
image: "https://assets-prd.ignimgs.com/2024/04/08/sheepy-1712557253260.jpg"
|
||||
}, {
|
||||
id: 271590,
|
||||
name: "Grand Theft Auto V",
|
||||
image: "https://assets-prd.ignimgs.com/2021/12/17/gta-5-button-2021-1639777058682.jpg"
|
||||
}, {
|
||||
id: 1086940,
|
||||
name: "Baldur's Gate 3",
|
||||
image: "https://assets-prd.ignimgs.com/2023/08/24/baldursg3-1692894717196.jpeg"
|
||||
}, {
|
||||
id: 1091500,
|
||||
name: "Cyberpunk 2077",
|
||||
image: "https://assets-prd.ignimgs.com/2020/07/16/cyberpunk-2077-button-fin-1594877291453.jpg"
|
||||
}, {
|
||||
id: 221100,
|
||||
name: "DayZ",
|
||||
image: "https://assets-prd.ignimgs.com/2021/12/20/dayz-1640044421966.jpg"
|
||||
},
|
||||
|
||||
]
|
||||
|
||||
export const useCurrentProfile = routeLoader$(async ({ sharedMap }) => {
|
||||
const res = sharedMap.get("profile") as Nestri.Users.UserRetrieveResponse.Data | null
|
||||
|
||||
return res
|
||||
// return {
|
||||
// avatarUrl: undefined,
|
||||
// discriminator: 47,
|
||||
// username: "WanjohiRyan"
|
||||
// }
|
||||
})
|
||||
//bg-blue-100 rounded-lg p-4 min-w-16 text-center
|
||||
const TimeUnit = ({ value, label }: { value: number, label: string }) => (
|
||||
<div class="flex flex-col items-center bg-blue-100 rounded-lg p-4">
|
||||
<div style={{ "--line-height": "2rem" }} class="flex leading-none items-center text-[2rem] font-medium font-title">
|
||||
{new Array(2).fill(0).map((_, key) => {
|
||||
const [digitOne, digitTwo] = value.toString().padStart(2, '0')
|
||||
return (
|
||||
<div style={{ "--digit-one": Number(digitOne), "--digit-two": Number(digitTwo) }} key={`digit-${key}`} class={cn("h-[--line-height] overflow-hidden", key == 0 ? "first-of-type:[--v:var(--digit-one)]" : "last-of-type:[--v:var(--digit-two)]")} >
|
||||
<div class={cn("digit_timing flex flex-col", key == 0 ? "items-end" : "items-start")}>
|
||||
<div>9</div>
|
||||
<div>0</div>
|
||||
<div>1</div>
|
||||
<div>2</div>
|
||||
<div>3</div>
|
||||
<div>4</div>
|
||||
<div>5</div>
|
||||
<div>6</div>
|
||||
<div>7</div>
|
||||
<div>8</div>
|
||||
<div>9</div>
|
||||
<div>0</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
<div class="text-xs mt-1 text-gray-600">{label}</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
const random = Math.floor(100 * Math.random())
|
||||
|
||||
export default component$(() => {
|
||||
const profile = useCurrentProfile()
|
||||
const isNewPerson = useSignal(false)
|
||||
const targetDate = new Date('2025-01-29T23:59:00Z');
|
||||
|
||||
const timeLeft = useStore({
|
||||
days: 0,
|
||||
hours: 0,
|
||||
minutes: 0,
|
||||
seconds: 0
|
||||
})
|
||||
|
||||
// eslint-disable-next-line qwik/no-use-visible-task
|
||||
useVisibleTask$(() => {
|
||||
isNewPerson.value = true
|
||||
|
||||
const calculateTimeLeft = () => {
|
||||
const difference = targetDate.getTime() - new Date().getTime();
|
||||
|
||||
if (difference > 0) {
|
||||
const days = Math.floor(difference / (1000 * 60 * 60 * 24));
|
||||
const hours = Math.floor((difference % (1000 * 60 * 60 * 24)) / (1000 * 60 * 60));
|
||||
const minutes = Math.floor((difference % (1000 * 60 * 60)) / (1000 * 60));
|
||||
const seconds = Math.floor((difference % (1000 * 60)) / 1000);
|
||||
|
||||
timeLeft.days = days
|
||||
timeLeft.hours = hours
|
||||
timeLeft.minutes = minutes
|
||||
timeLeft.seconds = seconds
|
||||
}
|
||||
};
|
||||
|
||||
calculateTimeLeft();
|
||||
|
||||
const timer = setInterval(calculateTimeLeft, 1000);
|
||||
|
||||
return () => clearInterval(timer);
|
||||
})
|
||||
|
||||
return (
|
||||
<main class="flex w-screen h-full flex-col relative">
|
||||
{profile.value && <HomeNavBar avatarUrl={profile.value.avatarUrl} discriminator={profile.value.discriminator} username={profile.value.username} />}
|
||||
<section class="max-w-[750px] w-full mx-auto flex flex-col gap-3 px-5 pt-20 pb-14 ">
|
||||
<div class="flex flex-col gap-6 w-full py-4">
|
||||
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<GameStoreButton />
|
||||
<Modal.Root class="w-full">
|
||||
<Modal.Trigger class="border-gray-400/70 w-full dark:border-gray-700/70 hover:ring-2 hover:ring-[#8f8f8f] dark:hover:ring-[#707070] group transition-all border-dashed duration-200 border-[2px] h-14 rounded-xl pl-4 gap-2 flex items-center justify-between overflow-hidden hover:bg-gray-300/70 dark:hover:bg-gray-700/70 outline-none disabled:opacity-50">
|
||||
<span class="py-2 text-gray-600/70 dark:text-gray-400/70 leading-none group-hover:text-black dark:group-hover:text-white shrink truncate flex text-start justify-center items-center gap-2">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="flex-shrink-0 size-5" viewBox="0 0 24 24"><path fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M11.505 2h-1.501c-3.281 0-4.921 0-6.084.814a4.5 4.5 0 0 0-1.106 1.105C2 5.08 2 6.72 2 10s0 4.919.814 6.081a4.5 4.5 0 0 0 1.106 1.105C5.083 18 6.723 18 10.004 18h4.002c3.28 0 4.921 0 6.084-.814a4.5 4.5 0 0 0 1.105-1.105c.63-.897.772-2.08.805-4.081m-8-6h4m0 0h4m-4 0V2m0 4v4m-7 5h2m-1 3v4m-4 0h8" color="currentColor" /></svg>
|
||||
Add another Linux machine
|
||||
</span>
|
||||
</Modal.Trigger>
|
||||
<Modal.Panel class="
|
||||
dark:backdrop:bg-[#0009] backdrop:bg-[#b3b5b799] backdrop:backdrop-grayscale-[.3] w-[340px] max-h-[75vh] rounded-xl border dark:border-[#343434] border-[#e2e2e2]
|
||||
dark:[box-shadow:0_0_0_1px_rgba(255,255,255,0.08),_0_3.3px_2.7px_rgba(0,0,0,.1),0_8.3px_6.9px_rgba(0,0,0,.13),0_17px_14.2px_rgba(0,0,0,.17),0_35px_29.2px_rgba(0,0,0,.22),0px_-4px_4px_0px_rgba(0,0,0,.04)_inset] dark:bg-[#222b]
|
||||
[box-shadow:0_0_0_1px_rgba(19,21,23,0.08),_0_3.3px_2.7px_rgba(0,0,0,.03),0_8.3px_6.9px_rgba(0,0,0,.04),0_17px_14.2px_rgba(0,0,0,.05),0_35px_29.2px_rgba(0,0,0,.06),0px_-4px_4px_0px_rgba(0,0,0,.07)_inset] bg-[#fffd]
|
||||
backdrop-blur-lg py-4 px-5 modal" >
|
||||
<div class="size-full flex flex-col">
|
||||
<div class="flex justify-between items-start ">
|
||||
<div class="mb-3 size-14 rounded-full text-[#939597] dark:text-[#d2d4d7] bg-[rgba(19,21,23,0.04)] dark:bg-white/[.08] flex items-center justify-center [&>svg]:size-8" >
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M11.505 2h-1.501c-3.281 0-4.921 0-6.084.814a4.5 4.5 0 0 0-1.106 1.105C2 5.08 2 6.72 2 10s0 4.919.814 6.081a4.5 4.5 0 0 0 1.106 1.105C5.083 18 6.723 18 10.004 18h4.002c3.28 0 4.921 0 6.084-.814a4.5 4.5 0 0 0 1.105-1.105c.63-.897.772-2.08.805-4.081m-8-6h4m0 0h4m-4 0V2m0 4v4m-7 5h2m-1 3v4m-4 0h8" color="currentColor" /></svg>
|
||||
</div>
|
||||
</div>
|
||||
<div class="dark:text-white text-black">
|
||||
<h3 class="font-semibold text-2xl tracking-tight mb-2 font-title">Add a Linux machine</h3>
|
||||
<div class="text-sm dark:text-white/[.79] text-[rgba(19,21,23,0.64)]" >
|
||||
Download and install Nestri on your remote server or computer to connect it. Then paste the generated machine id here.
|
||||
</div>
|
||||
</div>
|
||||
<form action="#" class="mt-3 flex flex-col gap-3" >
|
||||
<input placeholder="fc27f428f9ca47d4b41b707ae0c62090" class="transition-all duration-200 w-full px-2 py-3 h-10 border text-black dark:text-white dark:border-[#343434] border-[#e2e2e2] rounded-md text-sm outline-none bg-white dark:bg-[rgba(19,21,23,0.64)] leading-none [background-image:-webkit-linear-gradient(hsla(0,0%,100%,0),hsla(0,0%,100%,0))]
|
||||
focus:[box-shadow:0_0_0_2px_#fcfcfc,0_0_0_4px_#8f8f8f] dark:focus:[box-shadow:0_0_0_2px_#161616,0_0_0_4px_#707070]" />
|
||||
<button class="w-full h-[calc(2.25rem+2*1px)] transition-all duration-200 hover:[box-shadow:0_0_0_2px_#fcfcfc,0_0_0_4px_#8f8f8f] dark:hover:[box-shadow:0_0_0_2px_#161616,0_0_0_4px_#707070] focus:[box-shadow:0_0_0_2px_#fcfcfc,0_0_0_4px_#8f8f8f] dark:focus:[box-shadow:0_0_0_2px_#161616,0_0_0_4px_#707070] outline-none bg-primary-500 text-white rounded-lg text-sm" >Add machine</button>
|
||||
</form>
|
||||
</div>
|
||||
</Modal.Panel>
|
||||
</Modal.Root>
|
||||
</div>
|
||||
</div >
|
||||
<div class="gap-2 w-full flex-col flex">
|
||||
<hr class="border-none h-[1.5px] dark:bg-gray-700 bg-gray-300 w-full" />
|
||||
<div class="flex flex-col justify-center py-2 px-3 items-start w-full ">
|
||||
<div class="text-gray-600/70 dark:text-gray-400/70 text-sm leading-none flex justify-between items-center w-full py-1">
|
||||
<span class="text-xl text-gray-700 dark:text-gray-300 leading-none font-bold font-title flex gap-2 items-center pb-2">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="flex-shrink-0 size-5" viewBox="0 0 20 20"><path fill="currentColor" d="M2.049 9.112a8.001 8.001 0 1 1 9.718 8.692a1.5 1.5 0 0 0-.206-1.865l-.01-.01q.244-.355.47-.837a9.3 9.3 0 0 0 .56-1.592H9.744q.17-.478.229-1h2.82A15 15 0 0 0 13 10c0-.883-.073-1.725-.206-2.5H7.206l-.05.315a4.5 4.5 0 0 0-.971-.263l.008-.052H3.46q-.112.291-.198.595c-.462.265-.873.61-1.213 1.017m9.973-4.204C11.407 3.59 10.657 3 10 3s-1.407.59-2.022 1.908A9.3 9.3 0 0 0 7.42 6.5h5.162a9.3 9.3 0 0 0-.56-1.592M6.389 6.5c.176-.743.407-1.422.683-2.015c.186-.399.401-.773.642-1.103A7.02 7.02 0 0 0 3.936 6.5zm9.675 7H13.61a10.5 10.5 0 0 1-.683 2.015a6.6 6.6 0 0 1-.642 1.103a7.02 7.02 0 0 0 3.778-3.118m-2.257-1h2.733c.297-.776.46-1.62.46-2.5s-.163-1.724-.46-2.5h-2.733c.126.788.193 1.63.193 2.5s-.067 1.712-.193 2.5m2.257-6a7.02 7.02 0 0 0-3.778-3.118c.241.33.456.704.642 1.103c.276.593.507 1.272.683 2.015zm-7.76 7.596a3.5 3.5 0 1 0-.707.707l2.55 2.55a.5.5 0 0 0 .707-.707zM8 12a2.5 2.5 0 1 1-5 0a2.5 2.5 0 0 1 5 0" /></svg>
|
||||
Find people to play with
|
||||
</span>
|
||||
</div>
|
||||
<ul class="list-none ml-4 relative w-[calc(100%-1rem)]">
|
||||
{games.slice(5, 8).sort().map((game, key) => (
|
||||
<Modal.Root key={`find-${key}`} >
|
||||
<Modal.Trigger class="gap-3.5 text-left hover:bg-gray-300/70 dark:hover:bg-gray-700/70 hover:ring-2 hover:ring-[#8f8f8f] dark:hover:ring-[#707070] outline-none group rounded-lg px-3 [transition:all_0.3s_cubic-bezier(0.4,0,0.2,1)] flex items-center w-full">
|
||||
<img height={52} width={52} draggable={false} class="[transition:all_0.3s_cubic-bezier(0.4,0,0.2,1)] group-hover:scale-105 group-hover:shadow-lg group-hover:shadow-gray-900 select-none rounded-lg aspect-square w-[80px]" src={game.image} alt={game.name} />
|
||||
<div class={cn("w-full h-[100px] overflow-hidden border-b-2 border-gray-400/70 dark:border-gray-700/70 flex flex-col gap-2 justify-center", key == 2 && "border-none")}>
|
||||
<span class="font-medium tracking-tighter text-gray-700 dark:text-gray-300 max-w-full text-lg font-title truncate leading-none">
|
||||
{game.name}
|
||||
</span>
|
||||
<div class="flex items-center px-2 gap-2 w-full">
|
||||
<div
|
||||
class="items-center flex"
|
||||
style={{
|
||||
"--size": "1.25rem",
|
||||
"--cutout-avatar-percentage-visible": 0.4,
|
||||
"--head-margin-percentage": 0.2
|
||||
}}>
|
||||
{new Array(3).fill(0).map((_, key) => (
|
||||
<div key={key} class="relative items-start flex ml-[calc(-1*(1-var(--cutout-avatar-percentage-visible)-var(--head-margin-percentage))*var(--size))]">
|
||||
<div
|
||||
class="[&>svg]:size-5"
|
||||
style={{
|
||||
maskSize: "100% 100%",
|
||||
maskRepeat: "no-repeat",
|
||||
maskPosition: "center",
|
||||
maskComposite: "subtract",
|
||||
maskImage: `url('data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1 1"><circle r="0.5" cx="0.5" cy="0.5"/></svg>'),url('data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1 1"><circle r="0.6" cx="1.1" cy="0.5"/></svg>')`
|
||||
}}
|
||||
>
|
||||
<Avatar name={((key + 1) * random).toString()} />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
<div class="[&>svg]:size-[--size] ml-[calc(-1*(1-var(--cutout-avatar-percentage-visible)-var(--head-margin-percentage))*var(--size))] relative flex items-center justify-center">
|
||||
<Avatar name={((key + 1) * random).toString()} />
|
||||
</div>
|
||||
</div>
|
||||
<p class="font-normal text-gray-600 dark:text-gray-400 text-sm w-full truncate">{`${(key + 1) * random} people are currently playing this game`}</p>
|
||||
</div>
|
||||
</div>
|
||||
</Modal.Trigger>
|
||||
<Modal.Panel class="modal-sheet [&::-webkit-scrollbar]:hidden [-ms-overflow-style:none] [scrollbar-width:none] dark:backdrop:bg-[#0009] backdrop:bg-[#b3b5b799] backdrop:backdrop-grayscale-[.3] rounded-xl border dark:border-[#343434] border-[#e2e2e2] right-2 top-0 mr-2 mt-2
|
||||
dark:[box-shadow:0_0_0_1px_rgba(255,255,255,0.08),_0_3.3px_2.7px_rgba(0,0,0,.1),0_8.3px_6.9px_rgba(0,0,0,.13),0_17px_14.2px_rgba(0,0,0,.17),0_35px_29.2px_rgba(0,0,0,.22),0px_-4px_4px_0px_rgba(0,0,0,.04)_inset] dark:bg-[#222b]
|
||||
[box-shadow:0_0_0_1px_rgba(19,21,23,0.08),_0_3.3px_2.7px_rgba(0,0,0,.03),0_8.3px_6.9px_rgba(0,0,0,.04),0_17px_14.2px_rgba(0,0,0,.05),0_35px_29.2px_rgba(0,0,0,.06),0px_-4px_4px_0px_rgba(0,0,0,.07)_inset] bg-[#fffd]
|
||||
backdrop-blur-lg">
|
||||
<div class=" min-h-[calc(100dvh-1rem)] h-[calc(100dvh-1rem)] w-[550px] relative " >
|
||||
<div class="sticky top-0 w-full z-10 backdrop-blur-lg dark:bg-[rgba(19,21,23,0.48)] dark:border-white/[.08] border-b py-2 px-3 min-h-12 gap-3 flex justify-between items-center" >
|
||||
<Modal.Close class="dark:text-white/[.64] text-[rgba(19,21,23,0.64)] [&>svg]:size-5 [&>svg]:scale-[1.2] hover:text-white dark:hover:text-[rgb(19,21,23)] py-1.5 px-2.5 rounded-lg transition-all duration-200 hover:bg-[rgba(19,21,23,0.64)] dark:hover:bg-white/[.64]">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 24 24"><g fill="none" fill-rule="evenodd"><path d="M24 0v24H0V0zM12.594 23.258l-.012.002l-.071.035l-.02.004l-.014-.004l-.071-.036q-.016-.004-.024.006l-.004.01l-.017.428l.005.02l.01.013l.104.074l.015.004l.012-.004l.104-.074l.012-.016l.004-.017l-.017-.427q-.004-.016-.016-.018m.264-.113l-.014.002l-.184.093l-.01.01l-.003.011l.018.43l.005.012l.008.008l.201.092q.019.005.029-.008l.004-.014l-.034-.614q-.005-.018-.02-.022m-.715.002a.02.02 0 0 0-.027.006l-.006.014l-.034.614q.001.018.017.024l.015-.002l.201-.093l.01-.008l.003-.011l.018-.43l-.003-.012l-.01-.01z" /><path fill="currentColor" d="M6.293 6.293a1 1 0 0 1 1.414 0l5 5a1 1 0 0 1 0 1.414l-5 5a1 1 0 0 1-1.414-1.414L10.586 12L6.293 7.707a1 1 0 0 1 0-1.414m6 0a1 1 0 0 1 1.414 0l5 5a1 1 0 0 1 0 1.414l-5 5a1 1 0 0 1-1.414-1.414L16.586 12l-4.293-4.293a1 1 0 0 1 0-1.414" /></g></svg>
|
||||
</Modal.Close>
|
||||
<div class="gap-2 flex justify-between flex-1 items-center ">
|
||||
<div class="w-full flex items-center gap-2">
|
||||
<button class="dark:text-white/[.64] dark:bg-white/[.08] text-[rgba(19,21,23,0.64)] bg-[rgba(19,21,23,0.04)] font-medium py-1.5 px-2.5 rounded-lg flex items-center gap-1 transition-all duration-200 [&>svg]:size-5 text-sm hover:text-white dark:hover:text-[rgb(19,21,23)] hover:bg-[rgba(19,21,23,0.64)] dark:hover:bg-white/[.64]">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 24 24"><g fill="none" stroke="currentColor" stroke-width="2"><path d="M14 7c0-.932 0-1.398-.152-1.765a2 2 0 0 0-1.083-1.083C12.398 4 11.932 4 11 4H8c-1.886 0-2.828 0-3.414.586S4 6.114 4 8v3c0 .932 0 1.398.152 1.765a2 2 0 0 0 1.083 1.083C5.602 14 6.068 14 7 14" /><rect width="10" height="10" x="10" y="10" rx="2" /></g></svg>
|
||||
Copy link
|
||||
</button>
|
||||
<button class="dark:text-white/[.64] dark:bg-white/[.08] text-[rgba(19,21,23,0.64)] bg-[rgba(19,21,23,0.04)] font-medium py-1.5 px-2.5 rounded-lg flex items-center gap-1 transition-all duration-200 [&>svg]:size-5 text-sm hover:text-white dark:hover:text-[rgb(19,21,23)] hover:bg-[rgba(19,21,23,0.64)] dark:hover:bg-white/[.64]">
|
||||
Game page
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 24 24"><path fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M6 18L18 6m0 0H9m9 0v9" /></svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="p-4 pt-10 gap-6 flex flex-col text-white" >
|
||||
<div class="m-4 mb-2 relative flex items-center justify-center" >
|
||||
<img src={game.image} height={280} width={280} class="rounded-xl bg-white/[.08] aspect-square size-[280px]" />
|
||||
</div>
|
||||
<div class="flex gap-2 flex-col dark:text-white text-black" >
|
||||
<h1 class="text-3xl font-title font-bold tracking-tight leading-none" >{game.name}</h1>
|
||||
<p class="dark:text-gray-400 text-gray-600 [display:-webkit-box] max-w-full overflow-hidden [-webkit-line-clamp:3] [-webkit-box-orient:vertical]" >
|
||||
A short handcrafted pixel art platformer that follows Sheepy, an abandoned plushy brought to life. Sheepy: A Short Adventure is the first short game from MrSuicideSheep.
|
||||
</p>
|
||||
<div class="gap-y-1 gap-x-2 flex-wrap flex " >
|
||||
<button class="[&>svg]:size-[14px] cursor-pointer hover:border-primary-500 hover:text-primary-500 border-2 border-[rgba(19,21,23,0.08)] dark:border-white/[.16] items-center inline-flex py-1 px-2 rounded-[100px] gap-0.5 text-[rgba(19,21,23,0.36)] dark:text-[hsla(0,0%,100%,.5)] text-[0.875rem] font-medium transition-all duration-200" >
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 16 16"><path fill="currentColor" fill-rule="evenodd" d="M7.238 2.634a.75.75 0 1 0-1.476-.268L5.283 5H3a.75.75 0 1 0 0 1.5h2.01l-.545 3H2A.75.75 0 1 0 2 11h2.192l-.43 2.366a.75.75 0 1 0 1.476.268L5.717 11h3.475l-.43 2.366a.75.75 0 1 0 1.476.268L10.717 11H13a.75.75 0 0 0 0-1.5h-2.01l.545-3H14A.75.75 0 0 0 14 5h-2.192l.43-2.366a.75.75 0 1 0-1.476-.268L10.283 5H6.808zM9.465 9.5l.545-3H6.535l-.545 3z" clip-rule="evenodd" /></svg>
|
||||
Adventure
|
||||
</button>
|
||||
<button class="[&>svg]:size-[14px] cursor-pointer hover:border-primary-500 hover:text-primary-500 border-2 border-[rgba(19,21,23,0.08)] dark:border-white/[.16] items-center inline-flex py-1 px-2 rounded-[100px] gap-0.5 text-[rgba(19,21,23,0.36)] dark:text-[hsla(0,0%,100%,.5)] text-[0.875rem] font-medium transition-all duration-200" >
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 16 16"><path fill="currentColor" fill-rule="evenodd" d="M7.238 2.634a.75.75 0 1 0-1.476-.268L5.283 5H3a.75.75 0 1 0 0 1.5h2.01l-.545 3H2A.75.75 0 1 0 2 11h2.192l-.43 2.366a.75.75 0 1 0 1.476.268L5.717 11h3.475l-.43 2.366a.75.75 0 1 0 1.476.268L10.717 11H13a.75.75 0 0 0 0-1.5h-2.01l.545-3H14A.75.75 0 0 0 14 5h-2.192l.43-2.366a.75.75 0 1 0-1.476-.268L10.283 5H6.808zM9.465 9.5l.545-3H6.535l-.545 3z" clip-rule="evenodd" /></svg>
|
||||
Free to play
|
||||
</button>
|
||||
<button class="[&>svg]:size-[14px] cursor-pointer hover:border-primary-500 hover:text-primary-500 border-2 border-[rgba(19,21,23,0.08)] dark:border-white/[.16] items-center inline-flex py-1 px-2 rounded-[100px] gap-0.5 text-[rgba(19,21,23,0.36)] dark:text-[hsla(0,0%,100%,.5)] text-[0.875rem] font-medium transition-all duration-200" >
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 16 16"><path fill="currentColor" fill-rule="evenodd" d="M7.238 2.634a.75.75 0 1 0-1.476-.268L5.283 5H3a.75.75 0 1 0 0 1.5h2.01l-.545 3H2A.75.75 0 1 0 2 11h2.192l-.43 2.366a.75.75 0 1 0 1.476.268L5.717 11h3.475l-.43 2.366a.75.75 0 1 0 1.476.268L10.717 11H13a.75.75 0 0 0 0-1.5h-2.01l.545-3H14A.75.75 0 0 0 14 5h-2.192l.43-2.366a.75.75 0 1 0-1.476-.268L10.283 5H6.808zM9.465 9.5l.545-3H6.535l-.545 3z" clip-rule="evenodd" /></svg>
|
||||
Indie
|
||||
</button>
|
||||
</div>
|
||||
<div class="py-3 px-4 overflow-hidden bg-white/[.08] dark:bg-white/[.04] border dark:border-[#343434] border-[#e2e2e2] rounded-xl [box-shadow:0_1px_4px_rgba(0,0,0,.1)] dark:[box-shadow:0_1px_4px_rgba(0,0,0,.15)]" >
|
||||
<div class="dark:bg-white/[.08] bg-[rgba(19,21,23,0.04)] mx-[calc(-1rem+1px)] my-[calc(-0.75rem+1px)] mb-3 py-[calc(0.5rem-1px)] px-[calc(1rem-1px)]" >
|
||||
<p class="text-sm text-[rgba(19,21,23,0.64)] dark:text-[hsla(0,0%,100%,.79)] font-medium font-title" >Join a Nestri party</p>
|
||||
</div>
|
||||
<div class="gap-3 flex flex-col">
|
||||
<div class=" border-b border-[rgba(19,21,23,0.08)] dark:border-white/[.08] gap-3 flex flex-col [margin:-0.5rem_-1rem_0.25rem] [padding:0.5rem_1rem_0.75rem] ">
|
||||
<div class="flex gap-3">
|
||||
<div class="size-7 mt-2 shrink-0 flex items-center justify-center" >
|
||||
<img alt="ESRN-Teen" width={40} height={40} src="https://oyster.ignimgs.com/mediawiki/apis.ign.com/ratings/b/bf/ESRB-ver2013_T.png?width=325" />
|
||||
</div>
|
||||
<div>
|
||||
<p class="font-medium font-title" >Teen [13+]</p>
|
||||
<span class="mt-[1px] text-sm leading-none text-[rgba(19,21,23,0.64)] dark:text-[hsla(0,0%,100%,.79)]" >Mild Language, Violence, Blood and Gore, Drug References</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class=" mb-1 dark:text-white w-full leading-none -my-1 py-1">
|
||||
<button class="gap-3 outline-none hover:[box-shadow:0_0_0_2px_#fcfcfc,0_0_0_4px_#8f8f8f] dark:hover:[box-shadow:0_0_0_2px_#161616,0_0_0_4px_#707070] focus:[box-shadow:0_0_0_2px_#fcfcfc,0_0_0_4px_#8f8f8f] dark:focus:[box-shadow:0_0_0_2px_#161616,0_0_0_4px_#707070] [transition:all_0.3s_cubic-bezier(0.4,0,0.2,1)] font-medium font-title rounded-lg flex h-[calc(2.25rem+2*1px)] flex-col text-white w-full leading-none truncate bg-primary-500 items-center justify-center" >
|
||||
Join this party
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Modal.Panel>
|
||||
</Modal.Root>
|
||||
))}
|
||||
<div class="[border:1px_dashed_theme(colors.gray.300)] dark:[border:1px_dashed_theme(colors.gray.800)] [mask-image:linear-gradient(rgb(0,0,0)_0%,_rgb(0,0,0)_calc(100%-120px),_transparent_100%)] bottom-0 top-0 -left-[0.4625rem] absolute" />
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
<div class="gap-2 w-full flex-col flex">
|
||||
<hr class="border-none h-[1.5px] dark:bg-gray-700 bg-gray-300 w-full" />
|
||||
<div class="text-gray-600/70 dark:text-gray-400/70 text-sm leading-none flex justify-between py-2 px-3 items-end">
|
||||
<span class="text-xl text-gray-700 dark:text-gray-300 leading-none font-bold font-title flex gap-2 ">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="flex-shrink-0 size-5" viewBox="0 0 24 24"><path fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M12 22c-.818 0-1.6-.33-3.163-.99C4.946 19.366 3 18.543 3 17.16V7m9 15c.818 0 1.6-.33 3.163-.99C19.054 19.366 21 18.543 21 17.16V7m-9 15V11.355M8.326 9.691L5.405 8.278C3.802 7.502 3 7.114 3 6.5s.802-1.002 2.405-1.778l2.92-1.413C10.13 2.436 11.03 2 12 2s1.871.436 3.674 1.309l2.921 1.413C20.198 5.498 21 5.886 21 6.5s-.802 1.002-2.405 1.778l-2.92 1.413C13.87 10.564 12.97 11 12 11s-1.871-.436-3.674-1.309M6 12l2 1m9-9L7 9" color="currentColor" /></svg>
|
||||
Your Games
|
||||
</span>
|
||||
<button class="flex gap-1 items-center cursor-pointer hover:text-gray-800 dark:hover:text-gray-200 transition-all duration-200 outline-none">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="flex-shrink-0 size-5" viewBox="0 0 256 256"><path fill="currentColor" d="M248 128a87.34 87.34 0 0 1-17.6 52.81a8 8 0 1 1-12.8-9.62A71.34 71.34 0 0 0 232 128a72 72 0 0 0-144 0a8 8 0 0 1-16 0a88 88 0 0 1 3.29-23.88C74.2 104 73.1 104 72 104a48 48 0 0 0 0 96h24a8 8 0 0 1 0 16H72a64 64 0 1 1 9.29-127.32A88 88 0 0 1 248 128m-69.66 42.34L160 188.69V128a8 8 0 0 0-16 0v60.69l-18.34-18.35a8 8 0 0 0-11.32 11.32l32 32a8 8 0 0 0 11.32 0l32-32a8 8 0 0 0-11.32-11.32" /></svg>
|
||||
<span>Install a game</span>
|
||||
</button>
|
||||
</div>
|
||||
<ul class="relative py-3 w-full grid sm:grid-cols-3 grid-cols-2 list-none after:pointer-events-none after:select-none after:w-full after:h-[120px] after:fixed after:z-10 after:backdrop-blur-[1px] after:bg-gradient-to-b after:from-transparent after:to-gray-100 dark:after:to-gray-900 after:[-webkit-mask-image:linear-gradient(to_top,theme(colors.gray.100)_25%,transparent)] dark:after:[-webkit-mask-image:linear-gradient(to_top,theme(colors.gray.900)_25%,transparent)] after:left-0 after:-bottom-[1px]">
|
||||
{games.map((game, key) => (
|
||||
<Modal.Root key={`game-${key}`} >
|
||||
<Modal.Trigger class="hover:bg-gray-300/70 dark:hover:bg-gray-700/70 [transition:all_0.3s_cubic-bezier(0.4,0,0.2,1)] px-2 py-2 rounded-[15px] hover:ring-2 hover:ring-[#8f8f8f] dark:hover:ring-[#707070] outline-none size-full group [&_*]:transition-all [&_*]:duration-150 flex flex-col gap-2" key={key}>
|
||||
<img draggable={false} alt={game.name} class="select-none [transition:all_0.3s_cubic-bezier(0.4,0,0.2,1)] group-hover:scale-[1.01] group-hover:shadow-lg group-hover:shadow-gray-900 w-full rounded-xl aspect-square" src={game.image} height={90} width={90} />
|
||||
<div class="flex flex-col px-2 w-full">
|
||||
<span class="max-w-full truncate">{game.name}</span>
|
||||
</div>
|
||||
</Modal.Trigger>
|
||||
<Modal.Panel class="dark:backdrop:bg-[#0009] backdrop:bg-[#b3b5b799] backdrop:backdrop-grayscale-[.3] rounded-xl border dark:border-[#343434] border-gray-600/70
|
||||
dark:[box-shadow:0_0_0_1px_rgba(255,255,255,0.08),_0_3.3px_2.7px_rgba(0,0,0,.1),0_8.3px_6.9px_rgba(0,0,0,.13),0_17px_14.2px_rgba(0,0,0,.17),0_35px_29.2px_rgba(0,0,0,.22),0px_-4px_4px_0px_rgba(0,0,0,.04)_inset] dark:bg-[rgb(22,22,22)]
|
||||
box-shadow:0_0_0_1px_rgba(19,21,23,0.08),_0_3.3px_2.7px_rgba(0,0,0,.03),0_8.3px_6.9px_rgba(0,0,0,.04),0_17px_14.2px_rgba(0,0,0,.05),0_35px_29.2px_rgba(0,0,0,.06),0px_-4px_4px_0px_rgba(0,0,0,.07)_inset] bg-gray-300
|
||||
backdrop-blur-lg modal" >
|
||||
<div class="flex flex-col min-w-[17rem] relative text-black/70 dark:text-white/70 w-full max-w-[41.8125rem] min-h-[min(90%,100%-3rem)]" >
|
||||
<div class="flex-1 relative w-full " >
|
||||
<div class="relative w-full pb-[56.25%] overflow-hidden after:z-[2] after:absolute after:inset-0 dark:after:[background:linear-gradient(40deg,rgb(22,22,22)_24.16%,rgba(6,10,23,0)_56.61%),linear-gradient(0deg,rgb(22,22,22)_3.91%,rgba(6,10,23,0)_69.26%)] after:[background:linear-gradient(0deg,theme(colors.gray.300)_3.91%,theme(colors.gray.300/0.03)_69.26%)]" >
|
||||
<div
|
||||
style={{
|
||||
backgroundImage: `url(https://shared.cloudflare.steamstatic.com/store_item_assets/steam/apps/${game.id}/library_hero.jpg)`
|
||||
}}
|
||||
class={cn("absolute inset-0 z-[1] [transition:opacity_300ms_ease-in-out] bg-cover bg-[center_top] bg-no-repeat")} />
|
||||
<div
|
||||
style={{
|
||||
backgroundImage: `url(${game.image})`
|
||||
}}
|
||||
class={cn("absolute inset-0 -z-[1] bg-cover bg-[center_top] bg-no-repeat blur-[4rem]")} />
|
||||
<div
|
||||
style={{
|
||||
backgroundImage: `url(https://shared.cloudflare.steamstatic.com/store_item_assets/steam/apps/${game.id}/logo.png)`
|
||||
}}
|
||||
class="absolute dark:bottom-0 min-[600px]:left-10 left-4 bg-contain bg-[center_bottom] bg-no-repeat min-[600px]:max-w-[40%] w-full aspect-video z-[3] bottom-[20px] " />
|
||||
</div>
|
||||
<div class="min-[600px]:p-10 min-[600px]:pt-4 pt-4 px-4 pb-6" >
|
||||
<ul class="[&_svg]:-mt-[2px] xl:mb-3 min-[960px]:mb-2 min-[600px]:leading-5 mb-4 leading-[0.625rem] list-none flex w-full" >
|
||||
<li class="mr-2 flex gap-0.5 [&>svg]:size-[14px] items-center justify-center text-sm dark:bg-[rgb(65,65,65)] bg-[rgb(171,171,171)] px-2 py-1 w-max rounded-md" >
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 16 16"><path fill="currentColor" fill-rule="evenodd" d="M7.238 2.634a.75.75 0 1 0-1.476-.268L5.283 5H3a.75.75 0 1 0 0 1.5h2.01l-.545 3H2A.75.75 0 1 0 2 11h2.192l-.43 2.366a.75.75 0 1 0 1.476.268L5.717 11h3.475l-.43 2.366a.75.75 0 1 0 1.476.268L10.717 11H13a.75.75 0 0 0 0-1.5h-2.01l.545-3H14A.75.75 0 0 0 14 5h-2.192l.43-2.366a.75.75 0 1 0-1.476-.268L10.283 5H6.808zM9.465 9.5l.545-3H6.535l-.545 3z" clip-rule="evenodd" /></svg>
|
||||
Shooter
|
||||
</li>
|
||||
<li class="mr-2 flex gap-0.5 [&>svg]:size-[14px] items-center justify-center text-sm dark:bg-[rgb(65,65,65)] bg-[rgb(171,171,171)] px-2 py-1 w-max rounded-md" >
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 16 16"><path fill="currentColor" fill-rule="evenodd" d="M7.238 2.634a.75.75 0 1 0-1.476-.268L5.283 5H3a.75.75 0 1 0 0 1.5h2.01l-.545 3H2A.75.75 0 1 0 2 11h2.192l-.43 2.366a.75.75 0 1 0 1.476.268L5.717 11h3.475l-.43 2.366a.75.75 0 1 0 1.476.268L10.717 11H13a.75.75 0 0 0 0-1.5h-2.01l.545-3H14A.75.75 0 0 0 14 5h-2.192l.43-2.366a.75.75 0 1 0-1.476-.268L10.283 5H6.808zM9.465 9.5l.545-3H6.535l-.545 3z" clip-rule="evenodd" /></svg>
|
||||
Action
|
||||
</li>
|
||||
<li class="mr-2 flex gap-0.5 [&>svg]:size-[14px] items-center justify-center text-sm dark:bg-[rgb(65,65,65)] bg-[rgb(171,171,171)] px-2 py-1 w-max rounded-md" >
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 16 16"><path fill="currentColor" fill-rule="evenodd" d="M7.238 2.634a.75.75 0 1 0-1.476-.268L5.283 5H3a.75.75 0 1 0 0 1.5h2.01l-.545 3H2A.75.75 0 1 0 2 11h2.192l-.43 2.366a.75.75 0 1 0 1.476.268L5.717 11h3.475l-.43 2.366a.75.75 0 1 0 1.476.268L10.717 11H13a.75.75 0 0 0 0-1.5h-2.01l.545-3H14A.75.75 0 0 0 14 5h-2.192l.43-2.366a.75.75 0 1 0-1.476-.268L10.283 5H6.808zM9.465 9.5l.545-3H6.535l-.545 3z" clip-rule="evenodd" /></svg>
|
||||
Free to play
|
||||
</li>
|
||||
</ul>
|
||||
<p class="text-black/90 dark:text-white/90 tracking-tight" >
|
||||
Delta Force is a first-person shooter which offers players both a single player campaign based on the movie Black Hawk Down, but also large-scale PvP multiplayer action. The game was formerly known as Delta Force: Hawk Ops.
|
||||
</p>
|
||||
<div class="sm:pt-10 sm:block hidden" >
|
||||
<button class="gap-3 outline-none hover:[box-shadow:0_0_0_2px_rgba(200,200,200,0.95),0_0_0_4px_#8f8f8f] dark:hover:[box-shadow:0_0_0_2px_#161616,0_0_0_4px_#707070] focus:[box-shadow:0_0_0_2px_rgba(200,200,200,0.95),0_0_0_4px_#8f8f8f] dark:focus:[box-shadow:0_0_0_2px_#161616,0_0_0_4px_#707070] [transition:all_0.3s_cubic-bezier(0.4,0,0.2,1)] font-medium font-title rounded-lg flex h-[calc(2.25rem+2*1px)] flex-col text-white w-full leading-none truncate bg-primary-500 items-center justify-center" >
|
||||
Play Now
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Modal.Panel>
|
||||
</Modal.Root>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</section >
|
||||
<SimpleFooter />
|
||||
<Modal.Root bind:show={isNewPerson} closeOnBackdropClick={false}>
|
||||
<Modal.Panel
|
||||
class="dark:bg-gray-700 bg-white [box-shadow:0_8px_30px_rgba(0,0,0,.12)]
|
||||
dark:backdrop:bg-[#0009] backdrop:bg-[#b3b5b799] backdrop:backdrop-grayscale-[.3] rounded-xl
|
||||
backdrop-blur-md modal outline-none overflow-visible">
|
||||
<div class="relative select-none w-full max-w-[740px] max-h-[75vh] h-full">
|
||||
<div class="pointer-events-auto flex w-full flex-col rounded-xl relative bg-white dark:bg-gray-700/70">
|
||||
<span class="absolute inset-0 z-0 size-full rounded-xl bg-gradient-to-b from-[rgb(17,168,255)] to-[rgb(160,221,255)] dark:from-[rgb(93,94,162)] dark:via-[rgb(33,143,205)] dark:to-[rgb(112,203,255)]" />
|
||||
<div class="rounded-xl relative p-6">
|
||||
<div class="flex w-full flex-row justify-start gap-6">
|
||||
<div class="-mt-6 flex w-[280px] flex-col items-center gap-6 rounded-b-xl bg-gradient-to-b dark:from-gray-100 dark:to-gray-50 from-gray-200 to-gray-100 px-6 pb-6 shadow-lg">
|
||||
<div class="-mt-7 flex flex-row items-center justify-center gap-2 text-center text-sm text-white/80">
|
||||
<svg width="96" height="52" viewBox="0 0 96 52" fill="none" xmlns="http://www.w3.org/2000/svg"><g filter="url(#filter0_di_4064_903)">
|
||||
<mask id="path-1-inside-1_4064_903" fill="white"><path fill-rule="evenodd" clip-rule="evenodd" d="M64 20C64 22.0476 65.4906 24 67.5382 24H81.6C83.8402 24 84.9603 24 85.816 24.436C86.5686 24.8195 87.1805 25.4314 87.564 26.184C88 27.0397 88 28.1598 88 30.4V33.6C88 35.8402 88 36.9603 87.564 37.816C87.1805 38.5686 86.5686 39.1805 85.816 39.564C84.9603 40 83.8402 40 81.6 40H14.4C12.1598 40 11.0397 40 10.184 39.564C9.43139 39.1805 8.81947 38.5686 8.43597 37.816C8 36.9603 8 35.8402 8 33.6V30.4C8 28.1598 8 27.0397 8.43597 26.184C8.81947 25.4314 9.43139 24.8195 10.184 24.436C11.0397 24 12.1598 24 14.4 24H28.4617C30.5094 24 32 22.0476 32 20C32 11.1634 39.1634 4 48 4C56.8366 4 64 11.1634 64 20ZM48 24C50.2091 24 52 22.2091 52 20C52 17.7909 50.2091 16 48 16C45.7909 16 44 17.7909 44 20C44 22.2091 45.7909 24 48 24Z"></path></mask>
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M64 20C64 22.0476 65.4906 24 67.5382 24H81.6C83.8402 24 84.9603 24 85.816 24.436C86.5686 24.8195 87.1805 25.4314 87.564 26.184C88 27.0397 88 28.1598 88 30.4V33.6C88 35.8402 88 36.9603 87.564 37.816C87.1805 38.5686 86.5686 39.1805 85.816 39.564C84.9603 40 83.8402 40 81.6 40H14.4C12.1598 40 11.0397 40 10.184 39.564C9.43139 39.1805 8.81947 38.5686 8.43597 37.816C8 36.9603 8 35.8402 8 33.6V30.4C8 28.1598 8 27.0397 8.43597 26.184C8.81947 25.4314 9.43139 24.8195 10.184 24.436C11.0397 24 12.1598 24 14.4 24H28.4617C30.5094 24 32 22.0476 32 20C32 11.1634 39.1634 4 48 4C56.8366 4 64 11.1634 64 20ZM48 24C50.2091 24 52 22.2091 52 20C52 17.7909 50.2091 16 48 16C45.7909 16 44 17.7909 44 20C44 22.2091 45.7909 24 48 24Z" fill="url(#paint0_linear_4064_903)"></path>
|
||||
<path d="M10.184 39.564L9.73005 40.455L10.184 39.564ZM8.43597 37.816L7.54497 38.27L8.43597 37.816ZM87.564 37.816L86.673 37.362L87.564 37.816ZM85.816 39.564L86.27 40.455L85.816 39.564ZM85.816 24.436L86.27 23.545L85.816 24.436ZM87.564 26.184L86.673 26.638L87.564 26.184ZM8.43597 26.184L9.32698 26.638L8.43597 26.184ZM81.6 23H67.5382V25H81.6V23ZM89 33.6V30.4H87V33.6H89ZM14.4 41H81.6V39H14.4V41ZM7 30.4V33.6H9V30.4H7ZM28.4617 23H14.4V25H28.4617V23ZM48 3C38.6112 3 31 10.6112 31 20H33C33 11.7157 39.7157 5 48 5V3ZM65 20C65 10.6112 57.3888 3 48 3V5C56.2843 5 63 11.7157 63 20H65ZM51 20C51 21.6569 49.6569 23 48 23V25C50.7614 25 53 22.7614 53 20H51ZM48 17C49.6569 17 51 18.3431 51 20H53C53 17.2386 50.7614 15 48 15V17ZM45 20C45 18.3431 46.3431 17 48 17V15C45.2386 15 43 17.2386 43 20H45ZM48 23C46.3431 23 45 21.6569 45 20H43C43 22.7614 45.2386 25 48 25V23ZM28.4617 25C31.2131 25 33 22.4353 33 20H31C31 21.66 29.8057 23 28.4617 23V25ZM14.4 39C13.2634 39 12.4711 38.9992 11.8542 38.9488C11.2491 38.8994 10.9014 38.8072 10.638 38.673L9.73005 40.455C10.3223 40.7568 10.9625 40.8826 11.6914 40.9422C12.4086 41.0008 13.2964 41 14.4 41V39ZM7 33.6C7 34.7036 6.99922 35.5914 7.05782 36.3086C7.11737 37.0375 7.24318 37.6777 7.54497 38.27L9.32698 37.362C9.19279 37.0986 9.10062 36.7509 9.05118 36.1458C9.00078 35.5289 9 34.7366 9 33.6H7ZM10.638 38.673C10.0735 38.3854 9.6146 37.9265 9.32698 37.362L7.54497 38.27C8.02433 39.2108 8.78924 39.9757 9.73005 40.455L10.638 38.673ZM87 33.6C87 34.7366 86.9992 35.5289 86.9488 36.1458C86.8994 36.7509 86.8072 37.0986 86.673 37.362L88.455 38.27C88.7568 37.6777 88.8826 37.0375 88.9422 36.3086C89.0008 35.5914 89 34.7036 89 33.6H87ZM81.6 41C82.7036 41 83.5914 41.0008 84.3086 40.9422C85.0375 40.8826 85.6777 40.7568 86.27 40.455L85.362 38.673C85.0986 38.8072 84.7509 38.8994 84.1458 38.9488C83.5289 38.9992 82.7366 39 81.6 39V41ZM86.673 37.362C86.3854 37.9265 85.9265 38.3854 85.362 38.673L86.27 40.455C87.2108 39.9757 87.9757 39.2108 88.455 38.27L86.673 37.362ZM81.6 25C82.7366 25 83.5289 25.0008 84.1458 25.0512C84.7509 25.1006 85.0986 25.1928 85.362 25.327L86.27 23.545C85.6777 23.2432 85.0375 23.1174 84.3086 23.0578C83.5914 22.9992 82.7036 23 81.6 23V25ZM89 30.4C89 29.2964 89.0008 28.4086 88.9422 27.6914C88.8826 26.9625 88.7568 26.3223 88.455 25.73L86.673 26.638C86.8072 26.9014 86.8994 27.2491 86.9488 27.8542C86.9992 28.4711 87 29.2634 87 30.4H89ZM85.362 25.327C85.9265 25.6146 86.3854 26.0735 86.673 26.638L88.455 25.73C87.9757 24.7892 87.2108 24.0243 86.27 23.545L85.362 25.327ZM9 30.4C9 29.2634 9.00078 28.4711 9.05118 27.8542C9.10062 27.2491 9.19279 26.9014 9.32698 26.638L7.54497 25.73C7.24318 26.3223 7.11737 26.9625 7.05782 27.6914C6.99922 28.4086 7 29.2964 7 30.4H9ZM14.4 23C13.2964 23 12.4085 22.9992 11.6914 23.0578C10.9625 23.1174 10.3223 23.2432 9.73005 23.545L10.638 25.327C10.9014 25.1928 11.2491 25.1006 11.8542 25.0512C12.4711 25.0008 13.2634 25 14.4 25V23ZM9.32698 26.638C9.6146 26.0735 10.0735 25.6146 10.638 25.327L9.73005 23.545C8.78924 24.0243 8.02433 24.7892 7.54497 25.73L9.32698 26.638ZM63 20C63 22.4353 64.7869 25 67.5382 25V23C66.1943 23 65 21.66 65 20H63Z" fill="url(#paint1_linear_4064_903)" mask="url(#path-1-inside-1_4064_903)"></path></g>
|
||||
<defs><filter id="filter0_di_4064_903" x="0" y="0" width="96" height="52" filterUnits="userSpaceOnUse" color-interpolation-filters="s-rGB"><feFlood flood-opacity="0" result="BackgroundImageFix"></feFlood><feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"></feColorMatrix><feOffset dy="4"></feOffset><feGaussianBlur stdDeviation="4"></feGaussianBlur><feComposite in2="hardAlpha" operator="out"></feComposite><feColorMatrix type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.1 0"></feColorMatrix><feBlend mode="normal" in2="BackgroundImageFix" result="effect1_dropShadow_4064_903"></feBlend><feBlend mode="normal" in="SourceGraphic" in2="effect1_dropShadow_4064_903" result="shape"></feBlend><feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"></feColorMatrix><feOffset dy="-1"></feOffset><feGaussianBlur stdDeviation="0.5"></feGaussianBlur><feComposite in2="hardAlpha" operator="arithmetic" k2="-1" k3="1"></feComposite><feColorMatrix type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.3 0"></feColorMatrix><feBlend mode="normal" in2="shape" result="effect2_innerShadow_4064_903"></feBlend></filter><linearGradient id="paint0_linear_4064_903" x1="48" y1="4" x2="48" y2="40" gradientUnits="userSpaceOnUse"><stop stop-color="white"></stop><stop offset="0.6" stop-color="#CCCCCC"></stop><stop offset="1" stop-color="white"></stop></linearGradient><linearGradient id="paint1_linear_4064_903" x1="48" y1="4" x2="48" y2="12" gradientUnits="userSpaceOnUse"><stop stop-color="white"></stop><stop offset="1" stop-color="white" stop-opacity="0"></stop></linearGradient></defs>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="relative -mt-1 text-center text-base font-medium text-gray-800">Nestri needs your help</div>
|
||||
<div class="flex flex-row items-center justify-center gap-2 text-center text-sm text-white/80 [&>svg]:size-5">
|
||||
{profile.value && profile.value.avatarUrl ? (<img src={profile.value.avatarUrl} height={20} width={20} class="size-6 rounded-full" alt="Avatar" />) : (<Avatar name={profile.value ? `${profile.value.username}#${profile.value.discriminator}` : ""} />)}
|
||||
<span class="text-balance text-xs font-medium leading-tight text-black font-title">
|
||||
{profile.value && profile.value.username}
|
||||
</span>
|
||||
</div>
|
||||
<div class="w-full">
|
||||
<div class="relative flex items-center justify-center gap-4">
|
||||
<div class="h-[1px] flex-1 bg-gray-300/70" />
|
||||
<div class="text-center text-xs font-medium text-gray-500">What's wrong?</div>
|
||||
<div class="h-[1px] flex-1 bg-gray-300/70" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex flex-row items-start gap-4">
|
||||
<div class="flex size-4 shrink-0 items-center justify-center rounded-full bg-gray-200/70 text-xs font-semibold text-gray-400">1</div>
|
||||
<div class="text-xs text-gray-700">We're almost ready to launch Nestri, but server costs are our biggest hurdle right now.</div>
|
||||
</div>
|
||||
<div class="flex flex-row items-start gap-4">
|
||||
<div class="flex size-4 shrink-0 items-center justify-center rounded-full bg-gray-200/70 text-xs font-semibold text-gray-400">2</div>
|
||||
<div class="text-xs text-gray-700">As a bootstrapped startup (yeah, just a few passionate developers!), we're reaching out to our early believers.</div>
|
||||
</div>
|
||||
<div class="flex flex-row items-start gap-4">
|
||||
<div class="flex size-4 shrink-0 items-center justify-center rounded-full bg-gray-200/70 text-xs font-semibold text-gray-400">3</div>
|
||||
<div class="text-xs text-gray-700">Your early access subscription will directly fund our initial server infrastructure, helping us bring self-hosted cloud gaming to life.</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex h-max w-max max-w-[380px] flex-col items-start gap-6">
|
||||
<div>
|
||||
<div class="h-12 text-center font-title text-lg font-medium text-white [text-shadow:0_4px_10px_rgba(0,87,255,.2),_0_-4px_10px_rgba(255,90,0,.15),_0_0_30px_rgba(255,255,255,.2)]" >
|
||||
What you get
|
||||
</div>
|
||||
<div class="flex w-full flex-col rounded-xl border border-none border-separator bg-white/20 bg-gradient-to-b from-black/20 to-black/30 pl-3 pr-5 shadow-lg dark:bg-white/[.09]">
|
||||
<div class="relative flex items-start justify-center gap-2.5 pt-3">
|
||||
<div class="flex size-5 shrink-0 items-center justify-center text-gray-200 font-bold tabular-nums">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 24 24"><g fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2"><path stroke-dasharray="64" stroke-dashoffset="64" d="M8 3c0.5 0 2.5 4.5 2.5 5c0 1 -1.5 2 -2 3c-0.5 1 0.5 2 1.5 3c0.39 0.39 2 2 3 1.5c1 -0.5 2 -2 3 -2c0.5 0 5 2 5 2.5c0 2 -1.5 3.5 -3 4c-1.5 0.5 -2.5 0.5 -4.5 0c-2 -0.5 -3.5 -1 -6 -3.5c-2.5 -2.5 -3 -4 -3.5 -6c-0.5 -2 -0.5 -3 0 -4.5c0.5 -1.5 2 -3 4 -3Z"><animate fill="freeze" attributeName="stroke-dashoffset" dur="0.6s" values="64;0" /><animateTransform id="lineMdPhoneCallLoop0" fill="freeze" attributeName="transform" begin="0.6s;lineMdPhoneCallLoop0.begin+2.7s" dur="0.5s" type="rotate" values="0 12 12;15 12 12;0 12 12;-12 12 12;0 12 12;12 12 12;0 12 12;-15 12 12;0 12 12" /></path><path stroke-dasharray="4" stroke-dashoffset="4" d="M15.76 8.28c-0.5 -0.51 -1.1 -0.93 -1.76 -1.24M15.76 8.28c0.49 0.49 0.9 1.08 1.2 1.72"><animate fill="freeze" attributeName="stroke-dashoffset" begin="lineMdPhoneCallLoop0.begin+0s" dur="2.7s" keyTimes="0;0.111;0.259;0.37;1" values="4;0;0;4;4" /></path><path stroke-dasharray="6" stroke-dashoffset="6" d="M18.67 5.35c-1 -1 -2.26 -1.73 -3.67 -2.1M18.67 5.35c0.99 1 1.72 2.25 2.08 3.65"><animate fill="freeze" attributeName="stroke-dashoffset" begin="lineMdPhoneCallLoop0.begin+0.2s" dur="2.7s" keyTimes="0;0.074;0.185;0.333;0.444;1" values="6;6;0;0;6;6" /></path></g></svg>
|
||||
</div>
|
||||
<div class="flex w-full flex-row items-center justify-start gap-0.5 border-b border-white/10 pb-3 pt-1 max-w-full truncate">
|
||||
<span class="text-sm leading-tight text-white truncate max-w-full">Schedule 1-on-1 calls with the Founders</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="relative flex items-start justify-center gap-2.5 pt-3">
|
||||
<div class="flex size-5 shrink-0 items-center justify-center text-gray-200 font-bold tabular-nums">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 16 16"><g fill="currentColor" fill-rule="evenodd" clip-rule="evenodd"><path d="m8.427 11.073l1.205-1.205a.4.4 0 0 0 .118-.285a.8.8 0 0 0-.236-.569L8.427 7.927a.603.603 0 0 0-.854 0L6.486 9.014a.8.8 0 0 0-.236.57c0 .106.042.208.118.284l1.205 1.205a.604.604 0 0 0 .854 0" /><path d="M16 5.796v-.028a1.768 1.768 0 0 0-3.018-1.25l-.76.76l-.024.024l-.374.374l-.415.415a.335.335 0 0 1-.561-.149l-.155-.566l-.139-.51l-.009-.033l-.65-2.386a1.964 1.964 0 0 0-3.79 0l-.65 2.386l-.01.032l-.139.511l-.154.566a.335.335 0 0 1-.56.15l-.416-.416l-.374-.374l-.024-.024l-.76-.76A1.768 1.768 0 0 0 0 5.768v.028q0 .203.046.403l1.3 5.631a1.4 1.4 0 0 0 .778.958a14.02 14.02 0 0 0 11.752 0c.394-.182.681-.535.779-.958l1.299-5.63q.045-.2.046-.404M3.53 7.152c.997.997 2.698.545 3.07-.815l.952-3.495a.464.464 0 0 1 .896 0L9.4 6.337c.37 1.36 2.072 1.812 3.068.815l1.574-1.574a.268.268 0 0 1 .457.19v.028a.3.3 0 0 1-.008.066l-1.288 5.584a12.52 12.52 0 0 1-10.408 0L1.508 5.862a.3.3 0 0 1-.008-.066v-.028a.268.268 0 0 1 .457-.19z" /></g></svg>
|
||||
</div>
|
||||
<div class="flex w-full flex-row items-center justify-start gap-0.5 border-b border-white/10 pb-3 pt-1 max-w-full truncate">
|
||||
<span class="text-sm leading-tight text-white truncate max-w-full">Keep your special early supporter pricing forever</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="relative flex items-start justify-center gap-2.5 pt-3">
|
||||
<div class="flex size-5 shrink-0 items-center justify-center text-gray-200 font-bold tabular-nums">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 24 24"><g fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"><path d="M6.818 22v-2.857C6.662 17.592 5.633 16.416 4.682 15m9.772 7v-1.714c4.91 0 4.364-5.714 4.364-5.714s2.182 0 2.182-2.286l-2.182-3.428c0-4.572-3.709-6.816-7.636-6.857c-2.2-.023-3.957.53-5.27 1.499" /><path d="m13 7l2 2.5l-2 2.5M5 7L3 9.5L5 12m5-6l-2 7" /></g></svg>
|
||||
</div>
|
||||
<div class="flex w-full flex-row items-center justify-start gap-0.5 border-b border-white/10 pb-3 pt-1">
|
||||
<span class="text-sm leading-tight text-white truncate max-w-full">Priority feature requests</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="w-full justify-center items-center">
|
||||
<div class="mb-0.5 w-full flex items-center justify-center text-center text-sm leading-none text-gray-800" >Full access in</div>
|
||||
<div class="flex justify-center items-center gap-2 p-1">
|
||||
<TimeUnit value={timeLeft.days} label="Days" />
|
||||
<TimeUnit value={timeLeft.hours} label="Hours" />
|
||||
<TimeUnit value={timeLeft.minutes} label="Minutes" />
|
||||
<TimeUnit value={timeLeft.seconds} label="Seconds" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="w-full justify-center flex">
|
||||
<div class="group relative w-full cursor-pointer">
|
||||
{/**https://sandbox-api.polar.sh/v1/checkout-links/polar_cl_3Kf9mOEl8We2ZnmYr0tolrFPfHiPvlC71XgZy4Jd2ni/redirect */}
|
||||
<Link href="https://buy.polar.sh/polar_cl_Y1SAbDjOOU8kbnKz4yUbF0Aqus94YA4by9FdS1I4igY" class="appearance-none outline-none scale-100 active:scale-[0.98] flex h-9 w-full shrink-0 items-center justify-center rounded-lg bg-blue-500 px-4 text-sm font-semibold text-white transition-colors group-hover:bg-blue-400">Get early supporter price</Link>
|
||||
<div class="absolute -top-[22px] left-1/2 -translate-x-1/2">
|
||||
<Icons.specialOffer />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Modal.Panel>
|
||||
</Modal.Root>
|
||||
{/* <div class="absolute pointer-events-none size-full inset-0 dark:bg-[#0009] bg-[#b3b5b799] select-none backdrop-blur-[2px] z-50" /> */}
|
||||
</main >
|
||||
)
|
||||
})
|
||||
@@ -1,14 +1,14 @@
|
||||
import type Nestri from "@nestri/sdk"
|
||||
// import type Nestri from "@nestri/sdk"
|
||||
import { component$, Slot } from "@builder.io/qwik";
|
||||
import { type RequestHandler } from "@builder.io/qwik-city";
|
||||
// import { type RequestHandler } from "@builder.io/qwik-city";
|
||||
|
||||
export const onRequest: RequestHandler = async ({ url, redirect, sharedMap }) => {
|
||||
const currentProfile = sharedMap.get("profile") as Nestri.Users.UserRetrieveResponse.Data | null
|
||||
// export const onRequest: RequestHandler = async ({ url, redirect, sharedMap }) => {
|
||||
// // const currentProfile = sharedMap.get("profile") as Nestri.Users.UserRetrieveResponse.Data | null
|
||||
|
||||
if (!currentProfile) {
|
||||
throw redirect(308, `${url.origin}`)
|
||||
}
|
||||
}
|
||||
// // if (!currentProfile) {
|
||||
// // throw redirect(308, `${url.origin}`)
|
||||
// // }
|
||||
// }
|
||||
|
||||
export default component$(() => {
|
||||
return (
|
||||
|
||||
@@ -1,328 +1,309 @@
|
||||
// import posthog from "posthog-js";
|
||||
import { Modal } from "@nestri/ui";
|
||||
import { useLocation } from "@builder.io/qwik-city";
|
||||
import { Keyboard, Mouse, WebRTCStream } from "@nestri/input"
|
||||
import { component$, useSignal, useVisibleTask$ } from "@builder.io/qwik";
|
||||
import { $, component$, noSerialize, type NoSerialize, useSignal, useStore, useVisibleTask$ } from "@builder.io/qwik";
|
||||
// import Nestri from "@nestri/sdk";
|
||||
|
||||
//TODO: go full screen, then lock on "landscape" screen-orientation on mobile
|
||||
// FIXME: Add authentication and authorization
|
||||
// export const getUserSubscriptions = server$(
|
||||
// async function () {
|
||||
|
||||
// const access = this.cookie.get("access_token")
|
||||
// if (access) {
|
||||
// const bearerToken = access.value
|
||||
|
||||
// const nestriClient = new Nestri({ bearerToken, maxRetries: 5 })
|
||||
// const subscriptions = await nestriClient.users.session()
|
||||
|
||||
// return subscriptions as "Free" | "Pro"
|
||||
// }
|
||||
// }
|
||||
// );
|
||||
|
||||
type PlayState = {
|
||||
nestriMouse: NoSerialize<Mouse | undefined>
|
||||
nestriKeyboard: NoSerialize<Keyboard | undefined>
|
||||
webrtc: NoSerialize<WebRTCStream | undefined>
|
||||
nestriLock?: boolean
|
||||
hasStream?: boolean
|
||||
showOffline?: boolean
|
||||
video?: HTMLVideoElement
|
||||
inputInitialized?: boolean
|
||||
initializedVideo?: boolean
|
||||
}
|
||||
|
||||
export default component$(() => {
|
||||
const id = useLocation().params.id;
|
||||
const showBannerModal = useSignal(false)
|
||||
const showButtonModal = useSignal(false)
|
||||
const canvas = useSignal<HTMLCanvasElement>();
|
||||
const playState = useStore<PlayState>({
|
||||
nestriMouse: undefined,
|
||||
nestriKeyboard: undefined,
|
||||
nestriLock: undefined,
|
||||
webrtc: undefined,
|
||||
video: undefined,
|
||||
hasStream: undefined,
|
||||
showOffline: undefined,
|
||||
inputInitialized: false,
|
||||
initializedVideo: false
|
||||
})
|
||||
|
||||
const initializeInputDevices = $(() => {
|
||||
if (!canvas.value || !playState.webrtc || playState.inputInitialized) return;
|
||||
|
||||
try {
|
||||
playState.nestriMouse = noSerialize(new Mouse({
|
||||
canvas: canvas.value,
|
||||
webrtc: playState.webrtc
|
||||
}));
|
||||
playState.nestriKeyboard = noSerialize(new Keyboard({
|
||||
canvas: canvas.value,
|
||||
webrtc: playState.webrtc
|
||||
}));
|
||||
playState.inputInitialized = true;
|
||||
console.log("Input devices initialized successfully");
|
||||
} catch (error) {
|
||||
console.error("Failed to initialize input devices:", error);
|
||||
playState.inputInitialized = false;
|
||||
}
|
||||
});
|
||||
|
||||
const lockPlay = $(async () => {
|
||||
if (!canvas.value || !playState.hasStream) return;
|
||||
|
||||
try {
|
||||
await canvas.value.requestPointerLock();
|
||||
await canvas.value.requestFullscreen();
|
||||
|
||||
if (document.fullscreenElement !== null) {
|
||||
if ('keyboard' in navigator && 'lock' in (navigator.keyboard as any)) {
|
||||
const keys = [
|
||||
"AltLeft", "AltRight", "Tab", "Escape",
|
||||
"ContextMenu", "MetaLeft", "MetaRight"
|
||||
];
|
||||
|
||||
try {
|
||||
await (navigator.keyboard as any).lock(keys);
|
||||
playState.nestriLock = true;
|
||||
console.log("Keyboard lock acquired");
|
||||
} catch (e) {
|
||||
console.warn("Keyboard lock failed:", e);
|
||||
playState.nestriLock = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error during lock sequence:", error);
|
||||
}
|
||||
});
|
||||
|
||||
const setupPointerLockListener = $(() => {
|
||||
document.addEventListener("pointerlockchange", () => {
|
||||
if (!canvas.value) return;
|
||||
|
||||
if (document.pointerLockElement === canvas.value) {
|
||||
// Initialize input devices when pointer is locked
|
||||
if (!playState.inputInitialized) {
|
||||
initializeInputDevices();
|
||||
}
|
||||
} else {
|
||||
if (!showBannerModal.value) {
|
||||
const playing = sessionStorage.getItem("showedBanner");
|
||||
showBannerModal.value = !playing || playing !== "true";
|
||||
showButtonModal.value = playing === "true";
|
||||
}
|
||||
|
||||
// Clean up input devices
|
||||
if (playState.nestriKeyboard) {
|
||||
playState.nestriKeyboard.dispose();
|
||||
playState.nestriKeyboard = undefined;
|
||||
}
|
||||
if (playState.nestriMouse) {
|
||||
playState.nestriMouse.dispose();
|
||||
playState.nestriMouse = undefined;
|
||||
}
|
||||
playState.nestriLock = undefined;
|
||||
playState.inputInitialized = false;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
const handleVideoInput = $(async () => {
|
||||
if (!playState.video) return;
|
||||
if (playState.initializedVideo) return;
|
||||
|
||||
await playState.video.play().then(() => {
|
||||
if (canvas.value && playState.video) {
|
||||
canvas.value.width = playState.video.videoWidth;
|
||||
canvas.value.height = playState.video.videoHeight;
|
||||
playState.initializedVideo = true
|
||||
|
||||
const ctx = canvas.value.getContext("2d");
|
||||
const renderer = () => {
|
||||
if (ctx && playState.hasStream && playState.video) {
|
||||
ctx.drawImage(playState.video, 0, 0);
|
||||
playState.video.requestVideoFrameCallback(renderer);
|
||||
}
|
||||
};
|
||||
|
||||
playState.video.requestVideoFrameCallback(renderer);
|
||||
}
|
||||
}).catch(error => {
|
||||
console.error("Error playing video:", error);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
// eslint-disable-next-line qwik/no-use-visible-task
|
||||
useVisibleTask$(({ track }) => {
|
||||
track(() => canvas.value);
|
||||
|
||||
if (!canvas.value) return; // Ensure canvas is available
|
||||
|
||||
// Create video element and make it output to canvas (TODO: improve this)
|
||||
let video = document.getElementById("webrtc-video-player");
|
||||
if (!video) {
|
||||
video = document.createElement("video");
|
||||
video.id = "stream-video-player";
|
||||
video.style.visibility = "hidden";
|
||||
const webrtc = new WebRTCStream("https://relay.dathorse.com", id, (mediaStream) => {
|
||||
if (video && mediaStream && (video as HTMLVideoElement).srcObject === null) {
|
||||
console.log("Setting mediastream");
|
||||
(video as HTMLVideoElement).srcObject = mediaStream;
|
||||
setupPointerLockListener();
|
||||
try {
|
||||
if (!playState.video) {
|
||||
playState.video = document.createElement("video") as HTMLVideoElement
|
||||
playState.video.style.visibility = "hidden";
|
||||
playState.webrtc = noSerialize(new WebRTCStream("https://relay.dathorse.com", id, async (mediaStream) => {
|
||||
if (playState.video && mediaStream && playState.video.srcObject === null) {
|
||||
console.log("Setting mediastream");
|
||||
playState.video.srcObject = mediaStream;
|
||||
playState.hasStream = true;
|
||||
playState.showOffline = false;
|
||||
|
||||
// @ts-ignore
|
||||
window.hasstream = true;
|
||||
// @ts-ignore
|
||||
window.roomOfflineElement?.remove();
|
||||
// @ts-ignore
|
||||
window.playbtnelement?.remove();
|
||||
const playing = sessionStorage.getItem("showedBanner")
|
||||
if (!playing || playing != "true") {
|
||||
if (!showBannerModal.value) showBannerModal.value = true
|
||||
} else {
|
||||
if (!showButtonModal.value) showButtonModal.value = true
|
||||
}
|
||||
|
||||
const playbtn = document.createElement("button");
|
||||
playbtn.style.position = "absolute";
|
||||
playbtn.style.left = "50%";
|
||||
playbtn.style.top = "50%";
|
||||
playbtn.style.transform = "translateX(-50%) translateY(-50%)";
|
||||
playbtn.style.width = "12rem";
|
||||
playbtn.style.height = "6rem";
|
||||
playbtn.style.borderRadius = "1rem";
|
||||
playbtn.style.backgroundColor = "rgb(175, 50, 50)";
|
||||
playbtn.style.color = "black";
|
||||
playbtn.style.fontSize = "1.5em";
|
||||
playbtn.textContent = "< Start >";
|
||||
await handleVideoInput();
|
||||
} else if (mediaStream === null) {
|
||||
console.log("MediaStream is null, Room is offline");
|
||||
playState.showOffline = true
|
||||
playState.hasStream = false;
|
||||
// Clear canvas if it has been set
|
||||
if (canvas.value) {
|
||||
const ctx = canvas.value.getContext("2d");
|
||||
if (ctx) ctx.clearRect(0, 0, canvas.value.width, canvas.value.height);
|
||||
}
|
||||
} else if (playState.video && playState.video.srcObject !== null) {
|
||||
console.log("Setting new mediastream");
|
||||
playState.video.srcObject = mediaStream;
|
||||
playState.hasStream = true;
|
||||
playState.showOffline = true
|
||||
|
||||
playbtn.onclick = () => {
|
||||
playbtn.remove();
|
||||
(video as HTMLVideoElement).play().then(() => {
|
||||
if (canvas.value) {
|
||||
canvas.value.width = (video as HTMLVideoElement).videoWidth;
|
||||
canvas.value.height = (video as HTMLVideoElement).videoHeight;
|
||||
playState.video.play().then(() => {
|
||||
// window.roomOfflineElement?.remove();
|
||||
playState.showOffline = false
|
||||
if (canvas.value && playState.video) {
|
||||
canvas.value.width = playState.video.videoWidth;
|
||||
canvas.value.height = playState.video.videoHeight;
|
||||
|
||||
const ctx = canvas.value.getContext("2d");
|
||||
const renderer = () => {
|
||||
// @ts-ignore
|
||||
if (ctx && window.hasstream) {
|
||||
ctx.drawImage((video as HTMLVideoElement), 0, 0);
|
||||
(video as HTMLVideoElement).requestVideoFrameCallback(renderer);
|
||||
if (ctx && playState.hasStream && playState.video) {
|
||||
ctx.drawImage(playState.video, 0, 0);
|
||||
playState.video.requestVideoFrameCallback(renderer);
|
||||
}
|
||||
}
|
||||
(video as HTMLVideoElement).requestVideoFrameCallback(renderer);
|
||||
playState.video.requestVideoFrameCallback(renderer);
|
||||
}
|
||||
});
|
||||
|
||||
document.addEventListener("pointerlockchange", () => {
|
||||
if (!canvas.value) return; // Ensure canvas is available
|
||||
// @ts-ignore
|
||||
if (document.pointerLockElement && !window.nestrimouse && !window.nestrikeyboard) {
|
||||
// @ts-ignore
|
||||
window.nestrimouse = new Mouse({ canvas: canvas.value, webrtc });
|
||||
// @ts-ignore
|
||||
window.nestrikeyboard = new Keyboard({ canvas: canvas.value, webrtc });
|
||||
// @ts-ignore
|
||||
} else if (!document.pointerLockElement && window.nestrimouse && window.nestrikeyboard) {
|
||||
// @ts-ignore
|
||||
window.nestrimouse.dispose();
|
||||
// @ts-ignore
|
||||
window.nestrimouse = undefined;
|
||||
// @ts-ignore
|
||||
window.nestrikeyboard.dispose();
|
||||
// @ts-ignore
|
||||
window.nestrikeyboard = undefined;
|
||||
// @ts-ignore
|
||||
window.nestriLock = undefined;
|
||||
}
|
||||
});
|
||||
};
|
||||
document.body.append(playbtn);
|
||||
// @ts-ignore
|
||||
window.playbtnelement = playbtn;
|
||||
} else if (mediaStream === null) {
|
||||
console.log("MediaStream is null, Room is offline");
|
||||
// @ts-ignore
|
||||
window.playbtnelement?.remove();
|
||||
// @ts-ignore
|
||||
window.roomOfflineElement?.remove();
|
||||
// Add a message to the screen
|
||||
const offline = document.createElement("div");
|
||||
offline.style.position = "absolute";
|
||||
offline.style.left = "50%";
|
||||
offline.style.top = "50%";
|
||||
offline.style.transform = "translateX(-50%) translateY(-50%)";
|
||||
offline.style.width = "auto";
|
||||
offline.style.height = "auto";
|
||||
offline.style.color = "lightgray";
|
||||
offline.style.fontSize = "2em";
|
||||
offline.textContent = "Offline";
|
||||
document.body.append(offline);
|
||||
// @ts-ignore
|
||||
window.roomOfflineElement = offline;
|
||||
// @ts-ignore
|
||||
window.hasstream = false;
|
||||
// Clear canvas if it has been set
|
||||
if (canvas.value) {
|
||||
const ctx = canvas.value.getContext("2d");
|
||||
if (ctx) ctx.clearRect(0, 0, canvas.value.width, canvas.value.height);
|
||||
}
|
||||
} else if ((video as HTMLVideoElement).srcObject !== null) {
|
||||
console.log("Setting new mediastream");
|
||||
(video as HTMLVideoElement).srcObject = mediaStream;
|
||||
// @ts-ignore
|
||||
window.hasstream = true;
|
||||
// Start video rendering
|
||||
(video as HTMLVideoElement).play().then(() => {
|
||||
// @ts-ignore
|
||||
window.roomOfflineElement?.remove();
|
||||
if (canvas.value) {
|
||||
canvas.value.width = (video as HTMLVideoElement).videoWidth;
|
||||
canvas.value.height = (video as HTMLVideoElement).videoHeight;
|
||||
|
||||
const ctx = canvas.value.getContext("2d");
|
||||
const renderer = () => {
|
||||
// @ts-ignore
|
||||
if (ctx && window.hasstream) {
|
||||
ctx.drawImage((video as HTMLVideoElement), 0, 0);
|
||||
(video as HTMLVideoElement).requestVideoFrameCallback(renderer);
|
||||
}
|
||||
}
|
||||
(video as HTMLVideoElement).requestVideoFrameCallback(renderer);
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
}));
|
||||
}
|
||||
} catch (error) {
|
||||
console.log("error handling the media connection", error)
|
||||
}
|
||||
})
|
||||
|
||||
return (
|
||||
<canvas
|
||||
ref={canvas}
|
||||
onClick$={async () => {
|
||||
// @ts-ignore
|
||||
if (canvas.value && window.hasstream && !window.nestriLock) {
|
||||
// Do not use - unadjustedMovement: true - breaks input on linux
|
||||
await canvas.value.requestPointerLock();
|
||||
await canvas.value.requestFullscreen()
|
||||
if (document.fullscreenElement !== null) {
|
||||
// @ts-ignore
|
||||
if ('keyboard' in window.navigator && 'lock' in window.navigator.keyboard) {
|
||||
const keys = [
|
||||
"AltLeft",
|
||||
"AltRight",
|
||||
"Tab",
|
||||
"Escape",
|
||||
"ContextMenu",
|
||||
"MetaLeft",
|
||||
"MetaRight"
|
||||
];
|
||||
console.log("requesting keyboard lock");
|
||||
// @ts-ignore
|
||||
window.navigator.keyboard.lock(keys).then(
|
||||
() => {
|
||||
console.log("keyboard lock success");
|
||||
// @ts-ignore
|
||||
window.nestriLock = true;
|
||||
}
|
||||
).catch(
|
||||
(e: any) => {
|
||||
console.log("keyboard lock failed: ", e);
|
||||
// @ts-ignore
|
||||
window.nestriLock = false;
|
||||
}
|
||||
)
|
||||
} else {
|
||||
console.log("keyboard lock not supported, navigator is: ", window.navigator, navigator);
|
||||
// @ts-ignore
|
||||
window.nestriLock = undefined;
|
||||
}
|
||||
}
|
||||
}
|
||||
}}
|
||||
//TODO: go full screen, then lock on "landscape" screen-orientation on mobile
|
||||
class="aspect-video h-full w-full object-contain max-h-screen" />
|
||||
<>
|
||||
{playState.showOffline ? (
|
||||
<div class="w-screen h-screen flex justify-center items-center">
|
||||
<span class="text-2xl font-semibold flex items-center gap-2" >
|
||||
Offline
|
||||
</span>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
< canvas
|
||||
ref={canvas}
|
||||
onClick$={lockPlay}
|
||||
class="aspect-video h-full w-full object-contain max-h-screen" />
|
||||
{typeof playState.showOffline === "undefined" && (
|
||||
<div class="w-screen h-screen bg-gray-100 dark:bg-gray-900 absolute z-10 flex justify-center items-center">
|
||||
<span class="text-xl font-semibold flex items-center gap-2" >
|
||||
<div data-component="spinner">
|
||||
<div>
|
||||
{new Array(12).fill(0).map((i, k) => (
|
||||
<div key={k} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
Warming up the GPU...
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
<Modal.Root bind:show={showButtonModal} closeOnBackdropClick={false}>
|
||||
<Modal.Panel class="
|
||||
dark:backdrop:bg-[#0009] backdrop:bg-[#b3b5b799] backdrop:backdrop-grayscale-[.3] w-full max-w-[370px] max-h-[75vh] rounded-xl border dark:border-[#343434] border-[#e2e2e2]
|
||||
dark:[box-shadow:0_0_0_1px_rgba(255,255,255,0.08),_0_3.3px_2.7px_rgba(0,0,0,.1),0_8.3px_6.9px_rgba(0,0,0,.13),0_17px_14.2px_rgba(0,0,0,.17),0_35px_29.2px_rgba(0,0,0,.22),0px_-4px_4px_0px_rgba(0,0,0,.04)_inset] dark:bg-[#222b]
|
||||
[box-shadow:0_0_0_1px_rgba(19,21,23,0.08),_0_3.3px_2.7px_rgba(0,0,0,.03),0_8.3px_6.9px_rgba(0,0,0,.04),0_17px_14.2px_rgba(0,0,0,.05),0_35px_29.2px_rgba(0,0,0,.06),0px_-4px_4px_0px_rgba(0,0,0,.07)_inset] bg-[#fffd]
|
||||
backdrop-blur-lg py-4 px-5 modal" >
|
||||
<div class="size-full flex flex-col">
|
||||
<div class="flex flex-col gap-3" >
|
||||
<button
|
||||
onClick$={async () => {
|
||||
showButtonModal.value = false;
|
||||
await handleVideoInput()
|
||||
await lockPlay();
|
||||
}}
|
||||
class="transition-all duration-200 focus:ring-2 focus:ring-gray-300 focus:dark:ring-gray-700 outline-none w-full hover:bg-gray-300 hover:dark:bg-gray-700 bg-gray-200 dark:bg-gray-800 text-gray-800 dark:text-gray-200 items-center justify-center font-medium font-title rounded-lg flex py-3 px-4" >
|
||||
Continue Playing
|
||||
</button>
|
||||
<button class="transition-all duration-200 focus:ring-2 focus:ring-gray-300 focus:dark:ring-gray-700 outline-none w-full hover:bg-gray-300 hover:dark:bg-gray-700 bg-gray-200 dark:bg-gray-800 text-gray-800 dark:text-gray-200 items-center justify-center font-medium font-title rounded-lg flex py-3 px-4" >
|
||||
Shutdown Nestri
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal.Panel>
|
||||
</Modal.Root>
|
||||
<Modal.Root bind:show={showBannerModal} closeOnBackdropClick={false}>
|
||||
<Modal.Panel class="
|
||||
dark:backdrop:bg-[#0009] backdrop:bg-[#b3b5b799] backdrop:backdrop-grayscale-[.3] w-full max-w-[37%] max-h-[75vh] rounded-xl border dark:border-[#343434] border-[#e2e2e2]
|
||||
dark:[box-shadow:0_0_0_1px_rgba(255,255,255,0.08),_0_3.3px_2.7px_rgba(0,0,0,.1),0_8.3px_6.9px_rgba(0,0,0,.13),0_17px_14.2px_rgba(0,0,0,.17),0_35px_29.2px_rgba(0,0,0,.22),0px_-4px_4px_0px_rgba(0,0,0,.04)_inset] dark:bg-[#222b]
|
||||
[box-shadow:0_0_0_1px_rgba(19,21,23,0.08),_0_3.3px_2.7px_rgba(0,0,0,.03),0_8.3px_6.9px_rgba(0,0,0,.04),0_17px_14.2px_rgba(0,0,0,.05),0_35px_29.2px_rgba(0,0,0,.06),0px_-4px_4px_0px_rgba(0,0,0,.07)_inset] bg-[#fffd]
|
||||
backdrop-blur-lg py-4 px-5 modal" >
|
||||
<div class="size-full flex flex-col">
|
||||
<div class="dark:text-white text-black">
|
||||
<h3 class="font-semibold text-2xl tracking-tight mb-2 font-title">Important information from Nestri</h3>
|
||||
<div class="text-sm dark:text-white/[.79] text-[rgba(19,21,23,0.64)]" >
|
||||
This product is in Alpha — please share feedback whenever possible to help us improve. Thanks you for your support! 💖
|
||||
</div>
|
||||
</div>
|
||||
<div class="sm:pt-10 sm:block hidden" >
|
||||
<button
|
||||
onClick$={async () => {
|
||||
sessionStorage.setItem("showedBanner", "true");
|
||||
showBannerModal.value = false;
|
||||
await handleVideoInput()
|
||||
await lockPlay();
|
||||
}}
|
||||
class="gap-3 outline-none hover:[box-shadow:0_0_0_2px_rgba(200,200,200,0.95),0_0_0_4px_#8f8f8f] dark:hover:[box-shadow:0_0_0_2px_#161616,0_0_0_4px_#707070] focus:[box-shadow:0_0_0_2px_rgba(200,200,200,0.95),0_0_0_4px_#8f8f8f] dark:focus:[box-shadow:0_0_0_2px_#161616,0_0_0_4px_#707070] [transition:all_0.3s_cubic-bezier(0.4,0,0.2,1)] font-medium font-title rounded-lg flex h-[calc(2.25rem+2*1px)] flex-col text-white w-full leading-none truncate bg-primary-500 items-center justify-center" >
|
||||
Continue
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal.Panel>
|
||||
</Modal.Root>
|
||||
</>
|
||||
)
|
||||
})
|
||||
|
||||
{/**
|
||||
.spinningCircleInner_b6db20 {
|
||||
transform: rotate(280deg);
|
||||
}
|
||||
.inner_b6db20 {
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
contain: paint;
|
||||
} */
|
||||
}
|
||||
|
||||
{/* <div class="loadingPopout_a8c724" role="dialog" tabindex="-1" aria-modal="true"><div class="spinner_b6db20 spinningCircle_b6db20" role="img" aria-label="Loading"><div class="spinningCircleInner_b6db20 inner_b6db20"><svg class="circular_b6db20" viewBox="25 25 50 50"><circle class="path_b6db20 path3_b6db20" cx="50" cy="50" r="20"></circle><circle class="path_b6db20 path2_b6db20" cx="50" cy="50" r="20"></circle><circle class="path_b6db20" cx="50" cy="50" r="20"></circle></svg></div></div></div> */
|
||||
}
|
||||
// .loadingPopout_a8c724 {
|
||||
// background-color: var(--background-secondary);
|
||||
// display: flex;
|
||||
// justify-content: center;
|
||||
// padding: 8px;
|
||||
// }
|
||||
|
||||
// .circular_b6db20 {
|
||||
// animation: spinner-spinning-circle-rotate_b6db20 2s linear infinite;
|
||||
// height: 100%;
|
||||
// width: 100%;
|
||||
// }
|
||||
|
||||
// 100% {
|
||||
// transform: rotate(360deg);
|
||||
// }
|
||||
|
||||
|
||||
{/* .path3_b6db20 {
|
||||
animation-delay: .23s;
|
||||
stroke: var(--text-brand);
|
||||
}
|
||||
.path_b6db20 {
|
||||
animation: spinner-spinning-circle-dash_b6db20 2s ease-in-out infinite;
|
||||
stroke-dasharray: 1, 200;
|
||||
stroke-dashoffset: 0;
|
||||
fill: none;
|
||||
stroke-width: 6;
|
||||
stroke-miterlimit: 10;
|
||||
stroke-linecap: round;
|
||||
stroke: var(--brand-500);
|
||||
}
|
||||
circle[Attributes Style] {
|
||||
cx: 50;
|
||||
cy: 50;
|
||||
r: 20;
|
||||
}
|
||||
user agent stylesheet
|
||||
:not(svg) {
|
||||
transform-origin: 0px 0px;
|
||||
} */
|
||||
}
|
||||
|
||||
|
||||
// .path2_b6db20 {
|
||||
// animation-delay: .15s;
|
||||
// stroke: var(--text-brand);
|
||||
// opacity: .6;
|
||||
// }
|
||||
// .path_b6db20 {
|
||||
// animation: spinner-spinning-circle-dash_b6db20 2s ease-in-out infinite;
|
||||
// stroke-dasharray: 1, 200;
|
||||
// stroke-dashoffset: 0;
|
||||
// fill: none;
|
||||
// stroke-width: 6;
|
||||
// stroke-miterlimit: 10;
|
||||
// stroke-linecap: round;
|
||||
// stroke: var(--brand-500);
|
||||
// }
|
||||
// circle[Attributes Style] {
|
||||
// cx: 50;
|
||||
// cy: 50;
|
||||
// r: 20;
|
||||
|
||||
|
||||
// function throttle(func, limit) {
|
||||
// let inThrottle;
|
||||
// return function(...args) {
|
||||
// if (!inThrottle) {
|
||||
// func.apply(this, args);
|
||||
// inThrottle = true;
|
||||
// setTimeout(() => inThrottle = false, limit);
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
|
||||
// // Use it like this:
|
||||
// const throttledMouseMove = throttle((x, y) => {
|
||||
// websocket.send(JSON.stringify({
|
||||
// type: 'mousemove',
|
||||
// x: x,
|
||||
// y: y
|
||||
// }));
|
||||
// }, 16); // ~60fps
|
||||
|
||||
// use std::time::Instant;
|
||||
|
||||
// // Add these to your AppState
|
||||
// struct AppState {
|
||||
// pipeline: Arc<Mutex<gst::Pipeline>>,
|
||||
// last_mouse_move: Arc<Mutex<(i32, i32, Instant)>>, // Add this
|
||||
// }
|
||||
|
||||
// // Then in your MouseMove handler:
|
||||
// InputMessage::MouseMove { x, y } => {
|
||||
// let mut last_move = state.last_mouse_move.lock().unwrap();
|
||||
// let now = Instant::now();
|
||||
|
||||
// // Only process if coordinates are different or enough time has passed
|
||||
// if (last_move.0 != x || last_move.1 != y) &&
|
||||
// (now.duration_since(last_move.2).as_millis() > 16) { // ~60fps
|
||||
|
||||
// println!("Mouse moved to x: {}, y: {}", x, y);
|
||||
|
||||
// let structure = gst::Structure::builder("MouseMoveRelative")
|
||||
// .field("pointer_x", x as f64)
|
||||
// .field("pointer_y", y as f64)
|
||||
// .build();
|
||||
|
||||
// let event = gst::event::CustomUpstream::new(structure);
|
||||
// pipeline.send_event(event);
|
||||
|
||||
// // Update last position and time
|
||||
// *last_move = (x, y, now);
|
||||
// }
|
||||
// }
|
||||
})
|
||||
@@ -1,13 +1,13 @@
|
||||
.blog h1 {
|
||||
@apply text-4xl font-title font-bold text-gray-800 dark:text-gray-200;
|
||||
@apply text-4xl font-bricolage font-bold text-gray-800 dark:text-gray-200;
|
||||
}
|
||||
|
||||
.blog h2 {
|
||||
@apply text-3xl font-title font-bold text-gray-800 dark:text-gray-200;
|
||||
@apply text-3xl font-bricolage font-bold text-gray-800 dark:text-gray-200;
|
||||
}
|
||||
|
||||
.blog h3 {
|
||||
@apply text-2xl font-title font-bold text-gray-800 dark:text-gray-200;
|
||||
@apply text-2xl font-mona font-bold text-gray-800 dark:text-gray-200;
|
||||
}
|
||||
|
||||
.blog img {
|
||||
@@ -19,7 +19,7 @@
|
||||
}
|
||||
|
||||
.blog strong {
|
||||
@apply text-gray-700 dark:text-gray-300 font-title font-semibold;
|
||||
@apply text-gray-700 dark:text-gray-300 font-mona font-semibold;
|
||||
}
|
||||
|
||||
.blog ul {
|
||||
|
||||
@@ -30,7 +30,7 @@ export default component$(() => {
|
||||
<Link key={blog.title} class="border-b border-gray-300 dark:border-gray-700 outline-none w-full" href={`/blog/${blog.href}`}>
|
||||
<div class="w-full gap-3 py-6 hover:px-2 flex relative items-center rounded-md hover:bg-gray-200 dark:hover:bg-gray-800 transition-all duration-200">
|
||||
<div class="w-max flex flex-col max-w-[70%]">
|
||||
<h2 class="text-lg inline-block font-title font-bold dark:text-gray-100 text-gray-800">{blog.title}</h2>
|
||||
<h2 class="text-lg inline-block font-bricolage font-bold dark:text-gray-100 text-gray-800">{blog.title}</h2>
|
||||
<p class="text-sm text-gray-600 dark:text-gray-400 overflow-ellipsis whitespace-nowrap overflow-hidden">{blog.description}</p>
|
||||
</div>
|
||||
<div class="flex-1 relative min-w-[8px] box-border before:absolute before:-bottom-[1px] before:h-[1px] before:w-full before:bg-gray-600 dark:before:bg-gray-400 before:z-[5] before:duration-300 before:transition-all" />
|
||||
|
||||
@@ -57,7 +57,7 @@ export default component$(() => {
|
||||
</nav>
|
||||
</div>
|
||||
<div class="max-w-xl mx-auto w-full gap-1 flex flex-col">
|
||||
<h2 class="text-4xl text-start tracking-tight font-bold font-title">
|
||||
<h2 class="text-4xl text-start tracking-tight font-bold font-bricolage">
|
||||
{frontmatter.blogTitle}
|
||||
</h2>
|
||||
<div class="list-none flex flex-col items-start justify-start mt-2 gap-2 text-sm w-full">
|
||||
|
||||
@@ -64,7 +64,7 @@ export default component$(() => {
|
||||
</div>
|
||||
</div>
|
||||
</MotionComponent>
|
||||
<Footer showBanner={false} />
|
||||
<Footer/>
|
||||
</>
|
||||
)
|
||||
})
|
||||
|
||||
@@ -1,50 +1,10 @@
|
||||
// import Nestri from "@nestri/sdk";
|
||||
import { NavBar } from "@nestri/ui";
|
||||
import { component$, Slot } from "@builder.io/qwik";
|
||||
import { createClient } from "@openauthjs/openauth/client";
|
||||
import { type RequestHandler, routeLoader$ } from "@builder.io/qwik-city";
|
||||
|
||||
//FIXME: This seems not to work
|
||||
// export const onRequest: RequestHandler = async ({ cookie, sharedMap }) => {
|
||||
// const access = cookie.get("access_token")
|
||||
// if (access) {
|
||||
// const bearerToken = access.value
|
||||
|
||||
// const nestriClient = new Nestri({
|
||||
// bearerToken,
|
||||
// baseURL: "https://api.lauryn.dev.nestri.io"
|
||||
// })
|
||||
|
||||
// const currentProfile = await nestriClient.users.retrieve()
|
||||
// sharedMap.set("profile", currentProfile.data)
|
||||
// }
|
||||
// }
|
||||
|
||||
export const onRequest: RequestHandler = async ({url, sharedMap }) => {
|
||||
// const access = cookie.get("access_token")
|
||||
// if (!access) {
|
||||
const client = createClient({
|
||||
clientID: "www",
|
||||
issuer: "https://auth.nestri.io"
|
||||
})
|
||||
|
||||
const auth = await client.authorize(url.origin + "/callback", "code")
|
||||
sharedMap.set("auth_url", auth.url)
|
||||
// }
|
||||
}
|
||||
|
||||
export const useLink = routeLoader$(async ({sharedMap}) => {
|
||||
const url = sharedMap.get("auth_url") as string
|
||||
|
||||
return url
|
||||
})
|
||||
|
||||
export default component$(() => {
|
||||
const loginUrl = useLink()
|
||||
|
||||
return (
|
||||
<>
|
||||
<NavBar link={loginUrl.value} />
|
||||
<NavBar />
|
||||
<Slot />
|
||||
</>
|
||||
)
|
||||
|
||||
@@ -115,7 +115,7 @@ export default component$(() => {
|
||||
})
|
||||
|
||||
return (
|
||||
<>
|
||||
<div class="w-screen relative">
|
||||
<TitleSection client:load title="Pricing" description={"We're growing at the speed of trust. Choose a price that feels right for you and help support Nestri"} />
|
||||
<MotionComponent
|
||||
initial={{ opacity: 0, y: 100 }}
|
||||
@@ -519,7 +519,21 @@ export default component$(() => {
|
||||
</section>
|
||||
</div>
|
||||
</MotionComponent>
|
||||
<Footer />
|
||||
</>
|
||||
<Footer client:load>
|
||||
<div class="w-full flex justify-center flex-col items-center gap-3">
|
||||
<Link href="/auth/login" prefetch={false} class="flex font-bricolage text-sm sm:text-base rounded-full bg-primary-500 px-5 py-4 font-semibold text-white transition-all hover:scale-105 active:scale-95 sm:px-6" >
|
||||
Get early access
|
||||
</Link>
|
||||
<div class="mt-6 flex w-full items-center justify-center gap-2 text-xs sm:text-sm font-medium text-neutral-600 dark:text-neutral-400">
|
||||
<span class="hover:text-primary-500 transition-colors duration-200">
|
||||
<Link rel="noreferrer" href="/terms" >Terms of Service</Link></span>
|
||||
<span class="text-gray-400 dark:text-gray-600">•</span>
|
||||
<span class="hover:text-primary-500 transition-colors duration-200" >
|
||||
<Link href="/privacy">Privacy Policy</Link>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</Footer>
|
||||
</div>
|
||||
)
|
||||
})
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import posthog from "posthog-js";
|
||||
import Nestri from "@nestri/sdk";
|
||||
import { component$, useVisibleTask$ } from "@builder.io/qwik";
|
||||
import { createClient } from "@openauthjs/openauth/client";
|
||||
import { component$, useVisibleTask$ } from "@builder.io/qwik";
|
||||
import { routeLoader$, useNavigate, type CookieOptions } from "@builder.io/qwik-city";
|
||||
|
||||
export const useLoggedIn = routeLoader$(async ({ query, url, cookie }) => {
|
||||
@@ -9,7 +10,7 @@ export const useLoggedIn = routeLoader$(async ({ query, url, cookie }) => {
|
||||
const redirect_uri = url.origin + "/callback"
|
||||
const cookieOptions: CookieOptions = {
|
||||
path: "/",
|
||||
sameSite: "lax",
|
||||
sameSite: "lax",
|
||||
secure: false, // Only send cookies over HTTPS
|
||||
//FIXME: This causes weird issues in Qwik
|
||||
httpOnly: true, // Prevent JavaScript access to cookies
|
||||
@@ -38,23 +39,33 @@ export const useLoggedIn = routeLoader$(async ({ query, url, cookie }) => {
|
||||
|
||||
//TODO: Use subjects instead
|
||||
const currentProfile = await nestriClient.users.retrieve()
|
||||
const username = currentProfile.data.username
|
||||
return username
|
||||
const userProfile = currentProfile.data
|
||||
return userProfile
|
||||
}
|
||||
|
||||
}
|
||||
})
|
||||
|
||||
export default component$(() => {
|
||||
const username = useLoggedIn()
|
||||
const userProfile = useLoggedIn()
|
||||
const navigate = useNavigate();
|
||||
|
||||
// eslint-disable-next-line qwik/no-use-visible-task
|
||||
useVisibleTask$(() => {
|
||||
if (username.value) {
|
||||
setTimeout(async () => {
|
||||
await navigate(`${window.location.origin}/${username.value}`)
|
||||
}, 500);
|
||||
|
||||
if (userProfile.value) {
|
||||
posthog.identify(
|
||||
userProfile.value.id,
|
||||
{
|
||||
username: userProfile.value.username,
|
||||
joinedAt: userProfile.value.createdAt,
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
setTimeout(async () => {
|
||||
await navigate(`${window.location.origin}/home`)
|
||||
}, 500);
|
||||
})
|
||||
|
||||
return (
|
||||
13
apps/www/src/routes/auth/layout.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import { type RequestHandler } from "@builder.io/qwik-city";
|
||||
|
||||
export const onRequest: RequestHandler = async ({ json, url }) => {
|
||||
// const access = cookie.get("access_token")
|
||||
// if (!access) {
|
||||
// throw json(401, { error: "You are not authorized" })
|
||||
// }
|
||||
|
||||
if (!url.hostname.endsWith("nestri.io") && !url.hostname.endsWith("localhost")) {
|
||||
throw json(404, { error: "We could not serve your request" })
|
||||
}
|
||||
|
||||
}
|
||||
16
apps/www/src/routes/auth/login/index.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import { type RequestHandler } from "@builder.io/qwik-city";
|
||||
import { createClient } from "@openauthjs/openauth/client";
|
||||
|
||||
export const onGet: RequestHandler = async ({ cookie, redirect, url }) => {
|
||||
cookie.delete("access_token")
|
||||
cookie.delete("refresh_token")
|
||||
|
||||
const client = createClient({
|
||||
clientID: "www",
|
||||
issuer: "https://auth.nestri.io"
|
||||
})
|
||||
|
||||
const auth = await client.authorize(url.origin + "/auth/callback", "code")
|
||||
|
||||
throw redirect(308, auth.url)
|
||||
}
|
||||
7
apps/www/src/routes/auth/logout/index.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import { type RequestHandler } from "@builder.io/qwik-city";
|
||||
|
||||
export const onGet: RequestHandler = async ({ cookie, redirect, url }) => {
|
||||
cookie.delete("access_token")
|
||||
cookie.delete("refresh_token")
|
||||
throw redirect(308, url.origin)
|
||||
}
|
||||
@@ -1,5 +1,4 @@
|
||||
import posthog from "posthog-js";
|
||||
import Nestri from "@nestri/sdk";
|
||||
import { NavProgress } from "@nestri/ui";
|
||||
import { component$, Slot, useVisibleTask$ } from "@builder.io/qwik";
|
||||
import { type DocumentHead, type RequestHandler } from "@builder.io/qwik-city";
|
||||
@@ -15,29 +14,6 @@ export const onGet: RequestHandler = async ({ cacheControl }) => {
|
||||
});
|
||||
};
|
||||
|
||||
export const onRequest: RequestHandler = async ({ cookie, url, redirect, sharedMap }) => {
|
||||
const access = cookie.get("access_token")
|
||||
if (access) {
|
||||
try {
|
||||
|
||||
const bearerToken = access.value
|
||||
|
||||
const nestriClient = new Nestri({
|
||||
bearerToken,
|
||||
baseURL: "https://api.nestri.io"
|
||||
})
|
||||
const currentProfile = await nestriClient.users.retrieve()
|
||||
sharedMap.set("profile", currentProfile.data)
|
||||
} catch (error) {
|
||||
console.log("error working with bearer token", error)
|
||||
cookie.delete("access_token")
|
||||
cookie.delete("refresh_token")
|
||||
|
||||
throw redirect(302, url.origin)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default component$(() => {
|
||||
// eslint-disable-next-line qwik/no-use-visible-task
|
||||
useVisibleTask$(() => {
|
||||
|
||||
6
apps/www/src/routes/links/github/index.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import { type RequestHandler } from "@builder.io/qwik-city"
|
||||
|
||||
export const onGet: RequestHandler = async ({ redirect }) => {
|
||||
|
||||
throw redirect(308, "https://github.com/nestrilabs/nestri")
|
||||
}
|
||||
@@ -1,303 +1,222 @@
|
||||
import { useLocation } from "@builder.io/qwik-city";
|
||||
import {WebRTCStream } from "@nestri/input"
|
||||
import { component$, useSignal, useVisibleTask$ } from "@builder.io/qwik";
|
||||
// import posthog from "posthog-js";
|
||||
import { Modal } from "@nestri/ui";
|
||||
import { WebRTCStream } from "@nestri/input"
|
||||
import { useLocation, useNavigate } from "@builder.io/qwik-city";
|
||||
import { $, component$, noSerialize, type NoSerialize, useSignal, useStore, useVisibleTask$ } from "@builder.io/qwik";
|
||||
|
||||
//TODO: go full screen, then lock on "landscape" screen-orientation on mobile
|
||||
|
||||
type PlayState = {
|
||||
webrtc: NoSerialize<WebRTCStream | undefined>
|
||||
nestriLock?: boolean
|
||||
hasStream?: boolean
|
||||
showOffline?: boolean
|
||||
video?: HTMLVideoElement
|
||||
inputInitialized?: boolean
|
||||
initializedVideo?: boolean
|
||||
}
|
||||
|
||||
export default component$(() => {
|
||||
const id = useLocation().params.id;
|
||||
const nav = useNavigate()
|
||||
const showBannerModal = useSignal(false)
|
||||
const showButtonModal = useSignal(false)
|
||||
const canvas = useSignal<HTMLCanvasElement>();
|
||||
const playState = useStore<PlayState>({
|
||||
nestriLock: undefined,
|
||||
webrtc: undefined,
|
||||
video: undefined,
|
||||
hasStream: undefined,
|
||||
showOffline: undefined,
|
||||
inputInitialized: false,
|
||||
initializedVideo: false
|
||||
})
|
||||
|
||||
const lockPlay = $(async () => {
|
||||
if (!canvas.value || !playState.hasStream) return;
|
||||
|
||||
try {
|
||||
await canvas.value.requestFullscreen();
|
||||
} catch (error) {
|
||||
console.error("Error during lock sequence:", error);
|
||||
}
|
||||
});
|
||||
|
||||
const handleVideoInput = $(async () => {
|
||||
if (!playState.video) return;
|
||||
if (playState.initializedVideo) return;
|
||||
|
||||
await playState.video.play().then(() => {
|
||||
if (canvas.value && playState.video) {
|
||||
canvas.value.width = playState.video.videoWidth;
|
||||
canvas.value.height = playState.video.videoHeight;
|
||||
playState.initializedVideo = true
|
||||
|
||||
const ctx = canvas.value.getContext("2d");
|
||||
const renderer = () => {
|
||||
if (ctx && playState.hasStream && playState.video) {
|
||||
ctx.drawImage(playState.video, 0, 0);
|
||||
playState.video.requestVideoFrameCallback(renderer);
|
||||
}
|
||||
};
|
||||
|
||||
playState.video.requestVideoFrameCallback(renderer);
|
||||
}
|
||||
}).catch(error => {
|
||||
console.error("Error playing video:", error);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
// eslint-disable-next-line qwik/no-use-visible-task
|
||||
useVisibleTask$(({ track }) => {
|
||||
track(() => canvas.value);
|
||||
|
||||
if (!canvas.value) return; // Ensure canvas is available
|
||||
|
||||
// Create video element and make it output to canvas (TODO: improve this)
|
||||
let video = document.getElementById("webrtc-video-player");
|
||||
if (!video) {
|
||||
video = document.createElement("video");
|
||||
video.id = "stream-video-player";
|
||||
video.style.visibility = "hidden";
|
||||
new WebRTCStream("https://relay.dathorse.com", id, (mediaStream) => {
|
||||
if (video && mediaStream && (video as HTMLVideoElement).srcObject === null) {
|
||||
console.log("Setting mediastream");
|
||||
(video as HTMLVideoElement).srcObject = mediaStream;
|
||||
try {
|
||||
if (!playState.video) {
|
||||
playState.video = document.createElement("video") as HTMLVideoElement
|
||||
playState.video.style.visibility = "hidden";
|
||||
playState.webrtc = noSerialize(new WebRTCStream("https://relay.dathorse.com", id, async (mediaStream) => {
|
||||
if (playState.video && mediaStream && playState.video.srcObject === null) {
|
||||
console.log("Setting mediastream");
|
||||
playState.video.srcObject = mediaStream;
|
||||
playState.hasStream = true;
|
||||
playState.showOffline = false;
|
||||
|
||||
// @ts-ignore
|
||||
window.hasstream = true;
|
||||
// @ts-ignore
|
||||
window.roomOfflineElement?.remove();
|
||||
// @ts-ignore
|
||||
window.playbtnelement?.remove();
|
||||
const playing = sessionStorage.getItem("showedWatchBanner")
|
||||
if (!playing || playing != "true") {
|
||||
if (!showBannerModal.value) showBannerModal.value = true
|
||||
} else {
|
||||
if (!showButtonModal.value) showButtonModal.value = true
|
||||
}
|
||||
|
||||
const playbtn = document.createElement("button");
|
||||
playbtn.style.position = "absolute";
|
||||
playbtn.style.left = "50%";
|
||||
playbtn.style.top = "50%";
|
||||
playbtn.style.transform = "translateX(-50%) translateY(-50%)";
|
||||
playbtn.style.width = "12rem";
|
||||
playbtn.style.height = "6rem";
|
||||
playbtn.style.borderRadius = "1rem";
|
||||
playbtn.style.backgroundColor = "rgb(175, 50, 50)";
|
||||
playbtn.style.color = "black";
|
||||
playbtn.style.fontSize = "1.5em";
|
||||
playbtn.textContent = "< Start >";
|
||||
await handleVideoInput();
|
||||
} else if (mediaStream === null) {
|
||||
console.log("MediaStream is null, Room is offline");
|
||||
playState.showOffline = true
|
||||
playState.hasStream = false;
|
||||
// Clear canvas if it has been set
|
||||
if (canvas.value) {
|
||||
const ctx = canvas.value.getContext("2d");
|
||||
if (ctx) ctx.clearRect(0, 0, canvas.value.width, canvas.value.height);
|
||||
}
|
||||
} else if (playState.video && playState.video.srcObject !== null) {
|
||||
console.log("Setting new mediastream");
|
||||
playState.video.srcObject = mediaStream;
|
||||
playState.hasStream = true;
|
||||
playState.showOffline = true
|
||||
|
||||
playbtn.onclick = () => {
|
||||
playbtn.remove();
|
||||
(video as HTMLVideoElement).play().then(() => {
|
||||
if (canvas.value) {
|
||||
canvas.value.width = (video as HTMLVideoElement).videoWidth;
|
||||
canvas.value.height = (video as HTMLVideoElement).videoHeight;
|
||||
playState.video.play().then(() => {
|
||||
// window.roomOfflineElement?.remove();
|
||||
playState.showOffline = false
|
||||
if (canvas.value && playState.video) {
|
||||
canvas.value.width = playState.video.videoWidth;
|
||||
canvas.value.height = playState.video.videoHeight;
|
||||
|
||||
const ctx = canvas.value.getContext("2d");
|
||||
const renderer = () => {
|
||||
// @ts-ignore
|
||||
if (ctx && window.hasstream) {
|
||||
ctx.drawImage((video as HTMLVideoElement), 0, 0);
|
||||
(video as HTMLVideoElement).requestVideoFrameCallback(renderer);
|
||||
if (ctx && playState.hasStream && playState.video) {
|
||||
ctx.drawImage(playState.video, 0, 0);
|
||||
playState.video.requestVideoFrameCallback(renderer);
|
||||
}
|
||||
}
|
||||
(video as HTMLVideoElement).requestVideoFrameCallback(renderer);
|
||||
playState.video.requestVideoFrameCallback(renderer);
|
||||
}
|
||||
});
|
||||
};
|
||||
document.body.append(playbtn);
|
||||
// @ts-ignore
|
||||
window.playbtnelement = playbtn;
|
||||
} else if (mediaStream === null) {
|
||||
console.log("MediaStream is null, Room is offline");
|
||||
// @ts-ignore
|
||||
window.playbtnelement?.remove();
|
||||
// @ts-ignore
|
||||
window.roomOfflineElement?.remove();
|
||||
// Add a message to the screen
|
||||
const offline = document.createElement("div");
|
||||
offline.style.position = "absolute";
|
||||
offline.style.left = "50%";
|
||||
offline.style.top = "50%";
|
||||
offline.style.transform = "translateX(-50%) translateY(-50%)";
|
||||
offline.style.width = "auto";
|
||||
offline.style.height = "auto";
|
||||
offline.style.color = "lightgray";
|
||||
offline.style.fontSize = "2em";
|
||||
offline.textContent = "Offline";
|
||||
document.body.append(offline);
|
||||
// @ts-ignore
|
||||
window.roomOfflineElement = offline;
|
||||
// @ts-ignore
|
||||
window.hasstream = false;
|
||||
// Clear canvas if it has been set
|
||||
if (canvas.value) {
|
||||
const ctx = canvas.value.getContext("2d");
|
||||
if (ctx) ctx.clearRect(0, 0, canvas.value.width, canvas.value.height);
|
||||
}
|
||||
} else if ((video as HTMLVideoElement).srcObject !== null) {
|
||||
console.log("Setting new mediastream");
|
||||
(video as HTMLVideoElement).srcObject = mediaStream;
|
||||
// @ts-ignore
|
||||
window.hasstream = true;
|
||||
// Start video rendering
|
||||
(video as HTMLVideoElement).play().then(() => {
|
||||
// @ts-ignore
|
||||
window.roomOfflineElement?.remove();
|
||||
if (canvas.value) {
|
||||
canvas.value.width = (video as HTMLVideoElement).videoWidth;
|
||||
canvas.value.height = (video as HTMLVideoElement).videoHeight;
|
||||
|
||||
const ctx = canvas.value.getContext("2d");
|
||||
const renderer = () => {
|
||||
// @ts-ignore
|
||||
if (ctx && window.hasstream) {
|
||||
ctx.drawImage((video as HTMLVideoElement), 0, 0);
|
||||
(video as HTMLVideoElement).requestVideoFrameCallback(renderer);
|
||||
}
|
||||
}
|
||||
(video as HTMLVideoElement).requestVideoFrameCallback(renderer);
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
}));
|
||||
}
|
||||
} catch (error) {
|
||||
console.log("error handling the media connection", error)
|
||||
}
|
||||
})
|
||||
|
||||
return (
|
||||
<canvas
|
||||
ref={canvas}
|
||||
onClick$={async () => {
|
||||
// @ts-ignore
|
||||
if (canvas.value && window.hasstream && !window.nestriLock) {
|
||||
await canvas.value.requestFullscreen()
|
||||
if (document.fullscreenElement !== null) {
|
||||
// @ts-ignore
|
||||
if ('keyboard' in window.navigator && 'lock' in window.navigator.keyboard) {
|
||||
const keys = [
|
||||
"AltLeft",
|
||||
"AltRight",
|
||||
"Tab",
|
||||
"Escape",
|
||||
"ContextMenu",
|
||||
"MetaLeft",
|
||||
"MetaRight"
|
||||
];
|
||||
console.log("requesting keyboard lock");
|
||||
// @ts-ignore
|
||||
window.navigator.keyboard.lock(keys).then(
|
||||
() => {
|
||||
console.log("keyboard lock success");
|
||||
// @ts-ignore
|
||||
window.nestriLock = true;
|
||||
}
|
||||
).catch(
|
||||
(e: any) => {
|
||||
console.log("keyboard lock failed: ", e);
|
||||
// @ts-ignore
|
||||
window.nestriLock = false;
|
||||
}
|
||||
)
|
||||
} else {
|
||||
console.log("keyboard lock not supported, navigator is: ", window.navigator, navigator);
|
||||
// @ts-ignore
|
||||
window.nestriLock = undefined;
|
||||
}
|
||||
}
|
||||
}
|
||||
}}
|
||||
//TODO: go full screen, then lock on "landscape" screen-orientation on mobile
|
||||
class="aspect-video h-full w-full object-contain max-h-screen" />
|
||||
<>
|
||||
{playState.showOffline ? (
|
||||
<div class="w-screen h-screen flex justify-center items-center">
|
||||
<span class="text-2xl font-semibold flex items-center gap-2" >
|
||||
Offline
|
||||
</span>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
< canvas
|
||||
ref={canvas}
|
||||
class="aspect-video h-full w-full object-contain max-h-screen" />
|
||||
{typeof playState.showOffline === "undefined" && (
|
||||
<div class="w-screen h-screen bg-gray-100 dark:bg-gray-900 absolute z-10 flex justify-center items-center">
|
||||
<span class="text-xl font-semibold flex items-center gap-2" >
|
||||
<div data-component="spinner">
|
||||
<div>
|
||||
{new Array(12).fill(0).map((i, k) => (
|
||||
<div key={k} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
Connecting to the relays...
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
<Modal.Root bind:show={showButtonModal} closeOnBackdropClick={false}>
|
||||
<Modal.Panel class="
|
||||
dark:backdrop:bg-[#0009] backdrop:bg-[#b3b5b799] backdrop:backdrop-grayscale-[.3] w-full max-w-[370px] max-h-[75vh] rounded-xl border dark:border-[#343434] border-[#e2e2e2]
|
||||
dark:[box-shadow:0_0_0_1px_rgba(255,255,255,0.08),_0_3.3px_2.7px_rgba(0,0,0,.1),0_8.3px_6.9px_rgba(0,0,0,.13),0_17px_14.2px_rgba(0,0,0,.17),0_35px_29.2px_rgba(0,0,0,.22),0px_-4px_4px_0px_rgba(0,0,0,.04)_inset] dark:bg-[#222b]
|
||||
[box-shadow:0_0_0_1px_rgba(19,21,23,0.08),_0_3.3px_2.7px_rgba(0,0,0,.03),0_8.3px_6.9px_rgba(0,0,0,.04),0_17px_14.2px_rgba(0,0,0,.05),0_35px_29.2px_rgba(0,0,0,.06),0px_-4px_4px_0px_rgba(0,0,0,.07)_inset] bg-[#fffd]
|
||||
backdrop-blur-lg py-4 px-5 modal" >
|
||||
<div class="size-full flex flex-col">
|
||||
<div class="flex flex-col gap-3" >
|
||||
<button
|
||||
onClick$={async () => {
|
||||
showButtonModal.value = false;
|
||||
await handleVideoInput()
|
||||
await lockPlay();
|
||||
}}
|
||||
class="transition-all duration-200 focus:ring-2 focus:ring-gray-300 focus:dark:ring-gray-700 outline-none w-full hover:bg-gray-300 hover:dark:bg-gray-700 bg-gray-200 dark:bg-gray-800 text-gray-800 dark:text-gray-200 items-center justify-center font-medium font-title rounded-lg flex py-3 px-4" >
|
||||
Continue Watching
|
||||
</button>
|
||||
<button
|
||||
onClick$={() => {
|
||||
nav("/home")
|
||||
}}
|
||||
class="transition-all duration-200 focus:ring-2 focus:ring-gray-300 focus:dark:ring-gray-700 outline-none w-full hover:bg-gray-300 hover:dark:bg-gray-700 bg-gray-200 dark:bg-gray-800 text-gray-800 dark:text-gray-200 items-center justify-center font-medium font-title rounded-lg flex py-3 px-4" >
|
||||
Go Back Home
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal.Panel>
|
||||
</Modal.Root>
|
||||
<Modal.Root bind:show={showBannerModal} closeOnBackdropClick={false}>
|
||||
<Modal.Panel class="
|
||||
dark:backdrop:bg-[#0009] backdrop:bg-[#b3b5b799] backdrop:backdrop-grayscale-[.3] w-full max-w-[37%] max-h-[75vh] rounded-xl border dark:border-[#343434] border-[#e2e2e2]
|
||||
dark:[box-shadow:0_0_0_1px_rgba(255,255,255,0.08),_0_3.3px_2.7px_rgba(0,0,0,.1),0_8.3px_6.9px_rgba(0,0,0,.13),0_17px_14.2px_rgba(0,0,0,.17),0_35px_29.2px_rgba(0,0,0,.22),0px_-4px_4px_0px_rgba(0,0,0,.04)_inset] dark:bg-[#222b]
|
||||
[box-shadow:0_0_0_1px_rgba(19,21,23,0.08),_0_3.3px_2.7px_rgba(0,0,0,.03),0_8.3px_6.9px_rgba(0,0,0,.04),0_17px_14.2px_rgba(0,0,0,.05),0_35px_29.2px_rgba(0,0,0,.06),0px_-4px_4px_0px_rgba(0,0,0,.07)_inset] bg-[#fffd]
|
||||
backdrop-blur-lg py-4 px-5 modal" >
|
||||
<div class="size-full flex flex-col">
|
||||
<div class="dark:text-white text-black">
|
||||
<h3 class="font-semibold text-2xl tracking-tight mb-2 font-title">Important information from Nestri</h3>
|
||||
<div class="text-sm dark:text-white/[.79] text-[rgba(19,21,23,0.64)]" >
|
||||
This product is in Alpha — please share feedback whenever possible to help us improve. Thanks you for your support! 💖
|
||||
</div>
|
||||
</div>
|
||||
<div class="sm:pt-10 sm:block hidden" >
|
||||
<button
|
||||
onClick$={async () => {
|
||||
sessionStorage.setItem("showedWatchBanner", "true");
|
||||
showBannerModal.value = false;
|
||||
await handleVideoInput()
|
||||
await lockPlay();
|
||||
}}
|
||||
class="gap-3 outline-none hover:[box-shadow:0_0_0_2px_rgba(200,200,200,0.95),0_0_0_4px_#8f8f8f] dark:hover:[box-shadow:0_0_0_2px_#161616,0_0_0_4px_#707070] focus:[box-shadow:0_0_0_2px_rgba(200,200,200,0.95),0_0_0_4px_#8f8f8f] dark:focus:[box-shadow:0_0_0_2px_#161616,0_0_0_4px_#707070] [transition:all_0.3s_cubic-bezier(0.4,0,0.2,1)] font-medium font-title rounded-lg flex h-[calc(2.25rem+2*1px)] flex-col text-white w-full leading-none truncate bg-primary-500 items-center justify-center" >
|
||||
Continue
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal.Panel>
|
||||
</Modal.Root>
|
||||
</>
|
||||
)
|
||||
})
|
||||
|
||||
{/**
|
||||
.spinningCircleInner_b6db20 {
|
||||
transform: rotate(280deg);
|
||||
}
|
||||
.inner_b6db20 {
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
contain: paint;
|
||||
} */
|
||||
}
|
||||
|
||||
{/* <div class="loadingPopout_a8c724" role="dialog" tabindex="-1" aria-modal="true"><div class="spinner_b6db20 spinningCircle_b6db20" role="img" aria-label="Loading"><div class="spinningCircleInner_b6db20 inner_b6db20"><svg class="circular_b6db20" viewBox="25 25 50 50"><circle class="path_b6db20 path3_b6db20" cx="50" cy="50" r="20"></circle><circle class="path_b6db20 path2_b6db20" cx="50" cy="50" r="20"></circle><circle class="path_b6db20" cx="50" cy="50" r="20"></circle></svg></div></div></div> */
|
||||
}
|
||||
// .loadingPopout_a8c724 {
|
||||
// background-color: var(--background-secondary);
|
||||
// display: flex;
|
||||
// justify-content: center;
|
||||
// padding: 8px;
|
||||
// }
|
||||
|
||||
// .circular_b6db20 {
|
||||
// animation: spinner-spinning-circle-rotate_b6db20 2s linear infinite;
|
||||
// height: 100%;
|
||||
// width: 100%;
|
||||
// }
|
||||
|
||||
// 100% {
|
||||
// transform: rotate(360deg);
|
||||
// }
|
||||
|
||||
|
||||
{/* .path3_b6db20 {
|
||||
animation-delay: .23s;
|
||||
stroke: var(--text-brand);
|
||||
}
|
||||
.path_b6db20 {
|
||||
animation: spinner-spinning-circle-dash_b6db20 2s ease-in-out infinite;
|
||||
stroke-dasharray: 1, 200;
|
||||
stroke-dashoffset: 0;
|
||||
fill: none;
|
||||
stroke-width: 6;
|
||||
stroke-miterlimit: 10;
|
||||
stroke-linecap: round;
|
||||
stroke: var(--brand-500);
|
||||
}
|
||||
circle[Attributes Style] {
|
||||
cx: 50;
|
||||
cy: 50;
|
||||
r: 20;
|
||||
}
|
||||
user agent stylesheet
|
||||
:not(svg) {
|
||||
transform-origin: 0px 0px;
|
||||
} */
|
||||
}
|
||||
|
||||
|
||||
// .path2_b6db20 {
|
||||
// animation-delay: .15s;
|
||||
// stroke: var(--text-brand);
|
||||
// opacity: .6;
|
||||
// }
|
||||
// .path_b6db20 {
|
||||
// animation: spinner-spinning-circle-dash_b6db20 2s ease-in-out infinite;
|
||||
// stroke-dasharray: 1, 200;
|
||||
// stroke-dashoffset: 0;
|
||||
// fill: none;
|
||||
// stroke-width: 6;
|
||||
// stroke-miterlimit: 10;
|
||||
// stroke-linecap: round;
|
||||
// stroke: var(--brand-500);
|
||||
// }
|
||||
// circle[Attributes Style] {
|
||||
// cx: 50;
|
||||
// cy: 50;
|
||||
// r: 20;
|
||||
|
||||
|
||||
// function throttle(func, limit) {
|
||||
// let inThrottle;
|
||||
// return function(...args) {
|
||||
// if (!inThrottle) {
|
||||
// func.apply(this, args);
|
||||
// inThrottle = true;
|
||||
// setTimeout(() => inThrottle = false, limit);
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
|
||||
// // Use it like this:
|
||||
// const throttledMouseMove = throttle((x, y) => {
|
||||
// websocket.send(JSON.stringify({
|
||||
// type: 'mousemove',
|
||||
// x: x,
|
||||
// y: y
|
||||
// }));
|
||||
// }, 16); // ~60fps
|
||||
|
||||
// use std::time::Instant;
|
||||
|
||||
// // Add these to your AppState
|
||||
// struct AppState {
|
||||
// pipeline: Arc<Mutex<gst::Pipeline>>,
|
||||
// last_mouse_move: Arc<Mutex<(i32, i32, Instant)>>, // Add this
|
||||
// }
|
||||
|
||||
// // Then in your MouseMove handler:
|
||||
// InputMessage::MouseMove { x, y } => {
|
||||
// let mut last_move = state.last_mouse_move.lock().unwrap();
|
||||
// let now = Instant::now();
|
||||
|
||||
// // Only process if coordinates are different or enough time has passed
|
||||
// if (last_move.0 != x || last_move.1 != y) &&
|
||||
// (now.duration_since(last_move.2).as_millis() > 16) { // ~60fps
|
||||
|
||||
// println!("Mouse moved to x: {}, y: {}", x, y);
|
||||
|
||||
// let structure = gst::Structure::builder("MouseMoveRelative")
|
||||
// .field("pointer_x", x as f64)
|
||||
// .field("pointer_y", y as f64)
|
||||
// .build();
|
||||
|
||||
// let event = gst::event::CustomUpstream::new(structure);
|
||||
// pipeline.send_event(event);
|
||||
|
||||
// // Update last position and time
|
||||
// *last_move = (x, y, now);
|
||||
// }
|
||||
// }
|
||||
})
|
||||
57
apps/www/sst-env.d.ts
vendored
@@ -2,57 +2,8 @@
|
||||
/* tslint:disable */
|
||||
/* eslint-disable */
|
||||
/* deno-fmt-ignore-file */
|
||||
|
||||
/// <reference path="../../sst-env.d.ts" />
|
||||
|
||||
import "sst"
|
||||
export {}
|
||||
declare module "sst" {
|
||||
export interface Resource {
|
||||
"Api": {
|
||||
"type": "sst.cloudflare.Worker"
|
||||
"url": string
|
||||
}
|
||||
"Auth": {
|
||||
"type": "sst.cloudflare.Worker"
|
||||
"url": string
|
||||
}
|
||||
"AuthFingerprintKey": {
|
||||
"type": "random.index/randomString.RandomString"
|
||||
"value": string
|
||||
}
|
||||
"CloudflareAuthKV": {
|
||||
"type": "sst.cloudflare.Kv"
|
||||
}
|
||||
"DiscordClientID": {
|
||||
"type": "sst.sst.Secret"
|
||||
"value": string
|
||||
}
|
||||
"DiscordClientSecret": {
|
||||
"type": "sst.sst.Secret"
|
||||
"value": string
|
||||
}
|
||||
"GithubClientID": {
|
||||
"type": "sst.sst.Secret"
|
||||
"value": string
|
||||
}
|
||||
"GithubClientSecret": {
|
||||
"type": "sst.sst.Secret"
|
||||
"value": string
|
||||
}
|
||||
"InstantAdminToken": {
|
||||
"type": "sst.sst.Secret"
|
||||
"value": string
|
||||
}
|
||||
"InstantAppId": {
|
||||
"type": "sst.sst.Secret"
|
||||
"value": string
|
||||
}
|
||||
"LoopsApiKey": {
|
||||
"type": "sst.sst.Secret"
|
||||
"value": string
|
||||
}
|
||||
"Urls": {
|
||||
"api": string
|
||||
"auth": string
|
||||
"type": "sst.sst.Linkable"
|
||||
}
|
||||
}
|
||||
}
|
||||
export {}
|
||||
@@ -9,7 +9,8 @@ import tsconfigPaths from "vite-tsconfig-paths";
|
||||
import { qwikReact } from "@builder.io/qwik-react/vite";
|
||||
import pkg from "./package.json";
|
||||
import { partytownVite } from "@builder.io/partytown/utils";
|
||||
import { join } from "path";
|
||||
import { join, resolve } from "path";
|
||||
|
||||
type PkgDep = Record<string, string>;
|
||||
const { dependencies = {}, devDependencies = {} } = pkg as any as {
|
||||
dependencies: PkgDep;
|
||||
@@ -30,6 +31,11 @@ export default defineConfig((): UserConfig => {
|
||||
qwikReact(),
|
||||
partytownVite({ dest: join(__dirname, "dist", "~partytown") }),
|
||||
],
|
||||
resolve: {
|
||||
alias: [
|
||||
{ find: '~', replacement: resolve(__dirname,"public") },
|
||||
],
|
||||
},
|
||||
// This tells Vite which dependencies to pre-build in dev mode.
|
||||
optimizeDeps: {
|
||||
// Put problematic deps that break bundling here, mostly those with binaries.
|
||||
|
||||
30
infra/api.ts
@@ -1,25 +1,15 @@
|
||||
import { authFingerprintKey } from "./auth";
|
||||
import { domain } from "./dns";
|
||||
import { secret } from "./secrets"
|
||||
|
||||
sst.Linkable.wrap(random.RandomString, (resource) => ({
|
||||
properties: {
|
||||
value: resource.result,
|
||||
},
|
||||
}));
|
||||
|
||||
export const authFingerprintKey = new random.RandomString(
|
||||
"AuthFingerprintKey",
|
||||
{
|
||||
length: 32,
|
||||
},
|
||||
);
|
||||
// import { party } from "./party"
|
||||
import { gpuTaskDefinition, ecsCluster } from "./cluster";
|
||||
|
||||
export const urls = new sst.Linkable("Urls", {
|
||||
properties: {
|
||||
api: "https://api." + domain,
|
||||
auth: "https://auth." + domain,
|
||||
api: "https://api." + domain,
|
||||
auth: "https://auth." + domain,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
export const kv = new sst.cloudflare.Kv("CloudflareAuthKV")
|
||||
|
||||
@@ -44,10 +34,14 @@ export const auth = new sst.cloudflare.Worker("Auth", {
|
||||
export const api = new sst.cloudflare.Worker("Api", {
|
||||
link: [
|
||||
urls,
|
||||
ecsCluster,
|
||||
gpuTaskDefinition,
|
||||
authFingerprintKey,
|
||||
secret.InstantAdminToken,
|
||||
secret.InstantAppId,
|
||||
secret.LoopsApiKey,
|
||||
secret.InstantAppId,
|
||||
secret.AwsAccessKey,
|
||||
secret.AwsSecretKey,
|
||||
secret.InstantAdminToken,
|
||||
],
|
||||
url: true,
|
||||
handler: "./packages/functions/src/api/index.ts",
|
||||
|
||||
12
infra/auth.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
export const authFingerprintKey = new random.RandomString(
|
||||
"AuthFingerprintKey",
|
||||
{
|
||||
length: 32,
|
||||
},
|
||||
);
|
||||
|
||||
sst.Linkable.wrap(random.RandomString, (resource) => ({
|
||||
properties: {
|
||||
value: resource.result,
|
||||
},
|
||||
}));
|
||||
155
infra/cluster.ts
Normal file
@@ -0,0 +1,155 @@
|
||||
import { sshKey } from "./ssh";
|
||||
import { authFingerprintKey } from "./auth";
|
||||
|
||||
export const ecsCluster = new aws.ecs.Cluster("NestriGPUCluster", {
|
||||
name: "NestriGPUCluster",
|
||||
});
|
||||
|
||||
const ecsInstanceRole = new aws.iam.Role("NestriGPUInstanceRole", {
|
||||
name: "GPUAssumeRoleProd",
|
||||
assumeRolePolicy: JSON.stringify({
|
||||
Version: "2012-10-17",
|
||||
Statement: [{
|
||||
Action: "sts:AssumeRole",
|
||||
Principal: {
|
||||
Service: "ec2.amazonaws.com",
|
||||
},
|
||||
Effect: "Allow",
|
||||
Sid: "",
|
||||
}],
|
||||
}),
|
||||
});
|
||||
|
||||
new aws.iam.RolePolicyAttachment("NestriGPUInstancePolicyAttachment", {
|
||||
role: ecsInstanceRole.name,
|
||||
policyArn: "arn:aws:iam::aws:policy/service-role/AmazonEC2ContainerServiceforEC2Role",
|
||||
});
|
||||
|
||||
const ecsInstanceProfile = new aws.iam.InstanceProfile("NestriGPUInstanceProfile", {
|
||||
role: ecsInstanceRole.name,
|
||||
});
|
||||
|
||||
// const server = new aws.ec2.Instance("NestriGPU", {
|
||||
// instanceType: aws.ec2.InstanceType.G4dn_XLarge,
|
||||
// ami: "ami-046a6af96ef510bb6",//Fedora cloud
|
||||
// keyName: sshKey.keyName,
|
||||
// instanceMarketOptions: {
|
||||
// marketType: "spot",
|
||||
// spotOptions: {
|
||||
// maxPrice: "0.2",
|
||||
// spotInstanceType: "persistent",
|
||||
// instanceInterruptionBehavior: "stop"
|
||||
// },
|
||||
// },
|
||||
// iamInstanceProfile: ecsInstanceProfile,
|
||||
// });
|
||||
|
||||
const logGroup = new aws.cloudwatch.LogGroup("NestriGPULogGroup", {
|
||||
name: "/ecs/nestri-gpu-prod",
|
||||
retentionInDays: 7,
|
||||
});
|
||||
|
||||
// Create a Task Definition for the ECS service to test it
|
||||
export const gpuTaskDefinition = new aws.ecs.TaskDefinition("NestriGPUTask", {
|
||||
family: "NestriGPUTaskProd",
|
||||
requiresCompatibilities: ["EC2"],
|
||||
volumes: [
|
||||
{
|
||||
name: "host",
|
||||
hostPath: "/mnt/"
|
||||
// efsVolumeConfiguration: {
|
||||
// fileSystemId: storage.id,
|
||||
// authorizationConfig: { accessPointId: storage.accessPoint },
|
||||
// transitEncryption: "ENABLED",
|
||||
// }
|
||||
}
|
||||
],
|
||||
containerDefinitions: authFingerprintKey.result.apply(v => JSON.stringify([{
|
||||
"essential": true,
|
||||
"name": "nestri",
|
||||
"memory": 1024,
|
||||
"cpu": 200,
|
||||
"gpu": 1,
|
||||
"image": "ghcr.io/nestrilabs/nestri/runner:nightly",
|
||||
"environment": [
|
||||
{
|
||||
"name": "RESOLUTION",
|
||||
"value": "1920x1080"
|
||||
},
|
||||
{
|
||||
"name": "AUTH_FINGERPRINT",
|
||||
"value": v
|
||||
},
|
||||
{
|
||||
"name": "FRAMERATE",
|
||||
"value": "60"
|
||||
},
|
||||
{
|
||||
"name": "NESTRI_ROOM",
|
||||
"value": "aws-testing"
|
||||
},
|
||||
{
|
||||
"name": "RELAY_URL",
|
||||
"value": "https://relay.dathorse.com"
|
||||
},
|
||||
{
|
||||
"name": "NESTRI_PARAMS",
|
||||
"value": "--verbose=true --video-codec=h264 --video-bitrate=4000 --video-bitrate-max=6000 --gpu-card-path=/dev/dri/card0"
|
||||
},
|
||||
],
|
||||
"mountPoints": [{ "containerPath": "/home/nestri", "sourceVolume": "host" }],
|
||||
"disableNetworking": false,
|
||||
"linuxParameter": {
|
||||
"sharedMemorySize": 5120
|
||||
},
|
||||
"logConfiguration": {
|
||||
"logDriver": "awslogs",
|
||||
"options": {
|
||||
"awslogs-group": "/ecs/nestri-gpu-prod",
|
||||
"awslogs-region": "us-east-1",
|
||||
"awslogs-stream-prefix": "nestri-gpu-task"
|
||||
}
|
||||
}
|
||||
}]))
|
||||
});
|
||||
|
||||
sst.Linkable.wrap(aws.ecs.TaskDefinition, (resource) => ({
|
||||
properties: {
|
||||
value: resource.arn,
|
||||
},
|
||||
}));
|
||||
|
||||
sst.Linkable.wrap(aws.ecs.Cluster, (resource) => ({
|
||||
properties: {
|
||||
value: resource.arn,
|
||||
},
|
||||
}));
|
||||
|
||||
// userData: $interpolate`#!/bin/bash
|
||||
// sudo rm /etc/sysconfig/docker
|
||||
// echo DAEMON_MAXFILES=1048576 | sudo tee -a /etc/sysconfig/docker
|
||||
// echo DAEMON_PIDFILE_TIMEOUT=10 | sud o tee -a /etc/sysconfig/docker
|
||||
// echo OPTIONS="--default-ulimit nofile=32768:65536" | sudo tee -a /etc/sysconfig/docker
|
||||
// sudo tee "/etc/docker/daemon.json" > /dev/null <<EOF
|
||||
// {
|
||||
// "default-runtime": "nvidia",
|
||||
// "runtimes": {
|
||||
// "nvidia": {
|
||||
// "path": "/usr/bin/nvidia-container-runtime",
|
||||
// "runtimeArgs": []
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
// EOF
|
||||
// sudo systemctl restart docker
|
||||
// echo ECS_CLUSTER='${ecsCluster.name}' | sudo tee -a /etc/ecs/ecs.config
|
||||
// echo ECS_ENABLE_GPU_SUPPORT=true | sudo tee -a /etc/ecs/ecs.config
|
||||
// echo ECS_CONTAINER_STOP_TIMEOUT=3h | sudo tee -a /etc/ecs/ecs.config
|
||||
// echo ECS_ENABLE_SPOT_INSTANCE_DRAINING=true | sudo tee -a /etc/ecs/ecs.config
|
||||
// `,
|
||||
|
||||
// This is used for requesting a container to be deployed on AWS
|
||||
// const queue = new sst.aws.Queue("PartyQueue", { fifo: true });
|
||||
|
||||
// queue.subscribe({ handler: "packages/functions/src/party/subscriber.handler", permissions:{}, link:[taskF]})
|
||||
// const authRes = $interpolate`${authFingerprintKey.result}`
|
||||
33
infra/party.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
// This is for the websocket/MQTT endpoint that helps the API communicate with the container
|
||||
// [API] <-> party <-websocket-> container
|
||||
// The container is it's own this, and can listen to Websocket connections to start or stop a Steam Game
|
||||
|
||||
// import { authFingerprintKey } from "./auth";
|
||||
// import { ecsCluster, gpuTaskDefinition } from "./cluster";
|
||||
|
||||
// export const party = new sst.aws.Realtime("Party", {
|
||||
// authorizer: "packages/functions/src/party/authorizer.handler"
|
||||
// });
|
||||
|
||||
// export const partyFn = new sst.aws.Function("NestriPartyFn", {
|
||||
// handler: "packages/functions/src/party/create.handler",
|
||||
// // link: [queue],
|
||||
// link: [authFingerprintKey],
|
||||
// environment: {
|
||||
// TASK_DEFINITION: gpuTaskDefinition.arn,
|
||||
// // AUTH_FINGERPRINT: authFingerprintKey.result,
|
||||
// ECS_CLUSTER: ecsCluster.arn,
|
||||
// },
|
||||
// permissions: [
|
||||
// {
|
||||
// effect: "allow",
|
||||
// actions: ["ecs:RunTask"],
|
||||
// resources: [gpuTaskDefinition.arn]
|
||||
// }
|
||||
// ],
|
||||
// url: true,
|
||||
// });
|
||||
|
||||
// export const outputs = {
|
||||
// partyFunction: partyFn.url
|
||||
// }
|
||||
179
infra/relay.ts
Normal file
@@ -0,0 +1,179 @@
|
||||
// const vpc = new sst.aws.Vpc("NestriRelayVpc", { az: 2 })
|
||||
// import { subnet1, subnet2, securityGroup } from "./vpc"
|
||||
|
||||
// const taskExecutionRole = new aws.iam.Role('NestriRelayExecutionRole', {
|
||||
// assumeRolePolicy: JSON.stringify({
|
||||
// Version: '2012-10-17',
|
||||
// Statement: [
|
||||
// {
|
||||
// Effect: 'Allow',
|
||||
// Principal: {
|
||||
// Service: 'ecs-tasks.amazonaws.com',
|
||||
// },
|
||||
// Action: 'sts:AssumeRole',
|
||||
// },
|
||||
// ],
|
||||
// }),
|
||||
// });
|
||||
|
||||
// const taskRole = new aws.iam.Role('NestriRelayTaskRole', {
|
||||
// assumeRolePolicy: JSON.stringify({
|
||||
// Version: '2012-10-17',
|
||||
// Statement: [
|
||||
// {
|
||||
// Effect: 'Allow',
|
||||
// Principal: {
|
||||
// Service: 'ecs-tasks.amazonaws.com',
|
||||
// },
|
||||
// Action: 'sts:AssumeRole',
|
||||
// },
|
||||
// ],
|
||||
// }),
|
||||
// });
|
||||
|
||||
// new aws.cloudwatch.LogGroup('NestriRelayLogGroup', {
|
||||
// name: '/ecs/nestri-relay',
|
||||
// retentionInDays: 7,
|
||||
// });
|
||||
|
||||
// new aws.iam.RolePolicyAttachment('NestriRelayExecutionRoleAttachment', {
|
||||
// policyArn: 'arn:aws:iam::aws:policy/service-role/AmazonECSTaskExecutionRolePolicy',
|
||||
// role: taskRole,
|
||||
// });
|
||||
|
||||
// const logPolicy = new aws.iam.Policy('NestriRelayLogPolicy', {
|
||||
// policy: JSON.stringify({
|
||||
// Version: '2012-10-17',
|
||||
// Statement: [
|
||||
// {
|
||||
// Effect: 'Allow',
|
||||
// Action: ['logs:CreateLogStream', 'logs:PutLogEvents'],
|
||||
// Resource: 'arn:aws:logs:*:*:*',
|
||||
// },
|
||||
// ],
|
||||
// }),
|
||||
// });
|
||||
|
||||
// new aws.iam.RolePolicyAttachment('NestriRelayTaskRoleAttachment', {
|
||||
// policyArn: logPolicy.arn,
|
||||
// role: taskExecutionRole,
|
||||
// });
|
||||
|
||||
// const taskDefinition = new aws.ecs.TaskDefinition("NestriRelayTask", {
|
||||
// family: "NestriRelay",
|
||||
// cpu: "1024",
|
||||
// memory: "2048",
|
||||
// networkMode: "awsvpc",
|
||||
// taskRoleArn: taskRole.arn,
|
||||
// requiresCompatibilities: ["FARGATE"],
|
||||
// executionRoleArn: taskExecutionRole.arn,
|
||||
// containerDefinitions: JSON.stringify([{
|
||||
// name: "nestri-relay",
|
||||
// essential: true,
|
||||
// memory: 2048,
|
||||
// image: "ghcr.io/nestrilabs/nestri/relay:nightly",
|
||||
// portMappings: [
|
||||
// // HTTP port
|
||||
// {
|
||||
// protocol: "tcp",
|
||||
// hostPort: 80,
|
||||
// containerPort: 80,
|
||||
// },
|
||||
// // UDP port range (1,000 ports)
|
||||
// {
|
||||
// containerPortRange: "10000-11000",
|
||||
// protocol: "udp",
|
||||
// },
|
||||
// ],
|
||||
// "environment": [
|
||||
// {
|
||||
// name: "ENDPOINT_PORT",
|
||||
// value: "80"
|
||||
// },
|
||||
// ],
|
||||
// logConfiguration: {
|
||||
// logDriver: 'awslogs',
|
||||
// options: {
|
||||
// 'awslogs-group': '/ecs/nestri-relay',
|
||||
// 'awslogs-region': 'us-east-1',
|
||||
// 'awslogs-stream-prefix': 'ecs',
|
||||
// },
|
||||
// },
|
||||
// }]),
|
||||
// });
|
||||
|
||||
// const relayCluster = new aws.ecs.Cluster('NestriRelay');
|
||||
|
||||
// new aws.ecs.Service('NestriRelayService', {
|
||||
// name: 'NestriRelayService',
|
||||
// cluster: relayCluster.arn,
|
||||
// desiredCount: 1,
|
||||
// launchType: 'FARGATE',
|
||||
// taskDefinition: taskDefinition.arn,
|
||||
// deploymentCircuitBreaker: {
|
||||
// enable: true,
|
||||
// rollback: true,
|
||||
// },
|
||||
// enableExecuteCommand: true,
|
||||
// networkConfiguration: {
|
||||
// assignPublicIp: true,
|
||||
// subnets: [subnet1.id, subnet2.id],
|
||||
// securityGroups: [securityGroup.id],
|
||||
// },
|
||||
// });
|
||||
|
||||
//FIXME: I cannot create Global Accelerators (Something to do with Quotas - Yet my account is fine)
|
||||
// const usWest2 = new aws.Provider("GlobalAccelerator", { region: aws.Region.USWest2 })
|
||||
|
||||
// const accelerator = new aws.globalaccelerator.Accelerator('Accelerator', {
|
||||
// name: 'NestriRelayAccelerator',
|
||||
// enabled: true,
|
||||
// ipAddressType: 'IPV4',
|
||||
// }, { provider: usWest2 });
|
||||
|
||||
// const httpListener = new aws.globalaccelerator.Listener('TcpListener', {
|
||||
// acceleratorArn: accelerator.id,
|
||||
// clientAffinity: 'SOURCE_IP',
|
||||
// protocol: 'TCP',
|
||||
// portRanges: [{
|
||||
// fromPort: 80,
|
||||
// toPort: 80,
|
||||
// }],
|
||||
// }, { provider: usWest2 });
|
||||
|
||||
// const udpListener = new aws.globalaccelerator.Listener('UdpListener', {
|
||||
// acceleratorArn: accelerator.id,
|
||||
// clientAffinity: 'SOURCE_IP',
|
||||
// protocol: 'UDP',
|
||||
// portRanges: [{
|
||||
// fromPort: 10000,
|
||||
// toPort: 11000,
|
||||
// }],
|
||||
// }, { provider: usWest2 });
|
||||
|
||||
// new aws.globalaccelerator.EndpointGroup('TcpRelay', {
|
||||
// listenerArn: httpListener.id,
|
||||
// // healthCheckPath: '/',
|
||||
// endpointGroupRegion: aws.Region.USEast1,
|
||||
// endpointConfigurations: [{
|
||||
// clientIpPreservationEnabled: true,
|
||||
// endpointId: subnet1.id, //vpc.publicSubnets[0].apply(i => i),
|
||||
// weight: 100,
|
||||
// }],
|
||||
// }, { provider: usWest2 });
|
||||
|
||||
// new aws.globalaccelerator.EndpointGroup('UdpRelay', {
|
||||
// listenerArn: udpListener.id,
|
||||
// // healthCheckPort: 80,
|
||||
// // healthCheckPath: "/",
|
||||
// endpointGroupRegion: aws.Region.USEast1,
|
||||
// endpointConfigurations: [{
|
||||
// clientIpPreservationEnabled: true,
|
||||
// endpointId: subnet1.id,//vpc.publicSubnets[0].apply(i => i),
|
||||
// weight: 100,
|
||||
// }],
|
||||
// }, { provider: usWest2 });
|
||||
|
||||
// export const outputs = {
|
||||
// relay: accelerator.dnsName
|
||||
// }
|
||||
@@ -1,11 +1,13 @@
|
||||
export const secret = {
|
||||
InstantAdminToken: new sst.Secret("InstantAdminToken"),
|
||||
InstantAppId: new sst.Secret("InstantAppId"),
|
||||
LoopsApiKey: new sst.Secret("LoopsApiKey"),
|
||||
GithubClientSecret: new sst.Secret("GithubClientSecret"),
|
||||
GithubClientID: new sst.Secret("GithubClientID"),
|
||||
DiscordClientSecret: new sst.Secret("DiscordClientSecret"),
|
||||
DiscordClientID: new sst.Secret("DiscordClientID"),
|
||||
LoopsApiKey: new sst.Secret("LoopsApiKey"),
|
||||
InstantAppId: new sst.Secret("InstantAppId"),
|
||||
AwsSecretKey: new sst.Secret("AwsSecretKey"),
|
||||
AwsAccessKey: new sst.Secret("AwsAccessKey"),
|
||||
GithubClientID: new sst.Secret("GithubClientID"),
|
||||
DiscordClientID: new sst.Secret("DiscordClientID"),
|
||||
GithubClientSecret: new sst.Secret("GithubClientSecret"),
|
||||
InstantAdminToken: new sst.Secret("InstantAdminToken"),
|
||||
DiscordClientSecret: new sst.Secret("DiscordClientSecret"),
|
||||
};
|
||||
|
||||
export const allSecrets = Object.values(secret);
|
||||
19
infra/ssh.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import { resolve } from "path";
|
||||
import { writeFileSync } from "fs";
|
||||
|
||||
export const privateKey = new tls.PrivateKey("NestriGPUPrivateKey", {
|
||||
algorithm: "RSA",
|
||||
rsaBits: 4096,
|
||||
});
|
||||
|
||||
// Just in case you want to SSH
|
||||
export const sshKey = new aws.ec2.KeyPair("NestriGPUKey", {
|
||||
keyName: "NestriGPUKeyProd",
|
||||
publicKey: privateKey.publicKeyOpenssh
|
||||
})
|
||||
|
||||
export const keyPath = privateKey.privateKeyOpenssh.apply((key) => {
|
||||
const path = "key_ssh";
|
||||
writeFileSync(path, key, { mode: 0o600 });
|
||||
return resolve(path);
|
||||
});
|
||||
4
infra/storage.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
// export const vpc = new sst.aws.Vpc("Vpc")
|
||||
|
||||
// export const storage = new sst.aws.Efs("GameStorage",{ vpc })
|
||||
// //
|
||||
103
infra/vpc.ts
Normal file
@@ -0,0 +1,103 @@
|
||||
// export const vpc = new aws.ec2.Vpc('NestriVpc', {
|
||||
// cidrBlock: '172.16.0.0/16',
|
||||
// });
|
||||
|
||||
// export const subnet1 = new aws.ec2.Subnet('NestriSubnet1', {
|
||||
// vpcId: vpc.id,
|
||||
// cidrBlock: '172.16.1.0/24',
|
||||
// // cidrBlock: '110.0.12.0/22',
|
||||
// availabilityZone: 'us-east-1a',
|
||||
// });
|
||||
|
||||
// export const subnet2 = new aws.ec2.Subnet('NestriSubnet2', {
|
||||
// vpcId: vpc.id,
|
||||
// cidrBlock: '172.16.2.0/24',
|
||||
// // cidrBlock: '10.0.20.0/22',
|
||||
// availabilityZone: 'us-east-1b',
|
||||
// });
|
||||
|
||||
// const internetGateway = new aws.ec2.InternetGateway('NestriInternetGateway', {
|
||||
// vpcId: vpc.id,
|
||||
// });
|
||||
|
||||
// const routeTable = new aws.ec2.RouteTable('NestriRouteTable', {
|
||||
// vpcId: vpc.id,
|
||||
// routes: [
|
||||
// {
|
||||
// cidrBlock: '0.0.0.0/0',
|
||||
// gatewayId: internetGateway.id,
|
||||
// },
|
||||
// ],
|
||||
// });
|
||||
|
||||
// new aws.ec2.RouteTableAssociation('NestriSubnet1RouteTable', {
|
||||
// subnetId: subnet1.id,
|
||||
// routeTableId: routeTable.id,
|
||||
// });
|
||||
|
||||
// new aws.ec2.RouteTableAssociation('NestriSubnet2RouteTable', {
|
||||
// subnetId: subnet2.id,
|
||||
// routeTableId: routeTable.id,
|
||||
// });
|
||||
|
||||
// // const vpc = new sst.aws.Vpc("NestriRelayVpc")
|
||||
|
||||
// export const securityGroup = new aws.ec2.SecurityGroup("NestriSecurityGroup", {
|
||||
// vpcId: vpc.id,
|
||||
// description: "Managed thru SST",
|
||||
// ingress: [
|
||||
// {
|
||||
// protocol: "tcp",
|
||||
// fromPort: 80,
|
||||
// toPort: 80,
|
||||
// cidrBlocks: ["0.0.0.0/0"],
|
||||
// },
|
||||
// {
|
||||
// protocol: "udp",
|
||||
// fromPort: 10000,
|
||||
// toPort: 20000,
|
||||
// cidrBlocks: ["0.0.0.0/0"],
|
||||
// },
|
||||
// ],
|
||||
// egress: [
|
||||
// {
|
||||
// protocol: "-1",
|
||||
// cidrBlocks: ["0.0.0.0/0"],
|
||||
// fromPort: 0,
|
||||
// toPort: 0
|
||||
// }
|
||||
// ]
|
||||
// });
|
||||
|
||||
// const loadBalancer = new aws.lb.LoadBalancer('NestriVpcLoadBalancer', {
|
||||
// name: 'NestriVpcLoadBalancer',
|
||||
// internal: false,
|
||||
// securityGroups: [securityGroup.id],
|
||||
// subnets: vpc.publicSubnets
|
||||
// });
|
||||
|
||||
// const targetGroup = new aws.lb.TargetGroup('NestriVpcTargetGroup', {
|
||||
// name: 'NestriVpcTargetGroup',
|
||||
// port: 80,
|
||||
// protocol: 'HTTP',
|
||||
// targetType: 'ip',
|
||||
// vpcId: vpc.id,
|
||||
// healthCheck: {
|
||||
// path: '/',
|
||||
// protocol: 'HTTP',
|
||||
// },
|
||||
// });
|
||||
|
||||
// new aws.lb.Listener('NestriVpcLoadBalancerListener', {
|
||||
// loadBalancerArn: loadBalancer.arn,
|
||||
// port: 80,
|
||||
// protocol: 'HTTP',
|
||||
// defaultActions: [
|
||||
// {
|
||||
// type: 'forward',
|
||||
// targetGroupArn: targetGroup.arn,
|
||||
// },
|
||||
// ],
|
||||
// });
|
||||
|
||||
// // export const subnets = [subnet1, subnet2]
|
||||
@@ -29,6 +29,6 @@
|
||||
"workerd"
|
||||
],
|
||||
"dependencies": {
|
||||
"sst": "^3.4.32"
|
||||
"sst": "3.6.27"
|
||||
}
|
||||
}
|
||||
@@ -1,50 +0,0 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"runtime/debug"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
var rootCmd = &cobra.Command{
|
||||
Use: "nestri",
|
||||
Short: "A CLI tool to run and manage your self-hosted cloud gaming service",
|
||||
Args: cobra.NoArgs,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
return cmd.Help()
|
||||
},
|
||||
}
|
||||
|
||||
// Execute adds all child commands to the root command and sets flags appropriately.
|
||||
// This is called by main.main(). It only needs to happen once to the rootCmd.
|
||||
func Execute() error {
|
||||
err := rootCmd.Execute()
|
||||
return err
|
||||
}
|
||||
|
||||
var (
|
||||
// Version stores the build version of VHS at the time of package through
|
||||
// -ldflags.
|
||||
//
|
||||
// go build -ldflags "-s -w -X=main.Version=$(VERSION)"
|
||||
Version string
|
||||
|
||||
// CommitSHA stores the git commit SHA at the time of package through -ldflags.
|
||||
CommitSHA string
|
||||
)
|
||||
|
||||
func init() {
|
||||
rootCmd.AddCommand(runCmd)
|
||||
if len(CommitSHA) >= 7 { //nolint:gomnd
|
||||
vt := rootCmd.VersionTemplate()
|
||||
rootCmd.SetVersionTemplate(vt[:len(vt)-1] + " (" + CommitSHA[0:7] + ")\n")
|
||||
}
|
||||
if Version == "" {
|
||||
if info, ok := debug.ReadBuildInfo(); ok && info.Main.Sum != "" {
|
||||
Version = info.Main.Version
|
||||
} else {
|
||||
Version = "unknown (built from source)"
|
||||
}
|
||||
}
|
||||
rootCmd.Version = Version
|
||||
}
|
||||
@@ -1,26 +0,0 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"nestrilabs/cli/internal/auth"
|
||||
|
||||
"github.com/charmbracelet/log"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
var runCmd = &cobra.Command{
|
||||
Use: "run",
|
||||
Short: "Run a new Nestri node",
|
||||
Long: "Create and run a new Nestri node from this machine",
|
||||
Args: cobra.NoArgs,
|
||||
RunE: func(_ *cobra.Command, _ []string) error {
|
||||
credentials, err := auth.FetchUserCredentials()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
log.Info("Credentials", "access_token", credentials.AccessToken)
|
||||
log.Info("Credentials", "refresh_token", credentials.RefreshToken)
|
||||
|
||||
return nil
|
||||
},
|
||||
}
|
||||
@@ -1,54 +0,0 @@
|
||||
module nestrilabs/cli
|
||||
|
||||
go 1.23.3
|
||||
|
||||
require (
|
||||
github.com/charmbracelet/log v0.4.0
|
||||
github.com/docker/docker v27.4.1+incompatible
|
||||
github.com/gorilla/websocket v1.5.3
|
||||
github.com/nestrilabs/nestri-go-sdk v0.1.0-alpha.3
|
||||
github.com/spf13/cobra v1.8.1
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/Microsoft/go-winio v0.4.14 // indirect
|
||||
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
|
||||
github.com/charmbracelet/lipgloss v0.13.0 // indirect
|
||||
github.com/charmbracelet/x/ansi v0.2.3 // indirect
|
||||
github.com/containerd/log v0.1.0 // indirect
|
||||
github.com/distribution/reference v0.6.0 // indirect
|
||||
github.com/docker/go-connections v0.5.0 // indirect
|
||||
github.com/docker/go-units v0.5.0 // indirect
|
||||
github.com/felixge/httpsnoop v1.0.4 // indirect
|
||||
github.com/go-logfmt/logfmt v0.6.0 // indirect
|
||||
github.com/go-logr/logr v1.4.2 // indirect
|
||||
github.com/go-logr/stdr v1.2.2 // indirect
|
||||
github.com/gogo/protobuf v1.3.2 // indirect
|
||||
github.com/inconshreveable/mousetrap v1.1.0 // indirect
|
||||
github.com/lucasb-eyer/go-colorful v1.2.0 // indirect
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
github.com/moby/docker-image-spec v1.3.1 // indirect
|
||||
github.com/moby/term v0.5.0 // indirect
|
||||
github.com/morikuni/aec v1.0.0 // indirect
|
||||
github.com/muesli/termenv v0.15.3-0.20240618155329-98d742f6907a // indirect
|
||||
github.com/opencontainers/go-digest v1.0.0 // indirect
|
||||
github.com/opencontainers/image-spec v1.1.0 // indirect
|
||||
github.com/pkg/errors v0.9.1 // indirect
|
||||
github.com/rivo/uniseg v0.4.7 // indirect
|
||||
github.com/spf13/pflag v1.0.5 // indirect
|
||||
github.com/tidwall/gjson v1.18.0 // indirect
|
||||
github.com/tidwall/match v1.1.1 // indirect
|
||||
github.com/tidwall/pretty v1.2.1 // indirect
|
||||
github.com/tidwall/sjson v1.2.5 // indirect
|
||||
go.opentelemetry.io/auto/sdk v1.1.0 // indirect
|
||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.58.0 // indirect
|
||||
go.opentelemetry.io/otel v1.33.0 // indirect
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.33.0 // indirect
|
||||
go.opentelemetry.io/otel/metric v1.33.0 // indirect
|
||||
go.opentelemetry.io/otel/sdk v1.33.0 // indirect
|
||||
go.opentelemetry.io/otel/trace v1.33.0 // indirect
|
||||
golang.org/x/exp v0.0.0-20231006140011-7918f672742d // indirect
|
||||
golang.org/x/sys v0.28.0 // indirect
|
||||
golang.org/x/time v0.8.0 // indirect
|
||||
gotest.tools/v3 v3.5.1 // indirect
|
||||
)
|
||||
@@ -1,169 +0,0 @@
|
||||
github.com/Azure/go-ansiterm v0.0.0-20210617225240-d185dfc1b5a1 h1:UQHMgLO+TxOElx5B5HZ4hJQsoJ/PvUvKRhJHDQXO8P8=
|
||||
github.com/Azure/go-ansiterm v0.0.0-20210617225240-d185dfc1b5a1/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E=
|
||||
github.com/Microsoft/go-winio v0.4.14 h1:+hMXMk01us9KgxGb7ftKQt2Xpf5hH/yky+TDA+qxleU=
|
||||
github.com/Microsoft/go-winio v0.4.14/go.mod h1:qXqCSQ3Xa7+6tgxaGTIe4Kpcdsi+P8jBhyzoq1bpyYA=
|
||||
github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k=
|
||||
github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8=
|
||||
github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8=
|
||||
github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE=
|
||||
github.com/charmbracelet/lipgloss v0.13.0 h1:4X3PPeoWEDCMvzDvGmTajSyYPcZM4+y8sCA/SsA3cjw=
|
||||
github.com/charmbracelet/lipgloss v0.13.0/go.mod h1:nw4zy0SBX/F/eAO1cWdcvy6qnkDUxr8Lw7dvFrAIbbY=
|
||||
github.com/charmbracelet/log v0.4.0 h1:G9bQAcx8rWA2T3pWvx7YtPTPwgqpk7D68BX21IRW8ZM=
|
||||
github.com/charmbracelet/log v0.4.0/go.mod h1:63bXt/djrizTec0l11H20t8FDSvA4CRZJ1KH22MdptM=
|
||||
github.com/charmbracelet/x/ansi v0.2.3 h1:VfFN0NUpcjBRd4DnKfRaIRo53KRgey/nhOoEqosGDEY=
|
||||
github.com/charmbracelet/x/ansi v0.2.3/go.mod h1:dk73KoMTT5AX5BsX0KrqhsTqAnhZZoCBjs7dGWp4Ktw=
|
||||
github.com/containerd/log v0.1.0 h1:TCJt7ioM2cr/tfR8GPbGf9/VRAX8D2B4PjzCpfX540I=
|
||||
github.com/containerd/log v0.1.0/go.mod h1:VRRf09a7mHDIRezVKTRCrOq78v577GXq3bSa3EhrzVo=
|
||||
github.com/cpuguy83/go-md2man/v2 v2.0.4/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk=
|
||||
github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E=
|
||||
github.com/docker/docker v27.4.1+incompatible h1:ZJvcY7gfwHn1JF48PfbyXg7Jyt9ZCWDW+GGXOIxEwp4=
|
||||
github.com/docker/docker v27.4.1+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk=
|
||||
github.com/docker/go-connections v0.5.0 h1:USnMq7hx7gwdVZq1L49hLXaFtUdTADjXGp+uj1Br63c=
|
||||
github.com/docker/go-connections v0.5.0/go.mod h1:ov60Kzw0kKElRwhNs9UlUHAE/F9Fe6GLaXnqyDdmEXc=
|
||||
github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4=
|
||||
github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk=
|
||||
github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=
|
||||
github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
|
||||
github.com/go-logfmt/logfmt v0.6.0 h1:wGYYu3uicYdqXVgoYbvnkrPVXkuLM1p1ifugDMEdRi4=
|
||||
github.com/go-logfmt/logfmt v0.6.0/go.mod h1:WYhtIu8zTZfxdn5+rREduYbwxfcBr/Vr6KEVveWlfTs=
|
||||
github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
|
||||
github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY=
|
||||
github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
|
||||
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
|
||||
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
|
||||
github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q=
|
||||
github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q=
|
||||
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
|
||||
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
|
||||
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
|
||||
github.com/grpc-ecosystem/grpc-gateway/v2 v2.24.0 h1:TmHmbvxPmaegwhDubVz0lICL0J5Ka2vwTzhoePEXsGE=
|
||||
github.com/grpc-ecosystem/grpc-gateway/v2 v2.24.0/go.mod h1:qztMSjm835F2bXf+5HKAPIS5qsmQDqZna/PgVt4rWtI=
|
||||
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
|
||||
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
|
||||
github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8=
|
||||
github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck=
|
||||
github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ=
|
||||
github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY=
|
||||
github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
|
||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||
github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0=
|
||||
github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo=
|
||||
github.com/moby/term v0.5.0 h1:xt8Q1nalod/v7BqbG21f8mQPqH+xAaC9C3N3wfWbVP0=
|
||||
github.com/moby/term v0.5.0/go.mod h1:8FzsFHVUBGZdbDsJw/ot+X+d5HLUbvklYLJ9uGfcI3Y=
|
||||
github.com/morikuni/aec v1.0.0 h1:nP9CBfwrvYnBRgY6qfDQkygYDmYwOilePFkwzv4dU8A=
|
||||
github.com/morikuni/aec v1.0.0/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc=
|
||||
github.com/muesli/termenv v0.15.3-0.20240618155329-98d742f6907a h1:2MaM6YC3mGu54x+RKAA6JiFFHlHDY1UbkxqppT7wYOg=
|
||||
github.com/muesli/termenv v0.15.3-0.20240618155329-98d742f6907a/go.mod h1:hxSnBBYLK21Vtq/PHd0S2FYCxBXzBua8ov5s1RobyRQ=
|
||||
github.com/nestrilabs/nestri-go-sdk v0.1.0-alpha.3 h1:IqtLHbOF3y/SD3riYYKauQKj9dpqU7uuEExqL5zQ390=
|
||||
github.com/nestrilabs/nestri-go-sdk v0.1.0-alpha.3/go.mod h1:b4AuAQSxfqtAzu4ie0Q+NOVNF9YUZTyP4XnxK0ZN05U=
|
||||
github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U=
|
||||
github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM=
|
||||
github.com/opencontainers/image-spec v1.1.0 h1:8SG7/vwALn54lVB/0yZ/MMwhFrPYtpEHQb2IpWsCzug=
|
||||
github.com/opencontainers/image-spec v1.1.0/go.mod h1:W4s4sFTMaBeK1BQLXbG4AdM2szdn85PY75RI83NrTrM=
|
||||
github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
|
||||
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
|
||||
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
|
||||
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
|
||||
github.com/sirupsen/logrus v1.4.1/go.mod h1:ni0Sbl8bgC9z8RoU9G6nDWqqs/fq4eDPysMBDgk/93Q=
|
||||
github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ=
|
||||
github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ=
|
||||
github.com/spf13/cobra v1.8.1 h1:e5/vxKd/rZsfSJMUX1agtjeTDf+qv1/JdBF8gg5k9ZM=
|
||||
github.com/spf13/cobra v1.8.1/go.mod h1:wHxEcudfqmLYa8iTfL+OuZPbBZkmvliBWKIezN3kD9Y=
|
||||
github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA=
|
||||
github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
|
||||
github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
|
||||
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
|
||||
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||
github.com/tidwall/gjson v1.14.2/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
|
||||
github.com/tidwall/gjson v1.18.0 h1:FIDeeyB800efLX89e5a8Y0BNH+LOngJyGrIWxG2FKQY=
|
||||
github.com/tidwall/gjson v1.18.0/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
|
||||
github.com/tidwall/match v1.1.1 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA=
|
||||
github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM=
|
||||
github.com/tidwall/pretty v1.2.0/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU=
|
||||
github.com/tidwall/pretty v1.2.1 h1:qjsOFOWWQl+N3RsoF5/ssm1pHmJJwhjlSbZ51I6wMl4=
|
||||
github.com/tidwall/pretty v1.2.1/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU=
|
||||
github.com/tidwall/sjson v1.2.5 h1:kLy8mja+1c9jlljvWTlSazM7cKDRfJuR/bOJhcY5NcY=
|
||||
github.com/tidwall/sjson v1.2.5/go.mod h1:Fvgq9kS/6ociJEDnK0Fk1cpYF4FIW6ZF7LAe+6jwd28=
|
||||
github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
|
||||
github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
|
||||
go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA=
|
||||
go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A=
|
||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.58.0 h1:yd02MEjBdJkG3uabWP9apV+OuWRIXGDuJEUJbOHmCFU=
|
||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.58.0/go.mod h1:umTcuxiv1n/s/S6/c2AT/g2CQ7u5C59sHDNmfSwgz7Q=
|
||||
go.opentelemetry.io/otel v1.33.0 h1:/FerN9bax5LoK51X/sI0SVYrjSE0/yUL7DpxW4K3FWw=
|
||||
go.opentelemetry.io/otel v1.33.0/go.mod h1:SUUkR6csvUQl+yjReHu5uM3EtVV7MBm5FHKRlNx4I8I=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.33.0 h1:Vh5HayB/0HHfOQA7Ctx69E/Y/DcQSMPpKANYVMQ7fBA=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.33.0/go.mod h1:cpgtDBaqD/6ok/UG0jT15/uKjAY8mRA53diogHBg3UI=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.33.0 h1:wpMfgF8E1rkrT1Z6meFh1NDtownE9Ii3n3X2GJYjsaU=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.33.0/go.mod h1:wAy0T/dUbs468uOlkT31xjvqQgEVXv58BRFWEgn5v/0=
|
||||
go.opentelemetry.io/otel/metric v1.33.0 h1:r+JOocAyeRVXD8lZpjdQjzMadVZp2M4WmQ+5WtEnklQ=
|
||||
go.opentelemetry.io/otel/metric v1.33.0/go.mod h1:L9+Fyctbp6HFTddIxClbQkjtubW6O9QS3Ann/M82u6M=
|
||||
go.opentelemetry.io/otel/sdk v1.33.0 h1:iax7M131HuAm9QkZotNHEfstof92xM+N8sr3uHXc2IM=
|
||||
go.opentelemetry.io/otel/sdk v1.33.0/go.mod h1:A1Q5oi7/9XaMlIWzPSxLRWOI8nG3FnzHJNbiENQuihM=
|
||||
go.opentelemetry.io/otel/trace v1.33.0 h1:cCJuF7LRjUFso9LPnEAHJDB2pqzp+hbO8eu1qqW2d/s=
|
||||
go.opentelemetry.io/otel/trace v1.33.0/go.mod h1:uIcdVUZMpTAmz0tI1z04GoVSezK37CbGV4fr1f2nBck=
|
||||
go.opentelemetry.io/proto/otlp v1.4.0 h1:TA9WRvW6zMwP+Ssb6fLoUIuirti1gGbP28GcKG1jgeg=
|
||||
go.opentelemetry.io/proto/otlp v1.4.0/go.mod h1:PPBWZIP98o2ElSqI35IHfu7hIhSwvc5N38Jw8pXuGFY=
|
||||
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
|
||||
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
|
||||
golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI=
|
||||
golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo=
|
||||
golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
|
||||
golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
|
||||
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
||||
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||
golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||
golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
|
||||
golang.org/x/net v0.32.0 h1:ZqPmj8Kzc+Y6e0+skZsuACbx+wzMgo5MQsJh9Qd6aYI=
|
||||
golang.org/x/net v0.32.0/go.mod h1:CwU0IoeOlnQQWJ6ioyFrfRuomB8GKF6KbYXZVyeXNfs=
|
||||
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20190507160741-ecd444e8653b/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.28.0 h1:Fksou7UEQUWlKvIdsqzJmUmCX3cZuD2+P3XyyzwMhlA=
|
||||
golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/text v0.21.0 h1:zyQAAkrwaneQ066sspRyJaG9VNi/YJ1NfzcGB3hZ/qo=
|
||||
golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
|
||||
golang.org/x/time v0.8.0 h1:9i3RxcPv3PZnitoVGMPDKZSq1xW1gK1Xy3ArNOGZfEg=
|
||||
golang.org/x/time v0.8.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM=
|
||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||
golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
|
||||
golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
|
||||
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20241209162323-e6fa225c2576 h1:CkkIfIt50+lT6NHAVoRYEyAvQGFM7xEwXUUywFvEb3Q=
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20241209162323-e6fa225c2576/go.mod h1:1R3kvZ1dtP3+4p4d3G8uJ8rFk/fWlScl38vanWACI08=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20241209162323-e6fa225c2576 h1:8ZmaLZE4XWrtU3MyClkYqqtl6Oegr3235h7jxsDyqCY=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20241209162323-e6fa225c2576/go.mod h1:5uTbfoYQed2U9p3KIj2/Zzm02PYhndfdmML0qC3q3FU=
|
||||
google.golang.org/grpc v1.68.1 h1:oI5oTa11+ng8r8XMMN7jAOmWfPZWbYpCFaMUTACxkM0=
|
||||
google.golang.org/grpc v1.68.1/go.mod h1:+q1XYFJjShcqn0QZHvCyeR4CXPA+llXIeUIfIe00waw=
|
||||
google.golang.org/protobuf v1.35.2 h1:8Ar7bF+apOIoThw1EdZl0p1oWvMqTHmpA2fRTyZO8io=
|
||||
google.golang.org/protobuf v1.35.2/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gotest.tools/v3 v3.5.1 h1:EENdUnS3pdur5nybKYIh2Vfgc8IUNBjxDPSjtiJcOzU=
|
||||
gotest.tools/v3 v3.5.1/go.mod h1:isy3WKz7GK6uNw/sbHzfKBLvlvXwUyV06n6brMxxopU=
|
||||
@@ -1,26 +0,0 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"nestrilabs/cli/internal/resource"
|
||||
|
||||
"github.com/nestrilabs/nestri-go-sdk"
|
||||
"github.com/nestrilabs/nestri-go-sdk/option"
|
||||
)
|
||||
|
||||
func RegisterMachine(token string) {
|
||||
client := nestri.NewClient(
|
||||
option.WithBearerToken(token),
|
||||
option.WithBaseURL(resource.Resource.Api.Url),
|
||||
)
|
||||
|
||||
machine, err := client.Machines.New(
|
||||
context.TODO(),
|
||||
nestri.MachineNewParams{})
|
||||
|
||||
if err != nil {
|
||||
panic(err.Error())
|
||||
}
|
||||
fmt.Printf("%+v\n", machine.Data)
|
||||
}
|
||||
@@ -1,202 +0,0 @@
|
||||
package machine
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"github.com/charmbracelet/log"
|
||||
)
|
||||
|
||||
type Machine struct {
|
||||
OperatingSystem string
|
||||
Arch string
|
||||
Kernel string
|
||||
Virtualization string
|
||||
Hostname string
|
||||
}
|
||||
|
||||
func NewMachine() *Machine {
|
||||
var OS string
|
||||
var architecture string
|
||||
var kernel string
|
||||
var virtualisation string
|
||||
var hostname string
|
||||
|
||||
output, _ := exec.Command("hostnamectl", "status").Output()
|
||||
os := regexp.MustCompile(`Operating System:\s+(.*)`)
|
||||
matchingOS := os.FindStringSubmatch(string(output))
|
||||
if len(matchingOS) > 1 {
|
||||
OS = matchingOS[1]
|
||||
}
|
||||
|
||||
arch := regexp.MustCompile(`Architecture:\s+(\w+)`)
|
||||
matchingArch := arch.FindStringSubmatch(string(output))
|
||||
if len(matchingArch) > 1 {
|
||||
architecture = matchingArch[1]
|
||||
}
|
||||
|
||||
kern := regexp.MustCompile(`Kernel:\s+(.*)`)
|
||||
matchingKernel := kern.FindStringSubmatch(string(output))
|
||||
if len(matchingKernel) > 1 {
|
||||
kernel = matchingKernel[1]
|
||||
}
|
||||
|
||||
virt := regexp.MustCompile(`Virtualization:\s+(\w+)`)
|
||||
matchingVirt := virt.FindStringSubmatch(string(output))
|
||||
if len(matchingVirt) > 1 {
|
||||
virtualisation = matchingVirt[1]
|
||||
}
|
||||
|
||||
host := regexp.MustCompile(`Static hostname:\s+(.*)`)
|
||||
matchingHost := host.FindStringSubmatch(string(output))
|
||||
if len(matchingHost) > 1 {
|
||||
hostname = matchingHost[1]
|
||||
}
|
||||
|
||||
return &Machine{
|
||||
OperatingSystem: OS,
|
||||
Arch: architecture,
|
||||
Kernel: kernel,
|
||||
Virtualization: virtualisation,
|
||||
Hostname: hostname,
|
||||
}
|
||||
}
|
||||
|
||||
func (m *Machine) GetOS() string {
|
||||
if m.OperatingSystem != "" {
|
||||
return m.OperatingSystem
|
||||
}
|
||||
return "unknown"
|
||||
}
|
||||
|
||||
func (m *Machine) GetArchitecture() string {
|
||||
|
||||
if m.Arch != "" {
|
||||
return m.Arch
|
||||
}
|
||||
return "unknown"
|
||||
|
||||
}
|
||||
|
||||
func (m *Machine) GetKernel() string {
|
||||
if m.Kernel != "" {
|
||||
return m.Kernel
|
||||
}
|
||||
return "unknown"
|
||||
}
|
||||
|
||||
func (m *Machine) GetVirtualization() string {
|
||||
if m.Virtualization != "" {
|
||||
return m.Virtualization
|
||||
}
|
||||
return "none"
|
||||
}
|
||||
|
||||
func (m *Machine) GetHostname() string {
|
||||
if m.Hostname != "" {
|
||||
return m.Hostname
|
||||
}
|
||||
return "unknown"
|
||||
}
|
||||
|
||||
func (m *Machine) GetMachineID() string {
|
||||
id, err := os.ReadFile("/etc/machine-id")
|
||||
if err != nil {
|
||||
log.Error("Error getting your machine's ID", "err", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
return strings.TrimSpace(string(id))
|
||||
}
|
||||
|
||||
func (m *Machine) GPUInfo() (string, string, error) {
|
||||
// The command for GPU information varies depending on the system and drivers.
|
||||
// lshw is a good general-purpose tool, but might need adjustments for specific hardware.
|
||||
output, err := exec.Command("lshw", "-C", "display").Output()
|
||||
if err != nil {
|
||||
return "", "", fmt.Errorf("failed to get GPU information: %w", err)
|
||||
}
|
||||
|
||||
gpuType := ""
|
||||
gpuSize := ""
|
||||
|
||||
// Regular expressions for extracting product and size information. These might need to be
|
||||
// adapted based on the output of lshw on your specific system.
|
||||
typeRegex := regexp.MustCompile(`product:\s+(.*)`)
|
||||
sizeRegex := regexp.MustCompile(`size:\s+(\d+MiB)`) // Example: extracts size in MiB
|
||||
|
||||
typeMatch := typeRegex.FindStringSubmatch(string(output))
|
||||
if len(typeMatch) > 1 {
|
||||
gpuType = typeMatch[1]
|
||||
}
|
||||
|
||||
sizeMatch := sizeRegex.FindStringSubmatch(string(output))
|
||||
if len(sizeMatch) > 1 {
|
||||
gpuSize = sizeMatch[1]
|
||||
}
|
||||
|
||||
if gpuType == "" && gpuSize == "" {
|
||||
return "", "", fmt.Errorf("could not parse GPU information using lshw")
|
||||
}
|
||||
|
||||
return gpuType, gpuSize, nil
|
||||
}
|
||||
|
||||
func (m *Machine) GetCPUInfo() (string, string, error) {
|
||||
output, err := exec.Command("lscpu").Output()
|
||||
if err != nil {
|
||||
return "", "", fmt.Errorf("failed to get CPU information: %w", err)
|
||||
}
|
||||
|
||||
cpuType := ""
|
||||
cpuSize := "" // This will store the number of cores
|
||||
|
||||
typeRegex := regexp.MustCompile(`Model name:\s+(.*)`)
|
||||
coresRegex := regexp.MustCompile(`CPU\(s\):\s+(\d+)`)
|
||||
|
||||
typeMatch := typeRegex.FindStringSubmatch(string(output))
|
||||
if len(typeMatch) > 1 {
|
||||
cpuType = typeMatch[1]
|
||||
}
|
||||
|
||||
coresMatch := coresRegex.FindStringSubmatch(string(output))
|
||||
if len(coresMatch) > 1 {
|
||||
cpuSize = coresMatch[1]
|
||||
}
|
||||
|
||||
if cpuType == "" && cpuSize == "" {
|
||||
return "", "", fmt.Errorf("could not parse CPU information using lscpu")
|
||||
}
|
||||
|
||||
return cpuType, cpuSize, nil
|
||||
|
||||
}
|
||||
|
||||
func (m *Machine) GetRAMSize() (string, error) {
|
||||
output, err := exec.Command("free", "-h", "--si").Output() // Using -h for human-readable and --si for base-10 units
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to get RAM information: %w", err)
|
||||
}
|
||||
|
||||
ramSize := ""
|
||||
|
||||
ramRegex := regexp.MustCompile(`Mem:\s+(\S+)`) // Matches the total memory size
|
||||
|
||||
ramMatch := ramRegex.FindStringSubmatch(string(output))
|
||||
if len(ramMatch) > 1 {
|
||||
ramSize = ramMatch[1]
|
||||
} else {
|
||||
return "", fmt.Errorf("could not parse RAM information from free command")
|
||||
}
|
||||
|
||||
return ramSize, nil
|
||||
}
|
||||
|
||||
// func cleanString(s string) string {
|
||||
// s = strings.ToLower(s)
|
||||
|
||||
// reg := regexp.MustCompile("[^a-z0-9]+") // Matches one or more non-alphanumeric characters
|
||||
// return reg.ReplaceAllString(s, "")
|
||||
// }
|
||||
@@ -1,118 +0,0 @@
|
||||
package party
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"nestrilabs/cli/internal/machine"
|
||||
"nestrilabs/cli/internal/resource"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"time"
|
||||
|
||||
"github.com/charmbracelet/log"
|
||||
"github.com/gorilla/websocket"
|
||||
)
|
||||
|
||||
const (
|
||||
// Initial retry delay
|
||||
initialRetryDelay = 1 * time.Second
|
||||
// Maximum retry delay
|
||||
maxRetryDelay = 30 * time.Second
|
||||
// Factor to increase delay by after each attempt
|
||||
backoffFactor = 2
|
||||
)
|
||||
|
||||
type Party struct {
|
||||
// Channel to signal shutdown
|
||||
done chan struct{}
|
||||
fingerprint string
|
||||
hostname string
|
||||
}
|
||||
|
||||
func NewParty() *Party {
|
||||
m := machine.NewMachine()
|
||||
fingerpint := m.GetMachineID()
|
||||
return &Party{
|
||||
done: make(chan struct{}),
|
||||
fingerprint: fingerpint,
|
||||
hostname: m.Hostname,
|
||||
}
|
||||
}
|
||||
|
||||
// Shutdown gracefully closes the connection
|
||||
func (p *Party) Shutdown() {
|
||||
close(p.done)
|
||||
}
|
||||
|
||||
func (p *Party) Connect() {
|
||||
baseURL := fmt.Sprintf("ws://localhost:1999/parties/main/%s", p.fingerprint)
|
||||
params := url.Values{}
|
||||
params.Add("_pk", p.hostname)
|
||||
wsURL := baseURL + "?" + params.Encode()
|
||||
|
||||
retryDelay := initialRetryDelay
|
||||
header := http.Header{}
|
||||
bearer := fmt.Sprintf("Bearer %s", resource.Resource.AuthFingerprintKey.Value)
|
||||
header.Add("Authorization", bearer)
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-p.done:
|
||||
log.Info("Shutting down connection")
|
||||
return
|
||||
default:
|
||||
conn, _, err := websocket.DefaultDialer.Dial(wsURL, header)
|
||||
if err != nil {
|
||||
log.Error("Failed to connect to party server", "err", err)
|
||||
time.Sleep(retryDelay)
|
||||
// Increase retry delay exponentially, but cap it
|
||||
retryDelay = time.Duration(float64(retryDelay) * backoffFactor)
|
||||
if retryDelay > maxRetryDelay {
|
||||
retryDelay = maxRetryDelay
|
||||
}
|
||||
continue
|
||||
}
|
||||
log.Info("Connection to server", "url", wsURL)
|
||||
|
||||
// Reset retry delay on successful connection
|
||||
retryDelay = initialRetryDelay
|
||||
|
||||
// Handle connection in a separate goroutine
|
||||
connectionClosed := make(chan struct{})
|
||||
go func() {
|
||||
defer close(connectionClosed)
|
||||
defer conn.Close()
|
||||
|
||||
// Send initial message
|
||||
// if err := conn.WriteMessage(websocket.TextMessage, []byte("hello there")); err != nil {
|
||||
// log.Error("Failed to send initial message", "err", err)
|
||||
// return
|
||||
// }
|
||||
|
||||
// Read messages loop
|
||||
for {
|
||||
select {
|
||||
case <-p.done:
|
||||
return
|
||||
default:
|
||||
_, message, err := conn.ReadMessage()
|
||||
if err != nil {
|
||||
log.Error("Error reading message", "err", err)
|
||||
return
|
||||
}
|
||||
log.Info("Received message from party server", "message", string(message))
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
// Wait for either connection to close or shutdown signal
|
||||
select {
|
||||
case <-connectionClosed:
|
||||
log.Warn("Connection closed, attempting to reconnect...")
|
||||
time.Sleep(retryDelay)
|
||||
case <-p.done:
|
||||
log.Info("Shutting down connection")
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,125 +0,0 @@
|
||||
package party
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"nestrilabs/cli/internal/machine"
|
||||
"net/url"
|
||||
"time"
|
||||
|
||||
"github.com/charmbracelet/log"
|
||||
"github.com/gorilla/websocket"
|
||||
)
|
||||
|
||||
// RetryConfig holds configuration for retry behavior
|
||||
type RetryConfig struct {
|
||||
InitialDelay time.Duration
|
||||
MaxDelay time.Duration
|
||||
BackoffFactor float64
|
||||
MaxAttempts int // use 0 for infinite retries
|
||||
}
|
||||
|
||||
// DefaultRetryConfig provides sensible default values
|
||||
var DefaultRetryConfig = RetryConfig{
|
||||
InitialDelay: time.Second,
|
||||
MaxDelay: 30 * time.Second,
|
||||
BackoffFactor: 2.0,
|
||||
MaxAttempts: 0, // infinite retries
|
||||
}
|
||||
|
||||
// RetryFunc is a function that will be retried
|
||||
type RetryFunc[T any] func() (T, error)
|
||||
|
||||
// Retry executes the given function with retries based on the config
|
||||
func Retry[T any](config RetryConfig, operation RetryFunc[T]) (T, error) {
|
||||
var result T
|
||||
currentDelay := config.InitialDelay
|
||||
attempts := 0
|
||||
|
||||
for {
|
||||
if config.MaxAttempts > 0 && attempts >= config.MaxAttempts {
|
||||
return result, fmt.Errorf("max retry attempts (%d) exceeded", config.MaxAttempts)
|
||||
}
|
||||
|
||||
result, err := operation()
|
||||
if err == nil {
|
||||
return result, nil
|
||||
}
|
||||
|
||||
log.Warn("Operation failed, retrying...",
|
||||
"attempt", attempts+1,
|
||||
"delay", currentDelay,
|
||||
"error", err)
|
||||
|
||||
time.Sleep(currentDelay)
|
||||
|
||||
// Increase delay for next attempt
|
||||
currentDelay = time.Duration(float64(currentDelay) * config.BackoffFactor)
|
||||
if currentDelay > config.MaxDelay {
|
||||
currentDelay = config.MaxDelay
|
||||
}
|
||||
|
||||
attempts++
|
||||
}
|
||||
}
|
||||
|
||||
// MessageHandler processes a message and returns true if it's the expected type
|
||||
type MessageHandler[T any] func(msg T) bool
|
||||
|
||||
type TypeListener[T any] struct {
|
||||
retryConfig RetryConfig
|
||||
handler MessageHandler[T]
|
||||
fingerprint string
|
||||
hostname string
|
||||
}
|
||||
|
||||
func NewTypeListener[T any](handler MessageHandler[T]) *TypeListener[T] {
|
||||
m := machine.NewMachine()
|
||||
fingerprint := m.GetMachineID()
|
||||
|
||||
return &TypeListener[T]{
|
||||
retryConfig: DefaultRetryConfig,
|
||||
handler: handler,
|
||||
fingerprint: fingerprint,
|
||||
hostname: m.Hostname,
|
||||
}
|
||||
}
|
||||
|
||||
// SetRetryConfig allows customizing the retry behavior
|
||||
func (t *TypeListener[T]) SetRetryConfig(config RetryConfig) {
|
||||
t.retryConfig = config
|
||||
}
|
||||
|
||||
func (t *TypeListener[T]) ConnectUntilMessage() (T, error) {
|
||||
baseURL := fmt.Sprintf("ws://localhost:1999/parties/main/%s", t.fingerprint)
|
||||
params := url.Values{}
|
||||
params.Add("_pk", t.hostname)
|
||||
wsURL := baseURL + "?" + params.Encode()
|
||||
|
||||
return Retry(t.retryConfig, func() (T, error) {
|
||||
var result T
|
||||
|
||||
conn, _, err := websocket.DefaultDialer.Dial(wsURL, nil)
|
||||
if err != nil {
|
||||
return result, fmt.Errorf("connection failed: %w", err)
|
||||
}
|
||||
defer conn.Close()
|
||||
|
||||
// Read messages until we get the one we want
|
||||
for {
|
||||
_, message, err := conn.ReadMessage()
|
||||
if err != nil {
|
||||
return result, fmt.Errorf("read error: %w", err)
|
||||
}
|
||||
|
||||
if err := json.Unmarshal(message, &result); err != nil {
|
||||
// log.Error("Failed to unmarshal message", "err", err)
|
||||
continue
|
||||
}
|
||||
|
||||
if t.handler(result) {
|
||||
return result, nil
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -1,286 +0,0 @@
|
||||
package session
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"github.com/docker/docker/api/types/container"
|
||||
"github.com/docker/docker/api/types/image"
|
||||
"github.com/docker/docker/client"
|
||||
)
|
||||
|
||||
// GPUType represents the type of GPU available
|
||||
type GPUType int
|
||||
|
||||
const (
|
||||
GPUNone GPUType = iota
|
||||
GPUNvidia
|
||||
GPUIntelAMD
|
||||
)
|
||||
|
||||
// Session represents a Docker container session
|
||||
type Session struct {
|
||||
client *client.Client
|
||||
containerID string
|
||||
imageName string
|
||||
config *SessionConfig
|
||||
mu sync.RWMutex
|
||||
isRunning bool
|
||||
}
|
||||
|
||||
// SessionConfig holds the configuration for the session
|
||||
type SessionConfig struct {
|
||||
Room string
|
||||
Resolution string
|
||||
Framerate string
|
||||
RelayURL string
|
||||
Params string
|
||||
GamePath string
|
||||
}
|
||||
|
||||
// NewSession creates a new Docker session
|
||||
func NewSession(config *SessionConfig) (*Session, error) {
|
||||
cli, err := client.NewClientWithOpts(client.FromEnv)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create Docker client: %v", err)
|
||||
}
|
||||
|
||||
return &Session{
|
||||
client: cli,
|
||||
imageName: "archlinux", //"ghcr.io/datcaptainhorse/nestri-cachyos:latest-noavx2",
|
||||
config: config,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Start initiates the Docker container session
|
||||
func (s *Session) Start(ctx context.Context) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
if s.isRunning {
|
||||
return fmt.Errorf("session is already running")
|
||||
}
|
||||
|
||||
// Detect GPU type
|
||||
gpuType := detectGPU()
|
||||
if gpuType == GPUNone {
|
||||
return fmt.Errorf("no supported GPU detected")
|
||||
}
|
||||
|
||||
// Get GPU-specific configurations
|
||||
deviceRequests, err := getGPUDeviceRequests(gpuType)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
devices := getGPUDevices(gpuType)
|
||||
|
||||
// Check if image exists locally
|
||||
_, _, err = s.client.ImageInspectWithRaw(ctx, s.imageName)
|
||||
if err != nil {
|
||||
// Pull the image if it doesn't exist
|
||||
reader, err := s.client.ImagePull(ctx, s.imageName, image.PullOptions{})
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to pull image: %v", err)
|
||||
}
|
||||
defer reader.Close()
|
||||
|
||||
// Copy pull output to stdout
|
||||
io.Copy(os.Stdout, reader)
|
||||
}
|
||||
|
||||
// Create container
|
||||
resp, err := s.client.ContainerCreate(ctx, &container.Config{
|
||||
Image: s.imageName,
|
||||
Env: []string{
|
||||
fmt.Sprintf("NESTRI_ROOM=%s", s.config.Room),
|
||||
fmt.Sprintf("RESOLUTION=%s", s.config.Resolution),
|
||||
fmt.Sprintf("NESTRI_PARAMS=%s", s.config.Params),
|
||||
fmt.Sprintf("FRAMERATE=%s", s.config.Framerate),
|
||||
fmt.Sprintf("RELAY_URL=%s", s.config.RelayURL),
|
||||
},
|
||||
}, &container.HostConfig{
|
||||
Binds: []string{
|
||||
fmt.Sprintf("%s:/home/nestri/.steam/", s.config.GamePath),
|
||||
},
|
||||
Resources: container.Resources{
|
||||
DeviceRequests: deviceRequests,
|
||||
Devices: devices,
|
||||
},
|
||||
SecurityOpt: []string{"label=disable"},
|
||||
ShmSize: 5368709120, // 5GB
|
||||
// ShmSize: 1073741824, // 1GB
|
||||
}, nil, nil, "")
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create container: %v", err)
|
||||
}
|
||||
|
||||
// Start container
|
||||
if err := s.client.ContainerStart(ctx, resp.ID, container.StartOptions{}); err != nil {
|
||||
return fmt.Errorf("failed to start container: %v", err)
|
||||
}
|
||||
|
||||
// Store container ID and update state
|
||||
s.containerID = resp.ID
|
||||
s.isRunning = true
|
||||
|
||||
// Start logging in a goroutine
|
||||
go s.streamLogs(ctx)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Stop stops the Docker container session
|
||||
func (s *Session) Stop(ctx context.Context) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
if !s.isRunning {
|
||||
return fmt.Errorf("session is not running")
|
||||
}
|
||||
|
||||
timeout := 30 // seconds
|
||||
if err := s.client.ContainerStop(ctx, s.containerID, container.StopOptions{Timeout: &timeout}); err != nil {
|
||||
return fmt.Errorf("failed to stop container: %v", err)
|
||||
}
|
||||
|
||||
if err := s.client.ContainerRemove(ctx, s.containerID, container.RemoveOptions{}); err != nil {
|
||||
return fmt.Errorf("failed to remove container: %v", err)
|
||||
}
|
||||
|
||||
s.isRunning = false
|
||||
s.containerID = ""
|
||||
return nil
|
||||
}
|
||||
|
||||
// IsRunning returns the current state of the session
|
||||
func (s *Session) IsRunning() bool {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
return s.isRunning
|
||||
}
|
||||
|
||||
// GetContainerID returns the current container ID
|
||||
func (s *Session) GetContainerID() string {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
return s.containerID
|
||||
}
|
||||
|
||||
// streamLogs streams container logs to stdout
|
||||
func (s *Session) streamLogs(ctx context.Context) {
|
||||
opts := container.LogsOptions{
|
||||
ShowStdout: true,
|
||||
ShowStderr: true,
|
||||
Follow: true,
|
||||
}
|
||||
|
||||
logs, err := s.client.ContainerLogs(ctx, s.containerID, opts)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Error getting container logs: %v\n", err)
|
||||
return
|
||||
}
|
||||
defer logs.Close()
|
||||
|
||||
_, err = io.Copy(os.Stdout, logs)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Error streaming logs: %v\n", err)
|
||||
}
|
||||
}
|
||||
|
||||
// VerifyEnvironment checks if all expected environment variables are set correctly in the container
|
||||
func (s *Session) VerifyEnvironment(ctx context.Context) error {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
|
||||
if !s.isRunning {
|
||||
return fmt.Errorf("session is not running")
|
||||
}
|
||||
|
||||
// Get container info to verify it's actually running
|
||||
inspect, err := s.client.ContainerInspect(ctx, s.containerID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to inspect container: %v", err)
|
||||
}
|
||||
|
||||
if !inspect.State.Running {
|
||||
return fmt.Errorf("container is not in running state")
|
||||
}
|
||||
|
||||
// Expected environment variables
|
||||
expectedEnv := map[string]string{
|
||||
"NESTRI_ROOM": s.config.Room,
|
||||
"RESOLUTION": s.config.Resolution,
|
||||
"FRAMERATE": s.config.Framerate,
|
||||
"RELAY_URL": s.config.RelayURL,
|
||||
"NESTRI_PARAMS": s.config.Params,
|
||||
}
|
||||
|
||||
// Get actual environment variables from container
|
||||
containerEnv := make(map[string]string)
|
||||
for _, env := range inspect.Config.Env {
|
||||
parts := strings.SplitN(env, "=", 2)
|
||||
if len(parts) == 2 {
|
||||
containerEnv[parts[0]] = parts[1]
|
||||
}
|
||||
}
|
||||
|
||||
// Check each expected variable
|
||||
var missingVars []string
|
||||
var mismatchedVars []string
|
||||
|
||||
for key, expectedValue := range expectedEnv {
|
||||
actualValue, exists := containerEnv[key]
|
||||
if !exists {
|
||||
missingVars = append(missingVars, key)
|
||||
} else if actualValue != expectedValue {
|
||||
mismatchedVars = append(mismatchedVars, fmt.Sprintf("%s (expected: %s, got: %s)",
|
||||
key, expectedValue, actualValue))
|
||||
}
|
||||
}
|
||||
|
||||
// Build error message if there are any issues
|
||||
if len(missingVars) > 0 || len(mismatchedVars) > 0 {
|
||||
var errorMsg strings.Builder
|
||||
if len(missingVars) > 0 {
|
||||
errorMsg.WriteString(fmt.Sprintf("Missing environment variables: %s\n",
|
||||
strings.Join(missingVars, ", ")))
|
||||
}
|
||||
if len(mismatchedVars) > 0 {
|
||||
errorMsg.WriteString(fmt.Sprintf("Mismatched environment variables: %s",
|
||||
strings.Join(mismatchedVars, ", ")))
|
||||
}
|
||||
return fmt.Errorf(errorMsg.String())
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetEnvironment returns all environment variables in the container
|
||||
func (s *Session) GetEnvironment(ctx context.Context) (map[string]string, error) {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
|
||||
if !s.isRunning {
|
||||
return nil, fmt.Errorf("session is not running")
|
||||
}
|
||||
|
||||
inspect, err := s.client.ContainerInspect(ctx, s.containerID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to inspect container: %v", err)
|
||||
}
|
||||
|
||||
env := make(map[string]string)
|
||||
for _, e := range inspect.Config.Env {
|
||||
parts := strings.SplitN(e, "=", 2)
|
||||
if len(parts) == 2 {
|
||||
env[parts[0]] = parts[1]
|
||||
}
|
||||
}
|
||||
|
||||
return env, nil
|
||||
}
|
||||
@@ -1,76 +0,0 @@
|
||||
package session
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"strings"
|
||||
|
||||
"github.com/docker/docker/api/types/container"
|
||||
)
|
||||
|
||||
// ExecResult holds the output from a container command
|
||||
type ExecResult struct {
|
||||
ExitCode int
|
||||
Stdout string
|
||||
Stderr string
|
||||
}
|
||||
|
||||
func (s *Session) execInContainer(ctx context.Context, cmd []string) (*ExecResult, error) {
|
||||
execConfig := container.ExecOptions{
|
||||
Cmd: cmd,
|
||||
AttachStdout: true,
|
||||
AttachStderr: true,
|
||||
}
|
||||
|
||||
execID, err := s.client.ContainerExecCreate(ctx, s.containerID, execConfig)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
resp, err := s.client.ContainerExecAttach(ctx, execID.ID, container.ExecAttachOptions{})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Close()
|
||||
|
||||
var outBuf bytes.Buffer
|
||||
_, err = io.Copy(&outBuf, resp.Reader)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
inspect, err := s.client.ContainerExecInspect(ctx, execID.ID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &ExecResult{
|
||||
ExitCode: inspect.ExitCode,
|
||||
Stdout: outBuf.String(),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// CheckSteamGames returns the list of installed games in the container
|
||||
func (s *Session) CheckInstalledSteamGames(ctx context.Context) ([]uint64, error) {
|
||||
result, err := s.execInContainer(ctx, []string{
|
||||
"sh", "-c",
|
||||
"find /home/nestri/.steam/steam/steamapps -name '*.acf' -exec grep -H '\"appid\"' {} \\;",
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to check steam games: %v", err)
|
||||
}
|
||||
|
||||
var gameIDs []uint64
|
||||
for _, line := range strings.Split(result.Stdout, "\n") {
|
||||
if strings.Contains(line, "appid") {
|
||||
var id uint64
|
||||
if _, err := fmt.Sscanf(line, `"appid" "%d"`, &id); err == nil {
|
||||
gameIDs = append(gameIDs, id)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return gameIDs, nil
|
||||
}
|
||||
@@ -1,72 +0,0 @@
|
||||
package session
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
|
||||
"github.com/docker/docker/api/types/container"
|
||||
)
|
||||
|
||||
// detectGPU checks for available GPU type
|
||||
func detectGPU() GPUType {
|
||||
// First check for NVIDIA
|
||||
cmd := exec.Command("nvidia-smi")
|
||||
if err := cmd.Run(); err == nil {
|
||||
return GPUNvidia
|
||||
}
|
||||
|
||||
// Check for Intel/AMD GPU by looking for DRI devices
|
||||
if _, err := os.Stat("/dev/dri"); err == nil {
|
||||
return GPUIntelAMD
|
||||
}
|
||||
|
||||
return GPUNone
|
||||
}
|
||||
|
||||
// getGPUDeviceRequests returns appropriate device configuration based on GPU type
|
||||
func getGPUDeviceRequests(gpuType GPUType) ([]container.DeviceRequest, error) {
|
||||
switch gpuType {
|
||||
case GPUNvidia:
|
||||
return []container.DeviceRequest{
|
||||
{
|
||||
Driver: "nvidia",
|
||||
Count: 1,
|
||||
DeviceIDs: []string{"0"},
|
||||
Capabilities: [][]string{{"gpu"}},
|
||||
},
|
||||
}, nil
|
||||
case GPUIntelAMD:
|
||||
return []container.DeviceRequest{}, nil // Empty as we'll handle this in Devices
|
||||
default:
|
||||
return nil, fmt.Errorf("no supported GPU detected")
|
||||
}
|
||||
}
|
||||
|
||||
// getGPUDevices returns appropriate device mappings based on GPU type
|
||||
func getGPUDevices(gpuType GPUType) []container.DeviceMapping {
|
||||
if gpuType == GPUIntelAMD {
|
||||
devices := []container.DeviceMapping{}
|
||||
// Only look for card and renderD nodes
|
||||
for _, pattern := range []string{"card[0-9]*", "renderD[0-9]*"} {
|
||||
matches, err := filepath.Glob(fmt.Sprintf("/dev/dri/%s", pattern))
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
for _, match := range matches {
|
||||
// Verify it's a device file
|
||||
if info, err := os.Stat(match); err == nil && (info.Mode()&os.ModeDevice) != 0 {
|
||||
devices = append(devices, container.DeviceMapping{
|
||||
PathOnHost: match,
|
||||
PathInContainer: match,
|
||||
CgroupPermissions: "rwm",
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
return devices
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -1,58 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"nestrilabs/cli/internal/party"
|
||||
)
|
||||
|
||||
func main() {
|
||||
// err := cmd.Execute()
|
||||
// if err != nil {
|
||||
// log.Error("Error running the cmd command", "err", err)
|
||||
// }
|
||||
|
||||
// ctx := context.Background()
|
||||
|
||||
// config := &session.SessionConfig{
|
||||
// Room: "victortest",
|
||||
// Resolution: "1920x1080",
|
||||
// Framerate: "60",
|
||||
// RelayURL: "https://relay.dathorse.com",
|
||||
// Params: "--verbose=true --video-codec=h264 --video-bitrate=4000 --video-bitrate-max=6000 --gpu-card-path=/dev/dri/card1",
|
||||
// GamePath: "/path/to/your/game",
|
||||
// }
|
||||
|
||||
// sess, err := session.NewSession(config)
|
||||
// if err != nil {
|
||||
// log.Error("Failed to create session", "err", err)
|
||||
// }
|
||||
|
||||
// // Start the session
|
||||
// if err := sess.Start(ctx); err != nil {
|
||||
// log.Error("Failed to start session", "err", err)
|
||||
// }
|
||||
|
||||
// // Check if it's running
|
||||
// if sess.IsRunning() {
|
||||
// log.Info("Session is running with container ID", "containerId", sess.GetContainerID())
|
||||
// }
|
||||
|
||||
// env, err := sess.GetEnvironment(ctx)
|
||||
// if err != nil {
|
||||
// log.Printf("Failed to get environment: %v", err)
|
||||
// } else {
|
||||
// for key, value := range env {
|
||||
// log.Info("Found this environment variables", key, value)
|
||||
// }
|
||||
// }
|
||||
|
||||
// // Let it run for a while
|
||||
// // time.Sleep(time.Second * 50)
|
||||
|
||||
// // Stop the session
|
||||
// if err := sess.Stop(ctx); err != nil {
|
||||
// log.Error("Failed to stop session", "err", err)
|
||||
// }
|
||||
|
||||
party := party.NewParty()
|
||||
party.Connect()
|
||||
}
|
||||
@@ -5,42 +5,53 @@ const _schema = i.schema({
|
||||
$users: i.entity({
|
||||
email: i.string().unique().indexed(),
|
||||
}),
|
||||
machines: i.entity({
|
||||
// machines: i.entity({
|
||||
// hostname: i.string(),
|
||||
// fingerprint: i.string().unique().indexed(),
|
||||
// deletedAt: i.date().optional().indexed(),
|
||||
// createdAt: i.date()
|
||||
// }),
|
||||
tasks: i.entity({
|
||||
type: i.string(),
|
||||
lastStatus: i.string(),
|
||||
healthStatus: i.string(),
|
||||
startedAt: i.string(),
|
||||
lastUpdated: i.date(),
|
||||
stoppedAt: i.string().optional(),
|
||||
taskID: i.string().unique().indexed()
|
||||
}),
|
||||
instances: i.entity({
|
||||
hostname: i.string(),
|
||||
fingerprint: i.string().unique().indexed(),
|
||||
deletedAt: i.date().optional().indexed(),
|
||||
lastActive: i.date().optional(),
|
||||
createdAt: i.date()
|
||||
}),
|
||||
profiles: i.entity({
|
||||
avatarUrl: i.string().optional(),
|
||||
username: i.string().indexed(),
|
||||
updatedAt: i.date(),
|
||||
status: i.string().indexed(),
|
||||
updatedAt: i.date().indexed(),
|
||||
createdAt: i.date(),
|
||||
discriminator: i.string().indexed()
|
||||
}),
|
||||
teams: i.entity({
|
||||
name: i.string(),
|
||||
slug: i.string().unique().indexed(),
|
||||
deletedAt: i.date().optional().indexed(),
|
||||
deletedAt: i.date().optional(),//.indexed(),
|
||||
updatedAt: i.date(),
|
||||
createdAt: i.date(),
|
||||
}),
|
||||
games: i.entity({
|
||||
name: i.string(),
|
||||
steamID: i.number().unique().indexed(),
|
||||
}),
|
||||
// games: i.entity({
|
||||
// name: i.string(),
|
||||
// steamID: i.number().unique().indexed(),
|
||||
// }),
|
||||
sessions: i.entity({
|
||||
name: i.string(),
|
||||
startedAt: i.date(),
|
||||
endedAt: i.date().optional().indexed(),
|
||||
public: i.boolean().indexed(),
|
||||
}),
|
||||
subscriptions: i.entity({
|
||||
checkoutID: i.string(),
|
||||
// quantity: i.number(),
|
||||
// frequency: i.string(),
|
||||
canceledAt: i.date(),
|
||||
// next: i.date()
|
||||
})
|
||||
},
|
||||
links: {
|
||||
@@ -52,6 +63,18 @@ const _schema = i.schema({
|
||||
forward: { on: "profiles", has: "one", label: "owner" },
|
||||
reverse: { on: "$users", has: "one", label: "profile" }
|
||||
},
|
||||
UserTasks: {
|
||||
forward: { on: "tasks", has: "one", label: "owner" },
|
||||
reverse: { on: "$users", has: "many", label: "tasks" }
|
||||
},
|
||||
TaskSessions: {
|
||||
forward: { on: "tasks", has: "many", label: "sessions" },
|
||||
reverse: { on: "sessions", has: "one", label: "task" }
|
||||
},
|
||||
UserSession: {
|
||||
forward: { on: "sessions", has: "one", label: "owner" },
|
||||
reverse: { on: "$users", has: "many", label: "sessions" }
|
||||
},
|
||||
TeamsOwned: {
|
||||
forward: { on: "teams", has: "one", label: "owner" },
|
||||
reverse: { on: "$users", has: "many", label: "teamsOwned" },
|
||||
@@ -60,30 +83,34 @@ const _schema = i.schema({
|
||||
forward: { on: "teams", has: "many", label: "members" },
|
||||
reverse: { on: "$users", has: "many", label: "teamsJoined" },
|
||||
},
|
||||
UserMachines: {
|
||||
forward: { on: "machines", has: "one", label: "owner" },
|
||||
reverse: { on: "$users", has: "many", label: "machines" }
|
||||
},
|
||||
UserGames: {
|
||||
forward: { on: "games", has: "many", label: "owners" },
|
||||
reverse: { on: "$users", has: "many", label: "games" }
|
||||
},
|
||||
MachineSessions: {
|
||||
forward: { on: "machines", has: "many", label: "sessions" },
|
||||
reverse: { on: "sessions", has: "one", label: "machine" }
|
||||
},
|
||||
GamesMachines: {
|
||||
forward: { on: "machines", has: "many", label: "games" },
|
||||
reverse: { on: "games", has: "many", label: "machines" }
|
||||
},
|
||||
GameSessions: {
|
||||
forward: { on: "games", has: "many", label: "sessions" },
|
||||
reverse: { on: "sessions", has: "one", label: "game" }
|
||||
},
|
||||
UserSessions: {
|
||||
forward: { on: "sessions", has: "one", label: "owner" },
|
||||
reverse: { on: "$users", has: "many", label: "sessions" }
|
||||
}
|
||||
// UserMachines: {
|
||||
// forward: { on: "machines", has: "one", label: "owner" },
|
||||
// reverse: { on: "$users", has: "many", label: "machines" }
|
||||
// },
|
||||
// UserGames: {
|
||||
// forward: { on: "games", has: "many", label: "owners" },
|
||||
// reverse: { on: "$users", has: "many", label: "games" }
|
||||
// },
|
||||
// TeamInstances: {
|
||||
// forward: { on: "instances", has: "many", label: "owners" },
|
||||
// reverse: { on: "teams", has: "many", label: "instances" }
|
||||
// },
|
||||
// MachineSessions: {
|
||||
// forward: { on: "machines", has: "many", label: "sessions" },
|
||||
// reverse: { on: "sessions", has: "one", label: "machine" }
|
||||
// },
|
||||
// GamesMachines: {
|
||||
// forward: { on: "machines", has: "many", label: "games" },
|
||||
// reverse: { on: "games", has: "many", label: "machines" }
|
||||
// },
|
||||
// GameSessions: {
|
||||
// forward: { on: "games", has: "many", label: "sessions" },
|
||||
// reverse: { on: "sessions", has: "one", label: "game" }
|
||||
// },
|
||||
// UserSessions: {
|
||||
// forward: { on: "sessions", has: "one", label: "owner" },
|
||||
// reverse: { on: "$users", has: "many", label: "sessions" }
|
||||
// }
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -8,7 +8,10 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tsconfig/node20": "^20.1.4",
|
||||
"aws-iot-device-sdk-v2": "^1.21.1",
|
||||
"aws4fetch": "^1.0.20",
|
||||
"loops": "^3.4.1",
|
||||
"mqtt": "^5.10.3",
|
||||
"remeda": "^2.19.0",
|
||||
"ulid": "^2.3.0",
|
||||
"uuid": "^11.0.3",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { createContext } from "./context";
|
||||
import { VisibleError } from "./error";
|
||||
|
||||
|
||||
export interface UserActor {
|
||||
type: "user";
|
||||
properties: {
|
||||
@@ -21,8 +21,8 @@ export interface UserActor {
|
||||
export interface DeviceActor {
|
||||
type: "device";
|
||||
properties: {
|
||||
fingerprint: string;
|
||||
id: string;
|
||||
teamSlug: string;
|
||||
hostname: string;
|
||||
auth?:
|
||||
| {
|
||||
type: "personal";
|
||||
@@ -47,7 +47,7 @@ export function useCurrentUser() {
|
||||
const actor = ActorContext.use();
|
||||
if (actor.type === "user") return {
|
||||
id:actor.properties.userID,
|
||||
token: actor.properties.accessToken
|
||||
token: actor.properties.accessToken,
|
||||
};
|
||||
|
||||
throw new VisibleError(
|
||||
@@ -60,8 +60,8 @@ export function useCurrentUser() {
|
||||
export function useCurrentDevice() {
|
||||
const actor = ActorContext.use();
|
||||
if (actor.type === "device") return {
|
||||
fingerprint:actor.properties.fingerprint,
|
||||
id: actor.properties.id
|
||||
hostname:actor.properties.hostname,
|
||||
teamSlug: actor.properties.teamSlug
|
||||
};
|
||||
throw new VisibleError(
|
||||
"auth",
|
||||
|
||||
90
packages/core/src/aws/client.ts
Normal file
@@ -0,0 +1,90 @@
|
||||
import { z } from "zod"
|
||||
import { Resource } from "sst";
|
||||
import { doubleFn, fn } from "../utils";
|
||||
import { AwsClient } from "aws4fetch";
|
||||
import { DescribeTasksCommandOutput, StopTaskCommandOutput, type RunTaskCommandOutput } from "@aws-sdk/client-ecs";
|
||||
|
||||
|
||||
export module Aws {
|
||||
export const client = async () => {
|
||||
return new AwsClient({
|
||||
accessKeyId: Resource.AwsAccessKey.value,
|
||||
secretAccessKey: Resource.AwsSecretKey.value,
|
||||
region: "us-east-1",
|
||||
});
|
||||
}
|
||||
|
||||
export const EcsRunTask = fn(z.object({
|
||||
cluster: z.string(),
|
||||
count: z.number(),
|
||||
taskDefinition: z.string(),
|
||||
launchType: z.enum(["EC2", "FARGATE"]),
|
||||
overrides: z.object({
|
||||
containerOverrides: z.object({
|
||||
name: z.string(),
|
||||
environment: z.object({
|
||||
name: z.string(),
|
||||
value: z.string().or(z.number())
|
||||
}).array()
|
||||
}).array()
|
||||
})
|
||||
}), async (body) => {
|
||||
|
||||
const c = await client();
|
||||
|
||||
const url = new URL(`https://ecs.${c.region}.amazonaws.com/`)
|
||||
|
||||
const res = await c.fetch(url, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"X-Amz-Target": "AmazonEC2ContainerServiceV20141113.RunTask",
|
||||
"Content-Type": "application/x-amz-json-1.1",
|
||||
},
|
||||
body: JSON.stringify(body)
|
||||
})
|
||||
|
||||
return await res.json() as RunTaskCommandOutput
|
||||
})
|
||||
|
||||
export const EcsDescribeTasks = fn(z.object({ tasks: z.string().array(), cluster: z.string() }), async (body) => {
|
||||
const c = await client();
|
||||
|
||||
const url = new URL(`https://ecs.${c.region}.amazonaws.com/`)
|
||||
|
||||
const res = await c.fetch(url, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"X-Amz-Target": "AmazonEC2ContainerServiceV20141113.DescribeTasks",
|
||||
"Content-Type": "application/x-amz-json-1.1",
|
||||
},
|
||||
body: JSON.stringify(body)
|
||||
})
|
||||
|
||||
return await res.json() as DescribeTasksCommandOutput
|
||||
})
|
||||
|
||||
|
||||
export const EcsStopTask = fn(z.object({
|
||||
cluster: z.string().optional(),
|
||||
reason: z.string().optional(),
|
||||
task: z.string()
|
||||
}), async (body) => {
|
||||
const c = await client();
|
||||
|
||||
const url = new URL(`https://ecs.${c.region}.amazonaws.com/`)
|
||||
|
||||
const res = await c.fetch(url, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"X-Amz-Target": "AmazonEC2ContainerServiceV20141113.StopTask",
|
||||
"Content-Type": "application/x-amz-json-1.1",
|
||||
},
|
||||
body: JSON.stringify(body)
|
||||
})
|
||||
|
||||
return await res.json() as StopTaskCommandOutput
|
||||
})
|
||||
|
||||
}
|
||||
|
||||
|
||||
@@ -5,15 +5,27 @@ export module Examples {
|
||||
email: "john@example.com",
|
||||
};
|
||||
|
||||
export const Task = {
|
||||
id: "0bfcc712-df13-4454-81a8-fbee66eddca4",
|
||||
taskID: "b8302fca2d224d91ab342a2e4ab926d3",
|
||||
type: "AWS" as const, //or "on-premises",
|
||||
lastStatus: "RUNNING" as const,
|
||||
healthStatus: "UNKNOWN" as const,
|
||||
startedAt: '2025-01-09T01:56:23.902Z',
|
||||
lastUpdated: '2025-01-09T01:56:23.902Z',
|
||||
stoppedAt: '2025-01-09T04:46:23.902Z'
|
||||
}
|
||||
|
||||
export const Profile = {
|
||||
id: "0bfcb712-df13-4454-81a8-fbee66eddca4",
|
||||
username: "janedoe47",
|
||||
status: "active" as const,
|
||||
avatarUrl: "https://cdn.discordapp.com/avatars/xxxxxxx/xxxxxxx.png",
|
||||
discriminator: 12, //it needs to be two digits
|
||||
createdAt: '2025-01-04T11:56:23.902Z',
|
||||
updatedAt: '2025-01-09T01:56:23.902Z'
|
||||
}
|
||||
|
||||
|
||||
export const Subscription = {
|
||||
id: "0bfcb712-df13-4454-81a8-fbee66eddca4",
|
||||
checkoutID: "0bfcb712-df43-4454-81a8-fbee66eddca4",
|
||||
@@ -23,10 +35,10 @@ export module Examples {
|
||||
// next: '2025-01-09T01:56:23.902Z',
|
||||
canceledAt: '2025-02-09T01:56:23.902Z'
|
||||
}
|
||||
|
||||
|
||||
export const Team = {
|
||||
id: "0bfcb712-df13-4454-81a8-fbee66eddca4",
|
||||
owner: true,
|
||||
// owner: true,
|
||||
name: "Jane Doe's Games",
|
||||
slug: "jane-does-games",
|
||||
createdAt: '2025-01-04T11:56:23.902Z',
|
||||
@@ -41,6 +53,13 @@ export module Examples {
|
||||
deletedAt: '2025-01-09T01:56:23.902Z'
|
||||
}
|
||||
|
||||
export const Instance = {
|
||||
id: "0bfcb712-df13-4454-81a8-fbee66eddca4",
|
||||
hostname: "a955e059f05d",
|
||||
createdAt: '2025-01-04T11:56:23.902Z',
|
||||
lastActive: '2025-01-09T01:56:23.902Z'
|
||||
}
|
||||
|
||||
export const Game = {
|
||||
id: '0bfcb712-df13-4454-81a8-fbee66eddca4',
|
||||
name: "Control Ultimate Edition",
|
||||
@@ -50,8 +69,7 @@ export module Examples {
|
||||
export const Session = {
|
||||
id: "0bfcb712-df13-4454-81a8-fbee66eddca4",
|
||||
public: true,
|
||||
name: 'Late night chilling with the squad',
|
||||
startedAt: '2025-01-04T11:56:23.902Z',
|
||||
endedAt: '2025-01-04T11:56:23.902Z'
|
||||
endedAt: '2025-01-04T12:36:23.902Z'
|
||||
}
|
||||
}
|
||||
@@ -1,151 +1,151 @@
|
||||
import { z } from "zod"
|
||||
import { fn } from "../utils";
|
||||
import { Common } from "../common";
|
||||
import { Examples } from "../examples";
|
||||
import databaseClient from "../database"
|
||||
import { id as createID } from "@instantdb/admin";
|
||||
import { groupBy, map, pipe, values } from "remeda"
|
||||
import { useCurrentDevice, useCurrentUser } from "../actor";
|
||||
// import { z } from "zod"
|
||||
// import { fn } from "../utils";
|
||||
// import { Common } from "../common";
|
||||
// import { Examples } from "../examples";
|
||||
// import databaseClient from "../database"
|
||||
// import { id as createID } from "@instantdb/admin";
|
||||
// import { groupBy, map, pipe, values } from "remeda"
|
||||
// import { useCurrentDevice, useCurrentUser } from "../actor";
|
||||
|
||||
export module Games {
|
||||
export const Info = z
|
||||
.object({
|
||||
id: z.string().openapi({
|
||||
description: Common.IdDescription,
|
||||
example: Examples.Game.id,
|
||||
}),
|
||||
name: z.string().openapi({
|
||||
description: "A human-readable name for the game, used for easy identification.",
|
||||
example: Examples.Game.name,
|
||||
}),
|
||||
steamID: z.number().openapi({
|
||||
description: "The Steam ID of the game, used to identify it during installation and runtime.",
|
||||
example: Examples.Game.steamID,
|
||||
})
|
||||
})
|
||||
.openapi({
|
||||
ref: "Game",
|
||||
description: "Represents a Steam game that can be installed and played on a machine.",
|
||||
example: Examples.Game,
|
||||
});
|
||||
// export module Games {
|
||||
// export const Info = z
|
||||
// .object({
|
||||
// id: z.string().openapi({
|
||||
// description: Common.IdDescription,
|
||||
// example: Examples.Game.id,
|
||||
// }),
|
||||
// name: z.string().openapi({
|
||||
// description: "A human-readable name for the game, used for easy identification.",
|
||||
// example: Examples.Game.name,
|
||||
// }),
|
||||
// steamID: z.number().openapi({
|
||||
// description: "The Steam ID of the game, used to identify it during installation and runtime.",
|
||||
// example: Examples.Game.steamID,
|
||||
// })
|
||||
// })
|
||||
// .openapi({
|
||||
// ref: "Game",
|
||||
// description: "Represents a Steam game that can be installed and played on a machine.",
|
||||
// example: Examples.Game,
|
||||
// });
|
||||
|
||||
export type Info = z.infer<typeof Info>;
|
||||
// export type Info = z.infer<typeof Info>;
|
||||
|
||||
export const create = fn(Info.pick({ name: true, steamID: true }), async (input) => {
|
||||
const id = createID()
|
||||
const db = databaseClient()
|
||||
const device = useCurrentDevice()
|
||||
// export const create = fn(Info.pick({ name: true, steamID: true }), async (input) => {
|
||||
// const id = createID()
|
||||
// const db = databaseClient()
|
||||
// const device = useCurrentDevice()
|
||||
|
||||
await db.transact(
|
||||
db.tx.games[id]!.update({
|
||||
name: input.name,
|
||||
steamID: input.steamID,
|
||||
}).link({ machines: device.id })
|
||||
)
|
||||
// await db.transact(
|
||||
// db.tx.games[id]!.update({
|
||||
// name: input.name,
|
||||
// steamID: input.steamID,
|
||||
// }).link({ machines: device.id })
|
||||
// )
|
||||
// //
|
||||
// return id
|
||||
// })
|
||||
|
||||
return id
|
||||
})
|
||||
// export const list = async () => {
|
||||
// const db = databaseClient()
|
||||
// const user = useCurrentUser()
|
||||
|
||||
export const list = async () => {
|
||||
const db = databaseClient()
|
||||
const user = useCurrentUser()
|
||||
// const query = {
|
||||
// $users: {
|
||||
// $: { where: { id: user.id } },
|
||||
// games: {}
|
||||
// },
|
||||
// }
|
||||
|
||||
const query = {
|
||||
$users: {
|
||||
$: { where: { id: user.id } },
|
||||
games: {}
|
||||
},
|
||||
}
|
||||
// const res = await db.query(query)
|
||||
|
||||
const res = await db.query(query)
|
||||
// const games = res.$users[0]?.games
|
||||
// if (games && games.length > 0) {
|
||||
// const result = pipe(
|
||||
// games,
|
||||
// groupBy(x => x.id),
|
||||
// values(),
|
||||
// map((group): Info => ({
|
||||
// id: group[0].id,
|
||||
// name: group[0].name,
|
||||
// steamID: group[0].steamID,
|
||||
// }))
|
||||
// )
|
||||
// return result
|
||||
// }
|
||||
// return null
|
||||
// }
|
||||
|
||||
const games = res.$users[0]?.games
|
||||
if (games && games.length > 0) {
|
||||
const result = pipe(
|
||||
games,
|
||||
groupBy(x => x.id),
|
||||
values(),
|
||||
map((group): Info => ({
|
||||
id: group[0].id,
|
||||
name: group[0].name,
|
||||
steamID: group[0].steamID,
|
||||
}))
|
||||
)
|
||||
return result
|
||||
}
|
||||
return null
|
||||
}
|
||||
// export const fromSteamID = fn(z.number(), async (steamID) => {
|
||||
// const db = databaseClient()
|
||||
|
||||
export const fromSteamID = fn(z.number(), async (steamID) => {
|
||||
const db = databaseClient()
|
||||
// const query = {
|
||||
// games: {
|
||||
// $: {
|
||||
// where: {
|
||||
// steamID,
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
|
||||
const query = {
|
||||
games: {
|
||||
$: {
|
||||
where: {
|
||||
steamID,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// const res = await db.query(query)
|
||||
|
||||
const res = await db.query(query)
|
||||
// const games = res.games
|
||||
|
||||
const games = res.games
|
||||
// if (games.length > 0) {
|
||||
// const result = pipe(
|
||||
// games,
|
||||
// groupBy(x => x.id),
|
||||
// values(),
|
||||
// map((group): Info => ({
|
||||
// id: group[0].id,
|
||||
// name: group[0].name,
|
||||
// steamID: group[0].steamID,
|
||||
// }))
|
||||
// )
|
||||
// return result[0]
|
||||
// }
|
||||
|
||||
if (games.length > 0) {
|
||||
const result = pipe(
|
||||
games,
|
||||
groupBy(x => x.id),
|
||||
values(),
|
||||
map((group): Info => ({
|
||||
id: group[0].id,
|
||||
name: group[0].name,
|
||||
steamID: group[0].steamID,
|
||||
}))
|
||||
)
|
||||
return result[0]
|
||||
}
|
||||
// return null
|
||||
// })
|
||||
|
||||
return null
|
||||
})
|
||||
// export const linkToCurrentUser = fn(z.string(), async (steamID) => {
|
||||
// const user = useCurrentUser()
|
||||
// const db = databaseClient()
|
||||
|
||||
export const linkToCurrentUser = fn(z.string(), async (steamID) => {
|
||||
const user = useCurrentUser()
|
||||
const db = databaseClient()
|
||||
// await db.transact(db.tx.games[steamID]!.link({ owners: user.id }))
|
||||
|
||||
await db.transact(db.tx.games[steamID]!.link({ owners: user.id }))
|
||||
// return "ok"
|
||||
// })
|
||||
|
||||
return "ok"
|
||||
})
|
||||
// export const unLinkFromCurrentUser = fn(z.number(), async (steamID) => {
|
||||
// const user = useCurrentUser()
|
||||
// const db = databaseClient()
|
||||
|
||||
export const unLinkFromCurrentUser = fn(z.number(), async (steamID) => {
|
||||
const user = useCurrentUser()
|
||||
const db = databaseClient()
|
||||
// const query = {
|
||||
// $users: {
|
||||
// $: { where: { id: user.id } },
|
||||
// games: {
|
||||
// $: {
|
||||
// where: {
|
||||
// steamID,
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
// },
|
||||
// }
|
||||
|
||||
const query = {
|
||||
$users: {
|
||||
$: { where: { id: user.id } },
|
||||
games: {
|
||||
$: {
|
||||
where: {
|
||||
steamID,
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
}
|
||||
// const res = await db.query(query)
|
||||
// const games = res.$users[0]?.games
|
||||
// if (games && games.length > 0) {
|
||||
// const game = games[0] as Info
|
||||
// await db.transact(db.tx.games[game.id]!.unlink({ owners: user.id }))
|
||||
|
||||
const res = await db.query(query)
|
||||
const games = res.$users[0]?.games
|
||||
if (games && games.length > 0) {
|
||||
const game = games[0] as Info
|
||||
await db.transact(db.tx.games[game.id]!.unlink({ owners: user.id }))
|
||||
// return "ok"
|
||||
// }
|
||||
|
||||
return "ok"
|
||||
}
|
||||
// return null
|
||||
// })
|
||||
|
||||
return null
|
||||
})
|
||||
|
||||
}
|
||||
// }
|
||||
83
packages/core/src/instance/index.ts
Normal file
@@ -0,0 +1,83 @@
|
||||
import { z } from "zod"
|
||||
import { fn } from "../utils";
|
||||
import { Common } from "../common";
|
||||
import { Examples } from "../examples";
|
||||
import databaseClient from "../database"
|
||||
import { id as createID } from "@instantdb/admin";
|
||||
import { groupBy, map, pipe, values } from "remeda"
|
||||
|
||||
export module Instances {
|
||||
export const Info = z
|
||||
.object({
|
||||
id: z.string().openapi({
|
||||
description: Common.IdDescription,
|
||||
example: Examples.Instance.id,
|
||||
}),
|
||||
hostname: z.string().openapi({
|
||||
description: "The container's hostname",
|
||||
example: Examples.Instance.hostname,
|
||||
}),
|
||||
createdAt: z.string().or(z.number()).openapi({
|
||||
description: "The time this instances was registered on the network",
|
||||
example: Examples.Instance.createdAt,
|
||||
}),
|
||||
lastActive: z.string().or(z.number()).optional().openapi({
|
||||
description: "The time this instance was last seen on the network",
|
||||
example: Examples.Instance.lastActive,
|
||||
})
|
||||
})
|
||||
.openapi({
|
||||
ref: "Instance",
|
||||
description: "Represents a running container that is connected to the Nestri network..",
|
||||
example: Examples.Instance,
|
||||
});
|
||||
|
||||
export type Info = z.infer<typeof Info>;
|
||||
export const create = fn(z.object({ hostname: z.string(), teamID: z.string() }), async (input) => {
|
||||
const id = createID()
|
||||
const now = new Date().toISOString()
|
||||
const db = databaseClient()
|
||||
await db.transact(
|
||||
db.tx.instances[id]!.update({
|
||||
hostname: input.hostname,
|
||||
createdAt: now,
|
||||
}).link({ owners: input.teamID })
|
||||
)
|
||||
|
||||
return "ok"
|
||||
})
|
||||
|
||||
export const fromTeamID = fn(z.string(), async (teamID) => {
|
||||
const db = databaseClient()
|
||||
|
||||
const query = {
|
||||
instances: {
|
||||
$: {
|
||||
where: {
|
||||
owners: teamID
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const res = await db.query(query)
|
||||
const data = res.instances
|
||||
|
||||
if (data && data.length > 0) {
|
||||
const result = pipe(
|
||||
data,
|
||||
groupBy(x => x.id),
|
||||
values(),
|
||||
map((group): Info => ({
|
||||
id: group[0].id,
|
||||
lastActive: group[0].lastActive,
|
||||
hostname: group[0].hostname,
|
||||
createdAt: group[0].createdAt
|
||||
}))
|
||||
)
|
||||
return result
|
||||
}
|
||||
|
||||
return null
|
||||
})
|
||||
}
|
||||
@@ -1,232 +1,232 @@
|
||||
import { z } from "zod"
|
||||
import { fn } from "../utils";
|
||||
import { Common } from "../common";
|
||||
import { Examples } from "../examples";
|
||||
import { useCurrentUser } from "../actor";
|
||||
import databaseClient from "../database"
|
||||
import { id as createID } from "@instantdb/admin";
|
||||
import { groupBy, map, pipe, values } from "remeda"
|
||||
import { Games } from "../game"
|
||||
export module Machines {
|
||||
export const Info = z
|
||||
.object({
|
||||
id: z.string().openapi({
|
||||
description: Common.IdDescription,
|
||||
example: Examples.Machine.id,
|
||||
}),
|
||||
hostname: z.string().openapi({
|
||||
description: "The Linux hostname that identifies this machine",
|
||||
example: Examples.Machine.hostname,
|
||||
}),
|
||||
fingerprint: z.string().openapi({
|
||||
description: "A unique identifier derived from the machine's Linux machine ID.",
|
||||
example: Examples.Machine.fingerprint,
|
||||
}),
|
||||
createdAt: z.string().or(z.number()).openapi({
|
||||
description: "Represents a machine running on the Nestri network, containing its identifying information and metadata.",
|
||||
example: Examples.Machine.createdAt,
|
||||
})
|
||||
})
|
||||
.openapi({
|
||||
ref: "Machine",
|
||||
description: "Represents a physical or virtual machine connected to the Nestri network..",
|
||||
example: Examples.Machine,
|
||||
});
|
||||
// import { z } from "zod"
|
||||
// import { fn } from "../utils";
|
||||
// import { Games } from "../game"
|
||||
// import { Common } from "../common";
|
||||
// import { Examples } from "../examples";
|
||||
// import { useCurrentUser } from "../actor";
|
||||
// import databaseClient from "../database"
|
||||
// import { id as createID } from "@instantdb/admin";
|
||||
// import { groupBy, map, pipe, values } from "remeda"
|
||||
// export module Machines {
|
||||
// export const Info = z
|
||||
// .object({
|
||||
// id: z.string().openapi({
|
||||
// description: Common.IdDescription,
|
||||
// example: Examples.Machine.id,
|
||||
// }),
|
||||
// hostname: z.string().openapi({
|
||||
// description: "The Linux hostname that identifies this machine",
|
||||
// example: Examples.Machine.hostname,
|
||||
// }),
|
||||
// fingerprint: z.string().openapi({
|
||||
// description: "A unique identifier derived from the machine's Linux machine ID.",
|
||||
// example: Examples.Machine.fingerprint,
|
||||
// }),
|
||||
// createdAt: z.string().or(z.number()).openapi({
|
||||
// description: "Represents a machine running on the Nestri network, containing its identifying information and metadata.",
|
||||
// example: Examples.Machine.createdAt,
|
||||
// })
|
||||
// })
|
||||
// .openapi({
|
||||
// ref: "Machine",
|
||||
// description: "Represents a physical or virtual machine connected to the Nestri network..",
|
||||
// example: Examples.Machine,
|
||||
// });
|
||||
|
||||
export type Info = z.infer<typeof Info>;
|
||||
// export type Info = z.infer<typeof Info>;
|
||||
|
||||
export const create = fn(Info.pick({ fingerprint: true, hostname: true }), async (input) => {
|
||||
const id = createID()
|
||||
const now = new Date().toISOString()
|
||||
const db = databaseClient()
|
||||
await db.transact(
|
||||
db.tx.machines[id]!.update({
|
||||
fingerprint: input.fingerprint,
|
||||
hostname: input.hostname,
|
||||
createdAt: now,
|
||||
//Just in case it had been previously deleted
|
||||
deletedAt: undefined
|
||||
})
|
||||
)
|
||||
// export const create = fn(Info.pick({ fingerprint: true, hostname: true }), async (input) => {
|
||||
// const id = createID()
|
||||
// const now = new Date().toISOString()
|
||||
// const db = databaseClient()
|
||||
// await db.transact(
|
||||
// db.tx.machines[id]!.update({
|
||||
// fingerprint: input.fingerprint,
|
||||
// hostname: input.hostname,
|
||||
// createdAt: now,
|
||||
// //Just in case it had been previously deleted
|
||||
// deletedAt: undefined
|
||||
// })
|
||||
// )
|
||||
|
||||
return id
|
||||
})
|
||||
// return id
|
||||
// })
|
||||
|
||||
export const fromID = fn(z.string(), async (id) => {
|
||||
const db = databaseClient()
|
||||
// // export const fromID = fn(z.string(), async (id) => {
|
||||
// const db = databaseClient()
|
||||
|
||||
const query = {
|
||||
machines: {
|
||||
$: {
|
||||
where: {
|
||||
id: id,
|
||||
deletedAt: { $isNull: true }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// const query = {
|
||||
// machines: {
|
||||
// $: {
|
||||
// where: {
|
||||
// id: id,
|
||||
// deletedAt: { $isNull: true }
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
|
||||
const res = await db.query(query)
|
||||
const machines = res.machines
|
||||
// const res = await db.query(query)
|
||||
// const machines = res.machines
|
||||
|
||||
if (machines && machines.length > 0) {
|
||||
const result = pipe(
|
||||
machines,
|
||||
groupBy(x => x.id),
|
||||
values(),
|
||||
map((group): Info => ({
|
||||
id: group[0].id,
|
||||
fingerprint: group[0].fingerprint,
|
||||
hostname: group[0].hostname,
|
||||
createdAt: group[0].createdAt
|
||||
}))
|
||||
)
|
||||
return result
|
||||
}
|
||||
// if (machines && machines.length > 0) {
|
||||
// const result = pipe(
|
||||
// machines,
|
||||
// groupBy(x => x.id),
|
||||
// values(),
|
||||
// map((group): Info => ({
|
||||
// id: group[0].id,
|
||||
// fingerprint: group[0].fingerprint,
|
||||
// hostname: group[0].hostname,
|
||||
// createdAt: group[0].createdAt
|
||||
// }))
|
||||
// )
|
||||
// return result
|
||||
// }
|
||||
|
||||
return null
|
||||
})
|
||||
// return null
|
||||
// })
|
||||
|
||||
export const installedGames = fn(z.string(), async (id) => {
|
||||
const db = databaseClient()
|
||||
// export const installedGames = fn(z.string(), async (id) => {
|
||||
// const db = databaseClient()
|
||||
|
||||
const query = {
|
||||
machines: {
|
||||
$: {
|
||||
where: {
|
||||
id: id,
|
||||
deletedAt: { $isNull: true }
|
||||
}
|
||||
},
|
||||
games: {}
|
||||
}
|
||||
}
|
||||
// const query = {
|
||||
// machines: {
|
||||
// $: {
|
||||
// where: {
|
||||
// id: id,
|
||||
// deletedAt: { $isNull: true }
|
||||
// }
|
||||
// },
|
||||
// games: {}
|
||||
// }
|
||||
// }
|
||||
|
||||
const res = await db.query(query)
|
||||
const machines = res.machines
|
||||
// const res = await db.query(query)
|
||||
// const machines = res.machines
|
||||
|
||||
if (machines && machines.length > 0) {
|
||||
const games = machines[0]?.games as any
|
||||
if (games.length > 0) {
|
||||
return games as Games.Info[]
|
||||
}
|
||||
return null
|
||||
}
|
||||
// if (machines && machines.length > 0) {
|
||||
// const games = machines[0]?.games as any
|
||||
// if (games.length > 0) {
|
||||
// return games as Games.Info[]
|
||||
// }
|
||||
// return null
|
||||
// }
|
||||
|
||||
return null
|
||||
})
|
||||
// return null
|
||||
// })
|
||||
|
||||
export const fromFingerprint = fn(z.string(), async (input) => {
|
||||
const db = databaseClient()
|
||||
// export const fromFingerprint = fn(z.string(), async (input) => {
|
||||
// const db = databaseClient()
|
||||
|
||||
const query = {
|
||||
machines: {
|
||||
$: {
|
||||
where: {
|
||||
fingerprint: input,
|
||||
deletedAt: { $isNull: true }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// const query = {
|
||||
// machines: {
|
||||
// $: {
|
||||
// where: {
|
||||
// fingerprint: input,
|
||||
// deletedAt: { $isNull: true }
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
|
||||
const res = await db.query(query)
|
||||
// const res = await db.query(query)
|
||||
|
||||
const machines = res.machines
|
||||
// const machines = res.machines
|
||||
|
||||
if (machines.length > 0) {
|
||||
const result = pipe(
|
||||
machines,
|
||||
groupBy(x => x.id),
|
||||
values(),
|
||||
map((group): Info => ({
|
||||
id: group[0].id,
|
||||
fingerprint: group[0].fingerprint,
|
||||
hostname: group[0].hostname,
|
||||
createdAt: group[0].createdAt
|
||||
}))
|
||||
)
|
||||
return result[0]
|
||||
}
|
||||
// if (machines.length > 0) {
|
||||
// const result = pipe(
|
||||
// machines,
|
||||
// groupBy(x => x.id),
|
||||
// values(),
|
||||
// map((group): Info => ({
|
||||
// id: group[0].id,
|
||||
// fingerprint: group[0].fingerprint,
|
||||
// hostname: group[0].hostname,
|
||||
// createdAt: group[0].createdAt
|
||||
// }))
|
||||
// )
|
||||
// return result[0]
|
||||
// }
|
||||
|
||||
return null
|
||||
})
|
||||
// return null
|
||||
// })
|
||||
|
||||
export const list = async () => {
|
||||
const user = useCurrentUser()
|
||||
const db = databaseClient()
|
||||
// export const list = async () => {
|
||||
// const user = useCurrentUser()
|
||||
// const db = databaseClient()
|
||||
|
||||
const query = {
|
||||
$users: {
|
||||
$: { where: { id: user.id } },
|
||||
machines: {
|
||||
$: {
|
||||
where: {
|
||||
deletedAt: { $isNull: true }
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
}
|
||||
// const query = {
|
||||
// $users: {
|
||||
// $: { where: { id: user.id } },
|
||||
// machines: {
|
||||
// $: {
|
||||
// where: {
|
||||
// deletedAt: { $isNull: true }
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
// },
|
||||
// }
|
||||
|
||||
const res = await db.query(query)
|
||||
// const res = await db.query(query)
|
||||
|
||||
const machines = res.$users[0]?.machines
|
||||
if (machines && machines.length > 0) {
|
||||
const result = pipe(
|
||||
machines,
|
||||
groupBy(x => x.id),
|
||||
values(),
|
||||
map((group): Info => ({
|
||||
id: group[0].id,
|
||||
fingerprint: group[0].fingerprint,
|
||||
hostname: group[0].hostname,
|
||||
createdAt: group[0].createdAt
|
||||
}))
|
||||
)
|
||||
return result
|
||||
}
|
||||
return null
|
||||
}
|
||||
// const machines = res.$users[0]?.machines
|
||||
// if (machines && machines.length > 0) {
|
||||
// const result = pipe(
|
||||
// machines,
|
||||
// groupBy(x => x.id),
|
||||
// values(),
|
||||
// map((group): Info => ({
|
||||
// id: group[0].id,
|
||||
// fingerprint: group[0].fingerprint,
|
||||
// hostname: group[0].hostname,
|
||||
// createdAt: group[0].createdAt
|
||||
// }))
|
||||
// )
|
||||
// return result
|
||||
// }
|
||||
// return null
|
||||
// }
|
||||
|
||||
export const linkToCurrentUser = fn(z.string(), async (id) => {
|
||||
const user = useCurrentUser()
|
||||
const db = databaseClient()
|
||||
// export const linkToCurrentUser = fn(z.string(), async (id) => {
|
||||
// const user = useCurrentUser()
|
||||
// const db = databaseClient()
|
||||
|
||||
await db.transact(db.tx.machines[id]!.link({ owner: user.id }))
|
||||
// await db.transact(db.tx.machines[id]!.link({ owner: user.id }))
|
||||
|
||||
return "ok"
|
||||
})
|
||||
// return "ok"
|
||||
// })
|
||||
|
||||
export const unLinkFromCurrentUser = fn(z.string(), async (id) => {
|
||||
const user = useCurrentUser()
|
||||
const db = databaseClient()
|
||||
const now = new Date().toISOString()
|
||||
// export const unLinkFromCurrentUser = fn(z.string(), async (id) => {
|
||||
// const user = useCurrentUser()
|
||||
// const db = databaseClient()
|
||||
// const now = new Date().toISOString()
|
||||
|
||||
const query = {
|
||||
$users: {
|
||||
$: { where: { id: user.id } },
|
||||
machines: {
|
||||
$: {
|
||||
where: {
|
||||
id,
|
||||
deletedAt: { $isNull: true }
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
}
|
||||
// const query = {
|
||||
// $users: {
|
||||
// $: { where: { id: user.id } },
|
||||
// machines: {
|
||||
// $: {
|
||||
// where: {
|
||||
// id,
|
||||
// deletedAt: { $isNull: true }
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
// },
|
||||
// }
|
||||
|
||||
const res = await db.query(query)
|
||||
const machines = res.$users[0]?.machines
|
||||
if (machines && machines.length > 0) {
|
||||
const machine = machines[0] as Info
|
||||
await db.transact(db.tx.machines[machine.id]!.update({ deletedAt: now }))
|
||||
// const res = await db.query(query)
|
||||
// const machines = res.$users[0]?.machines
|
||||
// if (machines && machines.length > 0) {
|
||||
// const machine = machines[0] as Info
|
||||
// await db.transact(db.tx.machines[machine.id]!.update({ deletedAt: now }))
|
||||
|
||||
return "ok"
|
||||
}
|
||||
// return "ok"
|
||||
// }
|
||||
|
||||
return null
|
||||
})
|
||||
// return null
|
||||
// })
|
||||
|
||||
}
|
||||
// }
|
||||
@@ -4,9 +4,15 @@ import { Common } from "../common";
|
||||
import { Examples } from "../examples";
|
||||
import databaseClient from "../database";
|
||||
import { groupBy, map, pipe, values } from "remeda"
|
||||
import { id as createID } from "@instantdb/admin";
|
||||
import { id as createID, } from "@instantdb/admin";
|
||||
import { useCurrentUser } from "../actor";
|
||||
|
||||
export const userStatus = z.enum([
|
||||
"active", //online and playing a game
|
||||
"idle", //online and not playing
|
||||
"offline",
|
||||
]);
|
||||
|
||||
export module Profiles {
|
||||
const MAX_ATTEMPTS = 50;
|
||||
|
||||
@@ -24,6 +30,10 @@ export module Profiles {
|
||||
description: "The url to the profile picture.",
|
||||
example: Examples.Profile.username,
|
||||
}),
|
||||
status: userStatus.openapi({
|
||||
description: "Whether the user is active, idle or offline",
|
||||
example: Examples.Profile.status
|
||||
}),
|
||||
discriminator: z.string().or(z.number()).openapi({
|
||||
description: "The number discriminator for each username",
|
||||
example: Examples.Profile.discriminator,
|
||||
@@ -44,6 +54,7 @@ export module Profiles {
|
||||
});
|
||||
|
||||
export type Info = z.infer<typeof Info>;
|
||||
export type userStatus = z.infer<typeof userStatus>;
|
||||
|
||||
export const sanitizeUsername = (username: string): string => {
|
||||
// Remove spaces and numbers
|
||||
@@ -91,7 +102,8 @@ export module Profiles {
|
||||
username: group[0].username,
|
||||
createdAt: group[0].createdAt,
|
||||
discriminator: group[0].discriminator,
|
||||
updatedAt: group[0].updatedAt
|
||||
updatedAt: group[0].updatedAt,
|
||||
status: group[0].status as userStatus
|
||||
}))
|
||||
)
|
||||
})
|
||||
@@ -175,6 +187,7 @@ export module Profiles {
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
discriminator,
|
||||
status: "idle"
|
||||
}).link({ owner: input.owner })
|
||||
)
|
||||
})
|
||||
@@ -203,48 +216,197 @@ export module Profiles {
|
||||
return `${profiles[0]?.username}#${profiles[0]?.discriminator}`;
|
||||
}
|
||||
|
||||
export const getProfile = async (ownerID: string) => {
|
||||
export const fromOwnerID = async (ownerID: string) => {
|
||||
try {
|
||||
|
||||
const db = databaseClient()
|
||||
const db = databaseClient()
|
||||
|
||||
const query = {
|
||||
profiles: {
|
||||
$: {
|
||||
where: {
|
||||
owner: ownerID
|
||||
}
|
||||
},
|
||||
const query = {
|
||||
profiles: {
|
||||
$: {
|
||||
where: {
|
||||
owner: ownerID
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
const res = await db.query(query)
|
||||
const res = await db.query(query)
|
||||
|
||||
const profiles = res.profiles
|
||||
const profiles = res.profiles
|
||||
|
||||
if (!profiles || profiles.length === 0) {
|
||||
if (!profiles || profiles.length === 0) {
|
||||
throw new Error("No profiles were found");
|
||||
}
|
||||
|
||||
const profile = pipe(
|
||||
profiles,
|
||||
groupBy(x => x.id),
|
||||
values(),
|
||||
map((group): Info => ({
|
||||
id: group[0].id,
|
||||
username: group[0].username,
|
||||
createdAt: group[0].createdAt,
|
||||
updatedAt: group[0].updatedAt,
|
||||
avatarUrl: group[0].avatarUrl,
|
||||
discriminator: group[0].discriminator,
|
||||
status: group[0].status as userStatus
|
||||
}))
|
||||
)
|
||||
|
||||
return profile[0]
|
||||
} catch (error) {
|
||||
console.log("user fromOwnerID", error)
|
||||
return null
|
||||
}
|
||||
|
||||
const profile = pipe(
|
||||
profiles,
|
||||
groupBy(x => x.id),
|
||||
values(),
|
||||
map((group): Info => ({
|
||||
id: group[0].id,
|
||||
username: group[0].username,
|
||||
createdAt: group[0].createdAt,
|
||||
updatedAt: group[0].updatedAt,
|
||||
avatarUrl: group[0].avatarUrl,
|
||||
discriminator: group[0].discriminator
|
||||
}))
|
||||
)
|
||||
|
||||
return profile[0]
|
||||
}
|
||||
|
||||
export const fromID = async (id: string) => {
|
||||
try {
|
||||
|
||||
const db = databaseClient()
|
||||
|
||||
const query = {
|
||||
profiles: {
|
||||
$: {
|
||||
where: {
|
||||
id
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
const res = await db.query(query)
|
||||
|
||||
const profiles = res.profiles
|
||||
|
||||
if (!profiles || profiles.length === 0) {
|
||||
throw new Error("No profiles were found");
|
||||
}
|
||||
|
||||
const profile = pipe(
|
||||
profiles,
|
||||
groupBy(x => x.id),
|
||||
values(),
|
||||
map((group): Info => ({
|
||||
id: group[0].id,
|
||||
username: group[0].username,
|
||||
createdAt: group[0].createdAt,
|
||||
updatedAt: group[0].updatedAt,
|
||||
avatarUrl: group[0].avatarUrl,
|
||||
discriminator: group[0].discriminator,
|
||||
status: group[0].status as userStatus
|
||||
}))
|
||||
)
|
||||
|
||||
return profile[0]
|
||||
} catch (error) {
|
||||
console.log("user fromID", error)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export const fromIDToOwner = async (id: string) => {
|
||||
try {
|
||||
|
||||
const db = databaseClient()
|
||||
|
||||
const query = {
|
||||
profiles: {
|
||||
$: {
|
||||
where: {
|
||||
id
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
const res = await db.query(query)
|
||||
|
||||
const profiles = res.profiles as any
|
||||
|
||||
if (!profiles || profiles.length === 0) {
|
||||
throw new Error("No profiles were found");
|
||||
}
|
||||
|
||||
return profiles[0]!.owner as string
|
||||
} catch (error) {
|
||||
console.log("user fromID", error)
|
||||
return null
|
||||
}
|
||||
}
|
||||
export const getCurrentProfile = async () => {
|
||||
const user = useCurrentUser()
|
||||
const currentProfile = await getProfile(user.id);
|
||||
const currentProfile = await fromOwnerID(user.id);
|
||||
|
||||
return currentProfile
|
||||
}
|
||||
};
|
||||
|
||||
export const setStatus = fn(userStatus, async (status) => {
|
||||
try {
|
||||
const user = useCurrentUser()
|
||||
const db = databaseClient()
|
||||
|
||||
const now = new Date().toISOString()
|
||||
|
||||
await db.transact(
|
||||
db.tx.profiles[user.id]!.update({
|
||||
status,
|
||||
updatedAt: now
|
||||
})
|
||||
)
|
||||
} catch (error) {
|
||||
console.log("user setStatus error", error)
|
||||
return null
|
||||
}
|
||||
})
|
||||
|
||||
export const list = async () => {
|
||||
try {
|
||||
const db = databaseClient()
|
||||
// const ago = new Date(Date.now() - (60 * 1000 * 30)).toISOString()
|
||||
const ago = new Date(Date.now() - (24 * 60 * 60 * 1000)).toISOString()
|
||||
|
||||
const query = {
|
||||
profiles: {
|
||||
$: {
|
||||
limit: 10,
|
||||
where: {
|
||||
updatedAt: { $gt: ago },
|
||||
},
|
||||
order: {
|
||||
updatedAt: "desc" as const,
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const res = await db.query(query)
|
||||
|
||||
const profiles = res.profiles
|
||||
|
||||
if (!profiles || profiles.length === 0) {
|
||||
throw new Error("No profiles were found");
|
||||
|
||||
}
|
||||
|
||||
const result = pipe(
|
||||
profiles,
|
||||
groupBy(x => x.id),
|
||||
values(),
|
||||
map((group): Info => ({
|
||||
id: group[0].id,
|
||||
username: group[0].username,
|
||||
createdAt: group[0].createdAt,
|
||||
updatedAt: group[0].updatedAt,
|
||||
avatarUrl: group[0].avatarUrl,
|
||||
discriminator: group[0].discriminator,
|
||||
status: group[0].status as userStatus
|
||||
}))
|
||||
)
|
||||
|
||||
return result
|
||||
|
||||
} catch (error) {
|
||||
console.log("user list error", error)
|
||||
return null
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,5 @@
|
||||
import { z } from "zod"
|
||||
import { fn } from "../utils";
|
||||
import { Machines } from "../machine";
|
||||
import { Common } from "../common";
|
||||
import { Examples } from "../examples";
|
||||
import databaseClient from "../database"
|
||||
@@ -15,10 +14,6 @@ export module Sessions {
|
||||
description: Common.IdDescription,
|
||||
example: Examples.Session.id,
|
||||
}),
|
||||
name: z.string().openapi({
|
||||
description: "A human-readable name for the session to help identify it",
|
||||
example: Examples.Session.name,
|
||||
}),
|
||||
public: z.boolean().openapi({
|
||||
description: "If true, the session is publicly viewable by all users. If false, only authorized users can access it",
|
||||
example: Examples.Session.public,
|
||||
@@ -40,82 +35,31 @@ export module Sessions {
|
||||
|
||||
export type Info = z.infer<typeof Info>;
|
||||
|
||||
export const create = fn(z.object({ name: z.string(), public: z.boolean(), fingerprint: z.string(), steamID: z.number() }), async (input) => {
|
||||
const id = createID()
|
||||
const now = new Date().toISOString()
|
||||
const db = databaseClient()
|
||||
const user = useCurrentUser()
|
||||
const machine = await Machines.fromFingerprint(input.fingerprint)
|
||||
if (!machine) {
|
||||
return { error: "Such a machine does not exist" }
|
||||
export const create = fn(z.object({ public: z.boolean() }), async (input) => {
|
||||
try {
|
||||
const id = createID()
|
||||
const db = databaseClient()
|
||||
const user = useCurrentUser()
|
||||
const now = new Date().toISOString()
|
||||
|
||||
await db.transact(
|
||||
db.tx.sessions[id]!.update({
|
||||
public: input.public,
|
||||
startedAt: now,
|
||||
}).link({ owner: user.id })
|
||||
)
|
||||
|
||||
return id
|
||||
} catch (err) {
|
||||
return null
|
||||
}
|
||||
|
||||
const games = await Machines.installedGames(machine.id)
|
||||
|
||||
if (!games) {
|
||||
return { error: "The machine has no installed games" }
|
||||
}
|
||||
|
||||
const result = pipe(
|
||||
games,
|
||||
groupBy(x => x.steamID === input.steamID ? "similar" : undefined),
|
||||
)
|
||||
|
||||
if (!result.similar || result.similar.length == 0) {
|
||||
|
||||
return { error: "The machine does not have this game installed" }
|
||||
}
|
||||
|
||||
await db.transact(
|
||||
db.tx.sessions[id]!.update({
|
||||
name: input.name,
|
||||
public: input.public,
|
||||
startedAt: now,
|
||||
}).link({ owner: user.id, machine: machine.id, game: result.similar[0].id })
|
||||
)
|
||||
|
||||
return { data: id }
|
||||
})
|
||||
|
||||
export const list = async () => {
|
||||
const user = useCurrentUser()
|
||||
const db = databaseClient()
|
||||
|
||||
const query = {
|
||||
$users: {
|
||||
$: { where: { id: user.id } },
|
||||
sessions: {}
|
||||
},
|
||||
}
|
||||
|
||||
const res = await db.query(query)
|
||||
|
||||
const sessions = res.$users[0]?.sessions
|
||||
if (sessions && sessions.length > 0) {
|
||||
const result = pipe(
|
||||
sessions,
|
||||
groupBy(x => x.id),
|
||||
values(),
|
||||
map((group): Info => ({
|
||||
id: group[0].id,
|
||||
endedAt: group[0].endedAt,
|
||||
startedAt: group[0].startedAt,
|
||||
public: group[0].public,
|
||||
name: group[0].name
|
||||
}))
|
||||
)
|
||||
return result
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
export const getActive = async () => {
|
||||
const user = useCurrentUser()
|
||||
const db = databaseClient()
|
||||
try {
|
||||
const db = databaseClient()
|
||||
|
||||
const query = {
|
||||
$users: {
|
||||
$: { where: { id: user.id } },
|
||||
const query = {
|
||||
sessions: {
|
||||
$: {
|
||||
where: {
|
||||
@@ -123,48 +67,15 @@ export module Sessions {
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
const res = await db.query(query)
|
||||
|
||||
const sessions = res.$users[0]?.sessions
|
||||
if (sessions && sessions.length > 0) {
|
||||
const result = pipe(
|
||||
sessions,
|
||||
groupBy(x => x.id),
|
||||
values(),
|
||||
map((group): Info => ({
|
||||
id: group[0].id,
|
||||
endedAt: group[0].endedAt,
|
||||
startedAt: group[0].startedAt,
|
||||
public: group[0].public,
|
||||
name: group[0].name
|
||||
}))
|
||||
)
|
||||
return result
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
export const getPublicActive = async () => {
|
||||
const db = databaseClient()
|
||||
|
||||
const query = {
|
||||
sessions: {
|
||||
$: {
|
||||
where: {
|
||||
endedAt: { $isNull: true },
|
||||
public: true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const res = await db.query(query)
|
||||
const res = await db.query(query)
|
||||
|
||||
const sessions = res.sessions
|
||||
if (!sessions || sessions.length === 0) {
|
||||
throw new Error("No active sessions found")
|
||||
}
|
||||
|
||||
const sessions = res.sessions
|
||||
if (sessions && sessions.length > 0) {
|
||||
const result = pipe(
|
||||
sessions,
|
||||
groupBy(x => x.id),
|
||||
@@ -174,39 +85,37 @@ export module Sessions {
|
||||
endedAt: group[0].endedAt,
|
||||
startedAt: group[0].startedAt,
|
||||
public: group[0].public,
|
||||
name: group[0].name
|
||||
}))
|
||||
)
|
||||
|
||||
return result
|
||||
|
||||
} catch (error) {
|
||||
return null
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
export const fromSteamID = fn(z.number(), async (steamID) => {
|
||||
const db = databaseClient()
|
||||
export const fromID = fn(z.string(), async (id) => {
|
||||
try {
|
||||
const db = databaseClient()
|
||||
|
||||
const query = {
|
||||
games: {
|
||||
$: {
|
||||
where: {
|
||||
steamID
|
||||
}
|
||||
},
|
||||
const query = {
|
||||
sessions: {
|
||||
$: {
|
||||
where: {
|
||||
endedAt: { $isNull: true },
|
||||
public: true
|
||||
id: id,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const res = await db.query(query)
|
||||
const res = await db.query(query)
|
||||
const sessions = res.sessions
|
||||
|
||||
if (!sessions || sessions.length === 0) {
|
||||
throw new Error("No sessions were found");
|
||||
}
|
||||
|
||||
const sessions = res.games[0]?.sessions
|
||||
if (sessions && sessions.length > 0) {
|
||||
const result = pipe(
|
||||
sessions,
|
||||
groupBy(x => x.id),
|
||||
@@ -216,32 +125,38 @@ export module Sessions {
|
||||
endedAt: group[0].endedAt,
|
||||
startedAt: group[0].startedAt,
|
||||
public: group[0].public,
|
||||
name: group[0].name
|
||||
}))
|
||||
)
|
||||
return result
|
||||
} catch (err) {
|
||||
console.log("sessions error", err)
|
||||
return null
|
||||
}
|
||||
return null
|
||||
})
|
||||
|
||||
export const fromID = fn(z.string(), async (id) => {
|
||||
const db = databaseClient()
|
||||
useCurrentUser()
|
||||
export const fromTaskID = fn(z.string(), async (taskID) => {
|
||||
try {
|
||||
const db = databaseClient()
|
||||
|
||||
const query = {
|
||||
sessions: {
|
||||
$: {
|
||||
where: {
|
||||
id: id,
|
||||
const query = {
|
||||
sessions: {
|
||||
$: {
|
||||
where: {
|
||||
task: taskID,
|
||||
endedAt: { $isNull: true }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const res = await db.query(query)
|
||||
const sessions = res.sessions
|
||||
const res = await db.query(query)
|
||||
const sessions = res.sessions
|
||||
|
||||
if (!sessions || sessions.length === 0) {
|
||||
throw new Error("No sessions were found");
|
||||
}
|
||||
console.log("sessions", sessions)
|
||||
|
||||
if (sessions && sessions.length > 0) {
|
||||
const result = pipe(
|
||||
sessions,
|
||||
groupBy(x => x.id),
|
||||
@@ -251,42 +166,86 @@ export module Sessions {
|
||||
endedAt: group[0].endedAt,
|
||||
startedAt: group[0].startedAt,
|
||||
public: group[0].public,
|
||||
name: group[0].name
|
||||
}))
|
||||
)
|
||||
return result
|
||||
return result[0]
|
||||
} catch (err) {
|
||||
console.log("sessions error", err)
|
||||
return null
|
||||
}
|
||||
|
||||
return null
|
||||
})
|
||||
|
||||
export const end = fn(z.string(), async (id) => {
|
||||
const user = useCurrentUser()
|
||||
const db = databaseClient()
|
||||
const now = new Date().toISOString()
|
||||
try {
|
||||
const db = databaseClient()
|
||||
const now = new Date().toISOString()
|
||||
|
||||
const query = {
|
||||
$users: {
|
||||
$: { where: { id: user.id } },
|
||||
const query = {
|
||||
sessions: {
|
||||
$: {
|
||||
where: {
|
||||
owner: user.id,
|
||||
id,
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
const res = await db.query(query)
|
||||
const sessions = res.$users[0]?.sessions
|
||||
if (sessions && sessions.length > 0) {
|
||||
const session = sessions[0] as Info
|
||||
await db.transact(db.tx.sessions[session.id]!.update({ endedAt: now }))
|
||||
const res = await db.query(query)
|
||||
const sessions = res.sessions
|
||||
if (!sessions || sessions.length === 0) {
|
||||
throw new Error("No sessions were found");
|
||||
}
|
||||
|
||||
await db.transact(db.tx.sessions[sessions[0]!.id]!.update({ endedAt: now }))
|
||||
|
||||
return "ok"
|
||||
|
||||
} catch (error) {
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
return null
|
||||
})
|
||||
|
||||
export const fromOwnerID = fn(z.string(), async (id) => {
|
||||
try {
|
||||
const db = databaseClient()
|
||||
|
||||
const query = {
|
||||
sessions: {
|
||||
$: {
|
||||
where: {
|
||||
owner: id,
|
||||
endedAt: { $isNull: true }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const res = await db.query(query)
|
||||
const sessions = res.sessions
|
||||
|
||||
if (!sessions || sessions.length === 0) {
|
||||
throw new Error("No sessions were found");
|
||||
}
|
||||
|
||||
const result = pipe(
|
||||
sessions,
|
||||
groupBy(x => x.id),
|
||||
values(),
|
||||
map((group): Info => ({
|
||||
id: group[0].id,
|
||||
endedAt: group[0].endedAt,
|
||||
startedAt: group[0].startedAt,
|
||||
public: group[0].public,
|
||||
}))
|
||||
)
|
||||
return result[0]
|
||||
} catch (err) {
|
||||
console.log("session owner error", err)
|
||||
return null
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -59,15 +59,15 @@ export namespace Subscriptions {
|
||||
|
||||
export type Info = z.infer<typeof Info>;
|
||||
|
||||
export const list = async () => {
|
||||
export const list = fn(z.string().optional(), async (userID) => {
|
||||
const db = databaseClient()
|
||||
const user = useCurrentUser()
|
||||
const user = userID ? userID : useCurrentUser().id
|
||||
|
||||
const query = {
|
||||
subscriptions: {
|
||||
$: {
|
||||
where: {
|
||||
owner: user.id,
|
||||
owner: user,
|
||||
canceledAt: { $isNull: true }
|
||||
}
|
||||
},
|
||||
@@ -96,7 +96,7 @@ export namespace Subscriptions {
|
||||
)
|
||||
|
||||
return result
|
||||
}
|
||||
})
|
||||
|
||||
export const create = fn(Info.omit({ id: true, canceledAt: true }), async (input) => {
|
||||
// const id = createID()
|
||||
@@ -112,7 +112,7 @@ export namespace Subscriptions {
|
||||
checkoutID: input.checkoutID,
|
||||
}).link({ owner: user.id }))
|
||||
const res = await db.auth.getUser({ id: user.id })
|
||||
const profile = await Profiles.getProfile(user.id)
|
||||
const profile = await Profiles.fromOwnerID(user.id)
|
||||
if (profile) {
|
||||
await Email.sendWelcome(res.email, profile.username)
|
||||
}
|
||||
@@ -123,7 +123,7 @@ export namespace Subscriptions {
|
||||
const db = databaseClient()
|
||||
|
||||
await db.transact(db.tx.subscriptions[id]!.update({
|
||||
canceledAt: new Date().toString()
|
||||
canceledAt: new Date().toISOString()
|
||||
}))
|
||||
})
|
||||
|
||||
|
||||
331
packages/core/src/task/index.ts
Normal file
@@ -0,0 +1,331 @@
|
||||
import { z } from "zod";
|
||||
import { fn } from "../utils";
|
||||
import { Resource } from "sst";
|
||||
import { Aws } from "../aws/client";
|
||||
import { Common } from "../common";
|
||||
import { Examples } from "../examples";
|
||||
import databaseClient from "../database"
|
||||
import { useCurrentUser } from "../actor";
|
||||
import { id as createID } from "@instantdb/admin";
|
||||
import { groupBy, map, pipe, values } from "remeda"
|
||||
import { Sessions } from "../session";
|
||||
|
||||
export const lastStatus = z.enum([
|
||||
"RUNNING",
|
||||
"PENDING",
|
||||
"UNKNOWN",
|
||||
"STOPPED",
|
||||
]);
|
||||
|
||||
export const taskType = z.enum([
|
||||
"AWS",
|
||||
"ON_PREMISES",
|
||||
"UNKNOWN"
|
||||
]);
|
||||
|
||||
export const healthStatus = z.enum([
|
||||
"HEALTHY",
|
||||
"UNHEALTHY",
|
||||
"UNKNOWN",
|
||||
]);
|
||||
|
||||
export type taskType = z.infer<typeof taskType>;
|
||||
export type lastStatus = z.infer<typeof lastStatus>;
|
||||
export type healthStatus = z.infer<typeof healthStatus>;
|
||||
|
||||
export module Tasks {
|
||||
export const Info = z
|
||||
.object({
|
||||
id: z.string().openapi({
|
||||
description: Common.IdDescription,
|
||||
example: Examples.Task.id,
|
||||
}),
|
||||
type: taskType.openapi({
|
||||
description: "Where this task is hosted on",
|
||||
example: Examples.Task.type,
|
||||
}),
|
||||
taskID: z.string().openapi({
|
||||
description: "The id of this task as seen on AWS",
|
||||
example: Examples.Task.taskID,
|
||||
}),
|
||||
startedAt: z.string().or(z.number()).openapi({
|
||||
description: "The time this task was started",
|
||||
example: Examples.Task.startedAt,
|
||||
}),
|
||||
lastUpdated: z.string().or(z.number()).openapi({
|
||||
description: "The time the information about this task was last updated",
|
||||
example: Examples.Task.lastUpdated,
|
||||
}),
|
||||
stoppedAt: z.string().or(z.number()).optional().openapi({
|
||||
description: "The time this task was stopped or quit",
|
||||
example: Examples.Task.lastUpdated,
|
||||
}),
|
||||
lastStatus: lastStatus.openapi({
|
||||
description: "The last registered status of this task",
|
||||
example: Examples.Task.lastStatus,
|
||||
}),
|
||||
healthStatus: healthStatus.openapi({
|
||||
description: "The health status of this task",
|
||||
example: Examples.Task.healthStatus,
|
||||
})
|
||||
})
|
||||
.openapi({
|
||||
ref: "Subscription",
|
||||
description: "Subscription to a Nestri product.",
|
||||
example: Examples.Task,
|
||||
});
|
||||
|
||||
export type Info = z.infer<typeof Info>;
|
||||
|
||||
export const list = async () => {
|
||||
const db = databaseClient()
|
||||
const user = useCurrentUser()
|
||||
|
||||
try {
|
||||
const query = {
|
||||
tasks: {
|
||||
$: {
|
||||
where: {
|
||||
stoppedAt: { $isNull: true },
|
||||
owner: user.id
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
const data = await db.query(query)
|
||||
|
||||
const response = data.tasks
|
||||
if (!response || response.length === 0) {
|
||||
throw new Error("No task for this user were found");
|
||||
}
|
||||
|
||||
const result = pipe(
|
||||
response,
|
||||
groupBy(x => x.id),
|
||||
values(),
|
||||
map((group): Info => ({
|
||||
id: group[0].id,
|
||||
taskID: group[0].taskID,
|
||||
type: group[0].type as taskType,
|
||||
lastStatus: group[0].lastStatus as lastStatus,
|
||||
healthStatus: group[0].healthStatus as healthStatus,
|
||||
startedAt: group[0].startedAt,
|
||||
stoppedAt: group[0].stoppedAt,
|
||||
lastUpdated: group[0].lastUpdated,
|
||||
}))
|
||||
)
|
||||
|
||||
return result
|
||||
} catch (e) {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export const create = async () => {
|
||||
const user = useCurrentUser()
|
||||
|
||||
try {
|
||||
|
||||
//TODO: Use a simpler way to set the session ID
|
||||
// const sessionID = createID()
|
||||
|
||||
const sessionID = await Sessions.create({ public: true })
|
||||
if (!sessionID) throw new Error("No session id was given");
|
||||
|
||||
const run = await Aws.EcsRunTask({
|
||||
count: 1,
|
||||
cluster: Resource.NestriGPUCluster.value,
|
||||
taskDefinition: Resource.NestriGPUTask.value,
|
||||
launchType: "EC2",
|
||||
overrides: {
|
||||
containerOverrides: [
|
||||
{
|
||||
name: "nestri",
|
||||
environment: [
|
||||
{
|
||||
name: "NESTRI_ROOM",
|
||||
value: sessionID
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
})
|
||||
|
||||
if (!run.tasks || run.tasks.length === 0) {
|
||||
throw new Error(`No tasks were started`);
|
||||
}
|
||||
|
||||
// Extract task details
|
||||
const task = run.tasks[0];
|
||||
const taskArn = task?.taskArn!;
|
||||
const taskId = taskArn.split('/').pop()!; // Extract task ID from ARN
|
||||
const taskStatus = task?.lastStatus;
|
||||
const taskHealthStatus = task?.healthStatus;
|
||||
const startedAt = task?.startedAt!;
|
||||
|
||||
const id = createID()
|
||||
const db = databaseClient()
|
||||
const now = new Date().toISOString()
|
||||
await db.transact(db.tx.tasks[id]!.update({
|
||||
taskID: taskId,
|
||||
type: "AWS",
|
||||
healthStatus: taskHealthStatus ? taskHealthStatus.toString() : "UNKNOWN",
|
||||
startedAt: startedAt ? startedAt.toISOString() : now,
|
||||
lastStatus: taskStatus,
|
||||
lastUpdated: now,
|
||||
}).link({ owner: user.id, sessions: sessionID }))
|
||||
|
||||
return id
|
||||
} catch (e) {
|
||||
console.error("error", e)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export const fromID = fn(z.string(), async (taskID) => {
|
||||
const db = databaseClient()
|
||||
try {
|
||||
const query = {
|
||||
tasks: {
|
||||
$: {
|
||||
where: {
|
||||
id: taskID,
|
||||
stoppedAt: { $isNull: true }
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
const data = await db.query(query)
|
||||
|
||||
const response = data.tasks
|
||||
if (!response || response.length === 0) {
|
||||
throw new Error("No task with the given id was found");
|
||||
}
|
||||
|
||||
const result = pipe(
|
||||
response,
|
||||
groupBy(x => x.id),
|
||||
values(),
|
||||
map((group): Info => ({
|
||||
id: group[0].id,
|
||||
taskID: group[0].taskID,
|
||||
type: group[0].type as taskType,
|
||||
lastStatus: group[0].lastStatus as lastStatus,
|
||||
healthStatus: group[0].healthStatus as healthStatus,
|
||||
startedAt: group[0].startedAt,
|
||||
stoppedAt: group[0].stoppedAt,
|
||||
lastUpdated: group[0].lastUpdated,
|
||||
}))
|
||||
)
|
||||
|
||||
return result[0]
|
||||
|
||||
} catch (error) {
|
||||
return null
|
||||
}
|
||||
})
|
||||
|
||||
export const update = fn(z.string(), async (taskID) => {
|
||||
try {
|
||||
const db = databaseClient()
|
||||
|
||||
const query = {
|
||||
tasks: {
|
||||
$: {
|
||||
where: {
|
||||
id: taskID,
|
||||
stoppedAt: { $isNull: true }
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
const data = await db.query(query)
|
||||
|
||||
const response = data.tasks
|
||||
if (!response || response.length === 0) {
|
||||
throw new Error("No task with the given taskID was found");
|
||||
}
|
||||
|
||||
const now = new Date().toISOString()
|
||||
const describeResponse = await Aws.EcsDescribeTasks({
|
||||
tasks: [response[0]!.taskID],
|
||||
cluster: Resource.NestriGPUCluster.value
|
||||
})
|
||||
|
||||
if (!describeResponse.tasks || describeResponse.tasks.length === 0) {
|
||||
throw new Error("No tasks were found");
|
||||
}
|
||||
|
||||
const task = describeResponse.tasks[0]!
|
||||
|
||||
const updatedDb = {
|
||||
healthStatus: task.healthStatus ? task.healthStatus : "UNKNOWN",
|
||||
lastStatus: task.lastStatus ? task.lastStatus : "UNKNOWN",
|
||||
lastUpdated: now,
|
||||
}
|
||||
|
||||
await db.transact(db.tx.tasks[response[0]!.id]!.update({
|
||||
...updatedDb
|
||||
}))
|
||||
|
||||
const updatedRes = [{ ...response[0]!, ...updatedDb }]
|
||||
|
||||
const result = pipe(
|
||||
updatedRes,
|
||||
groupBy(x => x.id),
|
||||
values(),
|
||||
map((group): Info => ({
|
||||
id: group[0].id,
|
||||
taskID: group[0].taskID,
|
||||
type: group[0].type as taskType,
|
||||
lastStatus: group[0].lastStatus as lastStatus,
|
||||
healthStatus: group[0].healthStatus as healthStatus,
|
||||
startedAt: group[0].startedAt,
|
||||
stoppedAt: group[0].stoppedAt,
|
||||
lastUpdated: group[0].lastUpdated,
|
||||
}))
|
||||
)
|
||||
|
||||
return result
|
||||
|
||||
} catch (error) {
|
||||
console.error("update error", error)
|
||||
return null
|
||||
}
|
||||
})
|
||||
|
||||
export const stop = fn(z.object({ taskID: z.string(), id: z.string() }), async (input) => {
|
||||
const db = databaseClient()
|
||||
const now = new Date().toISOString()
|
||||
try {
|
||||
//TODO:Check whether they own this task first
|
||||
|
||||
const stopResponse = await Aws.EcsStopTask({
|
||||
task: input.taskID,
|
||||
cluster: Resource.NestriGPUCluster.value,
|
||||
reason: "Client requested a shutdown"
|
||||
})
|
||||
|
||||
if (!stopResponse.task) {
|
||||
throw new Error(`No task was stopped`);
|
||||
}
|
||||
|
||||
await db.transact(db.tx.tasks[input.id]!.update({
|
||||
stoppedAt: now,
|
||||
lastUpdated: now,
|
||||
lastStatus: "STOPPED",
|
||||
healthStatus: "UNKNOWN"
|
||||
}))
|
||||
|
||||
return "ok"
|
||||
|
||||
} catch (error) {
|
||||
console.error("stop error", error)
|
||||
return null
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -26,10 +26,10 @@ export namespace Teams {
|
||||
description: "The time when this team was last edited",
|
||||
example: Examples.Team.updatedAt,
|
||||
}),
|
||||
owner: z.boolean().openapi({
|
||||
description: "Whether this team is owned by this user",
|
||||
example: Examples.Team.owner,
|
||||
}),
|
||||
// owner: z.boolean().openapi({
|
||||
// description: "Whether this team is owned by this user",
|
||||
// example: Examples.Team.owner,
|
||||
// }),
|
||||
slug: z.string().openapi({
|
||||
description: "This is the unique name identifier for the team",
|
||||
example: Examples.Team.slug
|
||||
@@ -112,11 +112,10 @@ export namespace Teams {
|
||||
map((group): Info => ({
|
||||
id: group[0].id,
|
||||
name: group[0].name,
|
||||
createdAt: group[0].createdAt,
|
||||
slug: group[0].slug,
|
||||
createdAt: group[0].createdAt,
|
||||
updatedAt: group[0].updatedAt,
|
||||
//@ts-expect-error
|
||||
owner: group[0].owner === user.id
|
||||
// owner: group[0].owner === user.id
|
||||
}))
|
||||
)
|
||||
|
||||
|
||||
@@ -10,4 +10,18 @@ export function fn<
|
||||
};
|
||||
result.schema = arg1;
|
||||
return result;
|
||||
}
|
||||
|
||||
export function doubleFn<
|
||||
Arg1 extends ZodSchema,
|
||||
Arg2 extends ZodSchema,
|
||||
Callback extends (arg1: z.output<Arg1>, arg2: z.output<Arg2>) => any,
|
||||
>(arg1: Arg1, arg2: Arg2, cb: Callback) {
|
||||
const result = function (input: z.input<typeof arg1>, input2: z.input<typeof arg2>): ReturnType<Callback> {
|
||||
const parsed = arg1.parse(input);
|
||||
const parsed2 = arg2.parse(input2);
|
||||
return cb.apply(cb, [parsed as any, parsed2 as any]);
|
||||
};
|
||||
result.schema = arg1;
|
||||
return result;
|
||||
}
|
||||
57
packages/core/sst-env.d.ts
vendored
@@ -2,57 +2,8 @@
|
||||
/* tslint:disable */
|
||||
/* eslint-disable */
|
||||
/* deno-fmt-ignore-file */
|
||||
|
||||
/// <reference path="../../sst-env.d.ts" />
|
||||
|
||||
import "sst"
|
||||
export {}
|
||||
declare module "sst" {
|
||||
export interface Resource {
|
||||
"Api": {
|
||||
"type": "sst.cloudflare.Worker"
|
||||
"url": string
|
||||
}
|
||||
"Auth": {
|
||||
"type": "sst.cloudflare.Worker"
|
||||
"url": string
|
||||
}
|
||||
"AuthFingerprintKey": {
|
||||
"type": "random.index/randomString.RandomString"
|
||||
"value": string
|
||||
}
|
||||
"CloudflareAuthKV": {
|
||||
"type": "sst.cloudflare.Kv"
|
||||
}
|
||||
"DiscordClientID": {
|
||||
"type": "sst.sst.Secret"
|
||||
"value": string
|
||||
}
|
||||
"DiscordClientSecret": {
|
||||
"type": "sst.sst.Secret"
|
||||
"value": string
|
||||
}
|
||||
"GithubClientID": {
|
||||
"type": "sst.sst.Secret"
|
||||
"value": string
|
||||
}
|
||||
"GithubClientSecret": {
|
||||
"type": "sst.sst.Secret"
|
||||
"value": string
|
||||
}
|
||||
"InstantAdminToken": {
|
||||
"type": "sst.sst.Secret"
|
||||
"value": string
|
||||
}
|
||||
"InstantAppId": {
|
||||
"type": "sst.sst.Secret"
|
||||
"value": string
|
||||
}
|
||||
"LoopsApiKey": {
|
||||
"type": "sst.sst.Secret"
|
||||
"value": string
|
||||
}
|
||||
"Urls": {
|
||||
"api": string
|
||||
"auth": string
|
||||
"type": "sst.sst.Linkable"
|
||||
}
|
||||
}
|
||||
}
|
||||
export {}
|
||||
@@ -3,10 +3,11 @@
|
||||
"module": "index.ts",
|
||||
"type": "module",
|
||||
"devDependencies": {
|
||||
"@aws-sdk/client-ecs": "^3.738.0",
|
||||
"@aws-sdk/client-sqs": "^3.734.0",
|
||||
"@cloudflare/workers-types": "^4.20241224.0",
|
||||
"@nestri/core": "*",
|
||||
"@types/bun": "latest",
|
||||
"partykit": "^0.0.111",
|
||||
"valibot": "^1.0.0-beta.9"
|
||||
},
|
||||
"peerDependencies": {
|
||||
|
||||
@@ -1,6 +0,0 @@
|
||||
{
|
||||
"$schema": "https://www.partykit.io/schema.json",
|
||||
"name": "nestri-party",
|
||||
"main": "src/party/index.ts",
|
||||
"compatibilityDate": "2024-12-31"
|
||||
}
|
||||
@@ -1,264 +1,264 @@
|
||||
import { z } from "zod";
|
||||
import { Hono } from "hono";
|
||||
import { Result } from "../common";
|
||||
import { describeRoute } from "hono-openapi";
|
||||
import { Games } from "@nestri/core/game/index";
|
||||
import { Examples } from "@nestri/core/examples";
|
||||
import { validator, resolver } from "hono-openapi/zod";
|
||||
import { Sessions } from "@nestri/core/session/index";
|
||||
// import { z } from "zod";
|
||||
// import { Hono } from "hono";
|
||||
// import { Result } from "../common";
|
||||
// import { describeRoute } from "hono-openapi";
|
||||
// import { Games } from "@nestri/core/game/index";
|
||||
// import { Examples } from "@nestri/core/examples";
|
||||
// import { validator, resolver } from "hono-openapi/zod";
|
||||
// import { Sessions } from "@nestri/core/session/index";
|
||||
|
||||
export module GameApi {
|
||||
export const route = new Hono()
|
||||
.get(
|
||||
"/",
|
||||
//FIXME: Add a way to filter through query params
|
||||
describeRoute({
|
||||
tags: ["Game"],
|
||||
summary: "Retrieve all games in the user's library",
|
||||
description: "Returns a list of all (known) games associated with the authenticated user",
|
||||
responses: {
|
||||
200: {
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: Result(
|
||||
Games.Info.array().openapi({
|
||||
description: "A list of games owned by the user",
|
||||
example: [Examples.Game],
|
||||
}),
|
||||
),
|
||||
},
|
||||
},
|
||||
description: "Successfully retrieved the user's library of games",
|
||||
},
|
||||
404: {
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: resolver(z.object({ error: z.string() })),
|
||||
},
|
||||
},
|
||||
description: "No games were found in the authenticated user's library",
|
||||
},
|
||||
},
|
||||
}),
|
||||
async (c) => {
|
||||
const games = await Games.list();
|
||||
if (!games) return c.json({ error: "No games exist in this user's library" }, 404);
|
||||
return c.json({ data: games }, 200);
|
||||
},
|
||||
)
|
||||
.get(
|
||||
"/:steamID",
|
||||
describeRoute({
|
||||
tags: ["Game"],
|
||||
summary: "Retrieve a game by its Steam ID",
|
||||
description: "Fetches detailed metadata about a specific game using its Steam ID",
|
||||
responses: {
|
||||
404: {
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: resolver(z.object({ error: z.string() })),
|
||||
},
|
||||
},
|
||||
description: "No game found matching the provided Steam ID",
|
||||
},
|
||||
200: {
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: Result(
|
||||
Games.Info.openapi({
|
||||
description: "Detailed metadata about the requested game",
|
||||
example: Examples.Game,
|
||||
}),
|
||||
),
|
||||
},
|
||||
},
|
||||
description: "Successfully retrieved game metadata",
|
||||
},
|
||||
},
|
||||
}),
|
||||
validator(
|
||||
"param",
|
||||
z.object({
|
||||
steamID: Games.Info.shape.steamID.openapi({
|
||||
description: "The unique Steam ID used to identify a game",
|
||||
example: Examples.Game.steamID,
|
||||
}),
|
||||
}),
|
||||
),
|
||||
async (c) => {
|
||||
const params = c.req.valid("param");
|
||||
const game = await Games.fromSteamID(params.steamID);
|
||||
if (!game) return c.json({ error: "Game not found" }, 404);
|
||||
return c.json({ data: game }, 200);
|
||||
},
|
||||
)
|
||||
.post(
|
||||
"/:steamID",
|
||||
describeRoute({
|
||||
tags: ["Game"],
|
||||
summary: "Add a game to the user's library using its Steam ID",
|
||||
description: "Adds a game to the currently authenticated user's library. Once added, the user can play the game and share their progress with others",
|
||||
responses: {
|
||||
200: {
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: Result(z.literal("ok"))
|
||||
},
|
||||
},
|
||||
description: "Game successfully added to user's library",
|
||||
},
|
||||
404: {
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: resolver(z.object({ error: z.string() })),
|
||||
},
|
||||
},
|
||||
description: "No game was found matching the provided Steam ID",
|
||||
},
|
||||
},
|
||||
}),
|
||||
validator(
|
||||
"param",
|
||||
z.object({
|
||||
steamID: Games.Info.shape.steamID.openapi({
|
||||
description: "The unique Steam ID of the game to be added to the current user's library",
|
||||
example: Examples.Game.steamID,
|
||||
}),
|
||||
}),
|
||||
),
|
||||
async (c) => {
|
||||
const params = c.req.valid("param")
|
||||
const game = await Games.fromSteamID(params.steamID)
|
||||
if (!game) return c.json({ error: "Game not found" }, 404);
|
||||
const res = await Games.linkToCurrentUser(game.id)
|
||||
return c.json({ data: res }, 200);
|
||||
},
|
||||
)
|
||||
.delete(
|
||||
"/:steamID",
|
||||
describeRoute({
|
||||
tags: ["Game"],
|
||||
summary: "Remove game from user's library",
|
||||
description: "Removes a game from the authenticated user's library. The game remains in the system but will no longer be accessible to the user",
|
||||
responses: {
|
||||
200: {
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: Result(z.literal("ok")),
|
||||
},
|
||||
},
|
||||
description: "Game successfully removed from library",
|
||||
},
|
||||
404: {
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: resolver(z.object({ error: z.string() })),
|
||||
},
|
||||
},
|
||||
description: "The game with the specified Steam ID was not found",
|
||||
},
|
||||
}
|
||||
}),
|
||||
validator(
|
||||
"param",
|
||||
z.object({
|
||||
steamID: Games.Info.shape.steamID.openapi({
|
||||
description: "The Steam ID of the game to be removed",
|
||||
example: Examples.Game.steamID,
|
||||
}),
|
||||
}),
|
||||
),
|
||||
async (c) => {
|
||||
const params = c.req.valid("param");
|
||||
const res = await Games.unLinkFromCurrentUser(params.steamID)
|
||||
if (!res) return c.json({ error: "Game not found the library" }, 404);
|
||||
return c.json({ data: res }, 200);
|
||||
},
|
||||
)
|
||||
.put(
|
||||
"/",
|
||||
describeRoute({
|
||||
tags: ["Game"],
|
||||
summary: "Update game metadata",
|
||||
description: "Updates the metadata about a specific game using its Steam ID",
|
||||
responses: {
|
||||
200: {
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: Result(z.literal("ok")),
|
||||
},
|
||||
},
|
||||
description: "Game successfully updated",
|
||||
},
|
||||
404: {
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: resolver(z.object({ error: z.string() })),
|
||||
},
|
||||
},
|
||||
description: "The game with the specified Steam ID was not found",
|
||||
},
|
||||
}
|
||||
}),
|
||||
validator(
|
||||
"json",
|
||||
Games.Info.omit({ id: true }).openapi({
|
||||
description: "Game information",
|
||||
//@ts-expect-error
|
||||
example: { ...Examples.Game, id: undefined }
|
||||
})
|
||||
),
|
||||
async (c) => {
|
||||
const params = c.req.valid("json");
|
||||
const res = await Games.create(params)
|
||||
if (!res) return c.json({ error: "Something went seriously wrong" }, 404);
|
||||
return c.json({ data: res }, 200);
|
||||
},
|
||||
)
|
||||
.get(
|
||||
"/:steamID/sessions",
|
||||
describeRoute({
|
||||
tags: ["Game"],
|
||||
summary: "Retrieve game sessions by the associated game's Steam ID",
|
||||
description: "Fetches active and public game sessions associated with a specific game using its Steam ID",
|
||||
responses: {
|
||||
404: {
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: resolver(z.object({ error: z.string() })),
|
||||
},
|
||||
},
|
||||
description: "This game does not have nay publicly active sessions",
|
||||
},
|
||||
200: {
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: Result(
|
||||
Sessions.Info.array().openapi({
|
||||
description: "Publicly active sessions associated with the game",
|
||||
example: [Examples.Session],
|
||||
}),
|
||||
),
|
||||
},
|
||||
},
|
||||
description: "Successfully retrieved game sessions associated with this game",
|
||||
},
|
||||
},
|
||||
}),
|
||||
validator(
|
||||
"param",
|
||||
z.object({
|
||||
steamID: Games.Info.shape.steamID.openapi({
|
||||
description: "The unique Steam ID used to identify a game",
|
||||
example: Examples.Game.steamID,
|
||||
}),
|
||||
}),
|
||||
),
|
||||
async (c) => {
|
||||
const params = c.req.valid("param");
|
||||
const sessions = await Sessions.fromSteamID(params.steamID);
|
||||
if (!sessions) return c.json({ error: "This game does not have any publicly active game sessions" }, 404);
|
||||
return c.json({ data: sessions }, 200);
|
||||
},
|
||||
);
|
||||
}
|
||||
// export module GameApi {
|
||||
// export const route = new Hono()
|
||||
// .get(
|
||||
// "/",
|
||||
// //FIXME: Add a way to filter through query params
|
||||
// describeRoute({
|
||||
// tags: ["Game"],
|
||||
// summary: "Retrieve all games in the user's library",
|
||||
// description: "Returns a list of all (known) games associated with the authenticated user",
|
||||
// responses: {
|
||||
// 200: {
|
||||
// content: {
|
||||
// // "application/json": {
|
||||
// schema: Result(
|
||||
// Games.Info.array().openapi({
|
||||
// description: "A list of games owned by the user",
|
||||
// example: [Examples.Game],
|
||||
// }),
|
||||
// ),
|
||||
// },
|
||||
// },
|
||||
// description: "Successfully retrieved the user's library of games",
|
||||
// },
|
||||
// 404: {
|
||||
// content: {
|
||||
// "application/json": {
|
||||
// schema: resolver(z.object({ error: z.string() })),
|
||||
// },
|
||||
// },
|
||||
// description: "No games were found in the authenticated user's library",
|
||||
// },
|
||||
// },
|
||||
// }),
|
||||
// async (c) => {
|
||||
// const games = await Games.list();
|
||||
// if (!games) return c.json({ error: "No games exist in this user's library" }, 404);
|
||||
// return c.json({ data: games }, 200);
|
||||
// },
|
||||
// )
|
||||
// .get(
|
||||
// "/:steamID",
|
||||
// describeRoute({
|
||||
// tags: ["Game"],
|
||||
// summary: "Retrieve a game by its Steam ID",
|
||||
// description: "Fetches detailed metadata about a specific game using its Steam ID",
|
||||
// responses: {
|
||||
// 404: {
|
||||
// content: {
|
||||
// "application/json": {
|
||||
// schema: resolver(z.object({ error: z.string() })),
|
||||
// },
|
||||
// },
|
||||
// description: "No game found matching the provided Steam ID",
|
||||
// },
|
||||
// 200: {
|
||||
// content: {
|
||||
// "application/json": {
|
||||
// schema: Result(
|
||||
// Games.Info.openapi({
|
||||
// description: "Detailed metadata about the requested game",
|
||||
// example: Examples.Game,
|
||||
// }),
|
||||
// ),
|
||||
// },
|
||||
// },
|
||||
// description: "Successfully retrieved game metadata",
|
||||
// },
|
||||
// },
|
||||
// }),
|
||||
// validator(
|
||||
// "param",
|
||||
// z.object({
|
||||
// steamID: Games.Info.shape.steamID.openapi({
|
||||
// description: "The unique Steam ID used to identify a game",
|
||||
// example: Examples.Game.steamID,
|
||||
// }),
|
||||
// }),
|
||||
// ),
|
||||
// async (c) => {
|
||||
// const params = c.req.valid("param");
|
||||
// const game = await Games.fromSteamID(params.steamID);
|
||||
// if (!game) return c.json({ error: "Game not found" }, 404);
|
||||
// return c.json({ data: game }, 200);
|
||||
// },
|
||||
// )
|
||||
// .post(
|
||||
// "/:steamID",
|
||||
// describeRoute({
|
||||
// tags: ["Game"],
|
||||
// summary: "Add a game to the user's library using its Steam ID",
|
||||
// description: "Adds a game to the currently authenticated user's library. Once added, the user can play the game and share their progress with others",
|
||||
// responses: {
|
||||
// 200: {
|
||||
// content: {
|
||||
// "application/json": {
|
||||
// schema: Result(z.literal("ok"))
|
||||
// },
|
||||
// },
|
||||
// description: "Game successfully added to user's library",
|
||||
// },
|
||||
// 404: {
|
||||
// content: {
|
||||
// "application/json": {
|
||||
// schema: resolver(z.object({ error: z.string() })),
|
||||
// },
|
||||
// },
|
||||
// description: "No game was found matching the provided Steam ID",
|
||||
// },
|
||||
// },
|
||||
// }),
|
||||
// validator(
|
||||
// "param",
|
||||
// z.object({
|
||||
// steamID: Games.Info.shape.steamID.openapi({
|
||||
// description: "The unique Steam ID of the game to be added to the current user's library",
|
||||
// example: Examples.Game.steamID,
|
||||
// }),
|
||||
// }),
|
||||
// ),
|
||||
// async (c) => {
|
||||
// const params = c.req.valid("param")
|
||||
// const game = await Games.fromSteamID(params.steamID)
|
||||
// if (!game) return c.json({ error: "Game not found" }, 404);
|
||||
// const res = await Games.linkToCurrentUser(game.id)
|
||||
// return c.json({ data: res }, 200);
|
||||
// },
|
||||
// )
|
||||
// .delete(
|
||||
// "/:steamID",
|
||||
// describeRoute({
|
||||
// tags: ["Game"],
|
||||
// summary: "Remove game from user's library",
|
||||
// description: "Removes a game from the authenticated user's library. The game remains in the system but will no longer be accessible to the user",
|
||||
// responses: {
|
||||
// 200: {
|
||||
// content: {
|
||||
// "application/json": {
|
||||
// schema: Result(z.literal("ok")),
|
||||
// },
|
||||
// },
|
||||
// description: "Game successfully removed from library",
|
||||
// },
|
||||
// 404: {
|
||||
// content: {
|
||||
// "application/json": {
|
||||
// schema: resolver(z.object({ error: z.string() })),
|
||||
// },
|
||||
// },
|
||||
// description: "The game with the specified Steam ID was not found",
|
||||
// },
|
||||
// }
|
||||
// }),
|
||||
// validator(
|
||||
// "param",
|
||||
// z.object({
|
||||
// steamID: Games.Info.shape.steamID.openapi({
|
||||
// description: "The Steam ID of the game to be removed",
|
||||
// example: Examples.Game.steamID,
|
||||
// }),
|
||||
// }),
|
||||
// ),
|
||||
// async (c) => {
|
||||
// const params = c.req.valid("param");
|
||||
// const res = await Games.unLinkFromCurrentUser(params.steamID)
|
||||
// if (!res) return c.json({ error: "Game not found the library" }, 404);
|
||||
// return c.json({ data: res }, 200);
|
||||
// },
|
||||
// )
|
||||
// .put(
|
||||
// "/",
|
||||
// describeRoute({
|
||||
// tags: ["Game"],
|
||||
// summary: "Update game metadata",
|
||||
// description: "Updates the metadata about a specific game using its Steam ID",
|
||||
// responses: {
|
||||
// 200: {
|
||||
// content: {
|
||||
// "application/json": {
|
||||
// schema: Result(z.literal("ok")),
|
||||
// },
|
||||
// },
|
||||
// description: "Game successfully updated",
|
||||
// },
|
||||
// 404: {
|
||||
// content: {
|
||||
// "application/json": {
|
||||
// schema: resolver(z.object({ error: z.string() })),
|
||||
// },
|
||||
// },
|
||||
// description: "The game with the specified Steam ID was not found",
|
||||
// },
|
||||
// }
|
||||
// }),
|
||||
// validator(
|
||||
// "json",
|
||||
// Games.Info.omit({ id: true }).openapi({
|
||||
// description: "Game information",
|
||||
// //@ts-expect-error
|
||||
// example: { ...Examples.Game, id: undefined }
|
||||
// })
|
||||
// ),
|
||||
// async (c) => {
|
||||
// const params = c.req.valid("json");
|
||||
// const res = await Games.create(params)
|
||||
// if (!res) return c.json({ error: "Something went seriously wrong" }, 404);
|
||||
// return c.json({ data: res }, 200);
|
||||
// },
|
||||
// )
|
||||
// .get(
|
||||
// "/:steamID/sessions",
|
||||
// describeRoute({
|
||||
// tags: ["Game"],
|
||||
// summary: "Retrieve game sessions by the associated game's Steam ID",
|
||||
// description: "Fetches active and public game sessions associated with a specific game using its Steam ID",
|
||||
// responses: {
|
||||
// 404: {
|
||||
// content: {
|
||||
// "application/json": {
|
||||
// schema: resolver(z.object({ error: z.string() })),
|
||||
// },
|
||||
// },
|
||||
// description: "This game does not have nay publicly active sessions",
|
||||
// },
|
||||
// 200: {
|
||||
// content: {
|
||||
// "application/json": {
|
||||
// schema: Result(
|
||||
// Sessions.Info.array().openapi({
|
||||
// description: "Publicly active sessions associated with the game",
|
||||
// example: [Examples.Session],
|
||||
// }),
|
||||
// ),
|
||||
// },
|
||||
// },
|
||||
// description: "Successfully retrieved game sessions associated with this game",
|
||||
// },
|
||||
// },
|
||||
// }),
|
||||
// validator(
|
||||
// "param",
|
||||
// z.object({
|
||||
// steamID: Games.Info.shape.steamID.openapi({
|
||||
// description: "The unique Steam ID used to identify a game",
|
||||
// example: Examples.Game.steamID,
|
||||
// }),
|
||||
// }),
|
||||
// ),
|
||||
// async (c) => {
|
||||
// const params = c.req.valid("param");
|
||||
// const sessions = await Sessions.fromSteamID(params.steamID);
|
||||
// if (!sessions) return c.json({ error: "This game does not have any publicly active game sessions" }, 404);
|
||||
// return c.json({ data: sessions }, 200);
|
||||
// },
|
||||
// );
|
||||
// }
|
||||
@@ -2,12 +2,13 @@ import "zod-openapi/extend";
|
||||
import { Resource } from "sst";
|
||||
import { ZodError } from "zod";
|
||||
import { UserApi } from "./user";
|
||||
import { GameApi } from "./game";
|
||||
import { TeamApi } from "./team";
|
||||
import { TaskApi } from "./task";
|
||||
// import { GameApi } from "./game";
|
||||
// import { TeamApi } from "./team";
|
||||
import { logger } from "hono/logger";
|
||||
import { subjects } from "../subjects";
|
||||
import { SessionApi } from "./session";
|
||||
import { MachineApi } from "./machine";
|
||||
// import { MachineApi } from "./machine";
|
||||
import { openAPISpecs } from "hono-openapi";
|
||||
import { SubscriptionApi } from "./subscription";
|
||||
import { VisibleError } from "@nestri/core/error";
|
||||
@@ -58,8 +59,8 @@ const auth: MiddlewareHandler = async (c, next) => {
|
||||
{
|
||||
type: "device",
|
||||
properties: {
|
||||
fingerprint: result.subject.properties.fingerprint,
|
||||
id: result.subject.properties.id,
|
||||
hostname: result.subject.properties.hostname,
|
||||
teamSlug: result.subject.properties.teamSlug,
|
||||
auth: {
|
||||
type: "oauth",
|
||||
clientID: result.aud,
|
||||
@@ -81,14 +82,16 @@ app
|
||||
c.header("Cache-Control", "no-store");
|
||||
return next();
|
||||
})
|
||||
.use(auth);
|
||||
.use(auth)
|
||||
|
||||
const routes = app
|
||||
.get("/", (c) => c.text("Hello there 👋🏾"))
|
||||
.route("/users", UserApi.route)
|
||||
.route("/teams", TeamApi.route)
|
||||
.route("/games", GameApi.route)
|
||||
.route("/tasks", TaskApi.route)
|
||||
// .route("/teams", TeamApi.route)
|
||||
// .route("/games", GameApi.route)
|
||||
.route("/sessions", SessionApi.route)
|
||||
.route("/machines", MachineApi.route)
|
||||
// .route("/machines", MachineApi.route)
|
||||
.route("/subscriptions", SubscriptionApi.route)
|
||||
.onError((error, c) => {
|
||||
console.warn(error);
|
||||
|
||||
@@ -1,176 +1,176 @@
|
||||
import { z } from "zod";
|
||||
import { Hono } from "hono";
|
||||
import { Result } from "../common";
|
||||
import { describeRoute } from "hono-openapi";
|
||||
import { Examples } from "@nestri/core/examples";
|
||||
import { validator, resolver } from "hono-openapi/zod";
|
||||
import { Machines } from "@nestri/core/machine/index";
|
||||
export module MachineApi {
|
||||
export const route = new Hono()
|
||||
.get(
|
||||
"/",
|
||||
//FIXME: Add a way to filter through query params
|
||||
describeRoute({
|
||||
tags: ["Machine"],
|
||||
summary: "Retrieve all machines",
|
||||
description: "Returns a list of all machines registered to the authenticated user in the Nestri network",
|
||||
responses: {
|
||||
200: {
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: Result(
|
||||
Machines.Info.array().openapi({
|
||||
description: "A list of machines associated with the user",
|
||||
example: [Examples.Machine],
|
||||
}),
|
||||
),
|
||||
},
|
||||
},
|
||||
description: "Successfully retrieved the list of machines",
|
||||
},
|
||||
404: {
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: resolver(z.object({ error: z.string() })),
|
||||
},
|
||||
},
|
||||
description: "No machines found for the authenticated user",
|
||||
},
|
||||
},
|
||||
}),
|
||||
async (c) => {
|
||||
const machines = await Machines.list();
|
||||
if (!machines) return c.json({ error: "No machines found for this user" }, 404);
|
||||
return c.json({ data: machines }, 200);
|
||||
},
|
||||
)
|
||||
.get(
|
||||
"/:fingerprint",
|
||||
describeRoute({
|
||||
tags: ["Machine"],
|
||||
summary: "Retrieve machine by fingerprint",
|
||||
description: "Fetches detailed information about a specific machine using its unique fingerprint derived from the Linux machine ID",
|
||||
responses: {
|
||||
404: {
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: resolver(z.object({ error: z.string() })),
|
||||
},
|
||||
},
|
||||
description: "No machine found matching the provided fingerprint",
|
||||
},
|
||||
200: {
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: Result(
|
||||
Machines.Info.openapi({
|
||||
description: "Detailed information about the requested machine",
|
||||
example: Examples.Machine,
|
||||
}),
|
||||
),
|
||||
},
|
||||
},
|
||||
description: "Successfully retrieved machine information",
|
||||
},
|
||||
},
|
||||
}),
|
||||
validator(
|
||||
"param",
|
||||
z.object({
|
||||
fingerprint: Machines.Info.shape.fingerprint.openapi({
|
||||
description: "The unique fingerprint used to identify the machine, derived from its Linux machine ID",
|
||||
example: Examples.Machine.fingerprint,
|
||||
}),
|
||||
}),
|
||||
),
|
||||
async (c) => {
|
||||
const params = c.req.valid("param");
|
||||
const machine = await Machines.fromFingerprint(params.fingerprint);
|
||||
if (!machine) return c.json({ error: "Machine not found" }, 404);
|
||||
return c.json({ data: machine }, 200);
|
||||
},
|
||||
)
|
||||
.post(
|
||||
"/:fingerprint",
|
||||
describeRoute({
|
||||
tags: ["Machine"],
|
||||
summary: "Register a machine to an owner",
|
||||
description: "Associates a machine with the currently authenticated user's account, enabling them to manage and control the machine",
|
||||
responses: {
|
||||
200: {
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: Result(z.literal("ok"))
|
||||
},
|
||||
},
|
||||
description: "Machine successfully registered to user's account",
|
||||
},
|
||||
404: {
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: resolver(z.object({ error: z.string() })),
|
||||
},
|
||||
},
|
||||
description: "No machine found matching the provided fingerprint",
|
||||
},
|
||||
},
|
||||
}),
|
||||
validator(
|
||||
"param",
|
||||
z.object({
|
||||
fingerprint: Machines.Info.shape.fingerprint.openapi({
|
||||
description: "The unique fingerprint of the machine to be registered, derived from its Linux machine ID",
|
||||
example: Examples.Machine.fingerprint,
|
||||
}),
|
||||
}),
|
||||
),
|
||||
async (c) => {
|
||||
const params = c.req.valid("param")
|
||||
const machine = await Machines.fromFingerprint(params.fingerprint)
|
||||
if (!machine) return c.json({ error: "Machine not found" }, 404);
|
||||
const res = await Machines.linkToCurrentUser(machine.id)
|
||||
return c.json({ data: res }, 200);
|
||||
},
|
||||
)
|
||||
.delete(
|
||||
"/:fingerprint",
|
||||
describeRoute({
|
||||
tags: ["Machine"],
|
||||
summary: "Unregister machine from user",
|
||||
description: "Removes the association between a machine and the authenticated user's account. This does not delete the machine itself, but removes the user's ability to manage it",
|
||||
responses: {
|
||||
200: {
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: Result(z.literal("ok")),
|
||||
},
|
||||
},
|
||||
description: "Machine successfully unregistered from user's account",
|
||||
},
|
||||
404: {
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: resolver(z.object({ error: z.string() })),
|
||||
},
|
||||
},
|
||||
description: "The machine with the specified fingerprint was not found",
|
||||
},
|
||||
}
|
||||
}),
|
||||
validator(
|
||||
"param",
|
||||
z.object({
|
||||
fingerprint: Machines.Info.shape.fingerprint.openapi({
|
||||
description: "The unique fingerprint of the machine to be unregistered, derived from its Linux machine ID",
|
||||
example: Examples.Machine.fingerprint,
|
||||
}),
|
||||
}),
|
||||
),
|
||||
async (c) => {
|
||||
const params = c.req.valid("param");
|
||||
const res = await Machines.unLinkFromCurrentUser(params.fingerprint)
|
||||
if (!res) return c.json({ error: "Machine not found for this user" }, 404);
|
||||
return c.json({ data: res }, 200);
|
||||
},
|
||||
);
|
||||
}
|
||||
// import { z } from "zod";
|
||||
// import { Hono } from "hono";
|
||||
// import { Result } from "../common";
|
||||
// import { describeRoute } from "hono-openapi";
|
||||
// import { Examples } from "@nestri/core/examples";
|
||||
// import { validator, resolver } from "hono-openapi/zod";
|
||||
// import { Machines } from "@nestri/core/machine/index";
|
||||
// export module MachineApi {
|
||||
// export const route = new Hono()
|
||||
// .get(
|
||||
// "/",
|
||||
// //FIXME: Add a way to filter through query params
|
||||
// describeRoute({
|
||||
// tags: ["Machine"],
|
||||
// summary: "Retrieve all machines",
|
||||
// description: "Returns a list of all machines registered to the authenticated user in the Nestri network",
|
||||
// responses: {
|
||||
// 200: {
|
||||
// content: {
|
||||
// "application/json": {
|
||||
// schema: Result(
|
||||
// // Machines.Info.array().openapi({
|
||||
// description: "A list of machines associated with the user",
|
||||
// example: [Examples.Machine],
|
||||
// }),
|
||||
// ),
|
||||
// },
|
||||
// },
|
||||
// description: "Successfully retrieved the list of machines",
|
||||
// },
|
||||
// 404: {
|
||||
// content: {
|
||||
// "application/json": {
|
||||
// schema: resolver(z.object({ error: z.string() })),
|
||||
// },
|
||||
// },
|
||||
// description: "No machines found for the authenticated user",
|
||||
// },
|
||||
// },
|
||||
// }),
|
||||
// async (c) => {
|
||||
// const machines = await Machines.list();
|
||||
// if (!machines) return c.json({ error: "No machines found for this user" }, 404);
|
||||
// return c.json({ data: machines }, 200);
|
||||
// },
|
||||
// )
|
||||
// .get(
|
||||
// "/:fingerprint",
|
||||
// describeRoute({
|
||||
// tags: ["Machine"],
|
||||
// summary: "Retrieve machine by fingerprint",
|
||||
// description: "Fetches detailed information about a specific machine using its unique fingerprint derived from the Linux machine ID",
|
||||
// responses: {
|
||||
// 404: {
|
||||
// content: {
|
||||
// "application/json": {
|
||||
// schema: resolver(z.object({ error: z.string() })),
|
||||
// },
|
||||
// },
|
||||
// description: "No machine found matching the provided fingerprint",
|
||||
// },
|
||||
// 200: {
|
||||
// content: {
|
||||
// "application/json": {
|
||||
// schema: Result(
|
||||
// Machines.Info.openapi({
|
||||
// description: "Detailed information about the requested machine",
|
||||
// example: Examples.Machine,
|
||||
// }),
|
||||
// ),
|
||||
// },
|
||||
// },
|
||||
// description: "Successfully retrieved machine information",
|
||||
// },
|
||||
// },
|
||||
// }),
|
||||
// validator(
|
||||
// "param",
|
||||
// z.object({
|
||||
// fingerprint: Machines.Info.shape.fingerprint.openapi({
|
||||
// description: "The unique fingerprint used to identify the machine, derived from its Linux machine ID",
|
||||
// example: Examples.Machine.fingerprint,
|
||||
// }),
|
||||
// }),
|
||||
// ),
|
||||
// async (c) => {
|
||||
// const params = c.req.valid("param");
|
||||
// const machine = await Machines.fromFingerprint(params.fingerprint);
|
||||
// if (!machine) return c.json({ error: "Machine not found" }, 404);
|
||||
// return c.json({ data: machine }, 200);
|
||||
// },
|
||||
// )
|
||||
// .post(
|
||||
// "/:fingerprint",
|
||||
// describeRoute({
|
||||
// tags: ["Machine"],
|
||||
// summary: "Register a machine to an owner",
|
||||
// description: "Associates a machine with the currently authenticated user's account, enabling them to manage and control the machine",
|
||||
// responses: {
|
||||
// 200: {
|
||||
// content: {
|
||||
// "application/json": {
|
||||
// schema: Result(z.literal("ok"))
|
||||
// },
|
||||
// },
|
||||
// description: "Machine successfully registered to user's account",
|
||||
// },
|
||||
// 404: {
|
||||
// content: {
|
||||
// "application/json": {
|
||||
// schema: resolver(z.object({ error: z.string() })),
|
||||
// },
|
||||
// },
|
||||
// description: "No machine found matching the provided fingerprint",
|
||||
// },
|
||||
// },
|
||||
// }),
|
||||
// validator(
|
||||
// "param",
|
||||
// z.object({
|
||||
// fingerprint: Machines.Info.shape.fingerprint.openapi({
|
||||
// description: "The unique fingerprint of the machine to be registered, derived from its Linux machine ID",
|
||||
// example: Examples.Machine.fingerprint,
|
||||
// }),
|
||||
// }),
|
||||
// ),
|
||||
// async (c) => {
|
||||
// const params = c.req.valid("param")
|
||||
// const machine = await Machines.fromFingerprint(params.fingerprint)
|
||||
// if (!machine) return c.json({ error: "Machine not found" }, 404);
|
||||
// const res = await Machines.linkToCurrentUser(machine.id)
|
||||
// return c.json({ data: res }, 200);
|
||||
// },
|
||||
// )
|
||||
// .delete(
|
||||
// "/:fingerprint",
|
||||
// describeRoute({
|
||||
// tags: ["Machine"],
|
||||
// summary: "Unregister machine from user",
|
||||
// description: "Removes the association between a machine and the authenticated user's account. This does not delete the machine itself, but removes the user's ability to manage it",
|
||||
// responses: {
|
||||
// 200: {
|
||||
// content: {
|
||||
// "application/json": {
|
||||
// schema: Result(z.literal("ok")),
|
||||
// },
|
||||
// },
|
||||
// description: "Machine successfully unregistered from user's account",
|
||||
// },
|
||||
// 404: {
|
||||
// content: {
|
||||
// "application/json": {
|
||||
// schema: resolver(z.object({ error: z.string() })),
|
||||
// },
|
||||
// },
|
||||
// description: "The machine with the specified fingerprint was not found",
|
||||
// },
|
||||
// }
|
||||
// }),
|
||||
// validator(
|
||||
// "param",
|
||||
// z.object({
|
||||
// fingerprint: Machines.Info.shape.fingerprint.openapi({
|
||||
// description: "The unique fingerprint of the machine to be unregistered, derived from its Linux machine ID",
|
||||
// example: Examples.Machine.fingerprint,
|
||||
// }),
|
||||
// }),
|
||||
// ),
|
||||
// async (c) => {
|
||||
// const params = c.req.valid("param");
|
||||
// const res = await Machines.unLinkFromCurrentUser(params.fingerprint)
|
||||
// if (!res) return c.json({ error: "Machine not found for this user" }, 404);
|
||||
// return c.json({ data: res }, 200);
|
||||
// },
|
||||
// );
|
||||
// }
|
||||
@@ -2,51 +2,12 @@ import { z } from "zod";
|
||||
import { Hono } from "hono";
|
||||
import { Result } from "../common";
|
||||
import { describeRoute } from "hono-openapi";
|
||||
import { Games } from "@nestri/core/game/index";
|
||||
import { Examples } from "@nestri/core/examples";
|
||||
import { validator, resolver } from "hono-openapi/zod";
|
||||
import { Sessions } from "@nestri/core/session/index";
|
||||
import { Machines } from "@nestri/core/machine/index";
|
||||
|
||||
export module SessionApi {
|
||||
export const route = new Hono()
|
||||
.get(
|
||||
"/",
|
||||
//FIXME: Add a way to filter through query params
|
||||
describeRoute({
|
||||
tags: ["Session"],
|
||||
summary: "Retrieve all gaming sessions",
|
||||
description: "Returns a list of all gaming sessions associated with the authenticated user",
|
||||
responses: {
|
||||
200: {
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: Result(
|
||||
Sessions.Info.array().openapi({
|
||||
description: "A list of gaming sessions associated with the user",
|
||||
example: [{ ...Examples.Session, public: false }],
|
||||
}),
|
||||
),
|
||||
},
|
||||
},
|
||||
description: "Successfully retrieved the list of gaming sessions",
|
||||
},
|
||||
404: {
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: resolver(z.object({ error: z.string() })),
|
||||
},
|
||||
},
|
||||
description: "No gaming sessions found for the authenticated user",
|
||||
},
|
||||
},
|
||||
}),
|
||||
async (c) => {
|
||||
const res = await Sessions.list();
|
||||
if (!res) return c.json({ error: "No gaming sessions found for this user" }, 404);
|
||||
return c.json({ data: res }, 200);
|
||||
},
|
||||
)
|
||||
.get(
|
||||
"/active",
|
||||
describeRoute({
|
||||
@@ -60,7 +21,7 @@ export module SessionApi {
|
||||
schema: Result(
|
||||
Sessions.Info.array().openapi({
|
||||
description: "A list of active gaming sessions associated with the user",
|
||||
example: [{ ...Examples.Session, public: false, endedAt: undefined }],
|
||||
example: [{ ...Examples.Session, public: true, endedAt: undefined }],
|
||||
}),
|
||||
),
|
||||
},
|
||||
@@ -83,42 +44,6 @@ export module SessionApi {
|
||||
return c.json({ data: res }, 200);
|
||||
},
|
||||
)
|
||||
.get(
|
||||
"/active/public",
|
||||
describeRoute({
|
||||
tags: ["Session"],
|
||||
summary: "Retrieve all publicly active gaming sessions",
|
||||
description: "Returns a list of all publicly active gaming sessions associated",
|
||||
responses: {
|
||||
200: {
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: Result(
|
||||
Sessions.Info.array().openapi({
|
||||
description: "A list of publicly active gaming sessions",
|
||||
example: [{ ...Examples.Session, public: true, endedAt: undefined }],
|
||||
}),
|
||||
),
|
||||
},
|
||||
},
|
||||
description: "Successfully retrieved the list of all publicly active gaming sessions",
|
||||
},
|
||||
404: {
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: resolver(z.object({ error: z.string() })),
|
||||
},
|
||||
},
|
||||
description: "No publicly active gaming sessions found",
|
||||
},
|
||||
},
|
||||
}),
|
||||
async (c) => {
|
||||
const res = await Sessions.getPublicActive();
|
||||
if (!res) return c.json({ error: "No publicly active gaming sessions found" }, 404);
|
||||
return c.json({ data: res }, 200);
|
||||
},
|
||||
)
|
||||
.get(
|
||||
"/:id",
|
||||
describeRoute({
|
||||
@@ -197,26 +122,13 @@ export module SessionApi {
|
||||
description: "Whether the session is publicly viewable by all users. If false, only authorized users can access it",
|
||||
example: Examples.Session.public
|
||||
}),
|
||||
steamID: Games.Info.shape.steamID.openapi({
|
||||
description: "The Steam ID of the game the user wants to play",
|
||||
example: Examples.Game.steamID
|
||||
}),
|
||||
fingerprint: Machines.Info.shape.fingerprint.openapi({
|
||||
description: "The unique fingerprint of the machine to play on, derived from its Linux machine ID",
|
||||
example: Examples.Machine.fingerprint
|
||||
}),
|
||||
name: Sessions.Info.shape.name.openapi({
|
||||
description: "The human readable name to give this session",
|
||||
example: Examples.Session.name
|
||||
})
|
||||
}),
|
||||
),
|
||||
async (c) => {
|
||||
const params = c.req.valid("json")
|
||||
//FIXME:
|
||||
const session = await Sessions.create(params)
|
||||
if (session.error) return c.json({ error: session.error }, 422);
|
||||
return c.json({ data: session.data }, 200);
|
||||
if (!session) return c.json({ error: "Something went wrong while creating a session" }, 422);
|
||||
return c.json({ data: session }, 200);
|
||||
},
|
||||
)
|
||||
.delete(
|
||||
@@ -240,7 +152,7 @@ export module SessionApi {
|
||||
schema: resolver(z.object({ error: z.string() })),
|
||||
},
|
||||
},
|
||||
description: "The session with the specified ID could not be found",
|
||||
description: "The session with the specified ID could not be found by this user",
|
||||
},
|
||||
}
|
||||
}),
|
||||
@@ -256,7 +168,7 @@ export module SessionApi {
|
||||
async (c) => {
|
||||
const params = c.req.valid("param");
|
||||
const res = await Sessions.end(params.id)
|
||||
if (!res) return c.json({ error: "Session not found for this user" }, 404);
|
||||
if (!res) return c.json({ error: "Session is not owned by this user" }, 404);
|
||||
return c.json({ data: res }, 200);
|
||||
},
|
||||
);
|
||||
|
||||
@@ -5,8 +5,6 @@ import { describeRoute } from "hono-openapi";
|
||||
import { Examples } from "@nestri/core/examples";
|
||||
import { validator, resolver } from "hono-openapi/zod";
|
||||
import { Subscriptions } from "@nestri/core/subscription/index";
|
||||
import { Email } from "@nestri/core/email/index";
|
||||
|
||||
export module SubscriptionApi {
|
||||
export const route = new Hono()
|
||||
.get(
|
||||
@@ -40,7 +38,7 @@ export module SubscriptionApi {
|
||||
},
|
||||
}),
|
||||
async (c) => {
|
||||
const data = await Subscriptions.list();
|
||||
const data = await Subscriptions.list(undefined);
|
||||
if (!data) return c.json({ error: "No subscriptions found for this user" }, 404);
|
||||
return c.json({ data }, 200);
|
||||
},
|
||||
|
||||
277
packages/functions/src/api/task.ts
Normal file
@@ -0,0 +1,277 @@
|
||||
import { z } from "zod";
|
||||
import { Hono } from "hono";
|
||||
import { Result } from "../common";
|
||||
import { describeRoute } from "hono-openapi";
|
||||
import { Tasks } from "@nestri/core/task/index";
|
||||
import { Examples } from "@nestri/core/examples";
|
||||
import { validator, resolver } from "hono-openapi/zod";
|
||||
import { useCurrentUser } from "@nestri/core/actor";
|
||||
import { Subscriptions } from "@nestri/core/subscription/index";
|
||||
import { Sessions } from "@nestri/core/session/index";
|
||||
|
||||
export module TaskApi {
|
||||
export const route = new Hono()
|
||||
.get("/",
|
||||
describeRoute({
|
||||
tags: ["Task"],
|
||||
summary: "List Tasks",
|
||||
description: "List all tasks by this user",
|
||||
responses: {
|
||||
200: {
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: Result(
|
||||
Tasks.Info.openapi({
|
||||
description: "A task example gotten from this task id",
|
||||
examples: [Examples.Task],
|
||||
}))
|
||||
},
|
||||
},
|
||||
description: "Tasks owned by this user were found",
|
||||
},
|
||||
404: {
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: resolver(z.object({ error: z.string() })),
|
||||
},
|
||||
},
|
||||
description: "No tasks for this user were not found.",
|
||||
},
|
||||
},
|
||||
}),
|
||||
async (c) => {
|
||||
const task = await Tasks.list();
|
||||
if (!task) return c.json({ error: "No tasks were found for this user" }, 404);
|
||||
return c.json({ data: task }, 200);
|
||||
},
|
||||
)
|
||||
.get("/:id",
|
||||
describeRoute({
|
||||
tags: ["Task"],
|
||||
summary: "Get Task",
|
||||
description: "Get a task by its id",
|
||||
responses: {
|
||||
200: {
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: Result(
|
||||
Tasks.Info.openapi({
|
||||
description: "A task example gotten from this task id",
|
||||
example: Examples.Task,
|
||||
}))
|
||||
},
|
||||
},
|
||||
description: "A task with this id was found",
|
||||
},
|
||||
404: {
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: resolver(z.object({ error: z.string() })),
|
||||
},
|
||||
},
|
||||
description: "A task with this id was not found.",
|
||||
},
|
||||
},
|
||||
}),
|
||||
validator(
|
||||
"param",
|
||||
z.object({
|
||||
id: Tasks.Info.shape.id.openapi({
|
||||
description: "ID of the task to get",
|
||||
example: Examples.Task.id,
|
||||
}),
|
||||
}),
|
||||
),
|
||||
async (c) => {
|
||||
const param = c.req.valid("param");
|
||||
const task = await Tasks.fromID(param.id);
|
||||
if (!task) return c.json({ error: "Task was not found" }, 404);
|
||||
return c.json({ data: task }, 200);
|
||||
},
|
||||
)
|
||||
.get("/:id/session",
|
||||
describeRoute({
|
||||
tags: ["Task"],
|
||||
summary: "Get the current session running on this task",
|
||||
description: "Get a task by its id",
|
||||
responses: {
|
||||
200: {
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: Result(
|
||||
Sessions.Info.openapi({
|
||||
description: "A session running on this task",
|
||||
example: Examples.Session,
|
||||
}))
|
||||
},
|
||||
},
|
||||
description: "A task with this id was found",
|
||||
},
|
||||
404: {
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: resolver(z.object({ error: z.string() })),
|
||||
},
|
||||
},
|
||||
description: "A task with this id was not found.",
|
||||
},
|
||||
},
|
||||
}),
|
||||
validator(
|
||||
"param",
|
||||
z.object({
|
||||
id: Tasks.Info.shape.id.openapi({
|
||||
description: "ID of the task to get session information about",
|
||||
example: Examples.Task.id,
|
||||
}),
|
||||
}),
|
||||
),
|
||||
async (c) => {
|
||||
const param = c.req.valid("param");
|
||||
const task = await Tasks.fromID(param.id);
|
||||
if (!task) return c.json({ error: "Task was not found" }, 404);
|
||||
const session = await Sessions.fromTaskID(task.id)
|
||||
if (!session) return c.json({ error: "No session was found running on this task" }, 404);
|
||||
return c.json({ data: session }, 200);
|
||||
},
|
||||
)
|
||||
.delete("/:id",
|
||||
describeRoute({
|
||||
tags: ["Task"],
|
||||
summary: "Stop Task",
|
||||
description: "Stop a running task by its id",
|
||||
responses: {
|
||||
200: {
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: Result(z.literal("ok"))
|
||||
},
|
||||
},
|
||||
description: "A task with this id was found",
|
||||
},
|
||||
404: {
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: resolver(z.object({ error: z.string() })),
|
||||
},
|
||||
},
|
||||
description: "A task with this id was not found.",
|
||||
},
|
||||
},
|
||||
}),
|
||||
validator(
|
||||
"param",
|
||||
z.object({
|
||||
id: Tasks.Info.shape.id.openapi({
|
||||
description: "The id of the task to get",
|
||||
example: Examples.Task.id,
|
||||
}),
|
||||
}),
|
||||
),
|
||||
async (c) => {
|
||||
const param = c.req.valid("param");
|
||||
const task = await Tasks.fromID(param.id);
|
||||
if (!task) return c.json({ error: "Task was not found" }, 404);
|
||||
|
||||
//End any running tasks then (and only then) kill the task
|
||||
const session = await Sessions.fromTaskID(task.id)
|
||||
if (session) { await Sessions.end(session.id) }
|
||||
|
||||
const res = await Tasks.stop({ taskID: task.taskID, id: param.id })
|
||||
if (!res) return c.json({ error: "Something went wrong trying to stop the task" }, 404);
|
||||
return c.json({ data: "ok" }, 200);
|
||||
},
|
||||
)
|
||||
.post("/",
|
||||
describeRoute({
|
||||
tags: ["Task"],
|
||||
summary: "Create Task",
|
||||
description: "Create a task",
|
||||
responses: {
|
||||
200: {
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: Result(Tasks.Info.shape.id.openapi({
|
||||
description: "The id of the task created",
|
||||
example: Examples.Task.id,
|
||||
}))
|
||||
},
|
||||
},
|
||||
description: "A task with this id was created",
|
||||
},
|
||||
404: {
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: resolver(z.object({ error: z.string() })),
|
||||
},
|
||||
},
|
||||
description: "A task with this id could not be created",
|
||||
},
|
||||
401: {
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: resolver(z.object({ error: z.string() })),
|
||||
},
|
||||
},
|
||||
description: "You are not authorised to do this",
|
||||
},
|
||||
},
|
||||
}),
|
||||
async (c) => {
|
||||
const user = useCurrentUser();
|
||||
// const data = await Subscriptions.list(undefined);
|
||||
// if (!data) return c.json({ error: "You need a subscription to create a task" }, 404);
|
||||
if (user) {
|
||||
const task = await Tasks.create();
|
||||
if (!task) return c.json({ error: "Task could not be created" }, 404);
|
||||
return c.json({ data: task }, 200);
|
||||
}
|
||||
|
||||
return c.json({ error: "You are not authorized to do this" }, 401);
|
||||
},
|
||||
)
|
||||
.put(
|
||||
"/:id",
|
||||
describeRoute({
|
||||
tags: ["Task"],
|
||||
summary: "Get an update on a task",
|
||||
description: "Updates the metadata about a task by querying remote task",
|
||||
responses: {
|
||||
200: {
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: Result(Tasks.Info.openapi({
|
||||
description: "The updated information about this task",
|
||||
example: Examples.Task
|
||||
})),
|
||||
},
|
||||
},
|
||||
description: "Task successfully updated",
|
||||
},
|
||||
404: {
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: resolver(z.object({ error: z.string() })),
|
||||
},
|
||||
},
|
||||
description: "The task specified id was not found",
|
||||
},
|
||||
}
|
||||
}),
|
||||
validator(
|
||||
"param",
|
||||
z.object({
|
||||
id: Tasks.Info.shape.id.openapi({
|
||||
description: "The id of the task to update on",
|
||||
example: Examples.Task.id
|
||||
})
|
||||
})
|
||||
),
|
||||
async (c) => {
|
||||
const params = c.req.valid("param");
|
||||
const res = await Tasks.update(params.id)
|
||||
if (!res) return c.json({ error: "Something went seriously wrong" }, 404);
|
||||
return c.json({ data: res[0] }, 200);
|
||||
},
|
||||
)
|
||||
}
|
||||
@@ -184,7 +184,7 @@ export module TeamApi {
|
||||
const params = c.req.valid("param");
|
||||
const team = await Teams.fromSlug(params.slug)
|
||||
if (!team) return c.json({ error: "Team not found" }, 404);
|
||||
if (!team.owner) return c.json({ error: "Your are not authorised to delete this team" }, 401)
|
||||
// if (!team.owner) return c.json({ error: "Your are not authorised to delete this team" }, 401)
|
||||
const res = await Teams.remove(team.id);
|
||||
return c.json({ data: res }, 200);
|
||||
},
|
||||
@@ -231,7 +231,7 @@ export module TeamApi {
|
||||
const params = c.req.valid("param");
|
||||
const team = await Teams.fromSlug(params.slug)
|
||||
if (!team) return c.json({ error: "Team not found" }, 404);
|
||||
if (!team.owner) return c.json({ error: "Your are not authorized to delete this team" }, 401)
|
||||
// if (!team.owner) return c.json({ error: "Your are not authorized to delete this team" }, 401)
|
||||
return c.json({ data: "ok" }, 200);
|
||||
},
|
||||
)
|
||||
|
||||
@@ -5,6 +5,7 @@ import { describeRoute } from "hono-openapi";
|
||||
import { Examples } from "@nestri/core/examples";
|
||||
import { Profiles } from "@nestri/core/profile/index";
|
||||
import { validator, resolver } from "hono-openapi/zod";
|
||||
import { Sessions } from "@nestri/core/session/index";
|
||||
|
||||
export module UserApi {
|
||||
export const route = new Hono()
|
||||
@@ -12,7 +13,7 @@ export module UserApi {
|
||||
"/@me",
|
||||
describeRoute({
|
||||
tags: ["User"],
|
||||
summary: "Retrieve current user profile",
|
||||
summary: "Retrieve current user's profile",
|
||||
description: "Returns the current authenticate user's profile",
|
||||
responses: {
|
||||
200: {
|
||||
@@ -43,4 +44,134 @@ export module UserApi {
|
||||
return c.json({ data: profile }, 200);
|
||||
},
|
||||
)
|
||||
.get(
|
||||
"/",
|
||||
describeRoute({
|
||||
tags: ["User"],
|
||||
summary: "List all user profiles",
|
||||
description: "Returns all user profiles",
|
||||
responses: {
|
||||
200: {
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: Result(
|
||||
Profiles.Info.openapi({
|
||||
description: "The profiles of all users",
|
||||
examples: [Examples.Profile],
|
||||
}),
|
||||
),
|
||||
},
|
||||
},
|
||||
description: "Successfully retrieved all user profiles",
|
||||
},
|
||||
404: {
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: resolver(z.object({ error: z.string() })),
|
||||
},
|
||||
},
|
||||
description: "No user profiles were found",
|
||||
},
|
||||
},
|
||||
}), async (c) => {
|
||||
const profiles = await Profiles.list();
|
||||
if (!profiles) return c.json({ error: "No user profiles were found" }, 404);
|
||||
return c.json({ data: profiles }, 200);
|
||||
},
|
||||
)
|
||||
.get(
|
||||
"/:id",
|
||||
describeRoute({
|
||||
tags: ["User"],
|
||||
summary: "Retrieve a user's profile",
|
||||
description: "Gets a user's profile by their id",
|
||||
responses: {
|
||||
200: {
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: Result(
|
||||
Profiles.Info.openapi({
|
||||
description: "The profile of the users",
|
||||
example: Examples.Profile,
|
||||
}),
|
||||
),
|
||||
},
|
||||
},
|
||||
description: "Successfully retrieved the user profile",
|
||||
},
|
||||
404: {
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: resolver(z.object({ error: z.string() })),
|
||||
},
|
||||
},
|
||||
description: "No user profile was found",
|
||||
},
|
||||
},
|
||||
}),
|
||||
validator(
|
||||
"param",
|
||||
z.object({
|
||||
id: Profiles.Info.shape.id.openapi({
|
||||
description: "ID of the user profile to get",
|
||||
example: Examples.Profile.id,
|
||||
}),
|
||||
}),
|
||||
),
|
||||
async (c) => {
|
||||
const param = c.req.valid("param");
|
||||
console.log("id", param.id)
|
||||
const profiles = await Profiles.fromID(param.id);
|
||||
if (!profiles) return c.json({ error: "No user profile was found" }, 404);
|
||||
return c.json({ data: profiles }, 200);
|
||||
},
|
||||
)
|
||||
.get(
|
||||
"/:id/session",
|
||||
describeRoute({
|
||||
tags: ["User"],
|
||||
summary: "Retrieve a user's active session",
|
||||
description: "Get a user's active gaming session details by their id",
|
||||
responses: {
|
||||
200: {
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: Result(
|
||||
Sessions.Info.openapi({
|
||||
description: "The active session of this user",
|
||||
example: Examples.Session,
|
||||
}),
|
||||
),
|
||||
},
|
||||
},
|
||||
description: "Successfully retrieved the active user gaming session",
|
||||
},
|
||||
404: {
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: resolver(z.object({ error: z.string() })),
|
||||
},
|
||||
},
|
||||
description: "No active gaming session for this user",
|
||||
},
|
||||
},
|
||||
}),
|
||||
validator(
|
||||
"param",
|
||||
z.object({
|
||||
id: Sessions.Info.shape.id.openapi({
|
||||
description: "ID of the user's gaming session to get",
|
||||
example: Examples.Session.id,
|
||||
}),
|
||||
}),
|
||||
),
|
||||
async (c) => {
|
||||
const param = c.req.valid("param");
|
||||
const ownerID = await Profiles.fromIDToOwner(param.id);
|
||||
if (!ownerID) return c.json({ error: "We could not get the owner of this profile" }, 404);
|
||||
const session = await Sessions.fromOwnerID(ownerID)
|
||||
if(!session) return c.json({ error: "This user profile does not have active sessions" }, 404);
|
||||
return c.json({ data: session }, 200);
|
||||
},
|
||||
)
|
||||
}
|
||||
@@ -8,16 +8,19 @@ import { subjects } from "./subjects"
|
||||
import { PasswordUI } from "./ui/password"
|
||||
import { Email } from "@nestri/core/email/index"
|
||||
import { Users } from "@nestri/core/user/index"
|
||||
import { Teams } from "@nestri/core/team/index"
|
||||
import { authorizer } from "@openauthjs/openauth"
|
||||
import { Profiles } from "@nestri/core/profile/index"
|
||||
import { handleDiscord, handleGithub } from "./utils";
|
||||
import { type CFRequest } from "@nestri/core/types"
|
||||
import { GithubAdapter } from "./ui/adapters/github";
|
||||
import { DiscordAdapter } from "./ui/adapters/discord";
|
||||
import { Machines } from "@nestri/core/machine/index"
|
||||
import { Instances } from "@nestri/core/instance/index"
|
||||
import { PasswordAdapter } from "./ui/adapters/password"
|
||||
import { type Adapter } from "@openauthjs/openauth/adapter/adapter"
|
||||
import { CloudflareStorage } from "@openauthjs/openauth/storage/cloudflare"
|
||||
import { Subscriptions } from "@nestri/core/subscription/index";
|
||||
import type { Subscription } from "./type";
|
||||
interface Env {
|
||||
CloudflareAuthKV: KVNamespace
|
||||
}
|
||||
@@ -57,8 +60,8 @@ export default {
|
||||
title: "Nestri | Auth",
|
||||
primary: "#FF4F01",
|
||||
//TODO: Change this in prod
|
||||
logo: "https://nestri.pages.dev/logo.webp",
|
||||
favicon: "https://nestri.pages.dev/seo/favicon.ico",
|
||||
logo: "https://nestri.io/logo.webp",
|
||||
favicon: "https://nestri.io/seo/favicon.ico",
|
||||
background: {
|
||||
light: "#f5f5f5 ",
|
||||
dark: "#171717"
|
||||
@@ -100,23 +103,23 @@ export default {
|
||||
if (input.clientSecret !== Resource.AuthFingerprintKey.value) {
|
||||
throw new Error("Invalid authorization token");
|
||||
}
|
||||
|
||||
const fingerprint = input.params.fingerprint;
|
||||
if (!fingerprint) {
|
||||
throw new Error("Fingerprint is required");
|
||||
const teamSlug = input.params.team;
|
||||
if (!teamSlug) {
|
||||
throw new Error("Team slug is required");
|
||||
}
|
||||
|
||||
const hostname = input.params.hostname;
|
||||
if (!hostname) {
|
||||
throw new Error("Hostname is required");
|
||||
}
|
||||
|
||||
return {
|
||||
fingerprint,
|
||||
hostname
|
||||
hostname,
|
||||
teamSlug
|
||||
};
|
||||
},
|
||||
init() { }
|
||||
} as Adapter<{ fingerprint: string; hostname: string }>,
|
||||
} as Adapter<{ teamSlug: string; hostname: string; }>,
|
||||
},
|
||||
allow: async (input) => {
|
||||
const url = new URL(input.redirectURI);
|
||||
@@ -127,24 +130,17 @@ export default {
|
||||
},
|
||||
success: async (ctx, value) => {
|
||||
if (value.provider === "device") {
|
||||
let exists = await Machines.fromFingerprint(value.fingerprint);
|
||||
if (!exists) {
|
||||
const machineID = await Machines.create({
|
||||
fingerprint: value.fingerprint,
|
||||
hostname: value.hostname,
|
||||
});
|
||||
const team = await Teams.fromSlug(value.teamSlug)
|
||||
console.log("team", team)
|
||||
console.log("teamSlug", value.teamSlug)
|
||||
if (team) {
|
||||
await Instances.create({ hostname: value.hostname, teamID: team.id })
|
||||
|
||||
return await ctx.subject("device", {
|
||||
id: machineID,
|
||||
fingerprint: value.fingerprint
|
||||
teamSlug: value.teamSlug,
|
||||
hostname: value.hostname,
|
||||
})
|
||||
}
|
||||
|
||||
return await ctx.subject("device", {
|
||||
id: exists.id,
|
||||
fingerprint: value.fingerprint
|
||||
})
|
||||
|
||||
}
|
||||
|
||||
if (value.provider === "password") {
|
||||
@@ -152,14 +148,14 @@ export default {
|
||||
const username = value.username
|
||||
const token = await Users.create(email)
|
||||
const usr = await Users.fromEmail(email);
|
||||
const exists = await Profiles.getProfile(usr.id)
|
||||
if(username && !exists){
|
||||
const exists = await Profiles.fromOwnerID(usr.id)
|
||||
if (username && !exists) {
|
||||
await Profiles.create({ owner: usr.id, username })
|
||||
}
|
||||
|
||||
return await ctx.subject("user", {
|
||||
accessToken: token,
|
||||
userID: usr.id
|
||||
userID: usr.id,
|
||||
});
|
||||
|
||||
}
|
||||
@@ -180,15 +176,15 @@ export default {
|
||||
try {
|
||||
const token = await Users.create(user.primary.email)
|
||||
const usr = await Users.fromEmail(user.primary.email);
|
||||
const exists = await Profiles.getProfile(usr.id)
|
||||
console.log("exists",exists)
|
||||
const exists = await Profiles.fromOwnerID(usr.id)
|
||||
console.log("exists", exists)
|
||||
if (!exists) {
|
||||
await Profiles.create({ owner: usr.id, avatarUrl: user.avatar, username: user.username })
|
||||
}
|
||||
|
||||
return await ctx.subject("user", {
|
||||
accessToken: token,
|
||||
userID: usr.id
|
||||
userID: usr.id,
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
|
||||
38
packages/functions/src/party/authorizer.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import { Resource } from "sst";
|
||||
import { subjects } from "../subjects";
|
||||
import { realtime } from "sst/aws/realtime";
|
||||
import { createClient } from "@openauthjs/openauth/client";
|
||||
|
||||
export const handler = realtime.authorizer(async (token) => {
|
||||
//TODO: Use the following criteria for a topic - team-slug/container-id (container ids are not unique globally)
|
||||
//TODO: Allow the authorizer to subscriber/publisher to listen on - team-slug topics only (as the container will listen on the team-slug/container-id topic to be specific)
|
||||
// Return the topics to subscribe and publish
|
||||
|
||||
const client = createClient({
|
||||
clientID: "api",
|
||||
issuer: Resource.Urls.auth
|
||||
});
|
||||
|
||||
const result = await client.verify(subjects, token);
|
||||
|
||||
if (result.err) {
|
||||
console.log("error", result.err)
|
||||
return {
|
||||
subscribe: [],
|
||||
publish: [],
|
||||
};
|
||||
}
|
||||
|
||||
if (result.subject.type != "device") {
|
||||
return {
|
||||
subscribe: [],
|
||||
publish: [],
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
//It can publish and listen to other instances under this team
|
||||
subscribe: [`${Resource.App.name}/${Resource.App.stage}/${result.subject.properties.teamSlug}/*`],
|
||||
publish: [`${Resource.App.name}/${Resource.App.stage}/${result.subject.properties.teamSlug}/*`],
|
||||
};
|
||||
});
|
||||
64
packages/functions/src/party/create.ts
Normal file
@@ -0,0 +1,64 @@
|
||||
import { ECSClient, RunTaskCommand } from "@aws-sdk/client-ecs";
|
||||
const client = new ECSClient()
|
||||
|
||||
export const handler = async (event: any) => {
|
||||
console.log("event", event)
|
||||
const clusterArn = process.env.ECS_CLUSTER
|
||||
const taskDefinitionArn = process.env.TASK_DEFINITION
|
||||
const authFingerprintKey = process.env.AUTH_FINGERPRINT
|
||||
|
||||
try {
|
||||
|
||||
const runResponse = await client.send(new RunTaskCommand({
|
||||
taskDefinition: taskDefinitionArn,
|
||||
cluster: clusterArn,
|
||||
count: 1,
|
||||
launchType: "EC2",
|
||||
overrides: {
|
||||
containerOverrides: [
|
||||
{
|
||||
name: "nestri",
|
||||
environment: [
|
||||
{
|
||||
name: "AUTH_FINGERPRINT_KEY",
|
||||
value: authFingerprintKey
|
||||
},
|
||||
{
|
||||
name: "NESTRI_ROOM",
|
||||
value: "testing-right-now"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}))
|
||||
|
||||
// Check if tasks were started
|
||||
if (!runResponse.tasks || runResponse.tasks.length === 0) {
|
||||
throw new Error("No tasks were started");
|
||||
}
|
||||
|
||||
// Extract task details
|
||||
const task = runResponse.tasks[0];
|
||||
const taskArn = task.taskArn!;
|
||||
const taskId = taskArn.split('/').pop()!; // Extract task ID from ARN
|
||||
const taskStatus = task.lastStatus!;
|
||||
|
||||
return {
|
||||
statusCode: 200,
|
||||
body: JSON.stringify({
|
||||
status: "sent",
|
||||
taskId: taskId,
|
||||
taskStatus: taskStatus,
|
||||
taskArn: taskArn
|
||||
}, null, 2),
|
||||
};
|
||||
} catch (err) {
|
||||
console.error("Error starting task:", err);
|
||||
return {
|
||||
statusCode: 500,
|
||||
body: JSON.stringify({ error: "Failed to start task" }, null, 2),
|
||||
};
|
||||
|
||||
}
|
||||
};
|
||||
@@ -1,65 +0,0 @@
|
||||
import "zod-openapi/extend";
|
||||
import { Hono } from "hono";
|
||||
import { logger } from "hono/logger";
|
||||
import type { HonoBindings } from "./types";
|
||||
import { ApiSession } from "./session";
|
||||
import { openAPISpecs } from "hono-openapi";
|
||||
|
||||
const app = new Hono<{ Bindings: HonoBindings }>().basePath('/parties/main/:room');
|
||||
|
||||
app
|
||||
.use(logger(), async (c, next) => {
|
||||
c.header("Cache-Control", "no-store");
|
||||
try {
|
||||
await next();
|
||||
} catch (e: any) {
|
||||
return c.json(
|
||||
{
|
||||
error: {
|
||||
message: e.message || "Internal Server Error",
|
||||
status: e.status || 500,
|
||||
},
|
||||
},
|
||||
e.status || 500
|
||||
);
|
||||
}
|
||||
})
|
||||
|
||||
const routes = app
|
||||
.get("/health", (c) => {
|
||||
return c.json({
|
||||
status: "healthy",
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
})
|
||||
.route("/session", ApiSession.route)
|
||||
|
||||
app.get(
|
||||
"/doc",
|
||||
openAPISpecs(routes, {
|
||||
documentation: {
|
||||
info: {
|
||||
title: "Nestri Realtime API",
|
||||
description:
|
||||
"The Nestri realtime API gives you the power to connect to your remote machine and relays from a single station",
|
||||
version: "0.3.0",
|
||||
},
|
||||
components: {
|
||||
securitySchemes: {
|
||||
Bearer: {
|
||||
type: "http",
|
||||
scheme: "bearer",
|
||||
bearerFormat: "JWT",
|
||||
},
|
||||
},
|
||||
},
|
||||
security: [{ Bearer: [] }],
|
||||
servers: [
|
||||
{ description: "Production", url: "https://api.nestri.io" },
|
||||
],
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
export type Routes = typeof routes;
|
||||
export default app
|
||||
@@ -1,63 +0,0 @@
|
||||
import app from "./hono"
|
||||
import type * as Party from "partykit/server";
|
||||
import { tryAuthentication } from "./utils";
|
||||
|
||||
export default class Server implements Party.Server {
|
||||
constructor(readonly room: Party.Room) { }
|
||||
|
||||
static async onBeforeRequest(req: Party.Request, lobby: Party.Lobby) {
|
||||
const docs = new URL(req.url).toString().endsWith("/doc")
|
||||
if (docs) {
|
||||
return req
|
||||
}
|
||||
|
||||
try {
|
||||
return await tryAuthentication(req, lobby)
|
||||
} catch (e: any) {
|
||||
// authentication failed!
|
||||
return new Response(e, { status: 401 });
|
||||
}
|
||||
}
|
||||
|
||||
static async onBeforeConnect(request: Party.Request, lobby: Party.Lobby) {
|
||||
try {
|
||||
return await tryAuthentication(request, lobby)
|
||||
} catch (e: any) {
|
||||
// authentication failed!
|
||||
return new Response(e, { status: 401 });
|
||||
}
|
||||
}
|
||||
|
||||
onRequest(req: Party.Request): Response | Promise<Response> {
|
||||
|
||||
return app.fetch(req as any, { room: this.room })
|
||||
}
|
||||
|
||||
getConnectionTags(conn: Party.Connection, ctx: Party.ConnectionContext) {
|
||||
|
||||
return [conn.id, ctx.request.cf?.country as any]
|
||||
}
|
||||
|
||||
onConnect(conn: Party.Connection, ctx: Party.ConnectionContext): void | Promise<void> {
|
||||
console.log(`Connected:, id:${conn.id}, room: ${this.room.id}, url: ${new URL(ctx.request.url).pathname}`);
|
||||
|
||||
this.getConnectionTags(conn, ctx)
|
||||
}
|
||||
|
||||
onMessage(message: string, sender: Party.Connection) {
|
||||
// let's log the message
|
||||
console.log(`connection ${sender.id} sent message: ${message}`);
|
||||
// console.log("tags", this.room.getConnections())
|
||||
// for (const british of this.room.getConnections(sender.id)) {
|
||||
// british.send(`Pip-pip!`);
|
||||
// }
|
||||
// // as well as broadcast it to all the other connections in the room...
|
||||
// this.room.broadcast(
|
||||
// `${sender.id}: ${message}`,
|
||||
// // ...except for the connection it came from
|
||||
// [sender.id]
|
||||
// );
|
||||
}
|
||||
}
|
||||
|
||||
Server satisfies Party.Worker;
|
||||
@@ -1,217 +0,0 @@
|
||||
import { z } from "zod";
|
||||
import { Hono } from "hono";
|
||||
import { Result } from "../common"
|
||||
import { describeRoute } from "hono-openapi";
|
||||
import type { HonoBindings, WSMessage } from "./types";
|
||||
import { validator, resolver } from "hono-openapi/zod";
|
||||
|
||||
export module ApiSession {
|
||||
export const route = new Hono<{ Bindings: HonoBindings }>()
|
||||
.post("/:sessionID/start",
|
||||
describeRoute({
|
||||
tags: ["Session"],
|
||||
summary: "Start a session",
|
||||
description: "Start a session on this machine",
|
||||
responses: {
|
||||
200: {
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: Result(z.object({
|
||||
success: z.boolean(),
|
||||
message: z.string(),
|
||||
sessionID: z.string()
|
||||
}))
|
||||
},
|
||||
},
|
||||
description: "Session started successfully",
|
||||
},
|
||||
500: {
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: resolver(z.object({ error: z.string(), details: z.string() })),
|
||||
},
|
||||
},
|
||||
description: "There was a problem trying to start your session",
|
||||
},
|
||||
},
|
||||
}),
|
||||
validator(
|
||||
"param",
|
||||
z.object({
|
||||
sessionID: z.string().openapi({
|
||||
description: "The session ID to start",
|
||||
example: "18d8b4b5-29ba-4a62-8cf9-7059449907a7",
|
||||
}),
|
||||
}),
|
||||
),
|
||||
async (c) => {
|
||||
const param = c.req.valid("param");
|
||||
const room = c.env.room
|
||||
|
||||
const message: WSMessage = {
|
||||
type: "START_GAME",
|
||||
sessionID: param.sessionID,
|
||||
};
|
||||
|
||||
try {
|
||||
|
||||
room.broadcast(JSON.stringify(message));
|
||||
|
||||
return c.json({
|
||||
success: true,
|
||||
message: "Game start signal sent",
|
||||
"sessionID": param.sessionID,
|
||||
});
|
||||
|
||||
} catch (error: any) {
|
||||
return c.json(
|
||||
{
|
||||
error: {
|
||||
message: "Failed to start game session",
|
||||
details: error.message,
|
||||
},
|
||||
},
|
||||
500
|
||||
);
|
||||
}
|
||||
}
|
||||
)
|
||||
.post("/:sessionID/end",
|
||||
describeRoute({
|
||||
tags: ["Session"],
|
||||
summary: "End a session",
|
||||
description: "End a session on this machine",
|
||||
responses: {
|
||||
200: {
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: Result(z.object({
|
||||
success: z.boolean(),
|
||||
message: z.string(),
|
||||
sessionID: z.string()
|
||||
}))
|
||||
},
|
||||
},
|
||||
description: "Session successfully ended",
|
||||
},
|
||||
500: {
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: resolver(z.object({ error: z.string(), details: z.string() })),
|
||||
},
|
||||
},
|
||||
description: "There was a problem trying to end your session",
|
||||
},
|
||||
},
|
||||
}),
|
||||
validator(
|
||||
"param",
|
||||
z.object({
|
||||
sessionID: z.string().openapi({
|
||||
description: "The session ID to end",
|
||||
example: "18d8b4b5-29ba-4a62-8cf9-7059449907a7",
|
||||
}),
|
||||
}),
|
||||
),
|
||||
async (c) => {
|
||||
const param = c.req.valid("param");
|
||||
const room = c.env.room
|
||||
|
||||
const message: WSMessage = {
|
||||
type: "END_GAME",
|
||||
sessionID: param.sessionID,
|
||||
};
|
||||
|
||||
try {
|
||||
|
||||
room.broadcast(JSON.stringify(message));
|
||||
|
||||
return c.json({
|
||||
success: true,
|
||||
message: "Game end signal sent",
|
||||
"sessionID": param.sessionID,
|
||||
});
|
||||
|
||||
} catch (error: any) {
|
||||
return c.json(
|
||||
{
|
||||
error: {
|
||||
message: "Failed to end game session",
|
||||
details: error.message,
|
||||
},
|
||||
},
|
||||
500
|
||||
);
|
||||
}
|
||||
}
|
||||
)
|
||||
.post("/:sessionID/status",
|
||||
describeRoute({
|
||||
tags: ["Session"],
|
||||
summary: "Get the status of a session",
|
||||
description: "Get the status of a session on this machine",
|
||||
responses: {
|
||||
200: {
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: Result(z.object({
|
||||
success: z.boolean(),
|
||||
message: z.string(),
|
||||
sessionID: z.string()
|
||||
}))
|
||||
},
|
||||
},
|
||||
description: "Session status query was successful"
|
||||
},
|
||||
500: {
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: resolver(z.object({ error: z.string(), details: z.string() })),
|
||||
},
|
||||
},
|
||||
description: "There was a problem trying to querying the status of your session",
|
||||
},
|
||||
},
|
||||
}),
|
||||
validator(
|
||||
"param",
|
||||
z.object({
|
||||
sessionID: z.string().openapi({
|
||||
description: "The session ID to query",
|
||||
example: "18d8b4b5-29ba-4a62-8cf9-7059449907a7",
|
||||
}),
|
||||
}),
|
||||
),
|
||||
async (c) => {
|
||||
const param = c.req.valid("param");
|
||||
const room = c.env.room
|
||||
|
||||
const message: WSMessage = {
|
||||
type: "END_GAME",
|
||||
sessionID: param.sessionID,
|
||||
};
|
||||
|
||||
try {
|
||||
|
||||
room.broadcast(JSON.stringify(message));
|
||||
|
||||
return c.json({
|
||||
success: true,
|
||||
message: "Game end signal sent",
|
||||
"sessionID": param.sessionID,
|
||||
});
|
||||
|
||||
} catch (error: any) {
|
||||
return c.json(
|
||||
{
|
||||
error: {
|
||||
message: "Failed to end game session",
|
||||
details: error.message,
|
||||
},
|
||||
},
|
||||
500
|
||||
);
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
4
packages/functions/src/party/subscriber.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export const handler = async (event: any) => {
|
||||
console.log(event);
|
||||
return "ok";
|
||||
};
|
||||
@@ -1,11 +0,0 @@
|
||||
import type * as Party from "partykit/server";
|
||||
|
||||
export interface HonoBindings {
|
||||
room: Party.Room;
|
||||
}
|
||||
|
||||
export type WSMessage = {
|
||||
type: "START_GAME" | "END_GAME" | "GAME_STATUS";
|
||||
sessionID: string;
|
||||
payload?: any;
|
||||
};
|
||||
@@ -1,21 +0,0 @@
|
||||
import type * as Party from "partykit/server";
|
||||
|
||||
export async function tryAuthentication(req: Party.Request, lobby: Party.Lobby) {
|
||||
const authHeader = req.headers.get("authorization") ?? new URL(req.url).searchParams.get("authorization")
|
||||
if (authHeader) {
|
||||
const match = authHeader.match(/^Bearer (.+)$/);
|
||||
|
||||
if (!match || !match[1]) {
|
||||
throw new Error("Bearer token not found or improperly formatted");
|
||||
}
|
||||
|
||||
const bearerToken = match[1];
|
||||
|
||||
if (bearerToken !== lobby.env.AUTH_FINGERPRINT) {
|
||||
throw new Error("Invalid authorization token");
|
||||
}
|
||||
|
||||
return req// app.fetch(req as any, { room: this.room })
|
||||
}
|
||||
throw new Error("You are not authorized to be here")
|
||||
}
|
||||