⭐ feat(www): Add logic to the homepage and Steam integration (#258)
## Description <!-- Briefly describe the purpose and scope of your changes --> <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit - **New Features** - Upgraded API and authentication services with dynamic scaling, enhanced load balancing, and real-time interaction endpoints. - Introduced new commands to streamline local development and container builds. - Added new endpoints for retrieving Steam account information and managing connections. - Implemented a QR code authentication interface for Steam, enhancing user login experiences. - **Database Updates** - Rolled out comprehensive schema migrations that improve data integrity and indexing. - Introduced new tables for managing Steam user credentials and machine information. - **UI Enhancements** - Added refreshed animated assets and an improved QR code login flow for a more engaging experience. - Introduced new styled components for displaying friends and games. - **Maintenance** - Completed extensive refactoring and configuration updates to optimize performance and development workflows. - Updated logging configurations and improved error handling mechanisms. - Streamlined resource definitions in the configuration files. <!-- end of auto-generated comment: release notes by coderabbit.ai --> --------- Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
79
infra/api.ts
@@ -1,53 +1,50 @@
|
||||
import { vpc } from "./vpc";
|
||||
import { bus } from "./bus";
|
||||
import { auth } from "./auth";
|
||||
import { domain } from "./dns";
|
||||
import { secret } from "./secret";
|
||||
import { cluster } from "./cluster";
|
||||
import { postgres } from "./postgres";
|
||||
|
||||
sst.Linkable.wrap(random.RandomString, (resource) => ({
|
||||
properties: {
|
||||
value: resource.result,
|
||||
},
|
||||
}));
|
||||
|
||||
export const urls = new sst.Linkable("Urls", {
|
||||
properties: {
|
||||
api: "https://api." + domain,
|
||||
auth: "https://auth." + domain,
|
||||
site: $dev ? "http://localhost:3000" : "https://" + domain,
|
||||
},
|
||||
});
|
||||
|
||||
export const apiFunction = new sst.aws.Function("ApiFn", {
|
||||
vpc,
|
||||
handler: "packages/functions/src/api/index.handler",
|
||||
permissions: [
|
||||
{
|
||||
actions: ["iot:*"],
|
||||
resources: ["*"],
|
||||
},
|
||||
],
|
||||
export const api = new sst.aws.Service("Api", {
|
||||
cpu: $app.stage === "production" ? "2 vCPU" : undefined,
|
||||
memory: $app.stage === "production" ? "4 GB" : undefined,
|
||||
cluster,
|
||||
command: ["bun", "run", "./src/api/index.ts"],
|
||||
link: [
|
||||
bus,
|
||||
urls,
|
||||
auth,
|
||||
postgres,
|
||||
secret.PolarSecret,
|
||||
],
|
||||
timeout: "3 minutes",
|
||||
streaming: !$dev,
|
||||
url: true
|
||||
})
|
||||
|
||||
export const api = new sst.aws.Router("Api", {
|
||||
routes: {
|
||||
"/*": apiFunction.url
|
||||
image: {
|
||||
dockerfile: "packages/functions/Containerfile",
|
||||
},
|
||||
domain: {
|
||||
name: "api." + domain,
|
||||
dns: sst.cloudflare.dns(),
|
||||
environment: {
|
||||
NO_COLOR: "1",
|
||||
},
|
||||
})
|
||||
|
||||
export const outputs = {
|
||||
api: api.url,
|
||||
};
|
||||
loadBalancer: {
|
||||
domain: "api." + domain,
|
||||
rules: [
|
||||
{
|
||||
listen: "80/http",
|
||||
forward: "3001/http",
|
||||
},
|
||||
{
|
||||
listen: "443/https",
|
||||
forward: "3001/http",
|
||||
},
|
||||
],
|
||||
},
|
||||
dev: {
|
||||
command: "bun dev:api",
|
||||
directory: "packages/functions",
|
||||
url: "http://localhost:3001",
|
||||
},
|
||||
scaling:
|
||||
$app.stage === "production"
|
||||
? {
|
||||
min: 2,
|
||||
max: 10,
|
||||
}
|
||||
: undefined,
|
||||
});
|
||||
@@ -1,46 +1,75 @@
|
||||
import { vpc } from "./vpc";
|
||||
import { bus } from "./bus";
|
||||
import { domain } from "./dns";
|
||||
// import { email } from "./email";
|
||||
import { secret } from "./secret";
|
||||
import { postgres } from "./postgres";
|
||||
import { cluster } from "./cluster";
|
||||
import { vpc } from "./vpc";
|
||||
|
||||
export const authFingerprintKey = new random.RandomString(
|
||||
"AuthFingerprintKey",
|
||||
{
|
||||
length: 32,
|
||||
// sst.Linkable.wrap(random.RandomString, (resource) => ({
|
||||
// properties: {
|
||||
// value: resource.result,
|
||||
// },
|
||||
// }));
|
||||
|
||||
// export const authFingerprintKey = new random.RandomString(
|
||||
// "AuthFingerprintKey",
|
||||
// {
|
||||
// length: 32,
|
||||
// },
|
||||
// );
|
||||
|
||||
export const auth = new sst.aws.Service("Auth", {
|
||||
cpu: $app.stage === "production" ? "1 vCPU" : undefined,
|
||||
memory: $app.stage === "production" ? "2 GB" : undefined,
|
||||
cluster,
|
||||
command: ["bun", "run", "./src/auth.ts"],
|
||||
link: [
|
||||
bus,
|
||||
postgres,
|
||||
secret.PolarSecret,
|
||||
secret.GithubClientID,
|
||||
secret.DiscordClientID,
|
||||
secret.GithubClientSecret,
|
||||
secret.DiscordClientSecret,
|
||||
],
|
||||
image: {
|
||||
dockerfile: "packages/functions/Containerfile",
|
||||
},
|
||||
);
|
||||
|
||||
export const auth = new sst.aws.Auth("Auth", {
|
||||
issuer: {
|
||||
vpc,
|
||||
timeout: "3 minutes",
|
||||
handler: "packages/functions/src/auth.handler",
|
||||
link: [
|
||||
bus,
|
||||
// email,
|
||||
postgres,
|
||||
authFingerprintKey,
|
||||
secret.PolarSecret,
|
||||
secret.GithubClientID,
|
||||
secret.DiscordClientID,
|
||||
secret.GithubClientSecret,
|
||||
secret.DiscordClientSecret,
|
||||
],
|
||||
permissions: [
|
||||
environment: {
|
||||
NO_COLOR: "1",
|
||||
STORAGE: $dev ? "/tmp/persist.json" : "/mnt/efs/persist.json"
|
||||
},
|
||||
//TODO: Use API gateway instead, because of the API headers
|
||||
loadBalancer: {
|
||||
domain: "auth." + domain,
|
||||
rules: [
|
||||
{
|
||||
actions: ["ses:SendEmail"],
|
||||
resources: ["*"],
|
||||
listen: "80/http",
|
||||
forward: "3002/http",
|
||||
},
|
||||
{
|
||||
listen: "443/https",
|
||||
forward: "3002/http",
|
||||
},
|
||||
],
|
||||
},
|
||||
domain: {
|
||||
name: "auth." + domain,
|
||||
dns: sst.cloudflare.dns(),
|
||||
permissions: [
|
||||
{
|
||||
actions: ["ses:SendEmail"],
|
||||
resources: ["*"],
|
||||
},
|
||||
],
|
||||
dev: {
|
||||
command: "bun dev:auth",
|
||||
directory: "packages/functions",
|
||||
url: "http://localhost:3002",
|
||||
},
|
||||
})
|
||||
|
||||
export const outputs = {
|
||||
auth: auth.url,
|
||||
};
|
||||
scaling:
|
||||
$app.stage === "production"
|
||||
? {
|
||||
min: 2,
|
||||
max: 10,
|
||||
}
|
||||
: undefined,
|
||||
});
|
||||
@@ -43,7 +43,7 @@ export const postgres = new sst.aws.Aurora("Database", {
|
||||
new sst.x.DevCommand("Studio", {
|
||||
link: [postgres],
|
||||
dev: {
|
||||
command: "bun db studio",
|
||||
command: "bun db:dev studio",
|
||||
directory: "packages/core",
|
||||
autostart: true,
|
||||
},
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { urls } from "./api";
|
||||
import { auth } from "./auth";
|
||||
import { postgres } from "./postgres";
|
||||
|
||||
export const device = new sst.aws.Realtime("Realtime", {
|
||||
authorizer: {
|
||||
link: [urls, postgres],
|
||||
handler: "./packages/functions/src/realtime/authorizer.handler"
|
||||
link: [auth, postgres],
|
||||
handler: "packages/functions/src/realtime/authorizer.handler"
|
||||
}
|
||||
})
|
||||
@@ -1,43 +1,7 @@
|
||||
import { domain } from "./dns";
|
||||
import { cluster } from "./cluster";
|
||||
import { auth } from "./auth";
|
||||
|
||||
export const steam = new sst.aws.Service("Steam", {
|
||||
cluster,
|
||||
wait: true,
|
||||
image: {
|
||||
context: "packages/steam",
|
||||
},
|
||||
loadBalancer: {
|
||||
domain:
|
||||
$app.stage === "production"
|
||||
? undefined
|
||||
: {
|
||||
name: "steam." + domain,
|
||||
dns: sst.cloudflare.dns(),
|
||||
},
|
||||
rules: [
|
||||
{ listen: "443/https", forward: "5289/http" },
|
||||
{ listen: "80/http", forward: "5289/http" },
|
||||
],
|
||||
},
|
||||
environment: {
|
||||
NESTRI_AUTH_JWKS_URL: $interpolate`${auth.url}`
|
||||
},
|
||||
scaling:
|
||||
$app.stage === "production"
|
||||
? {
|
||||
min: 2,
|
||||
max: 4,
|
||||
}
|
||||
: undefined,
|
||||
logging: {
|
||||
retention: "1 month",
|
||||
},
|
||||
architecture: "arm64",
|
||||
new sst.x.DevCommand("Steam", {
|
||||
dev: {
|
||||
command: "bun dev",
|
||||
directory: "packages/steam",
|
||||
command: "dotnet run",
|
||||
url: "http://localhost:5289",
|
||||
autostart: true,
|
||||
},
|
||||
})
|
||||
});
|
||||
@@ -3,7 +3,6 @@ import { api } from "./api";
|
||||
import { auth } from "./auth";
|
||||
import { zero } from "./zero";
|
||||
import { domain } from "./dns";
|
||||
import { steam } from "./steam";
|
||||
|
||||
new sst.aws.StaticSite("Web", {
|
||||
path: "packages/www",
|
||||
@@ -20,6 +19,5 @@ new sst.aws.StaticSite("Web", {
|
||||
VITE_STAGE: $app.stage,
|
||||
VITE_AUTH_URL: auth.url,
|
||||
VITE_ZERO_URL: zero.url,
|
||||
VITE_STEAM_URL: steam.url,
|
||||
},
|
||||
})
|
||||
@@ -26,4 +26,4 @@ Global
|
||||
GlobalSection(ExtensibilityGlobals) = postSolution
|
||||
SolutionGuid = {526AD703-4D15-43CF-B7C0-83F10D3158DB}
|
||||
EndGlobalSection
|
||||
EndGlobal
|
||||
EndGlobal
|
||||
@@ -36,6 +36,6 @@
|
||||
"packages/*"
|
||||
],
|
||||
"dependencies": {
|
||||
"sst": "3.9.36"
|
||||
"sst": "^3.11.21"
|
||||
}
|
||||
}
|
||||
|
||||
17
packages/core/migrations/0002_simple_outlaw_kid.sql
Normal file
@@ -0,0 +1,17 @@
|
||||
CREATE TABLE "steam" (
|
||||
"id" char(30) NOT NULL,
|
||||
"user_id" char(30) NOT NULL,
|
||||
"time_created" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"time_updated" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"time_deleted" timestamp with time zone,
|
||||
"avatar_url" text NOT NULL,
|
||||
"access_token" text NOT NULL,
|
||||
"email" varchar(255) NOT NULL,
|
||||
"country" varchar(255) NOT NULL,
|
||||
"username" varchar(255) NOT NULL,
|
||||
"persona_name" varchar(255) NOT NULL,
|
||||
CONSTRAINT "steam_user_id_id_pk" PRIMARY KEY("user_id","id")
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE INDEX "global_steam_email" ON "steam" USING btree ("email");--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX "steam_email" ON "steam" USING btree ("user_id","email");
|
||||
22
packages/core/migrations/0003_first_big_bertha.sql
Normal file
@@ -0,0 +1,22 @@
|
||||
CREATE TABLE "machine" (
|
||||
"id" char(30) PRIMARY KEY NOT NULL,
|
||||
"time_created" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"time_updated" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"time_deleted" timestamp with time zone,
|
||||
"country" text NOT NULL,
|
||||
"timezone" text NOT NULL,
|
||||
"location" "point" NOT NULL,
|
||||
"fingerprint" varchar(32) NOT NULL,
|
||||
"country_code" varchar(2) NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "steam" RENAME COLUMN "country" TO "country_code";--> statement-breakpoint
|
||||
DROP INDEX "global_steam_email";--> statement-breakpoint
|
||||
ALTER TABLE "steam" ADD COLUMN "time_seen" timestamp with time zone;--> statement-breakpoint
|
||||
ALTER TABLE "steam" ADD COLUMN "steam_id" integer NOT NULL;--> statement-breakpoint
|
||||
ALTER TABLE "steam" ADD COLUMN "last_game" json NOT NULL;--> statement-breakpoint
|
||||
ALTER TABLE "steam" ADD COLUMN "steam_email" varchar(255) NOT NULL;--> statement-breakpoint
|
||||
ALTER TABLE "steam" ADD COLUMN "limitation" json NOT NULL;--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX "machine_fingerprint" ON "machine" USING btree ("fingerprint");--> statement-breakpoint
|
||||
ALTER TABLE "steam" DROP COLUMN "access_token";--> statement-breakpoint
|
||||
ALTER TABLE "user" DROP COLUMN "flags";
|
||||
@@ -1,94 +1,9 @@
|
||||
{
|
||||
"id": "aa60489b-b4e2-4a69-aee7-16e050d02ef9",
|
||||
"id": "227c54d2-b643-48d5-964b-af6fe004369a",
|
||||
"prevId": "6f428226-b5d8-4182-a676-d04f842f9ded",
|
||||
"version": "7",
|
||||
"dialect": "postgresql",
|
||||
"tables": {
|
||||
"public.machine": {
|
||||
"name": "machine",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "char(30)",
|
||||
"primaryKey": true,
|
||||
"notNull": true
|
||||
},
|
||||
"time_created": {
|
||||
"name": "time_created",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
},
|
||||
"time_updated": {
|
||||
"name": "time_updated",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
},
|
||||
"time_deleted": {
|
||||
"name": "time_deleted",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"country": {
|
||||
"name": "country",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"timezone": {
|
||||
"name": "timezone",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"location": {
|
||||
"name": "location",
|
||||
"type": "point",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"fingerprint": {
|
||||
"name": "fingerprint",
|
||||
"type": "varchar(32)",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"country_code": {
|
||||
"name": "country_code",
|
||||
"type": "varchar(2)",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
}
|
||||
},
|
||||
"indexes": {
|
||||
"machine_fingerprint": {
|
||||
"name": "machine_fingerprint",
|
||||
"columns": [
|
||||
{
|
||||
"expression": "fingerprint",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
}
|
||||
],
|
||||
"isUnique": true,
|
||||
"concurrently": false,
|
||||
"method": "btree",
|
||||
"with": {}
|
||||
}
|
||||
},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.member": {
|
||||
"name": "member",
|
||||
"schema": "",
|
||||
@@ -191,6 +106,132 @@
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.steam": {
|
||||
"name": "steam",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "char(30)",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"user_id": {
|
||||
"name": "user_id",
|
||||
"type": "char(30)",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"time_created": {
|
||||
"name": "time_created",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
},
|
||||
"time_updated": {
|
||||
"name": "time_updated",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
},
|
||||
"time_deleted": {
|
||||
"name": "time_deleted",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"avatar_url": {
|
||||
"name": "avatar_url",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"access_token": {
|
||||
"name": "access_token",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"email": {
|
||||
"name": "email",
|
||||
"type": "varchar(255)",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"country": {
|
||||
"name": "country",
|
||||
"type": "varchar(255)",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"username": {
|
||||
"name": "username",
|
||||
"type": "varchar(255)",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"persona_name": {
|
||||
"name": "persona_name",
|
||||
"type": "varchar(255)",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
}
|
||||
},
|
||||
"indexes": {
|
||||
"global_steam_email": {
|
||||
"name": "global_steam_email",
|
||||
"columns": [
|
||||
{
|
||||
"expression": "email",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
}
|
||||
],
|
||||
"isUnique": false,
|
||||
"concurrently": false,
|
||||
"method": "btree",
|
||||
"with": {}
|
||||
},
|
||||
"steam_email": {
|
||||
"name": "steam_email",
|
||||
"columns": [
|
||||
{
|
||||
"expression": "user_id",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
},
|
||||
{
|
||||
"expression": "email",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
}
|
||||
],
|
||||
"isUnique": true,
|
||||
"concurrently": false,
|
||||
"method": "btree",
|
||||
"with": {}
|
||||
}
|
||||
},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {
|
||||
"steam_user_id_id_pk": {
|
||||
"name": "steam_user_id_id_pk",
|
||||
"columns": [
|
||||
"user_id",
|
||||
"id"
|
||||
]
|
||||
}
|
||||
},
|
||||
"uniqueConstraints": {},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.team": {
|
||||
"name": "team",
|
||||
"schema": "",
|
||||
|
||||
507
packages/core/migrations/meta/0003_snapshot.json
Normal file
@@ -0,0 +1,507 @@
|
||||
{
|
||||
"id": "eb5d41aa-5f85-4b2d-8633-fc021b211241",
|
||||
"prevId": "227c54d2-b643-48d5-964b-af6fe004369a",
|
||||
"version": "7",
|
||||
"dialect": "postgresql",
|
||||
"tables": {
|
||||
"public.machine": {
|
||||
"name": "machine",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "char(30)",
|
||||
"primaryKey": true,
|
||||
"notNull": true
|
||||
},
|
||||
"time_created": {
|
||||
"name": "time_created",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
},
|
||||
"time_updated": {
|
||||
"name": "time_updated",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
},
|
||||
"time_deleted": {
|
||||
"name": "time_deleted",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"country": {
|
||||
"name": "country",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"timezone": {
|
||||
"name": "timezone",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"location": {
|
||||
"name": "location",
|
||||
"type": "point",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"fingerprint": {
|
||||
"name": "fingerprint",
|
||||
"type": "varchar(32)",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"country_code": {
|
||||
"name": "country_code",
|
||||
"type": "varchar(2)",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
}
|
||||
},
|
||||
"indexes": {
|
||||
"machine_fingerprint": {
|
||||
"name": "machine_fingerprint",
|
||||
"columns": [
|
||||
{
|
||||
"expression": "fingerprint",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
}
|
||||
],
|
||||
"isUnique": true,
|
||||
"concurrently": false,
|
||||
"method": "btree",
|
||||
"with": {}
|
||||
}
|
||||
},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.member": {
|
||||
"name": "member",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "char(30)",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"team_id": {
|
||||
"name": "team_id",
|
||||
"type": "char(30)",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"time_created": {
|
||||
"name": "time_created",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
},
|
||||
"time_updated": {
|
||||
"name": "time_updated",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
},
|
||||
"time_deleted": {
|
||||
"name": "time_deleted",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"time_seen": {
|
||||
"name": "time_seen",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"email": {
|
||||
"name": "email",
|
||||
"type": "varchar(255)",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
}
|
||||
},
|
||||
"indexes": {
|
||||
"email_global": {
|
||||
"name": "email_global",
|
||||
"columns": [
|
||||
{
|
||||
"expression": "email",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
}
|
||||
],
|
||||
"isUnique": false,
|
||||
"concurrently": false,
|
||||
"method": "btree",
|
||||
"with": {}
|
||||
},
|
||||
"member_email": {
|
||||
"name": "member_email",
|
||||
"columns": [
|
||||
{
|
||||
"expression": "team_id",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
},
|
||||
{
|
||||
"expression": "email",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
}
|
||||
],
|
||||
"isUnique": true,
|
||||
"concurrently": false,
|
||||
"method": "btree",
|
||||
"with": {}
|
||||
}
|
||||
},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {
|
||||
"member_team_id_id_pk": {
|
||||
"name": "member_team_id_id_pk",
|
||||
"columns": [
|
||||
"team_id",
|
||||
"id"
|
||||
]
|
||||
}
|
||||
},
|
||||
"uniqueConstraints": {},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.steam": {
|
||||
"name": "steam",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "char(30)",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"user_id": {
|
||||
"name": "user_id",
|
||||
"type": "char(30)",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"time_created": {
|
||||
"name": "time_created",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
},
|
||||
"time_updated": {
|
||||
"name": "time_updated",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
},
|
||||
"time_deleted": {
|
||||
"name": "time_deleted",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"time_seen": {
|
||||
"name": "time_seen",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"steam_id": {
|
||||
"name": "steam_id",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"avatar_url": {
|
||||
"name": "avatar_url",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"email": {
|
||||
"name": "email",
|
||||
"type": "varchar(255)",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"last_game": {
|
||||
"name": "last_game",
|
||||
"type": "json",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"username": {
|
||||
"name": "username",
|
||||
"type": "varchar(255)",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"country_code": {
|
||||
"name": "country_code",
|
||||
"type": "varchar(2)",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"steam_email": {
|
||||
"name": "steam_email",
|
||||
"type": "varchar(255)",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"persona_name": {
|
||||
"name": "persona_name",
|
||||
"type": "varchar(255)",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"limitation": {
|
||||
"name": "limitation",
|
||||
"type": "json",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
}
|
||||
},
|
||||
"indexes": {
|
||||
"steam_email": {
|
||||
"name": "steam_email",
|
||||
"columns": [
|
||||
{
|
||||
"expression": "user_id",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
},
|
||||
{
|
||||
"expression": "email",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
}
|
||||
],
|
||||
"isUnique": true,
|
||||
"concurrently": false,
|
||||
"method": "btree",
|
||||
"with": {}
|
||||
}
|
||||
},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {
|
||||
"steam_user_id_id_pk": {
|
||||
"name": "steam_user_id_id_pk",
|
||||
"columns": [
|
||||
"user_id",
|
||||
"id"
|
||||
]
|
||||
}
|
||||
},
|
||||
"uniqueConstraints": {},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.team": {
|
||||
"name": "team",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "char(30)",
|
||||
"primaryKey": true,
|
||||
"notNull": true
|
||||
},
|
||||
"time_created": {
|
||||
"name": "time_created",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
},
|
||||
"time_updated": {
|
||||
"name": "time_updated",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
},
|
||||
"time_deleted": {
|
||||
"name": "time_deleted",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"name": {
|
||||
"name": "name",
|
||||
"type": "varchar(255)",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"slug": {
|
||||
"name": "slug",
|
||||
"type": "varchar(255)",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"plan_type": {
|
||||
"name": "plan_type",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
}
|
||||
},
|
||||
"indexes": {
|
||||
"slug": {
|
||||
"name": "slug",
|
||||
"columns": [
|
||||
{
|
||||
"expression": "slug",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
}
|
||||
],
|
||||
"isUnique": true,
|
||||
"concurrently": false,
|
||||
"method": "btree",
|
||||
"with": {}
|
||||
}
|
||||
},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.user": {
|
||||
"name": "user",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "char(30)",
|
||||
"primaryKey": true,
|
||||
"notNull": true
|
||||
},
|
||||
"time_created": {
|
||||
"name": "time_created",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
},
|
||||
"time_updated": {
|
||||
"name": "time_updated",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
},
|
||||
"time_deleted": {
|
||||
"name": "time_deleted",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"avatar_url": {
|
||||
"name": "avatar_url",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"name": {
|
||||
"name": "name",
|
||||
"type": "varchar(255)",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"discriminator": {
|
||||
"name": "discriminator",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"email": {
|
||||
"name": "email",
|
||||
"type": "varchar(255)",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"polar_customer_id": {
|
||||
"name": "polar_customer_id",
|
||||
"type": "varchar(255)",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
}
|
||||
},
|
||||
"indexes": {
|
||||
"user_email": {
|
||||
"name": "user_email",
|
||||
"columns": [
|
||||
{
|
||||
"expression": "email",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
}
|
||||
],
|
||||
"isUnique": true,
|
||||
"concurrently": false,
|
||||
"method": "btree",
|
||||
"with": {}
|
||||
}
|
||||
},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {
|
||||
"user_polar_customer_id_unique": {
|
||||
"name": "user_polar_customer_id_unique",
|
||||
"nullsNotDistinct": false,
|
||||
"columns": [
|
||||
"polar_customer_id"
|
||||
]
|
||||
}
|
||||
},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
}
|
||||
},
|
||||
"enums": {},
|
||||
"schemas": {},
|
||||
"sequences": {},
|
||||
"roles": {},
|
||||
"policies": {},
|
||||
"views": {},
|
||||
"_meta": {
|
||||
"columns": {},
|
||||
"schemas": {},
|
||||
"tables": {}
|
||||
}
|
||||
}
|
||||
@@ -19,8 +19,15 @@
|
||||
{
|
||||
"idx": 2,
|
||||
"version": "7",
|
||||
"when": 1743028682022,
|
||||
"tag": "0002_tiny_toad_men",
|
||||
"when": 1743794969007,
|
||||
"tag": "0002_simple_outlaw_kid",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 3,
|
||||
"version": "7",
|
||||
"when": 1744287542918,
|
||||
"tag": "0003_first_big_bertha",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
"sideEffects": false,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"db:dev": "drizzle-kit",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"db": "sst shell drizzle-kit",
|
||||
"db:exec": "sst shell ../scripts/src/psql.sh",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { z } from "zod";
|
||||
import { eq } from "./drizzle";
|
||||
import { VisibleError } from "./error";
|
||||
import { ErrorCodes, VisibleError } from "./error";
|
||||
import { createContext } from "./context";
|
||||
import { UserFlags, userTable } from "./user/user.sql";
|
||||
import { useTransaction } from "./drizzle/transaction";
|
||||
@@ -60,11 +60,42 @@ export const ActorContext = createContext<Actor>("actor");
|
||||
export const useActor = ActorContext.use;
|
||||
export const withActor = ActorContext.with;
|
||||
|
||||
/**
|
||||
* Retrieves the user ID of the current actor.
|
||||
*
|
||||
* This function accesses the actor context and returns the `userID` if the current
|
||||
* actor is of type "user". If the actor is not a user, it throws a `VisibleError`
|
||||
* with an authentication error code, indicating that the caller is not authorized
|
||||
* to access user-specific resources.
|
||||
*
|
||||
* @throws {VisibleError} When the current actor is not of type "user".
|
||||
*/
|
||||
export function useUserID() {
|
||||
const actor = ActorContext.use();
|
||||
if (actor.type === "user") return actor.properties.userID;
|
||||
throw new VisibleError(
|
||||
"unauthorized",
|
||||
"authentication",
|
||||
ErrorCodes.Authentication.UNAUTHORIZED,
|
||||
`You don't have permission to access this resource`,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves the properties of the current user actor.
|
||||
*
|
||||
* This function obtains the current actor from the context and returns its properties if the actor is identified as a user.
|
||||
* If the actor is not of type "user", it throws a {@link VisibleError} with an authentication error code,
|
||||
* indicating that the user is not authorized to access user-specific resources.
|
||||
*
|
||||
* @returns The properties of the current user actor, typically including user-specific details such as userID and email.
|
||||
* @throws {VisibleError} If the current actor is not a user.
|
||||
*/
|
||||
export function useUser() {
|
||||
const actor = ActorContext.use();
|
||||
if (actor.type === "user") return actor.properties;
|
||||
throw new VisibleError(
|
||||
"authentication",
|
||||
ErrorCodes.Authentication.UNAUTHORIZED,
|
||||
`You don't have permission to access this resource`,
|
||||
);
|
||||
}
|
||||
@@ -90,6 +121,17 @@ export function useMachine() {
|
||||
throw new Error(`Expected actor to have fingerprint`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Asserts that the current user possesses the specified flag.
|
||||
*
|
||||
* This function executes a database transaction that queries the user table for the current user's flags.
|
||||
* If the flags are missing, it throws a {@link VisibleError} with the code {@link ErrorCodes.Validation.MISSING_REQUIRED_FIELD}
|
||||
* and a message indicating that the required flag is absent.
|
||||
*
|
||||
* @param flag - The name of the user flag to verify.
|
||||
*
|
||||
* @throws {VisibleError} If the user's flag is missing.
|
||||
*/
|
||||
export async function assertUserFlag(flag: keyof UserFlags) {
|
||||
return useTransaction((tx) =>
|
||||
tx
|
||||
@@ -100,7 +142,8 @@ export async function assertUserFlag(flag: keyof UserFlags) {
|
||||
const flags = rows[0]?.flags;
|
||||
if (!flags)
|
||||
throw new VisibleError(
|
||||
"user.flags",
|
||||
"not_found",
|
||||
ErrorCodes.Validation.MISSING_REQUIRED_FIELD,
|
||||
"Actor does not have " + flag + " flag",
|
||||
);
|
||||
}),
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { z } from "zod";
|
||||
import "zod-openapi/extend";
|
||||
|
||||
export module Common {
|
||||
export namespace Common {
|
||||
export const IdDescription = `Unique object identifier.
|
||||
The format and length of IDs may change over time.`;
|
||||
}
|
||||
@@ -17,6 +17,15 @@ export const teamID = {
|
||||
},
|
||||
};
|
||||
|
||||
export const userID = {
|
||||
get id() {
|
||||
return ulid("id").notNull();
|
||||
},
|
||||
get userID() {
|
||||
return ulid("user_id").notNull();
|
||||
},
|
||||
};
|
||||
|
||||
export const utc = (name: string) =>
|
||||
rawTs(name, {
|
||||
withTimezone: true,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { prefixes } from "./utils";
|
||||
export module Examples {
|
||||
export namespace Examples {
|
||||
export const Id = (prefix: keyof typeof prefixes) =>
|
||||
`${prefixes[prefix]}_XXXXXXXXXXXXXXXXXXXXXXXXX`;
|
||||
|
||||
@@ -31,8 +31,30 @@ export module Examples {
|
||||
timeSeen: new Date("2025-02-23T13:39:52.249Z"),
|
||||
}
|
||||
|
||||
export const Steam = {
|
||||
id: Id("steam"),
|
||||
userID: Id("user"),
|
||||
countryCode: "KE",
|
||||
steamID: 74839300282033,
|
||||
limitation: {
|
||||
isLimited: false,
|
||||
isBanned: false,
|
||||
isLocked: false,
|
||||
isAllowedToInviteFriends: false,
|
||||
},
|
||||
lastGame: {
|
||||
gameID: 2531310,
|
||||
gameName: "The Last of Us™ Part II Remastered",
|
||||
},
|
||||
personaName: "John",
|
||||
username: "johnsteamaccount",
|
||||
steamEmail: "john@example.com",
|
||||
avatarUrl: "https://avatars.akamai.steamstatic.com/XXXXXXXXXXXX_full.jpg",
|
||||
}
|
||||
|
||||
export const Machine = {
|
||||
id: Id("machine"),
|
||||
userID: Id("user"),
|
||||
country: "Kenya",
|
||||
countryCode: "KE",
|
||||
timezone: "Africa/Nairobi",
|
||||
|
||||
@@ -6,13 +6,17 @@ import { machineTable } from "./machine.sql";
|
||||
import { getTableColumns, eq, sql, and, isNull } from "../drizzle";
|
||||
import { createTransaction, useTransaction } from "../drizzle/transaction";
|
||||
|
||||
export module Machine {
|
||||
export namespace Machine {
|
||||
export const Info = z
|
||||
.object({
|
||||
id: z.string().openapi({
|
||||
description: Common.IdDescription,
|
||||
example: Examples.Machine.id,
|
||||
}),
|
||||
userID: z.string().nullable().openapi({
|
||||
description: "The userID of the user who owns this machine, in the case of BYOG",
|
||||
example: Examples.Machine.userID
|
||||
}),
|
||||
country: z.string().openapi({
|
||||
description: "The fullname of the country this machine is running in",
|
||||
example: Examples.Machine.country
|
||||
@@ -42,7 +46,7 @@ export module Machine {
|
||||
|
||||
export type Info = z.infer<typeof Info>;
|
||||
|
||||
export const create = fn(Info.partial({ id: true }), async (input) =>
|
||||
export const create = fn(Info.partial({ id: true }), async (input) =>
|
||||
createTransaction(async (tx) => {
|
||||
const id = input.id ?? createID("machine");
|
||||
await tx.insert(machineTable).values({
|
||||
@@ -51,6 +55,7 @@ export module Machine {
|
||||
timezone: input.timezone,
|
||||
fingerprint: input.fingerprint,
|
||||
countryCode: input.countryCode,
|
||||
userID: input.userID,
|
||||
location: { x: input.location.longitude, y: input.location.latitude },
|
||||
})
|
||||
|
||||
@@ -63,12 +68,23 @@ export module Machine {
|
||||
})
|
||||
)
|
||||
|
||||
export const list = fn(z.void(), async () =>
|
||||
useTransaction(async (tx) =>
|
||||
export const fromUserID = fn(z.string(), async (userID) =>
|
||||
useTransaction(async (tx) =>
|
||||
tx
|
||||
.select()
|
||||
.from(machineTable)
|
||||
.where(isNull(machineTable.timeDeleted))
|
||||
.where(and(eq(machineTable.userID, userID), isNull(machineTable.timeDeleted)))
|
||||
.then((rows) => rows.map(serialize))
|
||||
)
|
||||
)
|
||||
|
||||
export const list = fn(z.void(), async () =>
|
||||
useTransaction(async (tx) =>
|
||||
tx
|
||||
.select()
|
||||
.from(machineTable)
|
||||
// Show only hosted machines, not BYOG machines
|
||||
.where(and(isNull(machineTable.userID), isNull(machineTable.timeDeleted)))
|
||||
.then((rows) => rows.map(serialize))
|
||||
)
|
||||
)
|
||||
@@ -116,7 +132,7 @@ export module Machine {
|
||||
distance: sql`round((${sqlDistance})::numeric, 2)`
|
||||
})
|
||||
.from(machineTable)
|
||||
.where(isNull(machineTable.timeDeleted)) //Should have a status update
|
||||
.where(isNull(machineTable.timeDeleted))
|
||||
.orderBy(sqlDistance)
|
||||
.limit(3)
|
||||
.then((rows) => rows.map(serialize))
|
||||
@@ -128,6 +144,7 @@ export module Machine {
|
||||
): z.infer<typeof Info> {
|
||||
return {
|
||||
id: input.id,
|
||||
userID: input.userID,
|
||||
country: input.country,
|
||||
timezone: input.timezone,
|
||||
fingerprint: input.fingerprint,
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import { } from "drizzle-orm/postgres-js";
|
||||
import { timestamps, id } from "../drizzle/types";
|
||||
import { timestamps, id, ulid } from "../drizzle/types";
|
||||
import {
|
||||
text,
|
||||
varchar,
|
||||
pgTable,
|
||||
uniqueIndex,
|
||||
point,
|
||||
primaryKey,
|
||||
} from "drizzle-orm/pg-core";
|
||||
|
||||
export const machineTable = pgTable(
|
||||
@@ -13,6 +14,7 @@ export const machineTable = pgTable(
|
||||
{
|
||||
...id,
|
||||
...timestamps,
|
||||
userID: ulid("user_id"),
|
||||
country: text('country').notNull(),
|
||||
timezone: text('timezone').notNull(),
|
||||
location: point('location', { mode: 'xy' }).notNull(),
|
||||
@@ -32,6 +34,7 @@ export const machineTable = pgTable(
|
||||
},
|
||||
(table) => [
|
||||
// uniqueIndex("external_id").on(table.externalID),
|
||||
uniqueIndex("machine_fingerprint").on(table.fingerprint)
|
||||
uniqueIndex("machine_fingerprint").on(table.fingerprint),
|
||||
primaryKey({ columns: [table.userID, table.id], }),
|
||||
],
|
||||
);
|
||||
@@ -10,7 +10,7 @@ import { memberTable } from "./member.sql";
|
||||
import { and, eq, sql, asc, isNull } from "../drizzle";
|
||||
import { afterTx, createTransaction, useTransaction } from "../drizzle/transaction";
|
||||
|
||||
export module Member {
|
||||
export namespace Member {
|
||||
export const Info = z
|
||||
.object({
|
||||
id: z.string().openapi({
|
||||
|
||||
@@ -10,7 +10,7 @@ import { useTransaction } from "../drizzle/transaction";
|
||||
|
||||
const polar = new PolarSdk({ accessToken: Resource.PolarSecret.value, server: Resource.App.stage !== "production" ? "sandbox" : "production" });
|
||||
|
||||
export module Polar {
|
||||
export namespace Polar {
|
||||
export const client = polar;
|
||||
|
||||
export const Info = z.object({
|
||||
|
||||
@@ -2,10 +2,10 @@ import {
|
||||
IoTDataPlaneClient,
|
||||
PublishCommand,
|
||||
} from "@aws-sdk/client-iot-data-plane";
|
||||
import {useMachine} from "../actor";
|
||||
import {Resource} from "sst";
|
||||
import { useMachine } from "../actor";
|
||||
import { Resource } from "sst";
|
||||
|
||||
export module Realtime {
|
||||
export namespace Realtime {
|
||||
const client = new IoTDataPlaneClient({});
|
||||
|
||||
export async function publish(message: any, subTopic?: string) {
|
||||
|
||||
137
packages/core/src/steam/index.ts
Normal file
@@ -0,0 +1,137 @@
|
||||
import { z } from "zod";
|
||||
import { Common } from "../common";
|
||||
import { Examples } from "../examples";
|
||||
import { createID, fn } from "../utils";
|
||||
import { useUser, useUserID } from "../actor";
|
||||
import { eq, and, isNull, sql } from "../drizzle";
|
||||
import { steamTable, AccountLimitation, LastGame } from "./steam.sql";
|
||||
import { createTransaction, useTransaction } from "../drizzle/transaction";
|
||||
|
||||
export namespace Steam {
|
||||
export const Info = z
|
||||
.object({
|
||||
id: z.string().openapi({
|
||||
description: Common.IdDescription,
|
||||
example: Examples.Steam.id,
|
||||
}),
|
||||
avatarUrl: z.string().openapi({
|
||||
description: "The avatar url of this Steam account",
|
||||
example: Examples.Steam.avatarUrl
|
||||
}),
|
||||
steamEmail: z.string().openapi({
|
||||
description: "The email regisered with this Steam account",
|
||||
example: Examples.Steam.steamEmail
|
||||
}),
|
||||
steamID: z.number().openapi({
|
||||
description: "The Steam ID this Steam account",
|
||||
example: Examples.Steam.steamID
|
||||
}),
|
||||
limitation: AccountLimitation.openapi({
|
||||
description: " The limitations of this Steam account",
|
||||
example: Examples.Steam.limitation
|
||||
}),
|
||||
lastGame: LastGame.openapi({
|
||||
description: "The last game played on this Steam account",
|
||||
example: Examples.Steam.lastGame
|
||||
}),
|
||||
userID: z.string().openapi({
|
||||
description: "The unique id of the user who owns this steam account",
|
||||
example: Examples.Steam.userID
|
||||
}),
|
||||
username: z.string().openapi({
|
||||
description: "The unique username of this steam user",
|
||||
example: Examples.Steam.username
|
||||
}),
|
||||
personaName: z.string().openapi({
|
||||
description: "The last recorded persona name used by this account",
|
||||
example: Examples.Steam.personaName
|
||||
}),
|
||||
countryCode: z.string().openapi({
|
||||
description: "The country this account is connected from",
|
||||
example: Examples.Steam.countryCode
|
||||
})
|
||||
})
|
||||
.openapi({
|
||||
ref: "Steam",
|
||||
description: "Represents a steam user's information stored on Nestri",
|
||||
example: Examples.Steam,
|
||||
});
|
||||
|
||||
export type Info = z.infer<typeof Info>;
|
||||
|
||||
export const create = fn(
|
||||
Info.partial({
|
||||
id: true,
|
||||
userID: true,
|
||||
}),
|
||||
(input) =>
|
||||
createTransaction(async (tx) => {
|
||||
const id = input.id ?? createID("steam");
|
||||
const user = useUser()
|
||||
await tx.insert(steamTable).values({
|
||||
id,
|
||||
lastSeen: sql`now()`,
|
||||
userID: input.userID ?? user.userID,
|
||||
countryCode: input.countryCode,
|
||||
username: input.username,
|
||||
steamID: input.steamID,
|
||||
lastGame: input.lastGame,
|
||||
limitation: input.limitation,
|
||||
steamEmail: input.steamEmail,
|
||||
avatarUrl: input.avatarUrl,
|
||||
personaName: input.personaName,
|
||||
})
|
||||
return id;
|
||||
}),
|
||||
);
|
||||
|
||||
export const fromUserID = fn(
|
||||
z.string(),
|
||||
(userID) =>
|
||||
useTransaction((tx) =>
|
||||
tx
|
||||
.select()
|
||||
.from(steamTable)
|
||||
.where(and(eq(steamTable.userID, userID), isNull(steamTable.timeDeleted)))
|
||||
.execute()
|
||||
.then((rows) => rows.map(serialize).at(0)),
|
||||
),
|
||||
)
|
||||
|
||||
export const list = () =>
|
||||
useTransaction((tx) =>
|
||||
tx
|
||||
.select()
|
||||
.from(steamTable)
|
||||
.where(and(eq(steamTable.userID, useUserID()), isNull(steamTable.timeDeleted)))
|
||||
.execute()
|
||||
.then((rows) => rows.map(serialize)),
|
||||
)
|
||||
|
||||
/**
|
||||
* Serializes a raw Steam table record into a standardized Info object.
|
||||
*
|
||||
* This function maps the fields from a database record (retrieved from the Steam table) to the
|
||||
* corresponding properties defined in the Info schema.
|
||||
*
|
||||
* @param input - A raw record from the Steam table containing user information.
|
||||
* @returns An object conforming to the Info schema.
|
||||
*/
|
||||
export function serialize(
|
||||
input: typeof steamTable.$inferSelect,
|
||||
): z.infer<typeof Info> {
|
||||
return {
|
||||
id: input.id,
|
||||
userID: input.userID,
|
||||
countryCode: input.countryCode,
|
||||
username: input.username,
|
||||
avatarUrl: input.avatarUrl,
|
||||
personaName: input.personaName,
|
||||
steamEmail: input.steamEmail,
|
||||
steamID: input.steamID,
|
||||
limitation: input.limitation,
|
||||
lastGame: input.lastGame,
|
||||
};
|
||||
}
|
||||
|
||||
}
|
||||
58
packages/core/src/steam/steam.sql.ts
Normal file
@@ -0,0 +1,58 @@
|
||||
import { z } from "zod";
|
||||
import { timestamps, userID, utc } from "../drizzle/types";
|
||||
import { index, pgTable, integer, uniqueIndex, varchar, text, primaryKey, json } from "drizzle-orm/pg-core";
|
||||
|
||||
|
||||
// public string Username { get; set; } = string.Empty;
|
||||
// public ulong SteamId { get; set; }
|
||||
// public string Email { get; set; } = string.Empty;
|
||||
// public string Country { get; set; } = string.Empty;
|
||||
// public string PersonaName { get; set; } = string.Empty;
|
||||
// public string AvatarUrl { get; set; } = string.Empty;
|
||||
// public bool IsLimited { get; set; }
|
||||
// public bool IsLocked { get; set; }
|
||||
// public bool IsBanned { get; set; }
|
||||
// public bool IsAllowedToInviteFriends { get; set; }
|
||||
// public ulong GameId { get; set; }
|
||||
// public string GamePlayingName { get; set; } = string.Empty;
|
||||
// public DateTime LastLogOn { get; set; }
|
||||
// public DateTime LastLogOff { get; set; }
|
||||
// public DateTime UpdatedAt { get; set; } = DateTime.UtcNow;
|
||||
|
||||
export const LastGame = z.object({
|
||||
gameID: z.number(),
|
||||
gameName: z.string()
|
||||
});
|
||||
|
||||
export const AccountLimitation = z.object({
|
||||
isLimited: z.boolean().nullable(),
|
||||
isBanned: z.boolean().nullable(),
|
||||
isLocked: z.boolean().nullable(),
|
||||
isAllowedToInviteFriends: z.boolean().nullable(),
|
||||
});
|
||||
|
||||
export type LastGame = z.infer<typeof LastGame>;
|
||||
export type AccountLimitation = z.infer<typeof AccountLimitation>;
|
||||
|
||||
export const steamTable = pgTable(
|
||||
"steam",
|
||||
{
|
||||
...userID,
|
||||
...timestamps,
|
||||
lastSeen: utc("time_seen"),
|
||||
steamID: integer("steam_id").notNull(),
|
||||
avatarUrl: text("avatar_url").notNull(),
|
||||
lastGame: json("last_game").$type<LastGame>().notNull(),
|
||||
username: varchar("username", { length: 255 }).notNull(),
|
||||
countryCode: varchar('country_code', { length: 2 }).notNull(),
|
||||
steamEmail: varchar("steam_email", { length: 255 }).notNull(),
|
||||
personaName: varchar("persona_name", { length: 255 }).notNull(),
|
||||
limitation: json("limitation").$type<AccountLimitation>().notNull(),
|
||||
},
|
||||
(table) => [
|
||||
primaryKey({
|
||||
columns: [table.userID, table.id],
|
||||
}),
|
||||
uniqueIndex("steam_email").on(table.userID, table.steamEmail),
|
||||
],
|
||||
);
|
||||
@@ -12,7 +12,7 @@ import { memberTable } from "../member/member.sql";
|
||||
import { ErrorCodes, VisibleError } from "../error";
|
||||
import { afterTx, createTransaction, useTransaction } from "../drizzle/transaction";
|
||||
|
||||
export module Team {
|
||||
export namespace Team {
|
||||
export const Info = z
|
||||
.object({
|
||||
id: z.string().openapi({
|
||||
|
||||
@@ -15,7 +15,7 @@ import { and, eq, isNull, asc, getTableColumns, sql } from "../drizzle";
|
||||
import { afterTx, createTransaction, useTransaction } from "../drizzle/transaction";
|
||||
|
||||
|
||||
export module User {
|
||||
export namespace User {
|
||||
const MAX_ATTEMPTS = 50;
|
||||
|
||||
export const Info = z
|
||||
|
||||
@@ -19,7 +19,7 @@ export const userTable = pgTable(
|
||||
discriminator: integer("discriminator").notNull(),
|
||||
email: varchar("email", { length: 255 }).notNull(),
|
||||
polarCustomerID: varchar("polar_customer_id", { length: 255 }).unique(),
|
||||
flags: json("flags").$type<UserFlags>().default({}),
|
||||
// flags: json("flags").$type<UserFlags>().default({}),
|
||||
},
|
||||
(user) => [
|
||||
uniqueIndex("user_email").on(user.email),
|
||||
|
||||
@@ -6,8 +6,19 @@ export const prefixes = {
|
||||
task: "tsk",
|
||||
machine: "mch",
|
||||
member: "mbr",
|
||||
steam: "stm",
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* Generates a unique identifier by concatenating a predefined prefix with a ULID.
|
||||
*
|
||||
* Given a key from the predefined prefixes mapping (e.g., "user", "team", "member", "steam"),
|
||||
* this function retrieves the corresponding prefix and combines it with a ULID using an underscore
|
||||
* as a separator. The resulting identifier is formatted as "prefix_ulid".
|
||||
*
|
||||
* @param prefix - A key from the prefixes mapping.
|
||||
* @returns A unique identifier string.
|
||||
*/
|
||||
export function createID(prefix: keyof typeof prefixes): string {
|
||||
return [prefixes[prefix], ulid()].join("_");
|
||||
}
|
||||
17
packages/functions/Containerfile
Normal file
@@ -0,0 +1,17 @@
|
||||
FROM mirror.gcr.io/oven/bun:1.2
|
||||
|
||||
# TODO: Add a way to build C# Steam.exe and start it to run in the container before the API
|
||||
|
||||
ADD ./package.json .
|
||||
ADD ./bun.lock .
|
||||
ADD ./packages/core/package.json ./packages/core/package.json
|
||||
ADD ./packages/functions/package.json ./packages/functions/package.json
|
||||
ADD ./patches ./patches
|
||||
RUN bun install --ignore-scripts
|
||||
|
||||
ADD ./packages/functions ./packages/functions
|
||||
ADD ./packages/core ./packages/core
|
||||
|
||||
WORKDIR ./packages/functions
|
||||
|
||||
CMD ["bun", "run", "./src/api/index.ts"]
|
||||
@@ -4,6 +4,10 @@
|
||||
"exports": {
|
||||
"./*": "./src/*.ts"
|
||||
},
|
||||
"scripts": {
|
||||
"dev:auth": "bun run --watch ./src/auth.ts",
|
||||
"dev:api": "bun run --watch ./src/api/index.ts"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@aws-sdk/client-ecs": "^3.738.0",
|
||||
"@aws-sdk/client-sqs": "^3.734.0",
|
||||
@@ -16,10 +20,12 @@
|
||||
"typescript": "^5.0.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"@actor-core/bun": "^0.7.9",
|
||||
"@openauthjs/openauth": "*",
|
||||
"actor-core": "^0.7.9",
|
||||
"hono": "^4.6.15",
|
||||
"hono-openapi": "^0.3.1",
|
||||
"partysocket": "1.0.3",
|
||||
"postgres": "^3.4.5"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -9,7 +9,7 @@ import { Examples } from "@nestri/core/examples";
|
||||
import { ErrorResponses, Result } from "./common";
|
||||
import { ErrorCodes, VisibleError } from "@nestri/core/error";
|
||||
|
||||
export module AccountApi {
|
||||
export namespace AccountApi {
|
||||
export const route = new Hono()
|
||||
.use(notPublic)
|
||||
.get("/",
|
||||
@@ -34,7 +34,8 @@ export module AccountApi {
|
||||
},
|
||||
description: "User account details"
|
||||
},
|
||||
404: ErrorResponses[404]
|
||||
404: ErrorResponses[404],
|
||||
429: ErrorResponses[429]
|
||||
}
|
||||
}),
|
||||
async (c) => {
|
||||
|
||||
@@ -1,20 +1,15 @@
|
||||
import { Resource } from "sst";
|
||||
import { subjects } from "../subjects";
|
||||
import { type MiddlewareHandler } from "hono";
|
||||
import { VisibleError } from "@nestri/core/error";
|
||||
import { ActorContext } from "@nestri/core/actor";
|
||||
import { HTTPException } from "hono/http-exception";
|
||||
import { useActor, withActor } from "@nestri/core/actor";
|
||||
import { createClient } from "@openauthjs/openauth/client";
|
||||
import { ErrorCodes, VisibleError } from "@nestri/core/error";
|
||||
|
||||
const client = createClient({
|
||||
issuer: Resource.Auth.url,
|
||||
clientID: "api",
|
||||
issuer: Resource.Urls.auth
|
||||
});
|
||||
|
||||
|
||||
|
||||
export const notPublic: MiddlewareHandler = async (c, next) => {
|
||||
const actor = useActor();
|
||||
if (actor.type === "public")
|
||||
@@ -29,7 +24,7 @@ export const notPublic: MiddlewareHandler = async (c, next) => {
|
||||
export const auth: MiddlewareHandler = async (c, next) => {
|
||||
const authHeader =
|
||||
c.req.query("authorization") ?? c.req.header("authorization");
|
||||
if (!authHeader) return next();
|
||||
if (!authHeader) return withActor({ type: "public", properties: {} }, next);
|
||||
const match = authHeader.match(/^Bearer (.+)$/);
|
||||
if (!match) {
|
||||
throw new VisibleError(
|
||||
@@ -53,34 +48,22 @@ export const auth: MiddlewareHandler = async (c, next) => {
|
||||
return withActor(result.subject, next);
|
||||
}
|
||||
|
||||
if (result.subject.type === "user") {
|
||||
const teamID = c.req.header("x-nestri-team") //|| c.req.query("teamID");
|
||||
if (!teamID) return withActor(result.subject, next);
|
||||
// const email = result.subject.properties.email;
|
||||
return withActor(
|
||||
{
|
||||
type: "system",
|
||||
properties: {
|
||||
teamID,
|
||||
},
|
||||
if (result.subject.type === "user") {
|
||||
const teamID = c.req.header("x-nestri-team");
|
||||
if (!teamID) return withActor(result.subject, next);
|
||||
return withActor(
|
||||
{
|
||||
type: "system",
|
||||
properties: {
|
||||
teamID,
|
||||
},
|
||||
},
|
||||
next
|
||||
// async () => {
|
||||
// const user = await User.fromEmail(email);
|
||||
// if (!user || user.length === 0) {
|
||||
// c.status(401);
|
||||
// return c.text("Unauthorized");
|
||||
// }
|
||||
// return withActor(
|
||||
// {
|
||||
// type: "member",
|
||||
// properties: { userID: user[0].id, workspaceID: user.workspaceID },
|
||||
// },
|
||||
// next,
|
||||
// );
|
||||
// },
|
||||
async () => {
|
||||
return withActor(
|
||||
result.subject,
|
||||
next,
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
return ActorContext.with({ type: "public", properties: {} }, next);
|
||||
};
|
||||
@@ -1,5 +1,5 @@
|
||||
import {type Hook } from "./hook";
|
||||
import { z, ZodSchema } from "zod";
|
||||
import {type Hook } from "./types/hook";
|
||||
import { ErrorCodes, ErrorResponse } from "@nestri/core/error";
|
||||
import type { MiddlewareHandler, ValidationTargets } from "hono";
|
||||
import { resolver, validator as zodValidator } from "hono-openapi/zod";
|
||||
|
||||
@@ -1,19 +1,23 @@
|
||||
import "zod-openapi/extend";
|
||||
import { Hono } from "hono";
|
||||
import { auth } from "./auth";
|
||||
import { cors } from "hono/cors";
|
||||
import { TeamApi } from "./team";
|
||||
import { SteamApi } from "./steam";
|
||||
import { logger } from "hono/logger";
|
||||
import { Realtime } from "./realtime";
|
||||
import { AccountApi } from "./account";
|
||||
import { MachineApi } from "./machine";
|
||||
import { openAPISpecs } from "hono-openapi";
|
||||
import { patchLogger } from "../log-polyfill";
|
||||
import { HTTPException } from "hono/http-exception";
|
||||
import { handle, streamHandle } from "hono/aws-lambda";
|
||||
import { ErrorCodes, VisibleError } from "@nestri/core/error";
|
||||
|
||||
|
||||
export const app = new Hono();
|
||||
app
|
||||
.use(logger(), async (c, next) => {
|
||||
.use(logger())
|
||||
.use(cors())
|
||||
.use(async (c, next) => {
|
||||
c.header("Cache-Control", "no-store");
|
||||
return next();
|
||||
})
|
||||
@@ -21,11 +25,12 @@ app
|
||||
|
||||
const routes = app
|
||||
.get("/", (c) => c.text("Hello World!"))
|
||||
.route("/realtime", Realtime.route)
|
||||
.route("/team", TeamApi.route)
|
||||
.route("/steam", SteamApi.route)
|
||||
.route("/account", AccountApi.route)
|
||||
.route("/machine", MachineApi.route)
|
||||
.onError((error, c) => {
|
||||
console.warn(error);
|
||||
if (error instanceof VisibleError) {
|
||||
console.error("api error:", error);
|
||||
// @ts-expect-error
|
||||
@@ -54,7 +59,6 @@ const routes = app
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
app.get(
|
||||
"/doc",
|
||||
openAPISpecs(routes, {
|
||||
@@ -82,10 +86,21 @@ app.get(
|
||||
security: [{ Bearer: [], TeamID: [] }],
|
||||
servers: [
|
||||
{ description: "Production", url: "https://api.nestri.io" },
|
||||
{ description: "Sandbox", url: "https://api.dev.nestri.io" },
|
||||
],
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
export type Routes = typeof routes;
|
||||
export const handler = process.env.SST_DEV ? handle(app) : streamHandle(app);
|
||||
patchLogger();
|
||||
|
||||
export default {
|
||||
port: 3001,
|
||||
idleTimeout: 255,
|
||||
webSocketHandler: Realtime.webSocketHandler,
|
||||
fetch: (req: Request) =>
|
||||
app.fetch(req, undefined, {
|
||||
waitUntil: (fn) => fn,
|
||||
passThroughOnException: () => { },
|
||||
}),
|
||||
};
|
||||
@@ -1,16 +1,92 @@
|
||||
import {z} from "zod"
|
||||
import {Hono} from "hono";
|
||||
import {notPublic} from "./auth";
|
||||
import {Result} from "../common";
|
||||
import {describeRoute} from "hono-openapi";
|
||||
import {assertActor} from "@nestri/core/actor";
|
||||
import {Realtime} from "@nestri/core/realtime/index";
|
||||
import {validator} from "hono-openapi/zod";
|
||||
import {CreateMessageSchema, StartMessageSchema, StopMessageSchema} from "./messages.ts";
|
||||
import { z } from "zod"
|
||||
import { Hono } from "hono";
|
||||
import { notPublic } from "./auth";
|
||||
import { describeRoute } from "hono-openapi";
|
||||
import { validator } from "hono-openapi/zod";
|
||||
import { Examples } from "@nestri/core/examples";
|
||||
import { assertActor } from "@nestri/core/actor";
|
||||
import { ErrorResponses, Result } from "./common";
|
||||
import { Machine } from "@nestri/core/machine/index";
|
||||
import { Realtime } from "@nestri/core/realtime/index";
|
||||
import { ErrorCodes, VisibleError } from "@nestri/core/error";
|
||||
import { CreateMessageSchema, StartMessageSchema, StopMessageSchema } from "./messages.ts";
|
||||
|
||||
export module MachineApi {
|
||||
export namespace MachineApi {
|
||||
export const route = new Hono()
|
||||
.use(notPublic)
|
||||
.get("/",
|
||||
describeRoute({
|
||||
tags: ["Machine"],
|
||||
summary: "Get all BYOG machines",
|
||||
description: "All the BYOG machines owned by this user",
|
||||
responses: {
|
||||
200: {
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: Result(
|
||||
Machine.Info.array().openapi({
|
||||
description: "All the user's BYOG machines",
|
||||
example: [Examples.Machine],
|
||||
}),
|
||||
),
|
||||
},
|
||||
},
|
||||
description: "Successfully retrieved all the user's machines",
|
||||
},
|
||||
404: ErrorResponses[404],
|
||||
429: ErrorResponses[429]
|
||||
}
|
||||
}),
|
||||
async (c) => {
|
||||
const user = assertActor("user");
|
||||
const machineInfo = await Machine.fromUserID(user.properties.userID);
|
||||
|
||||
if (!machineInfo)
|
||||
throw new VisibleError(
|
||||
"not_found",
|
||||
ErrorCodes.NotFound.RESOURCE_NOT_FOUND,
|
||||
"No machines not found",
|
||||
);
|
||||
|
||||
return c.json({ data: machineInfo, }, 200);
|
||||
|
||||
})
|
||||
.get("/hosted",
|
||||
describeRoute({
|
||||
tags: ["Machine"],
|
||||
summary: "Get all cloud machines",
|
||||
description: "All the machines that are connected to Nestri",
|
||||
responses: {
|
||||
200: {
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: Result(
|
||||
Machine.Info.array().openapi({
|
||||
description: "All the machines connected to Nestri",
|
||||
example: [{ ...Examples.Machine, userID: null }],
|
||||
}),
|
||||
),
|
||||
},
|
||||
},
|
||||
description: "Successfully retrieved all the hosted machines",
|
||||
},
|
||||
404: ErrorResponses[404],
|
||||
429: ErrorResponses[429]
|
||||
}
|
||||
}),
|
||||
async (c) => {
|
||||
const machineInfo = await Machine.list();
|
||||
|
||||
if (!machineInfo)
|
||||
throw new VisibleError(
|
||||
"not_found",
|
||||
ErrorCodes.NotFound.RESOURCE_NOT_FOUND,
|
||||
"No machines not found",
|
||||
);
|
||||
|
||||
return c.json({ data: machineInfo, }, 200);
|
||||
|
||||
})
|
||||
.post("/",
|
||||
describeRoute({
|
||||
tags: ["Machine"],
|
||||
@@ -27,14 +103,6 @@ export module MachineApi {
|
||||
},
|
||||
description: "Successfully sent the message to Maitred"
|
||||
},
|
||||
// 404: {
|
||||
// content: {
|
||||
// "application/json": {
|
||||
// schema: resolver(z.object({ error: z.string() })),
|
||||
// },
|
||||
// },
|
||||
// description: "This account does not exist",
|
||||
// },
|
||||
}
|
||||
}),
|
||||
validator(
|
||||
@@ -74,7 +142,7 @@ export module MachineApi {
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: Result(
|
||||
z.object({error: z.string()})
|
||||
z.object({ error: z.string() })
|
||||
),
|
||||
},
|
||||
},
|
||||
@@ -97,7 +165,7 @@ export module MachineApi {
|
||||
console.log("Published create request to");
|
||||
} catch (error) {
|
||||
console.error("Failed to publish to MQTT:", error);
|
||||
return c.json({error: "Failed to send create request"}, 400);
|
||||
return c.json({ error: "Failed to send create request" }, 400);
|
||||
}
|
||||
|
||||
return c.json({
|
||||
@@ -129,7 +197,7 @@ export module MachineApi {
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: Result(
|
||||
z.object({error: z.string()})
|
||||
z.object({ error: z.string() })
|
||||
),
|
||||
},
|
||||
},
|
||||
@@ -154,7 +222,7 @@ export module MachineApi {
|
||||
console.log("Published start request");
|
||||
} catch (error) {
|
||||
console.error("Failed to publish to MQTT:", error);
|
||||
return c.json({error: "Failed to send start request"}, 400);
|
||||
return c.json({ error: "Failed to send start request" }, 400);
|
||||
}
|
||||
|
||||
return c.json({
|
||||
@@ -186,7 +254,7 @@ export module MachineApi {
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: Result(
|
||||
z.object({error: z.string()})
|
||||
z.object({ error: z.string() })
|
||||
),
|
||||
},
|
||||
},
|
||||
@@ -211,7 +279,7 @@ export module MachineApi {
|
||||
console.log("Published stop request");
|
||||
} catch (error) {
|
||||
console.error("Failed to publish to MQTT:", error);
|
||||
return c.json({error: "Failed to send stop request"}, 400);
|
||||
return c.json({ error: "Failed to send stop request" }, 400);
|
||||
}
|
||||
|
||||
return c.json({
|
||||
|
||||
28
packages/functions/src/api/realtime/actor-core.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import { actor } from "actor-core";
|
||||
|
||||
// Define a chat room actor
|
||||
const chatRoom = actor({
|
||||
// Initialize state when the actor is first created
|
||||
createState: () => ({
|
||||
messages: [] as any[],
|
||||
}),
|
||||
|
||||
// Define actions clients can call
|
||||
actions: {
|
||||
// Action to send a message
|
||||
sendMessage: (c, sender, text) => {
|
||||
// Update state
|
||||
c.state.messages.push({ sender, text });
|
||||
|
||||
// Broadcast to all connected clients
|
||||
c.broadcast("newMessage", { sender, text });
|
||||
},
|
||||
|
||||
// Action to get chat history
|
||||
getHistory: (c) => {
|
||||
return c.state.messages;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
export default chatRoom;
|
||||
15
packages/functions/src/api/realtime/index.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import { setup } from "actor-core";
|
||||
import chatRoom from "./actor-core";
|
||||
import { createRouter } from "@actor-core/bun";
|
||||
|
||||
export namespace Realtime {
|
||||
const app = setup({
|
||||
actors: { chatRoom },
|
||||
basePath: "/realtime"
|
||||
});
|
||||
|
||||
const realtimeRouter = createRouter(app);
|
||||
|
||||
export const route = realtimeRouter.router;
|
||||
export const webSocketHandler = realtimeRouter.webSocketHandler;
|
||||
}
|
||||
47
packages/functions/src/api/steam.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
import { Hono } from "hono";
|
||||
import { ErrorResponses, Result } from "./common";
|
||||
import { Steam } from "@nestri/core/steam/index";
|
||||
import { describeRoute } from "hono-openapi";
|
||||
import { Examples } from "@nestri/core/examples";
|
||||
import { assertActor } from "@nestri/core/actor";
|
||||
import { ErrorCodes, VisibleError } from "@nestri/core/error";
|
||||
|
||||
export namespace SteamApi {
|
||||
export const route = new Hono()
|
||||
.get("/",
|
||||
describeRoute({
|
||||
tags: ["Steam"],
|
||||
summary: "Get Steam account information",
|
||||
description: "Get the user's Steam account information",
|
||||
responses: {
|
||||
200: {
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: Result(
|
||||
Steam.Info.openapi({
|
||||
description: "The Steam account information",
|
||||
example: Examples.Steam,
|
||||
}),
|
||||
),
|
||||
},
|
||||
},
|
||||
description: "Successfully got the Steam account information",
|
||||
},
|
||||
404: ErrorResponses[404],
|
||||
429: ErrorResponses[429],
|
||||
}
|
||||
}),
|
||||
async (c) => {
|
||||
const actor = assertActor("user");
|
||||
const steamInfo = await Steam.fromUserID(actor.properties.userID);
|
||||
if (!steamInfo)
|
||||
throw new VisibleError(
|
||||
"not_found",
|
||||
ErrorCodes.NotFound.RESOURCE_NOT_FOUND,
|
||||
"Steam account information not found",
|
||||
);
|
||||
|
||||
return c.json({ data: steamInfo }, 200);
|
||||
}
|
||||
)
|
||||
}
|
||||
@@ -9,7 +9,7 @@ import { Member } from "@nestri/core/member/index";
|
||||
import { assertActor, withActor } from "@nestri/core/actor";
|
||||
import { ErrorResponses, Result, validator } from "./common";
|
||||
|
||||
export module TeamApi {
|
||||
export namespace TeamApi {
|
||||
export const route = new Hono()
|
||||
.use(notPublic)
|
||||
.get("/",
|
||||
|
||||
@@ -2,8 +2,8 @@ import { Resource } from "sst"
|
||||
import { Select } from "./ui/select";
|
||||
import { subjects } from "./subjects"
|
||||
import { logger } from "hono/logger";
|
||||
import { handle } from "hono/aws-lambda";
|
||||
import { PasswordUI } from "./ui/password"
|
||||
import { patchLogger } from "./log-polyfill";
|
||||
import { issuer } from "@openauthjs/openauth";
|
||||
import { User } from "@nestri/core/user/index"
|
||||
import { Email } from "@nestri/core/email/index";
|
||||
@@ -11,8 +11,9 @@ import { handleDiscord, handleGithub } from "./utils";
|
||||
import { GithubAdapter } from "./ui/adapters/github";
|
||||
import { Machine } from "@nestri/core/machine/index"
|
||||
import { DiscordAdapter } from "./ui/adapters/discord";
|
||||
import { PasswordAdapter } from "./ui/adapters/password"
|
||||
import { PasswordAdapter } from "./ui/adapters/password";
|
||||
import { type Provider } from "@openauthjs/openauth/provider/provider"
|
||||
import { MemoryStorage } from "@openauthjs/openauth/storage/memory";
|
||||
|
||||
type OauthUser = {
|
||||
primary: {
|
||||
@@ -24,13 +25,13 @@ type OauthUser = {
|
||||
username: any;
|
||||
}
|
||||
|
||||
console.log("STORAGE", process.env.STORAGE)
|
||||
|
||||
const app = issuer({
|
||||
select: Select({
|
||||
providers: {
|
||||
machine: {
|
||||
hide: true,
|
||||
},
|
||||
},
|
||||
select: Select(),
|
||||
//TODO: Create our own Storage
|
||||
storage: MemoryStorage({
|
||||
persist: process.env.STORAGE //"/tmp/persist.json",
|
||||
}),
|
||||
theme: {
|
||||
title: "Nestri | Auth",
|
||||
@@ -46,9 +47,7 @@ const app = issuer({
|
||||
font: {
|
||||
family: "Geist, sans-serif",
|
||||
},
|
||||
css: `
|
||||
@import url('https://fonts.googleapis.com/css2?family=Geist:wght@100;200;300;400;500;600;700;800;900&display=swap');
|
||||
`,
|
||||
css: `@import url('https://fonts.googleapis.com/css2?family=Geist:wght@100;200;300;400;500;600;700;800;900&display=swap');`,
|
||||
},
|
||||
subjects,
|
||||
providers: {
|
||||
@@ -78,9 +77,10 @@ const app = issuer({
|
||||
machine: {
|
||||
type: "machine",
|
||||
async client(input) {
|
||||
if (input.clientSecret !== Resource.AuthFingerprintKey.value) {
|
||||
throw new Error("Invalid authorization token");
|
||||
}
|
||||
// FIXME: Do we really need this?
|
||||
// if (input.clientSecret !== Resource.AuthFingerprintKey.value) {
|
||||
// throw new Error("Invalid authorization token");
|
||||
// }
|
||||
|
||||
const fingerprint = input.params.fingerprint;
|
||||
if (!fingerprint) {
|
||||
@@ -120,7 +120,9 @@ const app = issuer({
|
||||
location: {
|
||||
latitude,
|
||||
longitude
|
||||
}
|
||||
},
|
||||
//FIXME: Make this better
|
||||
userID: null
|
||||
})
|
||||
return ctx.subject("machine", {
|
||||
machineID,
|
||||
@@ -134,7 +136,7 @@ const app = issuer({
|
||||
});
|
||||
}
|
||||
|
||||
//TODO: This works, so use this while registering the task
|
||||
// TODO: This works, so use this while registering the task
|
||||
// console.log("country_code", req.headers.get('CloudFront-Viewer-Country'))
|
||||
// console.log("country_name", req.headers.get('CloudFront-Viewer-Country-Name'))
|
||||
// console.log("latitude", req.headers.get('CloudFront-Viewer-Latitude'))
|
||||
@@ -225,4 +227,14 @@ const app = issuer({
|
||||
},
|
||||
}).use(logger())
|
||||
|
||||
export const handler = handle(app)
|
||||
patchLogger();
|
||||
|
||||
export default {
|
||||
port: 3002,
|
||||
idleTimeout: 255,
|
||||
fetch: (req: Request) =>
|
||||
app.fetch(req, undefined, {
|
||||
waitUntil: (fn) => fn,
|
||||
passThroughOnException: () => { },
|
||||
}),
|
||||
};
|
||||
27
packages/functions/src/log-polyfill.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import { format } from "util";
|
||||
|
||||
/**
|
||||
* Overrides the default Node.js console logging methods with a custom logger.
|
||||
*
|
||||
* This function patches console.log, console.warn, console.error, console.trace, and console.debug so that each logs
|
||||
* messages prefixed with a log level. The messages are formatted using Node.js formatting conventions, with newline
|
||||
* characters replaced by carriage returns, and are written directly to standard output.
|
||||
*
|
||||
* @example
|
||||
* patchLogger();
|
||||
* console.info("Server started on port %d", 3000);
|
||||
*/
|
||||
export function patchLogger() {
|
||||
const log =
|
||||
(level: "INFO" | "WARN" | "TRACE" | "DEBUG" | "ERROR") =>
|
||||
(msg: string, ...rest: any[]) => {
|
||||
let line = `${level}\t${format(msg, ...rest)}`;
|
||||
line = line.replace(/\n/g, "\r");
|
||||
process.stdout.write(line + "\n");
|
||||
};
|
||||
console.log = log("INFO");
|
||||
console.warn = log("WARN");
|
||||
console.error = log("ERROR");
|
||||
console.trace = log("TRACE");
|
||||
console.debug = log("DEBUG");
|
||||
}
|
||||
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.Auth.url
|
||||
});
|
||||
|
||||
const result = await client.verify(subjects, token);
|
||||
|
||||
if (result.err) {
|
||||
console.log("error", result.err)
|
||||
return {
|
||||
subscribe: [],
|
||||
publish: [],
|
||||
};
|
||||
}
|
||||
|
||||
if (result.subject.type != "user") {
|
||||
return {
|
||||
subscribe: [],
|
||||
publish: [],
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
//It can publish and listen to other instances under this team
|
||||
subscribe: [`${Resource.App.name}/${Resource.App.stage}/*`],
|
||||
publish: [`${Resource.App.name}/${Resource.App.stage}/*`],
|
||||
};
|
||||
});
|
||||
@@ -5,7 +5,7 @@ import { createClient } from "@openauthjs/openauth/client";
|
||||
|
||||
const client = createClient({
|
||||
clientID: "realtime",
|
||||
issuer: Resource.Urls.auth
|
||||
issuer: Resource.Auth.url
|
||||
});
|
||||
|
||||
export const handler = realtime.authorizer(async (token) => {
|
||||
|
||||
26
packages/functions/sst-env.d.ts
vendored
@@ -7,22 +7,15 @@ import "sst"
|
||||
declare module "sst" {
|
||||
export interface Resource {
|
||||
"Api": {
|
||||
"type": "sst.aws.Router"
|
||||
"url": string
|
||||
}
|
||||
"ApiFn": {
|
||||
"name": string
|
||||
"type": "sst.aws.Function"
|
||||
"service": string
|
||||
"type": "sst.aws.Service"
|
||||
"url": string
|
||||
}
|
||||
"Auth": {
|
||||
"type": "sst.aws.Auth"
|
||||
"service": string
|
||||
"type": "sst.aws.Service"
|
||||
"url": string
|
||||
}
|
||||
"AuthFingerprintKey": {
|
||||
"type": "random.index/randomString.RandomString"
|
||||
"value": string
|
||||
}
|
||||
"Bus": {
|
||||
"arn": string
|
||||
"name": string
|
||||
@@ -73,21 +66,10 @@ declare module "sst" {
|
||||
"endpoint": string
|
||||
"type": "sst.aws.Realtime"
|
||||
}
|
||||
"Steam": {
|
||||
"service": string
|
||||
"type": "sst.aws.Service"
|
||||
"url": string
|
||||
}
|
||||
"Storage": {
|
||||
"name": string
|
||||
"type": "sst.aws.Bucket"
|
||||
}
|
||||
"Urls": {
|
||||
"api": string
|
||||
"auth": string
|
||||
"site": string
|
||||
"type": "sst.sst.Linkable"
|
||||
}
|
||||
"VPC": {
|
||||
"bastion": string
|
||||
"type": "sst.aws.Vpc"
|
||||
|
||||
7
packages/steam/.gitignore
vendored
@@ -2,7 +2,8 @@
|
||||
## files generated by popular Visual Studio add-ons.
|
||||
##
|
||||
## Get latest from `dotnet new gitignore`
|
||||
|
||||
bin
|
||||
obj
|
||||
# dotenv files
|
||||
.env
|
||||
|
||||
@@ -24,6 +25,8 @@ mono_crash.*
|
||||
[Dd]ebugPublic/
|
||||
[Rr]elease/
|
||||
[Rr]eleases/
|
||||
bin/
|
||||
obj/
|
||||
x64/
|
||||
x86/
|
||||
[Ww][Ii][Nn]32/
|
||||
@@ -228,6 +231,8 @@ _pkginfo.txt
|
||||
*.appx
|
||||
*.appxbundle
|
||||
*.appxupload
|
||||
#Steam credentials file
|
||||
*steam*.json
|
||||
|
||||
# Visual Studio cache files
|
||||
# files ending in .cache can be ignored
|
||||
|
||||
@@ -1,18 +0,0 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
public class SteamDbContext : DbContext
|
||||
{
|
||||
public DbSet<SteamUserCredential> SteamUserCredentials { get; set; }
|
||||
|
||||
public SteamDbContext(DbContextOptions<SteamDbContext> options) : base(options)
|
||||
{
|
||||
}
|
||||
|
||||
protected override void OnModelCreating(ModelBuilder modelBuilder)
|
||||
{
|
||||
// Create a unique index on TeamId and UserId
|
||||
modelBuilder.Entity<SteamUserCredential>()
|
||||
.HasIndex(c => new { c.TeamId, c.UserId })
|
||||
.IsUnique();
|
||||
}
|
||||
}
|
||||
@@ -1,12 +0,0 @@
|
||||
public class SteamUserCredential
|
||||
{
|
||||
public int Id { get; set; }
|
||||
public required string TeamId { get; set; }
|
||||
public required string UserId { get; set; }
|
||||
public required string AccountName { get; set; }
|
||||
public required string RefreshToken { get; set; }
|
||||
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
|
||||
public DateTime UpdatedAt { get; set; } = DateTime.UtcNow;
|
||||
|
||||
// Composite key of TeamId and UserId will be unique
|
||||
}
|
||||
42
packages/steam/HttpClientFactory.cs
Normal file
@@ -0,0 +1,42 @@
|
||||
using System.Net.Http.Headers;
|
||||
using System.Net.Sockets;
|
||||
|
||||
namespace Steam;
|
||||
|
||||
public class HttpClientFactory
|
||||
{
|
||||
public static HttpClient CreateHttpClient()
|
||||
{
|
||||
var client = new HttpClient(new SocketsHttpHandler
|
||||
{
|
||||
ConnectCallback = IPv4ConnectAsync
|
||||
});
|
||||
|
||||
var assemblyVersion = typeof(HttpClientFactory).Assembly.GetName().Version?.ToString(fieldCount: 3);
|
||||
client.DefaultRequestHeaders.UserAgent.Add(new ProductInfoHeaderValue("DepotDownloader", assemblyVersion));
|
||||
|
||||
return client;
|
||||
}
|
||||
|
||||
static async ValueTask<Stream> IPv4ConnectAsync(SocketsHttpConnectionContext context, CancellationToken cancellationToken)
|
||||
{
|
||||
// By default, we create dual-mode sockets:
|
||||
// Socket socket = new Socket(SocketType.Stream, ProtocolType.Tcp);
|
||||
|
||||
var socket = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp)
|
||||
{
|
||||
NoDelay = true
|
||||
};
|
||||
|
||||
try
|
||||
{
|
||||
await socket.ConnectAsync(context.DnsEndPoint, cancellationToken).ConfigureAwait(false);
|
||||
return new NetworkStream(socket, ownsSocket: true);
|
||||
}
|
||||
catch
|
||||
{
|
||||
socket.Dispose();
|
||||
throw;
|
||||
}
|
||||
}
|
||||
}
|
||||
28
packages/steam/MachineInfo.cs
Normal file
@@ -0,0 +1,28 @@
|
||||
using System.Text;
|
||||
|
||||
namespace Steam;
|
||||
|
||||
/// <summary>
|
||||
/// Provides information about the machine that the application is running on.
|
||||
/// For Steam Guard purposes, these values must return consistent results when run
|
||||
/// on the same machine / in the same container / etc., otherwise it will be treated
|
||||
/// as a separate machine and you may need to reauthenticate.
|
||||
/// </summary>
|
||||
public class IMachineInfoProvider : SteamKit2.IMachineInfoProvider
|
||||
{
|
||||
/// <summary>
|
||||
/// Provides a unique machine ID as binary data.
|
||||
/// </summary>
|
||||
/// <returns>The unique machine ID, or <c>null</c> if no such value could be found.</returns>
|
||||
public byte[]? GetMachineGuid() => Encoding.UTF8.GetBytes("Nestri-Machine");
|
||||
/// <summary>
|
||||
/// Provides the primary MAC address as binary data.
|
||||
/// </summary>
|
||||
/// <returns>The primary MAC address, or <c>null</c> if no such value could be found.</returns>
|
||||
public byte[]? GetMacAddress() => Encoding.UTF8.GetBytes("Nestri-MacAddress");
|
||||
/// <summary>
|
||||
/// Provides the boot disk's unique ID as binary data.
|
||||
/// </summary>
|
||||
/// <returns>The boot disk's unique ID, or <c>null</c> if no such value could be found.</returns>
|
||||
public byte[]? GetDiskId() => Encoding.UTF8.GetBytes("Nestri-DiskId");
|
||||
}
|
||||
@@ -1,60 +0,0 @@
|
||||
// <auto-generated />
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace steam.Migrations
|
||||
{
|
||||
[DbContext(typeof(SteamDbContext))]
|
||||
[Migration("20250322023207_InitialCreate")]
|
||||
partial class InitialCreate
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void BuildTargetModel(ModelBuilder modelBuilder)
|
||||
{
|
||||
#pragma warning disable 612, 618
|
||||
modelBuilder.HasAnnotation("ProductVersion", "9.0.3");
|
||||
|
||||
modelBuilder.Entity("SteamUserCredential", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("AccountName")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("RefreshToken")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("TeamId")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTime>("UpdatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("UserId")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("TeamId", "UserId")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("SteamUserCredentials");
|
||||
});
|
||||
#pragma warning restore 612, 618
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,46 +0,0 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace steam.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class InitialCreate : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.CreateTable(
|
||||
name: "SteamUserCredentials",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<int>(type: "INTEGER", nullable: false)
|
||||
.Annotation("Sqlite:Autoincrement", true),
|
||||
TeamId = table.Column<string>(type: "TEXT", nullable: false),
|
||||
UserId = table.Column<string>(type: "TEXT", nullable: false),
|
||||
AccountName = table.Column<string>(type: "TEXT", nullable: false),
|
||||
RefreshToken = table.Column<string>(type: "TEXT", nullable: false),
|
||||
CreatedAt = table.Column<DateTime>(type: "TEXT", nullable: false),
|
||||
UpdatedAt = table.Column<DateTime>(type: "TEXT", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_SteamUserCredentials", x => x.Id);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_SteamUserCredentials_TeamId_UserId",
|
||||
table: "SteamUserCredentials",
|
||||
columns: new[] { "TeamId", "UserId" },
|
||||
unique: true);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropTable(
|
||||
name: "SteamUserCredentials");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,57 +0,0 @@
|
||||
// <auto-generated />
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace steam.Migrations
|
||||
{
|
||||
[DbContext(typeof(SteamDbContext))]
|
||||
partial class AppDbContextModelSnapshot : ModelSnapshot
|
||||
{
|
||||
protected override void BuildModel(ModelBuilder modelBuilder)
|
||||
{
|
||||
#pragma warning disable 612, 618
|
||||
modelBuilder.HasAnnotation("ProductVersion", "9.0.3");
|
||||
|
||||
modelBuilder.Entity("SteamUserCredential", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("AccountName")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("RefreshToken")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("TeamId")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTime>("UpdatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("UserId")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("TeamId", "UserId")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("SteamUserCredentials");
|
||||
});
|
||||
#pragma warning restore 612, 618
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,331 +1,118 @@
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using Microsoft.IdentityModel.Tokens;
|
||||
using System.IdentityModel.Tokens.Jwt;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Http.Features;
|
||||
using Microsoft.AspNetCore.Authentication.JwtBearer;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
// FYI: Am very new to C# if you find any bugs or have any feedback hit me up :P
|
||||
// TBH i dunno what this code does, only God and Claude know(in the slightest) what it does.
|
||||
// And yes! It does not sit right with me - am learning C# as we go, i guess 🤧
|
||||
// This is the server to connect to the Steam APIs and do stuff like:
|
||||
// - authenticate a user,
|
||||
// - get their library,
|
||||
// - generate .vdf files for Steam Client (Steam manifest files), etc etc
|
||||
var builder = WebApplication.CreateBuilder(args);
|
||||
|
||||
// Add JWT Authentication
|
||||
builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
|
||||
.AddJwtBearer(options =>
|
||||
namespace Steam
|
||||
{
|
||||
public class Program
|
||||
{
|
||||
options.TokenValidationParameters = new TokenValidationParameters
|
||||
const string UnixSocketPath = "/tmp/steam.sock";
|
||||
|
||||
public static void Main(string[] args)
|
||||
{
|
||||
ValidateIssuer = true,
|
||||
ValidIssuer = Environment.GetEnvironmentVariable("NESTRI_AUTH_JWKS_URL"),
|
||||
ValidateAudience = false,
|
||||
ValidateLifetime = true,
|
||||
ValidateIssuerSigningKey = true,
|
||||
RequireSignedTokens = true,
|
||||
RequireExpirationTime = true,
|
||||
ClockSkew = TimeSpan.Zero,
|
||||
|
||||
// Configure the issuer signing key provider
|
||||
IssuerSigningKeyResolver = (token, securityToken, kid, validationParameters) =>
|
||||
// Delete the socket file if it exists
|
||||
if (File.Exists(UnixSocketPath))
|
||||
{
|
||||
// Fetch the JWKS manually
|
||||
var jwksUrl = $"{Environment.GetEnvironmentVariable("NESTRI_AUTH_JWKS_URL")}/.well-known/jwks.json";
|
||||
var httpClient = new HttpClient();
|
||||
var jwksJson = httpClient.GetStringAsync(jwksUrl).Result;
|
||||
var jwks = JsonSerializer.Deserialize<JsonWebKeySet>(jwksJson);
|
||||
|
||||
// Return all keys or filter by kid if provided
|
||||
if (string.IsNullOrEmpty(kid))
|
||||
return jwks?.Keys;
|
||||
else
|
||||
return jwks?.Keys.Where(k => k.Kid == kid);
|
||||
File.Delete(UnixSocketPath);
|
||||
}
|
||||
};
|
||||
|
||||
// Add logging for debugging
|
||||
options.Events = new JwtBearerEvents
|
||||
{
|
||||
OnAuthenticationFailed = context =>
|
||||
var builder = WebApplication.CreateBuilder(args);
|
||||
|
||||
// Configure Kestrel to listen on Unix socket
|
||||
builder.WebHost.ConfigureKestrel(options =>
|
||||
{
|
||||
Console.WriteLine($"Authentication failed: {context.Exception.Message}");
|
||||
return Task.CompletedTask;
|
||||
},
|
||||
OnTokenValidated = context =>
|
||||
options.ListenUnixSocket(UnixSocketPath);
|
||||
options.Limits.KeepAliveTimeout = TimeSpan.FromMinutes(10);
|
||||
options.Limits.RequestHeadersTimeout = TimeSpan.FromMinutes(5);
|
||||
});
|
||||
|
||||
builder.Services.AddControllers();
|
||||
builder.Services.AddSingleton<SteamAuthService>();
|
||||
|
||||
var app = builder.Build();
|
||||
|
||||
// Health check endpoint
|
||||
app.MapGet("/", () => "Steam Auth Service is running");
|
||||
|
||||
// QR Code login endpoint with Server-Sent Events
|
||||
app.MapGet("/api/steam/login", async (HttpResponse response, SteamAuthService steamService) =>
|
||||
{
|
||||
Console.WriteLine("Token successfully validated");
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
};
|
||||
});
|
||||
// Generate a unique session ID for this login attempt
|
||||
string sessionId = Guid.NewGuid().ToString();
|
||||
|
||||
Console.WriteLine($"Starting new login session: {sessionId}");
|
||||
|
||||
builder.Services.AddAuthorization();
|
||||
// Set up SSE response
|
||||
response.Headers.Append("Content-Type", "text/event-stream");
|
||||
response.Headers.Append("Cache-Control", "no-cache");
|
||||
response.Headers.Append("Connection", "keep-alive");
|
||||
|
||||
// Configure CORS
|
||||
builder.Services.AddCors(options =>
|
||||
{
|
||||
options.AddDefaultPolicy(
|
||||
policy =>
|
||||
{
|
||||
policy.AllowAnyOrigin()
|
||||
.AllowAnyHeader()
|
||||
.AllowAnyMethod();
|
||||
});
|
||||
});
|
||||
|
||||
builder.Services.AddSingleton<SteamService>();
|
||||
|
||||
builder.Services.AddDbContext<SteamDbContext>(options =>
|
||||
options.UseSqlite($"Data Source=/tmp/steam.db"));
|
||||
|
||||
var app = builder.Build();
|
||||
|
||||
app.UseCors();
|
||||
app.UseAuthentication();
|
||||
app.UseAuthorization();
|
||||
|
||||
|
||||
app.MapGet("/", () => "Hello World!");
|
||||
|
||||
app.MapGet("/status", [Authorize] async (HttpContext context, SteamService steamService) =>
|
||||
{
|
||||
// Validate JWT
|
||||
var jwtToken = context.Request.Headers["Authorization"].ToString().Replace("Bearer ", "");
|
||||
var (isValid, userId, email) = await ValidateJwtToken(jwtToken);
|
||||
|
||||
if (!isValid)
|
||||
{
|
||||
return Results.Unauthorized();
|
||||
}
|
||||
|
||||
// Get team ID
|
||||
var teamId = context.Request.Headers["x-nestri-team"].ToString();
|
||||
if (string.IsNullOrEmpty(teamId))
|
||||
{
|
||||
return Results.BadRequest("Missing team ID");
|
||||
}
|
||||
|
||||
// Check if user is authenticated with Steam
|
||||
var userInfo = await steamService.GetUserInfoFromStoredCredentials(teamId, userId!);
|
||||
if (userInfo == null)
|
||||
{
|
||||
return Results.Ok(new { isAuthenticated = false });
|
||||
}
|
||||
|
||||
return Results.Ok(new
|
||||
{
|
||||
isAuthenticated = true,
|
||||
steamId = userInfo.SteamId,
|
||||
username = userInfo.Username
|
||||
});
|
||||
});
|
||||
|
||||
app.MapGet("/login", [Authorize] async (HttpContext context, SteamService steamService) =>
|
||||
{
|
||||
// Validate JWT
|
||||
var jwtToken = context.Request.Headers["Authorization"].ToString().Replace("Bearer ", "");
|
||||
var (isValid, userId, email) = await ValidateJwtToken(jwtToken);
|
||||
|
||||
Console.WriteLine($"User data: {userId}:{email}");
|
||||
|
||||
if (!isValid)
|
||||
{
|
||||
context.Response.StatusCode = 401;
|
||||
await context.Response.WriteAsync("Invalid JWT token");
|
||||
return;
|
||||
}
|
||||
|
||||
// Get team ID
|
||||
var teamId = context.Request.Headers["x-nestri-team"].ToString();
|
||||
if (string.IsNullOrEmpty(teamId))
|
||||
{
|
||||
context.Response.StatusCode = 400;
|
||||
await context.Response.WriteAsync("Missing team ID");
|
||||
return;
|
||||
}
|
||||
|
||||
// Set SSE headers
|
||||
context.Response.Headers.Append("Connection", "keep-alive");
|
||||
context.Response.Headers.Append("Cache-Control", "no-cache");
|
||||
context.Response.Headers.Append("Content-Type", "text/event-stream");
|
||||
context.Response.Headers.Append("Access-Control-Allow-Origin", "*");
|
||||
|
||||
// Disable response buffering
|
||||
var responseBodyFeature = context.Features.Get<IHttpResponseBodyFeature>();
|
||||
if (responseBodyFeature != null)
|
||||
{
|
||||
responseBodyFeature.DisableBuffering();
|
||||
}
|
||||
|
||||
// Create unique client ID
|
||||
var clientId = $"{teamId}:{userId}";
|
||||
var cancellationToken = context.RequestAborted;
|
||||
|
||||
// Start Steam authentication
|
||||
await steamService.StartAuthentication(teamId, userId!);
|
||||
|
||||
// Register for updates
|
||||
var subscription = steamService.SubscribeToEvents(clientId, async (evt) =>
|
||||
{
|
||||
try
|
||||
{
|
||||
// Serialize the event to SSE format
|
||||
string eventMessage = evt.Serialize();
|
||||
byte[] buffer = Encoding.UTF8.GetBytes(eventMessage);
|
||||
|
||||
await context.Response.Body.WriteAsync(buffer, 0, buffer.Length, cancellationToken);
|
||||
await context.Response.Body.FlushAsync(cancellationToken);
|
||||
|
||||
Console.WriteLine($"Sent event type '{evt.Type}' to client {clientId}");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.WriteLine($"Error sending event to client {clientId}: {ex.Message}");
|
||||
}
|
||||
});
|
||||
|
||||
// Keep the connection alive until canceled
|
||||
try
|
||||
{
|
||||
await Task.Delay(Timeout.Infinite, cancellationToken);
|
||||
}
|
||||
catch (TaskCanceledException)
|
||||
{
|
||||
Console.WriteLine($"Client {clientId} disconnected");
|
||||
}
|
||||
finally
|
||||
{
|
||||
steamService.Unsubscribe(clientId, subscription);
|
||||
}
|
||||
});
|
||||
|
||||
app.MapGet("/user", [Authorize] async (HttpContext context, SteamService steamService) =>
|
||||
{
|
||||
// Validate JWT
|
||||
var jwtToken = context.Request.Headers["Authorization"].ToString().Replace("Bearer ", "");
|
||||
var (isValid, userId, email) = await ValidateJwtToken(jwtToken);
|
||||
|
||||
if (!isValid)
|
||||
{
|
||||
return Results.Unauthorized();
|
||||
}
|
||||
|
||||
// Get team ID
|
||||
var teamId = context.Request.Headers["x-nestri-team"].ToString();
|
||||
if (string.IsNullOrEmpty(teamId))
|
||||
{
|
||||
return Results.BadRequest("Missing team ID");
|
||||
}
|
||||
|
||||
// Get user info from stored credentials
|
||||
var userInfo = await steamService.GetUserInfoFromStoredCredentials(teamId, userId);
|
||||
if (userInfo == null)
|
||||
{
|
||||
return Results.NotFound(new { error = "User not authenticated with Steam" });
|
||||
}
|
||||
|
||||
return Results.Ok(new
|
||||
{
|
||||
steamId = userInfo.SteamId,
|
||||
username = userInfo.Username
|
||||
});
|
||||
});
|
||||
|
||||
app.MapPost("/logout", [Authorize] async (HttpContext context, SteamService steamService) =>
|
||||
{
|
||||
// Validate JWT
|
||||
var jwtToken = context.Request.Headers["Authorization"].ToString().Replace("Bearer ", "");
|
||||
var (isValid, userId, email) = await ValidateJwtToken(jwtToken);
|
||||
|
||||
if (!isValid)
|
||||
{
|
||||
return Results.Unauthorized();
|
||||
}
|
||||
|
||||
// Get team ID
|
||||
var teamId = context.Request.Headers["x-nestri-team"].ToString();
|
||||
if (string.IsNullOrEmpty(teamId))
|
||||
{
|
||||
return Results.BadRequest("Missing team ID");
|
||||
}
|
||||
|
||||
// Delete the stored credentials
|
||||
using var scope = context.RequestServices.CreateScope();
|
||||
var dbContext = scope.ServiceProvider.GetRequiredService<SteamDbContext>();
|
||||
|
||||
var credentials = await dbContext.SteamUserCredentials
|
||||
.FirstOrDefaultAsync(c => c.TeamId == teamId && c.UserId == userId);
|
||||
|
||||
if (credentials != null)
|
||||
{
|
||||
dbContext.SteamUserCredentials.Remove(credentials);
|
||||
await dbContext.SaveChangesAsync();
|
||||
return Results.Ok(new { message = "Steam authentication revoked" });
|
||||
}
|
||||
|
||||
return Results.NotFound(new { error = "No Steam authentication found" });
|
||||
});
|
||||
|
||||
// JWT validation function
|
||||
async Task<(bool IsValid, string? UserId, string? Email)> ValidateJwtToken(string token)
|
||||
{
|
||||
try
|
||||
{
|
||||
var jwksUrl = Environment.GetEnvironmentVariable("NESTRI_AUTH_JWKS_URL");
|
||||
var handler = new JwtSecurityTokenHandler();
|
||||
var jwtToken = handler.ReadJwtToken(token);
|
||||
|
||||
// Log all claims for debugging
|
||||
// Console.WriteLine("JWT Claims:");
|
||||
// foreach (var claim in jwtToken.Claims)
|
||||
// {
|
||||
// Console.WriteLine($" {claim.Type}: {claim.Value}");
|
||||
// }
|
||||
|
||||
// Validate token using JWKS
|
||||
var httpClient = new HttpClient();
|
||||
var jwksJson = await httpClient.GetStringAsync($"{jwksUrl}/.well-known/jwks.json");
|
||||
var jwks = JsonSerializer.Deserialize<JsonWebKeySet>(jwksJson);
|
||||
|
||||
// Extract the properties claim which contains nested JSON
|
||||
var propertiesClaim = jwtToken.Claims.FirstOrDefault(c => c.Type == "properties")?.Value;
|
||||
if (!string.IsNullOrEmpty(propertiesClaim))
|
||||
{
|
||||
// Parse the nested JSON
|
||||
var properties = JsonSerializer.Deserialize<Dictionary<string, string>>(propertiesClaim);
|
||||
|
||||
// Extract userID from properties
|
||||
var email = properties?.GetValueOrDefault("email");
|
||||
var userId = properties?.GetValueOrDefault("userID");
|
||||
|
||||
if (string.IsNullOrEmpty(userId) || string.IsNullOrEmpty(email))
|
||||
{
|
||||
// Also check standard claims as fallback
|
||||
userId = jwtToken.Claims.FirstOrDefault(c => c.Type == "sub")?.Value;
|
||||
email = jwtToken.Claims.FirstOrDefault(c => c.Type == "email")?.Value;
|
||||
|
||||
if (string.IsNullOrEmpty(userId) || string.IsNullOrEmpty(email))
|
||||
try
|
||||
{
|
||||
return (false, null, null);
|
||||
// Start QR login session with SSE updates
|
||||
await steamService.StartQrLoginSessionAsync(response, sessionId);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.WriteLine($"Error in login session {sessionId}: {ex.Message}");
|
||||
|
||||
// Send error message as SSE
|
||||
await response.WriteAsync($"event: error\n");
|
||||
await response.WriteAsync($"data: {{\"message\":\"{ex.Message}\"}}\n\n");
|
||||
await response.Body.FlushAsync();
|
||||
}
|
||||
});
|
||||
|
||||
return (true, userId, email);
|
||||
// Login with credentials endpoint (returns JSON)
|
||||
app.MapPost("/api/steam/login-with-credentials", async (LoginCredentials credentials, SteamAuthService steamService) =>
|
||||
{
|
||||
if (string.IsNullOrEmpty(credentials.Username) || string.IsNullOrEmpty(credentials.RefreshToken))
|
||||
{
|
||||
return Results.BadRequest("Username and refresh token are required");
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var result = await steamService.LoginWithCredentialsAsync(
|
||||
credentials.Username,
|
||||
credentials.RefreshToken);
|
||||
|
||||
return Results.Ok(result);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.WriteLine($"Error logging in with credentials: {ex.Message}");
|
||||
return Results.Problem(ex.Message);
|
||||
}
|
||||
});
|
||||
|
||||
// Get user info endpoint (returns JSON)
|
||||
app.MapGet("/api/steam/user", async (HttpRequest request, SteamAuthService steamService) =>
|
||||
{
|
||||
// Get credentials from headers
|
||||
var username = request.Headers["X-Steam-Username"].ToString();
|
||||
var refreshToken = request.Headers["X-Steam-Token"].ToString();
|
||||
|
||||
if (string.IsNullOrEmpty(username) || string.IsNullOrEmpty(refreshToken))
|
||||
{
|
||||
return Results.BadRequest("Username and refresh token headers are required");
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var userInfo = await steamService.GetUserInfoAsync(username, refreshToken);
|
||||
return Results.Ok(userInfo);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.WriteLine($"Error getting user info: {ex.Message}");
|
||||
return Results.Problem(ex.Message);
|
||||
}
|
||||
});
|
||||
|
||||
app.Run();
|
||||
}
|
||||
|
||||
return (false, null, null);
|
||||
}
|
||||
catch (Exception ex)
|
||||
|
||||
public class LoginCredentials
|
||||
{
|
||||
Console.WriteLine($"JWT validation error: {ex.Message}");
|
||||
return (false, null, null);
|
||||
public string Username { get; set; } = string.Empty;
|
||||
public string RefreshToken { get; set; } = string.Empty;
|
||||
}
|
||||
}
|
||||
|
||||
Console.WriteLine("Server started. Press Ctrl+C to stop.");
|
||||
await app.RunAsync();
|
||||
}
|
||||
@@ -4,8 +4,8 @@
|
||||
"windowsAuthentication": false,
|
||||
"anonymousAuthentication": true,
|
||||
"iisExpress": {
|
||||
"applicationUrl": "http://localhost:12427",
|
||||
"sslPort": 44354
|
||||
"applicationUrl": "http://localhost:41347",
|
||||
"sslPort": 44359
|
||||
}
|
||||
},
|
||||
"profiles": {
|
||||
@@ -13,7 +13,8 @@
|
||||
"commandName": "Project",
|
||||
"dotnetRunMessages": true,
|
||||
"launchBrowser": true,
|
||||
"applicationUrl": "http://localhost:5289",
|
||||
"launchUrl": "swagger",
|
||||
"applicationUrl": "http://localhost:5221",
|
||||
"environmentVariables": {
|
||||
"ASPNETCORE_ENVIRONMENT": "Development"
|
||||
}
|
||||
@@ -22,7 +23,8 @@
|
||||
"commandName": "Project",
|
||||
"dotnetRunMessages": true,
|
||||
"launchBrowser": true,
|
||||
"applicationUrl": "https://localhost:7168;http://localhost:5289",
|
||||
"launchUrl": "swagger",
|
||||
"applicationUrl": "https://localhost:7060;http://localhost:5221",
|
||||
"environmentVariables": {
|
||||
"ASPNETCORE_ENVIRONMENT": "Development"
|
||||
}
|
||||
@@ -30,6 +32,7 @@
|
||||
"IIS Express": {
|
||||
"commandName": "IISExpress",
|
||||
"launchBrowser": true,
|
||||
"launchUrl": "swagger",
|
||||
"environmentVariables": {
|
||||
"ASPNETCORE_ENVIRONMENT": "Development"
|
||||
}
|
||||
|
||||
@@ -1,19 +0,0 @@
|
||||
using System.Text.Json;
|
||||
|
||||
public class ServerSentEvent
|
||||
{
|
||||
public string Type { get; set; }
|
||||
public object Data { get; set; }
|
||||
|
||||
public ServerSentEvent(string type, object data)
|
||||
{
|
||||
Type = type;
|
||||
Data = data;
|
||||
}
|
||||
|
||||
public string Serialize()
|
||||
{
|
||||
var dataJson = JsonSerializer.Serialize(Data);
|
||||
return $"event: {Type}\ndata: {dataJson}\n\n";
|
||||
}
|
||||
}
|
||||
389
packages/steam/SteamAuthService.cs
Normal file
@@ -0,0 +1,389 @@
|
||||
using SteamKit2;
|
||||
using SteamKit2.Authentication;
|
||||
|
||||
namespace Steam
|
||||
{
|
||||
public class SteamAuthService
|
||||
{
|
||||
private readonly SteamClient _steamClient;
|
||||
private readonly SteamUser _steamUser;
|
||||
private readonly SteamFriends _steamFriends;
|
||||
private readonly CallbackManager _manager;
|
||||
private CancellationTokenSource? _cts;
|
||||
private Task? _callbackTask;
|
||||
private readonly Dictionary<string, TaskCompletionSource<bool>> _authCompletionSources = new();
|
||||
|
||||
public SteamAuthService()
|
||||
{
|
||||
var configuration = SteamConfiguration.Create(config =>
|
||||
{
|
||||
config.WithHttpClientFactory(HttpClientFactory.CreateHttpClient);
|
||||
config.WithMachineInfoProvider(new IMachineInfoProvider());
|
||||
config.WithConnectionTimeout(TimeSpan.FromSeconds(10));
|
||||
});
|
||||
|
||||
_steamClient = new SteamClient(configuration);
|
||||
_manager = new CallbackManager(_steamClient);
|
||||
_steamUser = _steamClient.GetHandler<SteamUser>() ?? throw new InvalidOperationException("SteamUser handler not available");
|
||||
_steamFriends = _steamClient.GetHandler<SteamFriends>() ?? throw new InvalidOperationException("SteamFriends handler not available");
|
||||
|
||||
// Register basic callbacks
|
||||
_manager.Subscribe<SteamClient.ConnectedCallback>(OnConnected);
|
||||
_manager.Subscribe<SteamClient.DisconnectedCallback>(OnDisconnected);
|
||||
_manager.Subscribe<SteamUser.LoggedOnCallback>(OnLoggedOn);
|
||||
_manager.Subscribe<SteamUser.LoggedOffCallback>(OnLoggedOff);
|
||||
}
|
||||
|
||||
// Main login method - initiates QR authentication and sends SSE updates
|
||||
public async Task StartQrLoginSessionAsync(HttpResponse response, string sessionId)
|
||||
{
|
||||
response.Headers.Append("Content-Type", "text/event-stream");
|
||||
response.Headers.Append("Cache-Control", "no-cache");
|
||||
response.Headers.Append("Connection", "keep-alive");
|
||||
|
||||
// Create a completion source for this session
|
||||
var tcs = new TaskCompletionSource<bool>();
|
||||
_authCompletionSources[sessionId] = tcs;
|
||||
|
||||
try
|
||||
{
|
||||
// Connect to Steam if not already connected
|
||||
await EnsureConnectedAsync();
|
||||
|
||||
// Send initial status
|
||||
await SendSseEvent(response, "status", new { message = "Starting QR authentication..." });
|
||||
|
||||
// Begin auth session
|
||||
var authSession = await _steamClient.Authentication.BeginAuthSessionViaQRAsync(
|
||||
new AuthSessionDetails
|
||||
{
|
||||
PlatformType = SteamKit2.Internal.EAuthTokenPlatformType.k_EAuthTokenPlatformType_SteamClient,
|
||||
DeviceFriendlyName = "Nestri Cloud Gaming",
|
||||
ClientOSType = EOSType.Linux5x
|
||||
}
|
||||
);
|
||||
|
||||
// Handle URL changes
|
||||
authSession.ChallengeURLChanged = async () =>
|
||||
{
|
||||
await SendSseEvent(response, "challenge_url", new { url = authSession.ChallengeURL });
|
||||
};
|
||||
|
||||
// Send initial QR code URL
|
||||
await SendSseEvent(response, "challenge_url", new { url = authSession.ChallengeURL });
|
||||
|
||||
// Poll for authentication result
|
||||
try
|
||||
{
|
||||
var pollResponse = await authSession.PollingWaitForResultAsync();
|
||||
|
||||
// Send credentials to client
|
||||
await SendSseEvent(response, "credentials", new
|
||||
{
|
||||
username = pollResponse.AccountName,
|
||||
refreshToken = pollResponse.RefreshToken
|
||||
});
|
||||
|
||||
// Log in with obtained credentials
|
||||
await SendSseEvent(response, "status", new { message = $"Logging in as '{pollResponse.AccountName}'..." });
|
||||
|
||||
_steamUser.LogOn(new SteamUser.LogOnDetails
|
||||
{
|
||||
Username = pollResponse.AccountName,
|
||||
MachineName = "Nestri Cloud Gaming",
|
||||
ClientOSType = EOSType.Linux5x,
|
||||
AccessToken = pollResponse.RefreshToken
|
||||
});
|
||||
|
||||
// Wait for login to complete (handled by OnLoggedOn callback)
|
||||
await tcs.Task;
|
||||
|
||||
// Send final success message
|
||||
await SendSseEvent(response, "login-successful", new
|
||||
{
|
||||
steamId = _steamUser.SteamID?.ConvertToUInt64(),
|
||||
username = pollResponse.AccountName
|
||||
});
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
await SendSseEvent(response, "login-unsuccessful", new { error = ex.Message });
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
await SendSseEvent(response, "error", new { message = ex.Message });
|
||||
}
|
||||
finally
|
||||
{
|
||||
// Clean up
|
||||
_authCompletionSources.Remove(sessionId);
|
||||
await response.Body.FlushAsync();
|
||||
}
|
||||
}
|
||||
|
||||
// Method to login with existing credentials and return result (no SSE)
|
||||
public async Task<LoginResult> LoginWithCredentialsAsync(string username, string refreshToken)
|
||||
{
|
||||
var sessionId = Guid.NewGuid().ToString();
|
||||
var tcs = new TaskCompletionSource<bool>();
|
||||
_authCompletionSources[sessionId] = tcs;
|
||||
|
||||
try
|
||||
{
|
||||
// Connect to Steam if not already connected
|
||||
await EnsureConnectedAsync();
|
||||
|
||||
// Log in with provided credentials
|
||||
_steamUser.LogOn(new SteamUser.LogOnDetails
|
||||
{
|
||||
Username = username,
|
||||
MachineName = "Nestri Cloud Gaming",
|
||||
AccessToken = refreshToken,
|
||||
ClientOSType = EOSType.Linux5x,
|
||||
});
|
||||
|
||||
// Wait for login to complete (handled by OnLoggedOn callback)
|
||||
var success = await tcs.Task;
|
||||
|
||||
if (success)
|
||||
{
|
||||
return new LoginResult
|
||||
{
|
||||
Success = true,
|
||||
SteamId = _steamUser.SteamID?.ConvertToUInt64(),
|
||||
Username = username
|
||||
};
|
||||
}
|
||||
else
|
||||
{
|
||||
return new LoginResult
|
||||
{
|
||||
Success = false,
|
||||
ErrorMessage = "Login failed"
|
||||
};
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return new LoginResult
|
||||
{
|
||||
Success = false,
|
||||
ErrorMessage = ex.Message
|
||||
};
|
||||
}
|
||||
finally
|
||||
{
|
||||
_authCompletionSources.Remove(sessionId);
|
||||
}
|
||||
}
|
||||
|
||||
// Method to get user information - waits for all required callbacks to complete
|
||||
public async Task<UserInfo> GetUserInfoAsync(string username, string refreshToken)
|
||||
{
|
||||
// First ensure we're logged in
|
||||
var loginResult = await LoginWithCredentialsAsync(username, refreshToken);
|
||||
if (!loginResult.Success)
|
||||
{
|
||||
throw new Exception($"Failed to log in: {loginResult.ErrorMessage}");
|
||||
}
|
||||
|
||||
var userInfo = new UserInfo
|
||||
{
|
||||
SteamId = _steamUser.SteamID?.ConvertToUInt64() ?? 0,
|
||||
Username = username
|
||||
};
|
||||
|
||||
// Set up completion sources for each piece of information
|
||||
var accountInfoTcs = new TaskCompletionSource<bool>();
|
||||
var personaStateTcs = new TaskCompletionSource<bool>();
|
||||
var emailInfoTcs = new TaskCompletionSource<bool>();
|
||||
|
||||
// Subscribe to one-time callbacks
|
||||
var accountSub = _manager.Subscribe<SteamUser.AccountInfoCallback>(callback =>
|
||||
{
|
||||
userInfo.Country = callback.Country;
|
||||
userInfo.PersonaName = callback.PersonaName;
|
||||
accountInfoTcs.TrySetResult(true);
|
||||
});
|
||||
|
||||
var personaSub = _manager.Subscribe<SteamFriends.PersonaStateCallback>(callback =>
|
||||
{
|
||||
if (callback.FriendID == _steamUser.SteamID)
|
||||
{
|
||||
// Convert avatar hash to URL
|
||||
if (callback.AvatarHash != null && callback.AvatarHash.Length > 0)
|
||||
{
|
||||
var avatarStr = BitConverter.ToString(callback.AvatarHash).Replace("-", "").ToLowerInvariant();
|
||||
userInfo.AvatarUrl = $"https://avatars.akamai.steamstatic.com/{avatarStr}_full.jpg";
|
||||
}
|
||||
|
||||
userInfo.PersonaName = callback.Name;
|
||||
userInfo.GameId = callback.GameID?.ToUInt64() ?? 0;
|
||||
userInfo.GamePlayingName = callback.GameName;
|
||||
userInfo.LastLogOn = callback.LastLogOn;
|
||||
userInfo.LastLogOff = callback.LastLogOff;
|
||||
personaStateTcs.TrySetResult(true);
|
||||
}
|
||||
});
|
||||
|
||||
var emailSub = _manager.Subscribe<SteamUser.EmailAddrInfoCallback>(callback =>
|
||||
{
|
||||
userInfo.Email = callback.EmailAddress;
|
||||
emailInfoTcs.TrySetResult(true);
|
||||
});
|
||||
|
||||
try
|
||||
{
|
||||
// Request all the info
|
||||
if (_steamUser.SteamID != null)
|
||||
{
|
||||
_steamFriends.RequestFriendInfo(_steamUser.SteamID);
|
||||
}
|
||||
|
||||
// Wait for all callbacks with timeout
|
||||
var timeoutTask = Task.Delay(TimeSpan.FromSeconds(10));
|
||||
var tasks = new[]
|
||||
{
|
||||
accountInfoTcs.Task,
|
||||
personaStateTcs.Task,
|
||||
emailInfoTcs.Task
|
||||
};
|
||||
|
||||
await Task.WhenAny(Task.WhenAll(tasks), timeoutTask);
|
||||
|
||||
return userInfo;
|
||||
}
|
||||
finally
|
||||
{
|
||||
// Unsubscribe from callbacks
|
||||
// _manager.Unsubscribe(accountSub);
|
||||
// _manager.Unsubscribe(personaSub);
|
||||
// _manager.Unsubscribe(emailSub);
|
||||
}
|
||||
}
|
||||
|
||||
public void Disconnect()
|
||||
{
|
||||
_cts?.Cancel();
|
||||
|
||||
if (_steamUser.SteamID != null)
|
||||
{
|
||||
_steamUser.LogOff();
|
||||
}
|
||||
|
||||
_steamClient.Disconnect();
|
||||
}
|
||||
|
||||
#region Private Helper Methods
|
||||
|
||||
private async Task EnsureConnectedAsync()
|
||||
{
|
||||
if (_callbackTask == null)
|
||||
{
|
||||
_cts = new CancellationTokenSource();
|
||||
_steamClient.Connect();
|
||||
|
||||
// Run callback loop in background
|
||||
_callbackTask = Task.Run(() =>
|
||||
{
|
||||
while (!_cts.Token.IsCancellationRequested)
|
||||
{
|
||||
_manager.RunWaitCallbacks(TimeSpan.FromMilliseconds(500));
|
||||
Thread.Sleep(10);
|
||||
}
|
||||
}, _cts.Token);
|
||||
var connectionTcs = new TaskCompletionSource<bool>();
|
||||
var connectionSub = _manager.Subscribe<SteamClient.ConnectedCallback>(_ =>
|
||||
{
|
||||
connectionTcs.TrySetResult(true);
|
||||
});
|
||||
|
||||
try
|
||||
{
|
||||
// Wait up to 10 seconds for connection
|
||||
var timeoutTask = Task.Delay(TimeSpan.FromSeconds(10));
|
||||
var completedTask = await Task.WhenAny(connectionTcs.Task, timeoutTask);
|
||||
|
||||
if (completedTask == timeoutTask)
|
||||
{
|
||||
throw new TimeoutException("Connection to Steam timed out");
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
// _manager.Unsubscribe(connectionSub);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task SendSseEvent(HttpResponse response, string eventType, object data)
|
||||
{
|
||||
var json = System.Text.Json.JsonSerializer.Serialize(data);
|
||||
await response.WriteAsync($"event: {eventType}\n");
|
||||
await response.WriteAsync($"data: {json}\n\n");
|
||||
await response.Body.FlushAsync();
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Callback Handlers
|
||||
|
||||
private void OnConnected(SteamClient.ConnectedCallback callback)
|
||||
{
|
||||
Console.WriteLine("Connected to Steam");
|
||||
}
|
||||
|
||||
private void OnDisconnected(SteamClient.DisconnectedCallback callback)
|
||||
{
|
||||
Console.WriteLine("Disconnected from Steam");
|
||||
|
||||
// Only try to reconnect if not deliberately disconnected
|
||||
if (_callbackTask != null && !_cts!.IsCancellationRequested)
|
||||
{
|
||||
Task.Delay(TimeSpan.FromSeconds(5)).ContinueWith(_ => _steamClient.Connect());
|
||||
}
|
||||
}
|
||||
|
||||
private void OnLoggedOn(SteamUser.LoggedOnCallback callback)
|
||||
{
|
||||
var success = callback.Result == EResult.OK;
|
||||
Console.WriteLine($"Logged on: {success}");
|
||||
|
||||
// Complete all pending auth completion sources
|
||||
foreach (var tcs in _authCompletionSources.Values)
|
||||
{
|
||||
tcs.TrySetResult(success);
|
||||
}
|
||||
}
|
||||
|
||||
private void OnLoggedOff(SteamUser.LoggedOffCallback callback)
|
||||
{
|
||||
Console.WriteLine($"Logged off: {callback.Result}");
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
|
||||
public class LoginResult
|
||||
{
|
||||
public bool Success { get; set; }
|
||||
public ulong? SteamId { get; set; }
|
||||
public string? Username { get; set; }
|
||||
public string? ErrorMessage { get; set; }
|
||||
}
|
||||
|
||||
public class UserInfo
|
||||
{
|
||||
public ulong SteamId { get; set; }
|
||||
public string? Username { get; set; }
|
||||
public string? PersonaName { get; set; }
|
||||
public string? Country { get; set; }
|
||||
public string? Email { get; set; }
|
||||
public string? AvatarUrl { get; set; }
|
||||
public ulong GameId { get; set; }
|
||||
public string? GamePlayingName { get; set; }
|
||||
public DateTime LastLogOn { get; set; }
|
||||
public DateTime LastLogOff { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -1,357 +0,0 @@
|
||||
using SteamKit2;
|
||||
using SteamKit2.Authentication;
|
||||
|
||||
// Steam client handler
|
||||
public class SteamClientHandler
|
||||
{
|
||||
private readonly string _clientId;
|
||||
private readonly SteamClient _steamClient;
|
||||
private readonly CallbackManager _manager;
|
||||
private readonly SteamUser _steamUser;
|
||||
public event Action<ServerSentEvent>? OnEvent;
|
||||
private readonly List<Action<string>> _subscribers = new();
|
||||
private QrAuthSession? _authSession;
|
||||
private Task? _callbackTask;
|
||||
private CancellationTokenSource? _cts;
|
||||
private bool _isAuthenticated = false;
|
||||
|
||||
public SteamUserInfo? UserInfo { get; private set; }
|
||||
|
||||
// Add a callback for when credentials are obtained
|
||||
private readonly Action<string, string>? _onCredentialsObtained;
|
||||
|
||||
// Update constructor to optionally receive the callback
|
||||
public SteamClientHandler(string clientId, Action<string, string>? onCredentialsObtained = null)
|
||||
{
|
||||
_clientId = clientId;
|
||||
_onCredentialsObtained = onCredentialsObtained;
|
||||
_steamClient = new SteamClient(SteamConfiguration.Create(e => e.WithConnectionTimeout(TimeSpan.FromSeconds(120))));
|
||||
_manager = new CallbackManager(_steamClient);
|
||||
_steamUser = _steamClient.GetHandler<SteamUser>()!;
|
||||
|
||||
// Register callbacks
|
||||
_manager.Subscribe<SteamClient.ConnectedCallback>(OnConnected);
|
||||
_manager.Subscribe<SteamClient.DisconnectedCallback>(OnDisconnected);
|
||||
_manager.Subscribe<SteamUser.LoggedOnCallback>(OnLoggedOn);
|
||||
_manager.Subscribe<SteamUser.LoggedOffCallback>(OnLoggedOff);
|
||||
}
|
||||
|
||||
// Add method to login with stored credentials
|
||||
public async Task<bool> LoginWithStoredCredentialsAsync(string accountName, string refreshToken)
|
||||
{
|
||||
if (_callbackTask != null)
|
||||
{
|
||||
return _isAuthenticated; // Already connected
|
||||
}
|
||||
|
||||
_cts = new CancellationTokenSource();
|
||||
|
||||
// Connect to Steam
|
||||
Console.WriteLine($"[{_clientId}] Connecting to Steam with stored credentials...");
|
||||
_steamClient.Connect();
|
||||
|
||||
// Start callback loop
|
||||
_callbackTask = Task.Run(async () =>
|
||||
{
|
||||
while (!_cts.Token.IsCancellationRequested)
|
||||
{
|
||||
_manager.RunWaitCallbacks(TimeSpan.FromSeconds(1));
|
||||
await Task.Delay(10);
|
||||
}
|
||||
}, _cts.Token);
|
||||
|
||||
// Wait for connection
|
||||
var connectionTask = new TaskCompletionSource<bool>();
|
||||
var connectedHandler = _manager.Subscribe<SteamClient.ConnectedCallback>(callback =>
|
||||
{
|
||||
// Once connected, try to log in with stored credentials
|
||||
Console.WriteLine($"[{_clientId}] Connected to Steam, logging in with stored credentials");
|
||||
_steamUser.LogOn(new SteamUser.LogOnDetails
|
||||
{
|
||||
Username = accountName,
|
||||
AccessToken = refreshToken
|
||||
});
|
||||
connectionTask.TrySetResult(true);
|
||||
});
|
||||
|
||||
// Set up a handler for the login result
|
||||
var loginResultTask = new TaskCompletionSource<bool>();
|
||||
var loggedOnHandler = _manager.Subscribe<SteamUser.LoggedOnCallback>(callback =>
|
||||
{
|
||||
if (callback.Result == EResult.OK)
|
||||
{
|
||||
Console.WriteLine($"[{_clientId}] Successfully logged on with stored credentials");
|
||||
_isAuthenticated = true;
|
||||
UserInfo = new SteamUserInfo
|
||||
{
|
||||
SteamId = callback.ClientSteamID.ToString(),
|
||||
Username = accountName
|
||||
};
|
||||
loginResultTask.TrySetResult(true);
|
||||
}
|
||||
else
|
||||
{
|
||||
Console.WriteLine($"[{_clientId}] Failed to log on with stored credentials: {callback.Result}");
|
||||
loginResultTask.TrySetResult(false);
|
||||
}
|
||||
});
|
||||
|
||||
// Add a timeout
|
||||
var timeoutTask = Task.Delay(TimeSpan.FromSeconds(30));
|
||||
|
||||
try
|
||||
{
|
||||
await connectionTask.Task;
|
||||
|
||||
var completedTask = await Task.WhenAny(loginResultTask.Task, timeoutTask);
|
||||
|
||||
if (completedTask == timeoutTask)
|
||||
{
|
||||
Console.WriteLine($"[{_clientId}] Login with stored credentials timed out");
|
||||
Shutdown();
|
||||
return false;
|
||||
}
|
||||
|
||||
return await loginResultTask.Task;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.WriteLine($"[{_clientId}] Error logging in with stored credentials: {ex.Message}");
|
||||
return false;
|
||||
}
|
||||
// finally
|
||||
// {
|
||||
// _manager.Unsubscribe(connectedHandler);
|
||||
// _manager.Unsubscribe(loggedOnHandler);
|
||||
// }
|
||||
}
|
||||
|
||||
public async Task StartAuthenticationAsync()
|
||||
{
|
||||
if (_callbackTask != null)
|
||||
{
|
||||
// Authentication already in progress
|
||||
if (_authSession != null)
|
||||
{
|
||||
// Just resend the current QR code URL to all subscribers
|
||||
NotifySubscribers(_authSession.ChallengeURL);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
_cts = new CancellationTokenSource();
|
||||
|
||||
// Connect to Steam
|
||||
Console.WriteLine($"[{_clientId}] Connecting to Steam...");
|
||||
_steamClient.Connect();
|
||||
|
||||
// Start callback loop
|
||||
_callbackTask = Task.Run(async () =>
|
||||
{
|
||||
while (!_cts.Token.IsCancellationRequested)
|
||||
{
|
||||
_manager.RunWaitCallbacks(TimeSpan.FromSeconds(1));
|
||||
await Task.Delay(10);
|
||||
}
|
||||
}, _cts.Token);
|
||||
}
|
||||
|
||||
private void NotifyEvent(ServerSentEvent evt)
|
||||
{
|
||||
OnEvent?.Invoke(evt);
|
||||
|
||||
// Also notify the legacy subscribers with just the URL if this is a URL event
|
||||
if (evt.Type == "url" && evt.Data is string url)
|
||||
{
|
||||
NotifySubscribers(url);
|
||||
}
|
||||
}
|
||||
|
||||
private async void OnConnected(SteamClient.ConnectedCallback callback)
|
||||
{
|
||||
Console.WriteLine($"[{_clientId}] Connected to Steam");
|
||||
|
||||
try
|
||||
{
|
||||
// Start QR authentication session
|
||||
_authSession = await _steamClient.Authentication.BeginAuthSessionViaQRAsync(new AuthSessionDetails());
|
||||
|
||||
// Handle QR code URL changes
|
||||
_authSession.ChallengeURLChanged = () =>
|
||||
{
|
||||
Console.WriteLine($"[{_clientId}] QR challenge URL refreshed");
|
||||
NotifyEvent(new ServerSentEvent("url", _authSession.ChallengeURL));
|
||||
};
|
||||
|
||||
// Send initial QR code URL
|
||||
NotifyEvent(new ServerSentEvent("url", _authSession.ChallengeURL));
|
||||
|
||||
// Start polling for authentication result
|
||||
await Task.Run(async () =>
|
||||
{
|
||||
try
|
||||
{
|
||||
var pollResponse = await _authSession.PollingWaitForResultAsync();
|
||||
|
||||
Console.WriteLine($"[{_clientId}] Logging in as '{pollResponse.AccountName}'");
|
||||
|
||||
// Send login attempt event
|
||||
NotifyEvent(new ServerSentEvent("login-attempt", new { username = pollResponse.AccountName }));
|
||||
|
||||
// Login to Steam
|
||||
_steamUser.LogOn(new SteamUser.LogOnDetails
|
||||
{
|
||||
Username = pollResponse.AccountName,
|
||||
AccessToken = pollResponse.RefreshToken,
|
||||
});
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.WriteLine($"[{_clientId}] Authentication polling error: {ex.Message}");
|
||||
NotifyEvent(new ServerSentEvent("login-unsuccessful", new { error = ex.Message }));
|
||||
}
|
||||
});
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.WriteLine($"[{_clientId}] Error starting authentication: {ex.Message}");
|
||||
NotifyEvent(new ServerSentEvent("login-unsuccessful", new { error = ex.Message }));
|
||||
}
|
||||
}
|
||||
|
||||
private void OnDisconnected(SteamClient.DisconnectedCallback callback)
|
||||
{
|
||||
Console.WriteLine($"[{_clientId}] Disconnected from Steam");
|
||||
|
||||
_isAuthenticated = false;
|
||||
UserInfo = null;
|
||||
|
||||
// Reconnect if not intentionally stopped
|
||||
if (_callbackTask != null && !_cts.IsCancellationRequested)
|
||||
{
|
||||
Console.WriteLine($"[{_clientId}] Reconnecting...");
|
||||
_steamClient.Connect();
|
||||
}
|
||||
}
|
||||
|
||||
private void OnLoggedOn(SteamUser.LoggedOnCallback callback)
|
||||
{
|
||||
if (callback.Result != EResult.OK)
|
||||
{
|
||||
Console.WriteLine($"[{_clientId}] Unable to log on to Steam: {callback.Result} / {callback.ExtendedResult}");
|
||||
NotifyEvent(new ServerSentEvent("login-unsuccessful", new
|
||||
{
|
||||
error = $"Steam login failed: {callback.Result}",
|
||||
extendedError = callback.ExtendedResult.ToString()
|
||||
}));
|
||||
return;
|
||||
}
|
||||
|
||||
Console.WriteLine($"[{_clientId}] Successfully logged on as {callback.ClientSteamID}");
|
||||
|
||||
_isAuthenticated = true;
|
||||
|
||||
// Get the username from the authentication session
|
||||
string accountName = _authSession?.PollingWaitForResultAsync().Result.AccountName ?? "Unknown";
|
||||
string refreshToken = _authSession?.PollingWaitForResultAsync().Result.RefreshToken ?? "";
|
||||
|
||||
UserInfo = new SteamUserInfo
|
||||
{
|
||||
SteamId = callback.ClientSteamID.ToString(),
|
||||
Username = accountName
|
||||
};
|
||||
|
||||
// Send login success event
|
||||
NotifyEvent(new ServerSentEvent("login-success", new
|
||||
{
|
||||
steamId = callback.ClientSteamID.ToString(),
|
||||
username = accountName
|
||||
}));
|
||||
|
||||
// Save credentials if callback is provided
|
||||
if (_onCredentialsObtained != null && !string.IsNullOrEmpty(refreshToken))
|
||||
{
|
||||
_onCredentialsObtained(accountName, refreshToken);
|
||||
}
|
||||
}
|
||||
|
||||
private void OnLoggedOff(SteamUser.LoggedOffCallback callback)
|
||||
{
|
||||
Console.WriteLine($"[{_clientId}] Logged off of Steam: {callback.Result}");
|
||||
|
||||
_isAuthenticated = false;
|
||||
UserInfo = null;
|
||||
|
||||
//Unnecessary but just in case the frontend wants to listen to this
|
||||
NotifyEvent(new ServerSentEvent("logged-off", new
|
||||
{
|
||||
reason = callback.Result.ToString()
|
||||
}));
|
||||
}
|
||||
|
||||
public Action Subscribe(Action<ServerSentEvent> callback)
|
||||
{
|
||||
OnEvent += callback;
|
||||
|
||||
// If we already have a QR code URL, send it immediately
|
||||
if (_authSession != null)
|
||||
{
|
||||
callback(new ServerSentEvent("url", _authSession.ChallengeURL));
|
||||
}
|
||||
|
||||
return () => OnEvent -= callback;
|
||||
}
|
||||
|
||||
// Keep the old Subscribe method for backward compatibility
|
||||
public Action Subscribe(Action<string> callback)
|
||||
{
|
||||
lock (_subscribers)
|
||||
{
|
||||
_subscribers.Add(callback);
|
||||
|
||||
// If we already have a QR code URL, send it immediately
|
||||
if (_authSession != null)
|
||||
{
|
||||
callback(_authSession.ChallengeURL);
|
||||
}
|
||||
}
|
||||
|
||||
return () =>
|
||||
{
|
||||
lock (_subscribers)
|
||||
{
|
||||
_subscribers.Remove(callback);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
private void NotifySubscribers(string url)
|
||||
{
|
||||
lock (_subscribers)
|
||||
{
|
||||
foreach (var subscriber in _subscribers)
|
||||
{
|
||||
try
|
||||
{
|
||||
subscriber(url);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.WriteLine($"[{_clientId}] Error notifying subscriber: {ex.Message}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public void Shutdown()
|
||||
{
|
||||
_cts?.Cancel();
|
||||
_steamClient.Disconnect();
|
||||
}
|
||||
}
|
||||
|
||||
public class SteamUserInfo
|
||||
{
|
||||
public string SteamId { get; set; } = string.Empty;
|
||||
public string Username { get; set; } = string.Empty;
|
||||
}
|
||||
@@ -1,156 +0,0 @@
|
||||
using SteamKit2;
|
||||
using SteamKit2.Authentication;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using System.Collections.Concurrent;
|
||||
|
||||
// Steam Service
|
||||
public class SteamService
|
||||
{
|
||||
private readonly ConcurrentDictionary<string, SteamClientHandler> _clientHandlers = new();
|
||||
|
||||
private readonly IServiceProvider _serviceProvider;
|
||||
|
||||
public SteamService(IServiceProvider serviceProvider)
|
||||
{
|
||||
_serviceProvider = serviceProvider;
|
||||
}
|
||||
|
||||
public Action SubscribeToEvents(string clientId, Action<ServerSentEvent> callback)
|
||||
{
|
||||
if (_clientHandlers.TryGetValue(clientId, out var handler))
|
||||
{
|
||||
return handler.Subscribe(callback);
|
||||
}
|
||||
|
||||
return () => { }; // Empty unsubscribe function
|
||||
}
|
||||
public async Task StartAuthentication(string teamId, string userId)
|
||||
{
|
||||
var clientId = $"{teamId}:{userId}";
|
||||
|
||||
// Check if we already have stored credentials
|
||||
using var scope = _serviceProvider.CreateScope();
|
||||
var dbContext = scope.ServiceProvider.GetRequiredService<SteamDbContext>();
|
||||
var storedCredential = await dbContext.SteamUserCredentials
|
||||
.FirstOrDefaultAsync(c => c.TeamId == teamId && c.UserId == userId);
|
||||
|
||||
var handler = _clientHandlers.GetOrAdd(clientId, id => new SteamClientHandler(id,
|
||||
async (accountName, refreshToken) => await SaveCredentials(teamId, userId, accountName, refreshToken)));
|
||||
|
||||
if (storedCredential != null)
|
||||
{
|
||||
// We have stored credentials, try to use them
|
||||
var success = await handler.LoginWithStoredCredentialsAsync(storedCredential.AccountName, storedCredential.RefreshToken);
|
||||
|
||||
// If login failed, start fresh authentication
|
||||
if (!success)
|
||||
{
|
||||
await handler.StartAuthenticationAsync();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// No stored credentials, start fresh authentication
|
||||
await handler.StartAuthenticationAsync();
|
||||
}
|
||||
|
||||
private async Task SaveCredentials(string teamId, string userId, string accountName, string refreshToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
using var scope = _serviceProvider.CreateScope();
|
||||
var dbContext = scope.ServiceProvider.GetRequiredService<SteamDbContext>();
|
||||
|
||||
var existingCredential = await dbContext.SteamUserCredentials
|
||||
.FirstOrDefaultAsync(c => c.TeamId == teamId && c.UserId == userId);
|
||||
|
||||
if (existingCredential != null)
|
||||
{
|
||||
// Update existing record
|
||||
existingCredential.AccountName = accountName;
|
||||
existingCredential.RefreshToken = refreshToken;
|
||||
existingCredential.UpdatedAt = DateTime.UtcNow;
|
||||
}
|
||||
else
|
||||
{
|
||||
// Create new record
|
||||
dbContext.SteamUserCredentials.Add(new SteamUserCredential
|
||||
{
|
||||
TeamId = teamId,
|
||||
UserId = userId,
|
||||
AccountName = accountName,
|
||||
RefreshToken = refreshToken
|
||||
});
|
||||
}
|
||||
|
||||
await dbContext.SaveChangesAsync();
|
||||
Console.WriteLine($"Saved Steam credentials for {teamId}:{userId}");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.WriteLine($"Error saving credentials: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<SteamUserInfo?> GetUserInfoFromStoredCredentials(string teamId, string userId)
|
||||
{
|
||||
var clientId = $"{teamId}:{userId}";
|
||||
|
||||
// Check if we have an active session
|
||||
if (_clientHandlers.TryGetValue(clientId, out var activeHandler) && activeHandler.UserInfo != null)
|
||||
{
|
||||
return activeHandler.UserInfo;
|
||||
}
|
||||
|
||||
// Try to get stored credentials
|
||||
using var scope = _serviceProvider.CreateScope();
|
||||
var dbContext = scope.ServiceProvider.GetRequiredService<SteamDbContext>();
|
||||
var storedCredential = await dbContext.SteamUserCredentials
|
||||
.FirstOrDefaultAsync(c => c.TeamId == teamId && c.UserId == userId);
|
||||
|
||||
if (storedCredential == null)
|
||||
{
|
||||
return null; // No stored credentials
|
||||
}
|
||||
|
||||
// Create a new handler and try to log in
|
||||
var handler = new SteamClientHandler(clientId);
|
||||
var success = await handler.LoginWithStoredCredentialsAsync(
|
||||
storedCredential.AccountName,
|
||||
storedCredential.RefreshToken);
|
||||
|
||||
if (success)
|
||||
{
|
||||
_clientHandlers.TryAdd(clientId, handler);
|
||||
return handler.UserInfo;
|
||||
}
|
||||
|
||||
// Login failed, credentials might be invalid
|
||||
return null;
|
||||
}
|
||||
|
||||
public Action Subscribe(string clientId, Action<string> callback)
|
||||
{
|
||||
if (_clientHandlers.TryGetValue(clientId, out var handler))
|
||||
{
|
||||
return handler.Subscribe(callback);
|
||||
}
|
||||
|
||||
return () => { }; // Empty unsubscribe function
|
||||
}
|
||||
|
||||
public void Unsubscribe(string clientId, Action unsubscribeAction)
|
||||
{
|
||||
unsubscribeAction();
|
||||
}
|
||||
|
||||
public SteamUserInfo? GetUserInfo(string clientId)
|
||||
{
|
||||
if (_clientHandlers.TryGetValue(clientId, out var handler))
|
||||
{
|
||||
return handler.UserInfo;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -2,7 +2,8 @@
|
||||
"Logging": {
|
||||
"LogLevel": {
|
||||
"Default": "Information",
|
||||
"Microsoft.AspNetCore": "Warning"
|
||||
"Microsoft.AspNetCore": "Warning",
|
||||
"Microsoft.EntityFrameworkCore.Database.Command": "Warning"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
347
packages/steam/index.ts
Normal file
@@ -0,0 +1,347 @@
|
||||
// steam-auth-client.ts
|
||||
import { request as httpRequest } from 'node:http';
|
||||
import { connect as netConnect } from 'node:net';
|
||||
import { Socket } from 'node:net';
|
||||
|
||||
/**
|
||||
* Event types emitted by the SteamAuthClient
|
||||
*/
|
||||
export enum SteamAuthEvent {
|
||||
CHALLENGE_URL = 'challenge_url',
|
||||
STATUS_UPDATE = 'status_update',
|
||||
CREDENTIALS = 'credentials',
|
||||
LOGIN_SUCCESS = 'login_success',
|
||||
LOGIN_ERROR = 'login_error',
|
||||
ERROR = 'error'
|
||||
}
|
||||
|
||||
/**
|
||||
* Interface for Steam credentials
|
||||
*/
|
||||
export interface SteamCredentials {
|
||||
username: string;
|
||||
refreshToken: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Options for SteamAuthClient constructor
|
||||
*/
|
||||
export interface SteamAuthClientOptions {
|
||||
socketPath?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* SteamAuthClient provides methods to authenticate with Steam
|
||||
* through a C# service over Unix sockets.
|
||||
*/
|
||||
export class SteamAuthClient {
|
||||
private socketPath: string;
|
||||
private activeSocket: Socket | null = null;
|
||||
private eventListeners: Map<string, Function[]> = new Map();
|
||||
|
||||
/**
|
||||
* Creates a new Steam authentication client
|
||||
*
|
||||
* @param options Configuration options
|
||||
*/
|
||||
constructor(options: SteamAuthClientOptions = {}) {
|
||||
this.socketPath = options.socketPath || '/tmp/steam.sock';
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if the Steam service is healthy
|
||||
*
|
||||
* @returns Promise resolving to true if service is healthy
|
||||
*/
|
||||
async checkHealth(): Promise<boolean> {
|
||||
try {
|
||||
await this.makeRequest({ method: 'GET', path: '/' });
|
||||
return true;
|
||||
} catch (error) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Starts the QR code login flow
|
||||
*
|
||||
* @returns Promise that resolves when login completes (success or failure)
|
||||
*/
|
||||
startQRLogin(): Promise<void> {
|
||||
return new Promise<void>((resolve) => {
|
||||
// Create Socket connection for SSE
|
||||
this.activeSocket = netConnect({ path: this.socketPath });
|
||||
|
||||
// Build the HTTP request manually for SSE
|
||||
const request =
|
||||
'GET /api/steam/login HTTP/1.1\r\n' +
|
||||
'Host: localhost\r\n' +
|
||||
'Accept: text/event-stream\r\n' +
|
||||
'Cache-Control: no-cache\r\n' +
|
||||
'Connection: keep-alive\r\n\r\n';
|
||||
|
||||
this.activeSocket.on('connect', () => {
|
||||
this.activeSocket?.write(request);
|
||||
});
|
||||
|
||||
this.activeSocket.on('error', (error) => {
|
||||
this.emit(SteamAuthEvent.ERROR, { error: error.message });
|
||||
resolve();
|
||||
});
|
||||
|
||||
// Simple parser for SSE events over raw socket
|
||||
let buffer = '';
|
||||
let eventType = '';
|
||||
let eventData = '';
|
||||
|
||||
this.activeSocket.on('data', (data) => {
|
||||
const chunk = data.toString();
|
||||
buffer += chunk;
|
||||
|
||||
// Skip HTTP headers if present
|
||||
if (buffer.includes('\r\n\r\n')) {
|
||||
const headerEnd = buffer.indexOf('\r\n\r\n');
|
||||
buffer = buffer.substring(headerEnd + 4);
|
||||
}
|
||||
|
||||
// Process each complete event
|
||||
const lines = buffer.split('\n');
|
||||
buffer = lines.pop() || ''; // Keep the last incomplete line in buffer
|
||||
|
||||
for (const line of lines) {
|
||||
if (line.startsWith('event: ')) {
|
||||
eventType = line.substring(7);
|
||||
} else if (line.startsWith('data: ')) {
|
||||
eventData = line.substring(6);
|
||||
|
||||
// Complete event received
|
||||
if (eventType && eventData) {
|
||||
try {
|
||||
const parsedData = JSON.parse(eventData);
|
||||
|
||||
// Handle specific events
|
||||
if (eventType === 'challenge_url') {
|
||||
this.emit(SteamAuthEvent.CHALLENGE_URL, parsedData);
|
||||
} else if (eventType === 'credentials') {
|
||||
this.emit(SteamAuthEvent.CREDENTIALS, {
|
||||
username: parsedData.username,
|
||||
refreshToken: parsedData.refreshToken
|
||||
});
|
||||
} else if (eventType === 'login-success') {
|
||||
this.emit(SteamAuthEvent.LOGIN_SUCCESS, { steamId: parsedData.steamId });
|
||||
this.closeSocket();
|
||||
resolve();
|
||||
} else if (eventType === 'status') {
|
||||
this.emit(SteamAuthEvent.STATUS_UPDATE, parsedData);
|
||||
} else if (eventType === 'error' || eventType === 'login-unsuccessful') {
|
||||
this.emit(SteamAuthEvent.LOGIN_ERROR, {
|
||||
message: parsedData.message || parsedData.error
|
||||
});
|
||||
this.closeSocket();
|
||||
resolve();
|
||||
} else {
|
||||
// Emit any other events as is
|
||||
this.emit(eventType, parsedData);
|
||||
}
|
||||
} catch (e) {
|
||||
this.emit(SteamAuthEvent.ERROR, {
|
||||
error: `Error parsing event data: ${e}`
|
||||
});
|
||||
}
|
||||
|
||||
// Reset for next event
|
||||
eventType = '';
|
||||
eventData = '';
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Logs in with existing credentials
|
||||
*
|
||||
* @param credentials Steam credentials
|
||||
* @returns Promise resolving to login result
|
||||
*/
|
||||
async loginWithCredentials(credentials: SteamCredentials): Promise<{
|
||||
success: boolean,
|
||||
steamId?: string,
|
||||
errorMessage?: string
|
||||
}> {
|
||||
try {
|
||||
const response = await this.makeRequest({
|
||||
method: 'POST',
|
||||
path: '/api/steam/login-with-credentials',
|
||||
body: credentials
|
||||
});
|
||||
|
||||
if (response.success) {
|
||||
return {
|
||||
success: true,
|
||||
steamId: response.steamId
|
||||
};
|
||||
} else {
|
||||
return {
|
||||
success: false,
|
||||
errorMessage: response.errorMessage || 'Unknown error'
|
||||
};
|
||||
}
|
||||
} catch (error: any) {
|
||||
return {
|
||||
success: false,
|
||||
errorMessage: error.message
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets user information using the provided credentials
|
||||
*
|
||||
* @param credentials Steam credentials
|
||||
* @returns Promise resolving to user information
|
||||
*/
|
||||
async getUserInfo(credentials: SteamCredentials): Promise<any> {
|
||||
try {
|
||||
return await this.makeRequest({
|
||||
method: 'GET',
|
||||
path: '/api/steam/user',
|
||||
headers: {
|
||||
'X-Steam-Username': credentials.username,
|
||||
'X-Steam-Token': credentials.refreshToken
|
||||
}
|
||||
});
|
||||
} catch (error: any) {
|
||||
throw new Error(`Failed to fetch user info: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds an event listener
|
||||
*
|
||||
* @param event Event name to listen for
|
||||
* @param callback Function to call when event occurs
|
||||
*/
|
||||
on(event: string, callback: Function): void {
|
||||
if (!this.eventListeners.has(event)) {
|
||||
this.eventListeners.set(event, []);
|
||||
}
|
||||
this.eventListeners.get(event)?.push(callback);
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes an event listener
|
||||
*
|
||||
* @param event Event name
|
||||
* @param callback Function to remove
|
||||
*/
|
||||
off(event: string, callback: Function): void {
|
||||
if (!this.eventListeners.has(event)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const listeners = this.eventListeners.get(event);
|
||||
if (listeners) {
|
||||
const index = listeners.indexOf(callback);
|
||||
if (index !== -1) {
|
||||
listeners.splice(index, 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes all event listeners
|
||||
*/
|
||||
removeAllListeners(): void {
|
||||
this.eventListeners.clear();
|
||||
}
|
||||
|
||||
/**
|
||||
* Closes the active socket connection
|
||||
*/
|
||||
closeSocket(): void {
|
||||
if (this.activeSocket) {
|
||||
this.activeSocket.end();
|
||||
this.activeSocket = null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Cleans up resources
|
||||
*/
|
||||
destroy(): void {
|
||||
this.closeSocket();
|
||||
this.removeAllListeners();
|
||||
}
|
||||
|
||||
/**
|
||||
* Internal method to emit events to listeners
|
||||
*
|
||||
* @param event Event name
|
||||
* @param data Event data
|
||||
*/
|
||||
private emit(event: string, data: any): void {
|
||||
const listeners = this.eventListeners.get(event);
|
||||
if (listeners) {
|
||||
for (const callback of listeners) {
|
||||
callback(data);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Makes HTTP requests over Unix socket
|
||||
*
|
||||
* @param options Request options
|
||||
* @returns Promise resolving to response
|
||||
*/
|
||||
private makeRequest(options: {
|
||||
method: string;
|
||||
path: string;
|
||||
headers?: Record<string, string>;
|
||||
body?: any;
|
||||
}): Promise<any> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const req = httpRequest({
|
||||
socketPath: this.socketPath,
|
||||
method: options.method,
|
||||
path: options.path,
|
||||
headers: {
|
||||
'Accept': 'application/json',
|
||||
'Content-Type': 'application/json',
|
||||
...options.headers
|
||||
}
|
||||
}, (res) => {
|
||||
let data = '';
|
||||
res.on('data', (chunk) => {
|
||||
data += chunk;
|
||||
});
|
||||
res.on('end', () => {
|
||||
if (res.statusCode && res.statusCode >= 200 && res.statusCode < 300) {
|
||||
try {
|
||||
if (data && data.length > 0) {
|
||||
resolve(JSON.parse(data));
|
||||
} else {
|
||||
resolve(null);
|
||||
}
|
||||
} catch (e) {
|
||||
resolve(data); // Return raw data if not JSON
|
||||
}
|
||||
} else {
|
||||
reject(new Error(`Request failed with status ${res.statusCode}: ${data}`));
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
req.on('error', (error) => {
|
||||
reject(error);
|
||||
});
|
||||
|
||||
if (options.body) {
|
||||
req.write(JSON.stringify(options.body));
|
||||
}
|
||||
req.end();
|
||||
});
|
||||
}
|
||||
}
|
||||
26
packages/steam/package.json
Normal file
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"name": "steam",
|
||||
"module": "index.ts",
|
||||
"type": "module",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "dotnet watch run",
|
||||
"client": "bun index",
|
||||
"client:node": "ts-node index",
|
||||
"build": "dotnet build",
|
||||
"db:migrate": "dotnet ef migrations add"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/bun": "latest",
|
||||
"ts-node": "^10.9.2"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"typescript": "^5"
|
||||
},
|
||||
"dependencies": {
|
||||
"eventsource": "^3.0.6",
|
||||
"qrcode": "^1.5.4",
|
||||
"qrcode-terminal": "^0.12.0",
|
||||
"ws": "^8.18.1"
|
||||
}
|
||||
}
|
||||
9
packages/steam/sst-env.d.ts
vendored
Normal file
@@ -0,0 +1,9 @@
|
||||
/* This file is auto-generated by SST. Do not edit. */
|
||||
/* tslint:disable */
|
||||
/* eslint-disable */
|
||||
/* deno-fmt-ignore-file */
|
||||
|
||||
/// <reference path="../../sst-env.d.ts" />
|
||||
|
||||
import "sst"
|
||||
export {}
|
||||
@@ -7,13 +7,14 @@
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="8.0.14" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="8.0.14" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="9.0.3">
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
</PackageReference>
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="9.0.3" />
|
||||
<PackageReference Include="SteamKit2" Version="3.0.2" />
|
||||
<PackageReference Include="Swashbuckle.AspNetCore" Version="6.6.2" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
|
||||
6
packages/steam/steam.http
Normal file
@@ -0,0 +1,6 @@
|
||||
@steam_HostAddress = http://localhost:5221
|
||||
|
||||
GET {{steam_HostAddress}}/weatherforecast/
|
||||
Accept: application/json
|
||||
|
||||
###
|
||||
301
packages/steam/terminal.ts
Normal file
@@ -0,0 +1,301 @@
|
||||
import { Agent, request as httpRequest } from 'node:http';
|
||||
import { connect as netConnect } from 'node:net';
|
||||
import { readFileSync, writeFileSync, existsSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import qrcode from 'qrcode-terminal';
|
||||
import { createInterface } from 'node:readline';
|
||||
|
||||
// Socket path matching the one in your C# code
|
||||
const SOCKET_PATH = '/tmp/steam.sock';
|
||||
const CREDENTIALS_PATH = join(process.cwd(), 'steam-credentials.json');
|
||||
|
||||
// Create readline interface for user input
|
||||
const rl = createInterface({
|
||||
input: process.stdin,
|
||||
output: process.stdout
|
||||
});
|
||||
|
||||
// Function to prompt user for input
|
||||
const question = (query: string): Promise<string> => {
|
||||
return new Promise(resolve => rl.question(query, resolve));
|
||||
};
|
||||
|
||||
// Function to make HTTP requests over Unix socket
|
||||
function makeRequest(options: {
|
||||
method: string;
|
||||
path: string;
|
||||
headers?: Record<string, string>;
|
||||
body?: any;
|
||||
}): Promise<any> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const req = httpRequest({
|
||||
socketPath: SOCKET_PATH,
|
||||
method: options.method,
|
||||
path: options.path,
|
||||
headers: {
|
||||
'Accept': 'application/json',
|
||||
'Content-Type': 'application/json',
|
||||
...options.headers
|
||||
}
|
||||
}, (res) => {
|
||||
let data = '';
|
||||
res.on('data', (chunk) => {
|
||||
data += chunk;
|
||||
});
|
||||
res.on('end', () => {
|
||||
if (res.statusCode && res.statusCode >= 200 && res.statusCode < 300) {
|
||||
try {
|
||||
if (data && data.length > 0) {
|
||||
resolve(JSON.parse(data));
|
||||
} else {
|
||||
resolve(null);
|
||||
}
|
||||
} catch (e) {
|
||||
resolve(data); // Return raw data if not JSON
|
||||
}
|
||||
} else {
|
||||
reject(new Error(`Request failed with status ${res.statusCode}: ${data}`));
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
req.on('error', (error) => {
|
||||
reject(error);
|
||||
});
|
||||
|
||||
if (options.body) {
|
||||
req.write(JSON.stringify(options.body));
|
||||
}
|
||||
req.end();
|
||||
});
|
||||
}
|
||||
|
||||
// Check if credentials file exists
|
||||
const credentialsExist = (): boolean => {
|
||||
return existsSync(CREDENTIALS_PATH);
|
||||
};
|
||||
|
||||
// Load saved credentials
|
||||
const loadCredentials = (): { username: string, refreshToken: string } => {
|
||||
try {
|
||||
const data = readFileSync(CREDENTIALS_PATH, 'utf8');
|
||||
return JSON.parse(data);
|
||||
} catch (error) {
|
||||
console.error('Error loading credentials:', error);
|
||||
return { username: '', refreshToken: '' };
|
||||
}
|
||||
};
|
||||
|
||||
// Save credentials to file
|
||||
const saveCredentials = (credentials: { username: string, refreshToken: string }): void => {
|
||||
try {
|
||||
writeFileSync(CREDENTIALS_PATH, JSON.stringify(credentials, null, 2));
|
||||
console.log('💾 Credentials saved to', CREDENTIALS_PATH);
|
||||
} catch (error) {
|
||||
console.error('Error saving credentials:', error);
|
||||
}
|
||||
};
|
||||
|
||||
// Test health check endpoint
|
||||
async function testHealthCheck(): Promise<boolean> {
|
||||
console.log('\n🔍 Testing health check endpoint...');
|
||||
try {
|
||||
const response = await makeRequest({ method: 'GET', path: '/' });
|
||||
console.log('✅ Health check successful:', response);
|
||||
return true;
|
||||
} catch (error: any) {
|
||||
console.error('❌ Health check failed:', error.message);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Test QR code login endpoint (SSE)
|
||||
async function loginWithQrCode(): Promise<{ username: string, refreshToken: string } | null> {
|
||||
console.log('\n🔍 Starting QR code login...');
|
||||
|
||||
return new Promise<{ username: string, refreshToken: string } | null>((resolve) => {
|
||||
// Create Socket connection for SSE
|
||||
const socket = netConnect({ path: SOCKET_PATH });
|
||||
|
||||
// Build the HTTP request manually for SSE
|
||||
const request =
|
||||
'GET /api/steam/login HTTP/1.1\r\n' +
|
||||
'Host: localhost\r\n' +
|
||||
'Accept: text/event-stream\r\n' +
|
||||
'Cache-Control: no-cache\r\n' +
|
||||
'Connection: keep-alive\r\n\r\n';
|
||||
|
||||
socket.on('connect', () => {
|
||||
console.log('📡 Connected to socket, sending SSE request...');
|
||||
socket.write(request);
|
||||
});
|
||||
|
||||
socket.on('error', (error) => {
|
||||
console.error('❌ Socket error:', error.message);
|
||||
resolve(null);
|
||||
});
|
||||
|
||||
// Simple parser for SSE events over raw socket
|
||||
let buffer = '';
|
||||
let eventType = '';
|
||||
let eventData = '';
|
||||
let credentials: { username: string, refreshToken: string } | null = null;
|
||||
|
||||
socket.on('data', (data) => {
|
||||
const chunk = data.toString();
|
||||
buffer += chunk;
|
||||
|
||||
// Skip HTTP headers if present
|
||||
if (buffer.includes('\r\n\r\n')) {
|
||||
const headerEnd = buffer.indexOf('\r\n\r\n');
|
||||
buffer = buffer.substring(headerEnd + 4);
|
||||
}
|
||||
|
||||
// Process each complete event
|
||||
const lines = buffer.split('\n');
|
||||
buffer = lines.pop() || ''; // Keep the last incomplete line in buffer
|
||||
|
||||
for (const line of lines) {
|
||||
if (line.startsWith('event: ')) {
|
||||
eventType = line.substring(7);
|
||||
} else if (line.startsWith('data: ')) {
|
||||
eventData = line.substring(6);
|
||||
|
||||
// Complete event received
|
||||
if (eventType && eventData) {
|
||||
try {
|
||||
const parsedData = JSON.parse(eventData);
|
||||
console.log(`📬 Received event [${eventType}]`);
|
||||
|
||||
// Handle specific events
|
||||
if (eventType === 'challenge_url') {
|
||||
console.log('⚠️ Please scan this QR code with the Steam mobile app to authenticate:');
|
||||
qrcode.generate(parsedData.url, { small: true });
|
||||
} else if (eventType === 'credentials') {
|
||||
console.log('🔑 Received credentials!');
|
||||
credentials = {
|
||||
username: parsedData.username,
|
||||
refreshToken: parsedData.refreshToken
|
||||
};
|
||||
}else if (eventType === 'status') {
|
||||
console.log(`\n🔄 Status: ${parsedData.message}\n`);
|
||||
} else if (eventType === 'login-success' || eventType === 'login-successful') {
|
||||
console.log(`\n✅ Login successful, Steam ID: ${parsedData.steamId}\n`);
|
||||
socket.end();
|
||||
if (credentials) {
|
||||
saveCredentials(credentials);
|
||||
}
|
||||
resolve(credentials);
|
||||
} else if (eventType === 'error' || eventType === 'login-unsuccessful') {
|
||||
console.error('❌ Login failed:', parsedData.message || parsedData.error);
|
||||
socket.end();
|
||||
resolve(null);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('❌ Error parsing event data:', e);
|
||||
}
|
||||
|
||||
// Reset for next event
|
||||
eventType = '';
|
||||
eventData = '';
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// Login with existing credentials
|
||||
async function loginWithCredentials(credentials: { username: string, refreshToken: string }): Promise<boolean> {
|
||||
console.log('\n🔍 Logging in with saved credentials...');
|
||||
try {
|
||||
const response = await makeRequest({
|
||||
method: 'POST',
|
||||
path: '/api/steam/login-with-credentials',
|
||||
body: credentials
|
||||
});
|
||||
|
||||
if (response.success) {
|
||||
console.log('✅ Login successful, Steam ID:', response.steamId);
|
||||
return true;
|
||||
} else {
|
||||
console.error('❌ Login failed:', response.errorMessage);
|
||||
return false;
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.error('❌ Login request failed:', error.message);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Get user info
|
||||
async function getUserInfo(credentials: { username: string, refreshToken: string }): Promise<any> {
|
||||
console.log('\n🔍 Fetching user info...');
|
||||
try {
|
||||
const response = await makeRequest({
|
||||
method: 'GET',
|
||||
path: '/api/steam/user',
|
||||
headers: {
|
||||
'X-Steam-Username': credentials.username,
|
||||
'X-Steam-Token': credentials.refreshToken
|
||||
}
|
||||
});
|
||||
return response;
|
||||
} catch (error: any) {
|
||||
console.error('❌ Failed to fetch user info:', error.message);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// Main function
|
||||
async function main() {
|
||||
// Check health first
|
||||
const isHealthy = await testHealthCheck();
|
||||
if (!isHealthy) {
|
||||
console.error('❌ Service appears to be down. Exiting...');
|
||||
rl.close();
|
||||
return;
|
||||
}
|
||||
|
||||
let credentials: { username: string, refreshToken: string } | null = null;
|
||||
|
||||
// Check if we have saved credentials
|
||||
if (credentialsExist()) {
|
||||
const useExisting = await question('🔑 Found saved credentials. Use them? (y/n): ');
|
||||
if (useExisting.toLowerCase() === 'y') {
|
||||
credentials = loadCredentials();
|
||||
const success = await loginWithCredentials(credentials);
|
||||
if (!success) {
|
||||
console.log('⚠️ Saved credentials failed. Let\'s try QR login instead.');
|
||||
credentials = await loginWithQrCode();
|
||||
}
|
||||
} else {
|
||||
credentials = await loginWithQrCode();
|
||||
}
|
||||
} else {
|
||||
console.log('🔑 No saved credentials found. Starting QR login...');
|
||||
credentials = await loginWithQrCode();
|
||||
}
|
||||
|
||||
// If we have valid credentials, offer to fetch user info
|
||||
if (credentials) {
|
||||
const getInfo = await question('📋 Fetch user info? (y/n): ');
|
||||
if (getInfo.toLowerCase() === 'y') {
|
||||
const userInfo = await getUserInfo(credentials);
|
||||
if (userInfo) {
|
||||
console.log('\n👤 User Information:');
|
||||
console.log(JSON.stringify(userInfo, null, 2));
|
||||
}
|
||||
}
|
||||
} else {
|
||||
console.log('❌ Failed to obtain valid credentials.');
|
||||
}
|
||||
|
||||
rl.close();
|
||||
}
|
||||
|
||||
// Start the program
|
||||
main().catch(error => {
|
||||
console.error('Unhandled error:', error);
|
||||
rl.close();
|
||||
});
|
||||
27
packages/steam/tsconfig.json
Normal file
@@ -0,0 +1,27 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
// Enable latest features
|
||||
"lib": ["ESNext", "DOM"],
|
||||
"target": "ESNext",
|
||||
"module": "ESNext",
|
||||
"moduleDetection": "force",
|
||||
"jsx": "react-jsx",
|
||||
"allowJs": true,
|
||||
|
||||
// Bundler mode
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
"noEmit": true,
|
||||
|
||||
// Best practices
|
||||
"strict": true,
|
||||
"skipLibCheck": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
|
||||
// Some stricter flags (disabled by default)
|
||||
"noUnusedLocals": false,
|
||||
"noUnusedParameters": false,
|
||||
"noPropertyAccessFromIndexSignature": false
|
||||
}
|
||||
}
|
||||
@@ -39,6 +39,7 @@
|
||||
"focus-trap": "^7.6.4",
|
||||
"hono": "^4.7.4",
|
||||
"modern-normalize": "^3.0.1",
|
||||
"motion": "^12.6.2",
|
||||
"qrcode": "^1.5.4",
|
||||
"solid-js": "^1.9.5",
|
||||
"valibot": "^1.0.0-rc.3",
|
||||
|
||||
BIN
packages/www/src/assets/portal/play_button_disabled_bg.png
Normal file
|
After Width: | Height: | Size: 7.3 KiB |
BIN
packages/www/src/assets/portal/play_button_focused_bg.png
Normal file
|
After Width: | Height: | Size: 10 KiB |
2316
packages/www/src/assets/portal/play_button_idle.json
Normal file
BIN
packages/www/src/assets/portal/play_button_idle.png
Normal file
|
After Width: | Height: | Size: 1022 KiB |
588
packages/www/src/assets/portal/play_button_intro.json
Normal file
@@ -0,0 +1,588 @@
|
||||
{"frames": [
|
||||
|
||||
{
|
||||
"filename": "intro_00000.png",
|
||||
"frame": {"x":1,"y":1,"w":3,"h":3},
|
||||
"rotated": false,
|
||||
"trimmed": true,
|
||||
"spriteSourceSize": {"x":0,"y":0,"w":3,"h":3},
|
||||
"sourceSize": {"w":150,"h":150},
|
||||
"pivot": {"x":0.5,"y":0.5}
|
||||
},
|
||||
{
|
||||
"filename": "intro_00001.png",
|
||||
"frame": {"x":911,"y":364,"w":150,"h":150},
|
||||
"rotated": false,
|
||||
"trimmed": false,
|
||||
"spriteSourceSize": {"x":0,"y":0,"w":150,"h":150},
|
||||
"sourceSize": {"w":150,"h":150},
|
||||
"pivot": {"x":0.5,"y":0.5}
|
||||
},
|
||||
{
|
||||
"filename": "intro_00002.png",
|
||||
"frame": {"x":1063,"y":367,"w":150,"h":150},
|
||||
"rotated": false,
|
||||
"trimmed": false,
|
||||
"spriteSourceSize": {"x":0,"y":0,"w":150,"h":150},
|
||||
"sourceSize": {"w":150,"h":150},
|
||||
"pivot": {"x":0.5,"y":0.5}
|
||||
},
|
||||
{
|
||||
"filename": "intro_00003.png",
|
||||
"frame": {"x":1215,"y":372,"w":150,"h":150},
|
||||
"rotated": false,
|
||||
"trimmed": false,
|
||||
"spriteSourceSize": {"x":0,"y":0,"w":150,"h":150},
|
||||
"sourceSize": {"w":150,"h":150},
|
||||
"pivot": {"x":0.5,"y":0.5}
|
||||
},
|
||||
{
|
||||
"filename": "intro_00004.png",
|
||||
"frame": {"x":1367,"y":374,"w":150,"h":150},
|
||||
"rotated": false,
|
||||
"trimmed": false,
|
||||
"spriteSourceSize": {"x":0,"y":0,"w":150,"h":150},
|
||||
"sourceSize": {"w":150,"h":150},
|
||||
"pivot": {"x":0.5,"y":0.5}
|
||||
},
|
||||
{
|
||||
"filename": "intro_00005.png",
|
||||
"frame": {"x":1519,"y":375,"w":150,"h":150},
|
||||
"rotated": false,
|
||||
"trimmed": false,
|
||||
"spriteSourceSize": {"x":0,"y":0,"w":150,"h":150},
|
||||
"sourceSize": {"w":150,"h":150},
|
||||
"pivot": {"x":0.5,"y":0.5}
|
||||
},
|
||||
{
|
||||
"filename": "intro_00006.png",
|
||||
"frame": {"x":1671,"y":379,"w":150,"h":150},
|
||||
"rotated": false,
|
||||
"trimmed": false,
|
||||
"spriteSourceSize": {"x":0,"y":0,"w":150,"h":150},
|
||||
"sourceSize": {"w":150,"h":150},
|
||||
"pivot": {"x":0.5,"y":0.5}
|
||||
},
|
||||
{
|
||||
"filename": "intro_00007.png",
|
||||
"frame": {"x":1823,"y":381,"w":150,"h":150},
|
||||
"rotated": false,
|
||||
"trimmed": false,
|
||||
"spriteSourceSize": {"x":0,"y":0,"w":150,"h":150},
|
||||
"sourceSize": {"w":150,"h":150},
|
||||
"pivot": {"x":0.5,"y":0.5}
|
||||
},
|
||||
{
|
||||
"filename": "intro_00008.png",
|
||||
"frame": {"x":759,"y":362,"w":150,"h":149},
|
||||
"rotated": false,
|
||||
"trimmed": true,
|
||||
"spriteSourceSize": {"x":0,"y":0,"w":150,"h":149},
|
||||
"sourceSize": {"w":150,"h":150},
|
||||
"pivot": {"x":0.5,"y":0.5}
|
||||
},
|
||||
{
|
||||
"filename": "intro_00009.png",
|
||||
"frame": {"x":456,"y":355,"w":148,"h":149},
|
||||
"rotated": true,
|
||||
"trimmed": true,
|
||||
"spriteSourceSize": {"x":2,"y":0,"w":148,"h":149},
|
||||
"sourceSize": {"w":150,"h":150},
|
||||
"pivot": {"x":0.5,"y":0.5}
|
||||
},
|
||||
{
|
||||
"filename": "intro_00010.png",
|
||||
"frame": {"x":607,"y":360,"w":148,"h":150},
|
||||
"rotated": true,
|
||||
"trimmed": true,
|
||||
"spriteSourceSize": {"x":2,"y":0,"w":148,"h":150},
|
||||
"sourceSize": {"w":150,"h":150},
|
||||
"pivot": {"x":0.5,"y":0.5}
|
||||
},
|
||||
{
|
||||
"filename": "intro_00011.png",
|
||||
"frame": {"x":1,"y":349,"w":147,"h":150},
|
||||
"rotated": true,
|
||||
"trimmed": true,
|
||||
"spriteSourceSize": {"x":3,"y":0,"w":147,"h":150},
|
||||
"sourceSize": {"w":150,"h":150},
|
||||
"pivot": {"x":0.5,"y":0.5}
|
||||
},
|
||||
{
|
||||
"filename": "intro_00012.png",
|
||||
"frame": {"x":153,"y":351,"w":147,"h":150},
|
||||
"rotated": true,
|
||||
"trimmed": true,
|
||||
"spriteSourceSize": {"x":3,"y":0,"w":147,"h":150},
|
||||
"sourceSize": {"w":150,"h":150},
|
||||
"pivot": {"x":0.5,"y":0.5}
|
||||
},
|
||||
{
|
||||
"filename": "intro_00013.png",
|
||||
"frame": {"x":305,"y":353,"w":147,"h":149},
|
||||
"rotated": true,
|
||||
"trimmed": true,
|
||||
"spriteSourceSize": {"x":2,"y":1,"w":147,"h":149},
|
||||
"sourceSize": {"w":150,"h":150},
|
||||
"pivot": {"x":0.5,"y":0.5}
|
||||
},
|
||||
{
|
||||
"filename": "intro_00014.png",
|
||||
"frame": {"x":1867,"y":235,"w":144,"h":148},
|
||||
"rotated": true,
|
||||
"trimmed": true,
|
||||
"spriteSourceSize": {"x":4,"y":2,"w":144,"h":148},
|
||||
"sourceSize": {"w":150,"h":150},
|
||||
"pivot": {"x":0.5,"y":0.5}
|
||||
},
|
||||
{
|
||||
"filename": "intro_00015.png",
|
||||
"frame": {"x":1719,"y":235,"w":142,"h":146},
|
||||
"rotated": true,
|
||||
"trimmed": true,
|
||||
"spriteSourceSize": {"x":5,"y":3,"w":142,"h":146},
|
||||
"sourceSize": {"w":150,"h":150},
|
||||
"pivot": {"x":0.5,"y":0.5}
|
||||
},
|
||||
{
|
||||
"filename": "intro_00016.png",
|
||||
"frame": {"x":1574,"y":233,"w":140,"h":143},
|
||||
"rotated": true,
|
||||
"trimmed": true,
|
||||
"spriteSourceSize": {"x":6,"y":4,"w":140,"h":143},
|
||||
"sourceSize": {"w":150,"h":150},
|
||||
"pivot": {"x":0.5,"y":0.5}
|
||||
},
|
||||
{
|
||||
"filename": "intro_00017.png",
|
||||
"frame": {"x":1432,"y":233,"w":139,"h":140},
|
||||
"rotated": true,
|
||||
"trimmed": true,
|
||||
"spriteSourceSize": {"x":6,"y":6,"w":139,"h":140},
|
||||
"sourceSize": {"w":150,"h":150},
|
||||
"pivot": {"x":0.5,"y":0.5}
|
||||
},
|
||||
{
|
||||
"filename": "intro_00018.png",
|
||||
"frame": {"x":1292,"y":233,"w":138,"h":137},
|
||||
"rotated": false,
|
||||
"trimmed": true,
|
||||
"spriteSourceSize": {"x":6,"y":7,"w":138,"h":137},
|
||||
"sourceSize": {"w":150,"h":150},
|
||||
"pivot": {"x":0.5,"y":0.5}
|
||||
},
|
||||
{
|
||||
"filename": "intro_00019.png",
|
||||
"frame": {"x":1154,"y":231,"w":136,"h":134},
|
||||
"rotated": false,
|
||||
"trimmed": true,
|
||||
"spriteSourceSize": {"x":7,"y":9,"w":136,"h":134},
|
||||
"sourceSize": {"w":150,"h":150},
|
||||
"pivot": {"x":0.5,"y":0.5}
|
||||
},
|
||||
{
|
||||
"filename": "intro_00020.png",
|
||||
"frame": {"x":1018,"y":229,"w":134,"h":133},
|
||||
"rotated": false,
|
||||
"trimmed": true,
|
||||
"spriteSourceSize": {"x":8,"y":9,"w":134,"h":133},
|
||||
"sourceSize": {"w":150,"h":150},
|
||||
"pivot": {"x":0.5,"y":0.5}
|
||||
},
|
||||
{
|
||||
"filename": "intro_00021.png",
|
||||
"frame": {"x":885,"y":229,"w":131,"h":131},
|
||||
"rotated": false,
|
||||
"trimmed": true,
|
||||
"spriteSourceSize": {"x":9,"y":10,"w":131,"h":131},
|
||||
"sourceSize": {"w":150,"h":150},
|
||||
"pivot": {"x":0.5,"y":0.5}
|
||||
},
|
||||
{
|
||||
"filename": "intro_00022.png",
|
||||
"frame": {"x":754,"y":229,"w":129,"h":129},
|
||||
"rotated": false,
|
||||
"trimmed": true,
|
||||
"spriteSourceSize": {"x":10,"y":11,"w":129,"h":129},
|
||||
"sourceSize": {"w":150,"h":150},
|
||||
"pivot": {"x":0.5,"y":0.5}
|
||||
},
|
||||
{
|
||||
"filename": "intro_00023.png",
|
||||
"frame": {"x":624,"y":229,"w":128,"h":127},
|
||||
"rotated": false,
|
||||
"trimmed": true,
|
||||
"spriteSourceSize": {"x":11,"y":12,"w":128,"h":127},
|
||||
"sourceSize": {"w":150,"h":150},
|
||||
"pivot": {"x":0.5,"y":0.5}
|
||||
},
|
||||
{
|
||||
"filename": "intro_00024.png",
|
||||
"frame": {"x":496,"y":227,"w":126,"h":126},
|
||||
"rotated": false,
|
||||
"trimmed": true,
|
||||
"spriteSourceSize": {"x":12,"y":12,"w":126,"h":126},
|
||||
"sourceSize": {"w":150,"h":150},
|
||||
"pivot": {"x":0.5,"y":0.5}
|
||||
},
|
||||
{
|
||||
"filename": "intro_00025.png",
|
||||
"frame": {"x":370,"y":227,"w":124,"h":124},
|
||||
"rotated": false,
|
||||
"trimmed": true,
|
||||
"spriteSourceSize": {"x":13,"y":13,"w":124,"h":124},
|
||||
"sourceSize": {"w":150,"h":150},
|
||||
"pivot": {"x":0.5,"y":0.5}
|
||||
},
|
||||
{
|
||||
"filename": "intro_00026.png",
|
||||
"frame": {"x":246,"y":227,"w":122,"h":122},
|
||||
"rotated": false,
|
||||
"trimmed": true,
|
||||
"spriteSourceSize": {"x":14,"y":14,"w":122,"h":122},
|
||||
"sourceSize": {"w":150,"h":150},
|
||||
"pivot": {"x":0.5,"y":0.5}
|
||||
},
|
||||
{
|
||||
"filename": "intro_00027.png",
|
||||
"frame": {"x":1,"y":227,"w":121,"h":120},
|
||||
"rotated": false,
|
||||
"trimmed": true,
|
||||
"spriteSourceSize": {"x":14,"y":15,"w":121,"h":120},
|
||||
"sourceSize": {"w":150,"h":150},
|
||||
"pivot": {"x":0.5,"y":0.5}
|
||||
},
|
||||
{
|
||||
"filename": "intro_00028.png",
|
||||
"frame": {"x":124,"y":227,"w":120,"h":120},
|
||||
"rotated": false,
|
||||
"trimmed": true,
|
||||
"spriteSourceSize": {"x":15,"y":15,"w":120,"h":120},
|
||||
"sourceSize": {"w":150,"h":150},
|
||||
"pivot": {"x":0.5,"y":0.5}
|
||||
},
|
||||
{
|
||||
"filename": "intro_00029.png",
|
||||
"frame": {"x":1855,"y":115,"w":118,"h":118},
|
||||
"rotated": false,
|
||||
"trimmed": true,
|
||||
"spriteSourceSize": {"x":16,"y":16,"w":118,"h":118},
|
||||
"sourceSize": {"w":150,"h":150},
|
||||
"pivot": {"x":0.5,"y":0.5}
|
||||
},
|
||||
{
|
||||
"filename": "intro_00030.png",
|
||||
"frame": {"x":1735,"y":115,"w":117,"h":118},
|
||||
"rotated": true,
|
||||
"trimmed": true,
|
||||
"spriteSourceSize": {"x":16,"y":16,"w":117,"h":118},
|
||||
"sourceSize": {"w":150,"h":150},
|
||||
"pivot": {"x":0.5,"y":0.5}
|
||||
},
|
||||
{
|
||||
"filename": "intro_00031.png",
|
||||
"frame": {"x":1381,"y":115,"w":116,"h":116},
|
||||
"rotated": false,
|
||||
"trimmed": true,
|
||||
"spriteSourceSize": {"x":17,"y":17,"w":116,"h":116},
|
||||
"sourceSize": {"w":150,"h":150},
|
||||
"pivot": {"x":0.5,"y":0.5}
|
||||
},
|
||||
{
|
||||
"filename": "intro_00032.png",
|
||||
"frame": {"x":1499,"y":115,"w":116,"h":116},
|
||||
"rotated": false,
|
||||
"trimmed": true,
|
||||
"spriteSourceSize": {"x":17,"y":17,"w":116,"h":116},
|
||||
"sourceSize": {"w":150,"h":150},
|
||||
"pivot": {"x":0.5,"y":0.5}
|
||||
},
|
||||
{
|
||||
"filename": "intro_00033.png",
|
||||
"frame": {"x":1617,"y":115,"w":116,"h":116},
|
||||
"rotated": false,
|
||||
"trimmed": true,
|
||||
"spriteSourceSize": {"x":17,"y":17,"w":116,"h":116},
|
||||
"sourceSize": {"w":150,"h":150},
|
||||
"pivot": {"x":0.5,"y":0.5}
|
||||
},
|
||||
{
|
||||
"filename": "intro_00034.png",
|
||||
"frame": {"x":685,"y":113,"w":114,"h":114},
|
||||
"rotated": false,
|
||||
"trimmed": true,
|
||||
"spriteSourceSize": {"x":18,"y":18,"w":114,"h":114},
|
||||
"sourceSize": {"w":150,"h":150},
|
||||
"pivot": {"x":0.5,"y":0.5}
|
||||
},
|
||||
{
|
||||
"filename": "intro_00035.png",
|
||||
"frame": {"x":801,"y":113,"w":114,"h":114},
|
||||
"rotated": false,
|
||||
"trimmed": true,
|
||||
"spriteSourceSize": {"x":18,"y":18,"w":114,"h":114},
|
||||
"sourceSize": {"w":150,"h":150},
|
||||
"pivot": {"x":0.5,"y":0.5}
|
||||
},
|
||||
{
|
||||
"filename": "intro_00036.png",
|
||||
"frame": {"x":917,"y":113,"w":114,"h":114},
|
||||
"rotated": false,
|
||||
"trimmed": true,
|
||||
"spriteSourceSize": {"x":18,"y":18,"w":114,"h":114},
|
||||
"sourceSize": {"w":150,"h":150},
|
||||
"pivot": {"x":0.5,"y":0.5}
|
||||
},
|
||||
{
|
||||
"filename": "intro_00037.png",
|
||||
"frame": {"x":1033,"y":113,"w":114,"h":114},
|
||||
"rotated": false,
|
||||
"trimmed": true,
|
||||
"spriteSourceSize": {"x":18,"y":18,"w":114,"h":114},
|
||||
"sourceSize": {"w":150,"h":150},
|
||||
"pivot": {"x":0.5,"y":0.5}
|
||||
},
|
||||
{
|
||||
"filename": "intro_00038.png",
|
||||
"frame": {"x":1149,"y":113,"w":114,"h":114},
|
||||
"rotated": false,
|
||||
"trimmed": true,
|
||||
"spriteSourceSize": {"x":18,"y":18,"w":114,"h":114},
|
||||
"sourceSize": {"w":150,"h":150},
|
||||
"pivot": {"x":0.5,"y":0.5}
|
||||
},
|
||||
{
|
||||
"filename": "intro_00039.png",
|
||||
"frame": {"x":1265,"y":115,"w":114,"h":114},
|
||||
"rotated": false,
|
||||
"trimmed": true,
|
||||
"spriteSourceSize": {"x":18,"y":18,"w":114,"h":114},
|
||||
"sourceSize": {"w":150,"h":150},
|
||||
"pivot": {"x":0.5,"y":0.5}
|
||||
},
|
||||
{
|
||||
"filename": "intro_00040.png",
|
||||
"frame": {"x":1350,"y":1,"w":112,"h":112},
|
||||
"rotated": false,
|
||||
"trimmed": true,
|
||||
"spriteSourceSize": {"x":19,"y":19,"w":112,"h":112},
|
||||
"sourceSize": {"w":150,"h":150},
|
||||
"pivot": {"x":0.5,"y":0.5}
|
||||
},
|
||||
{
|
||||
"filename": "intro_00041.png",
|
||||
"frame": {"x":1464,"y":1,"w":112,"h":112},
|
||||
"rotated": false,
|
||||
"trimmed": true,
|
||||
"spriteSourceSize": {"x":19,"y":19,"w":112,"h":112},
|
||||
"sourceSize": {"w":150,"h":150},
|
||||
"pivot": {"x":0.5,"y":0.5}
|
||||
},
|
||||
{
|
||||
"filename": "intro_00042.png",
|
||||
"frame": {"x":1578,"y":1,"w":112,"h":112},
|
||||
"rotated": false,
|
||||
"trimmed": true,
|
||||
"spriteSourceSize": {"x":19,"y":19,"w":112,"h":112},
|
||||
"sourceSize": {"w":150,"h":150},
|
||||
"pivot": {"x":0.5,"y":0.5}
|
||||
},
|
||||
{
|
||||
"filename": "intro_00043.png",
|
||||
"frame": {"x":1692,"y":1,"w":112,"h":112},
|
||||
"rotated": false,
|
||||
"trimmed": true,
|
||||
"spriteSourceSize": {"x":19,"y":19,"w":112,"h":112},
|
||||
"sourceSize": {"w":150,"h":150},
|
||||
"pivot": {"x":0.5,"y":0.5}
|
||||
},
|
||||
{
|
||||
"filename": "intro_00044.png",
|
||||
"frame": {"x":1806,"y":1,"w":112,"h":112},
|
||||
"rotated": false,
|
||||
"trimmed": true,
|
||||
"spriteSourceSize": {"x":19,"y":19,"w":112,"h":112},
|
||||
"sourceSize": {"w":150,"h":150},
|
||||
"pivot": {"x":0.5,"y":0.5}
|
||||
},
|
||||
{
|
||||
"filename": "intro_00045.png",
|
||||
"frame": {"x":1920,"y":1,"w":112,"h":112},
|
||||
"rotated": false,
|
||||
"trimmed": true,
|
||||
"spriteSourceSize": {"x":19,"y":19,"w":112,"h":112},
|
||||
"sourceSize": {"w":150,"h":150},
|
||||
"pivot": {"x":0.5,"y":0.5}
|
||||
},
|
||||
{
|
||||
"filename": "intro_00046.png",
|
||||
"frame": {"x":1,"y":113,"w":112,"h":112},
|
||||
"rotated": false,
|
||||
"trimmed": true,
|
||||
"spriteSourceSize": {"x":19,"y":19,"w":112,"h":112},
|
||||
"sourceSize": {"w":150,"h":150},
|
||||
"pivot": {"x":0.5,"y":0.5}
|
||||
},
|
||||
{
|
||||
"filename": "intro_00047.png",
|
||||
"frame": {"x":115,"y":113,"w":112,"h":112},
|
||||
"rotated": false,
|
||||
"trimmed": true,
|
||||
"spriteSourceSize": {"x":19,"y":19,"w":112,"h":112},
|
||||
"sourceSize": {"w":150,"h":150},
|
||||
"pivot": {"x":0.5,"y":0.5}
|
||||
},
|
||||
{
|
||||
"filename": "intro_00048.png",
|
||||
"frame": {"x":229,"y":113,"w":112,"h":112},
|
||||
"rotated": false,
|
||||
"trimmed": true,
|
||||
"spriteSourceSize": {"x":19,"y":19,"w":112,"h":112},
|
||||
"sourceSize": {"w":150,"h":150},
|
||||
"pivot": {"x":0.5,"y":0.5}
|
||||
},
|
||||
{
|
||||
"filename": "intro_00049.png",
|
||||
"frame": {"x":343,"y":113,"w":112,"h":112},
|
||||
"rotated": false,
|
||||
"trimmed": true,
|
||||
"spriteSourceSize": {"x":19,"y":19,"w":112,"h":112},
|
||||
"sourceSize": {"w":150,"h":150},
|
||||
"pivot": {"x":0.5,"y":0.5}
|
||||
},
|
||||
{
|
||||
"filename": "intro_00050.png",
|
||||
"frame": {"x":457,"y":113,"w":112,"h":112},
|
||||
"rotated": false,
|
||||
"trimmed": true,
|
||||
"spriteSourceSize": {"x":19,"y":19,"w":112,"h":112},
|
||||
"sourceSize": {"w":150,"h":150},
|
||||
"pivot": {"x":0.5,"y":0.5}
|
||||
},
|
||||
{
|
||||
"filename": "intro_00051.png",
|
||||
"frame": {"x":571,"y":113,"w":112,"h":112},
|
||||
"rotated": false,
|
||||
"trimmed": true,
|
||||
"spriteSourceSize": {"x":19,"y":19,"w":112,"h":112},
|
||||
"sourceSize": {"w":150,"h":150},
|
||||
"pivot": {"x":0.5,"y":0.5}
|
||||
},
|
||||
{
|
||||
"filename": "intro_00052.png",
|
||||
"frame": {"x":6,"y":1,"w":110,"h":110},
|
||||
"rotated": false,
|
||||
"trimmed": true,
|
||||
"spriteSourceSize": {"x":20,"y":20,"w":110,"h":110},
|
||||
"sourceSize": {"w":150,"h":150},
|
||||
"pivot": {"x":0.5,"y":0.5}
|
||||
},
|
||||
{
|
||||
"filename": "intro_00053.png",
|
||||
"frame": {"x":118,"y":1,"w":110,"h":110},
|
||||
"rotated": false,
|
||||
"trimmed": true,
|
||||
"spriteSourceSize": {"x":20,"y":20,"w":110,"h":110},
|
||||
"sourceSize": {"w":150,"h":150},
|
||||
"pivot": {"x":0.5,"y":0.5}
|
||||
},
|
||||
{
|
||||
"filename": "intro_00054.png",
|
||||
"frame": {"x":230,"y":1,"w":110,"h":110},
|
||||
"rotated": false,
|
||||
"trimmed": true,
|
||||
"spriteSourceSize": {"x":20,"y":20,"w":110,"h":110},
|
||||
"sourceSize": {"w":150,"h":150},
|
||||
"pivot": {"x":0.5,"y":0.5}
|
||||
},
|
||||
{
|
||||
"filename": "intro_00055.png",
|
||||
"frame": {"x":342,"y":1,"w":110,"h":110},
|
||||
"rotated": false,
|
||||
"trimmed": true,
|
||||
"spriteSourceSize": {"x":20,"y":20,"w":110,"h":110},
|
||||
"sourceSize": {"w":150,"h":150},
|
||||
"pivot": {"x":0.5,"y":0.5}
|
||||
},
|
||||
{
|
||||
"filename": "intro_00056.png",
|
||||
"frame": {"x":454,"y":1,"w":110,"h":110},
|
||||
"rotated": false,
|
||||
"trimmed": true,
|
||||
"spriteSourceSize": {"x":20,"y":20,"w":110,"h":110},
|
||||
"sourceSize": {"w":150,"h":150},
|
||||
"pivot": {"x":0.5,"y":0.5}
|
||||
},
|
||||
{
|
||||
"filename": "intro_00057.png",
|
||||
"frame": {"x":566,"y":1,"w":110,"h":110},
|
||||
"rotated": false,
|
||||
"trimmed": true,
|
||||
"spriteSourceSize": {"x":20,"y":20,"w":110,"h":110},
|
||||
"sourceSize": {"w":150,"h":150},
|
||||
"pivot": {"x":0.5,"y":0.5}
|
||||
},
|
||||
{
|
||||
"filename": "intro_00058.png",
|
||||
"frame": {"x":678,"y":1,"w":110,"h":110},
|
||||
"rotated": false,
|
||||
"trimmed": true,
|
||||
"spriteSourceSize": {"x":20,"y":20,"w":110,"h":110},
|
||||
"sourceSize": {"w":150,"h":150},
|
||||
"pivot": {"x":0.5,"y":0.5}
|
||||
},
|
||||
{
|
||||
"filename": "intro_00059.png",
|
||||
"frame": {"x":790,"y":1,"w":110,"h":110},
|
||||
"rotated": false,
|
||||
"trimmed": true,
|
||||
"spriteSourceSize": {"x":20,"y":20,"w":110,"h":110},
|
||||
"sourceSize": {"w":150,"h":150},
|
||||
"pivot": {"x":0.5,"y":0.5}
|
||||
},
|
||||
{
|
||||
"filename": "intro_00060.png",
|
||||
"frame": {"x":902,"y":1,"w":110,"h":110},
|
||||
"rotated": false,
|
||||
"trimmed": true,
|
||||
"spriteSourceSize": {"x":20,"y":20,"w":110,"h":110},
|
||||
"sourceSize": {"w":150,"h":150},
|
||||
"pivot": {"x":0.5,"y":0.5}
|
||||
},
|
||||
{
|
||||
"filename": "intro_00061.png",
|
||||
"frame": {"x":1014,"y":1,"w":110,"h":110},
|
||||
"rotated": false,
|
||||
"trimmed": true,
|
||||
"spriteSourceSize": {"x":20,"y":20,"w":110,"h":110},
|
||||
"sourceSize": {"w":150,"h":150},
|
||||
"pivot": {"x":0.5,"y":0.5}
|
||||
},
|
||||
{
|
||||
"filename": "intro_00062.png",
|
||||
"frame": {"x":1126,"y":1,"w":110,"h":110},
|
||||
"rotated": false,
|
||||
"trimmed": true,
|
||||
"spriteSourceSize": {"x":20,"y":20,"w":110,"h":110},
|
||||
"sourceSize": {"w":150,"h":150},
|
||||
"pivot": {"x":0.5,"y":0.5}
|
||||
},
|
||||
{
|
||||
"filename": "intro_00063.png",
|
||||
"frame": {"x":1238,"y":1,"w":110,"h":110},
|
||||
"rotated": false,
|
||||
"trimmed": true,
|
||||
"spriteSourceSize": {"x":20,"y":20,"w":110,"h":110},
|
||||
"sourceSize": {"w":150,"h":150},
|
||||
"pivot": {"x":0.5,"y":0.5}
|
||||
}],
|
||||
"meta": {
|
||||
"app": "https://www.codeandweb.com/texturepacker",
|
||||
"version": "1.0",
|
||||
"image": "play_button_intro.png",
|
||||
"format": "RGBA8888",
|
||||
"size": {"w":2033,"h":532},
|
||||
"scale": "1",
|
||||
"smartupdate": "$TexturePacker:SmartUpdate:c182f7ef1f119bbfd7cc0a11dba8aad6:0f1ccfe8fb1cdc864d87b7e8f6fb3197:356e3de40db0da0fa679697918a56687$"
|
||||
}
|
||||
}
|
||||
BIN
packages/www/src/assets/portal/play_button_intro.png
Normal file
|
After Width: | Height: | Size: 218 KiB |
237
packages/www/src/assets/portal/play_icon_exit.json
Normal file
@@ -0,0 +1,237 @@
|
||||
{"frames": [
|
||||
|
||||
{
|
||||
"filename": "processing_outro_190822_00000.png",
|
||||
"frame": {"x":82,"y":37,"w":25,"h":13},
|
||||
"rotated": true,
|
||||
"trimmed": true,
|
||||
"spriteSourceSize": {"x":36,"y":16,"w":25,"h":13},
|
||||
"sourceSize": {"w":110,"h":110},
|
||||
"pivot": {"x":0.5,"y":0.5}
|
||||
},
|
||||
{
|
||||
"filename": "processing_outro_190822_00001.png",
|
||||
"frame": {"x":173,"y":1,"w":27,"h":11},
|
||||
"rotated": true,
|
||||
"trimmed": true,
|
||||
"spriteSourceSize": {"x":42,"y":16,"w":27,"h":11},
|
||||
"sourceSize": {"w":110,"h":110},
|
||||
"pivot": {"x":0.5,"y":0.5}
|
||||
},
|
||||
{
|
||||
"filename": "processing_outro_190822_00002.png",
|
||||
"frame": {"x":186,"y":1,"w":30,"h":15},
|
||||
"rotated": false,
|
||||
"trimmed": true,
|
||||
"spriteSourceSize": {"x":50,"y":16,"w":30,"h":15},
|
||||
"sourceSize": {"w":110,"h":110},
|
||||
"pivot": {"x":0.5,"y":0.5}
|
||||
},
|
||||
{
|
||||
"filename": "processing_outro_190822_00003.png",
|
||||
"frame": {"x":122,"y":35,"w":32,"h":27},
|
||||
"rotated": false,
|
||||
"trimmed": true,
|
||||
"spriteSourceSize": {"x":58,"y":16,"w":32,"h":27},
|
||||
"sourceSize": {"w":110,"h":110},
|
||||
"pivot": {"x":0.5,"y":0.5}
|
||||
},
|
||||
{
|
||||
"filename": "processing_outro_190822_00004.png",
|
||||
"frame": {"x":1,"y":1,"w":22,"h":37},
|
||||
"rotated": false,
|
||||
"trimmed": true,
|
||||
"spriteSourceSize": {"x":72,"y":23,"w":22,"h":37},
|
||||
"sourceSize": {"w":110,"h":110},
|
||||
"pivot": {"x":0.5,"y":0.5}
|
||||
},
|
||||
{
|
||||
"filename": "processing_outro_190822_00005.png",
|
||||
"frame": {"x":25,"y":1,"w":17,"h":37},
|
||||
"rotated": false,
|
||||
"trimmed": true,
|
||||
"spriteSourceSize": {"x":77,"y":40,"w":17,"h":37},
|
||||
"sourceSize": {"w":110,"h":110},
|
||||
"pivot": {"x":0.5,"y":0.5}
|
||||
},
|
||||
{
|
||||
"filename": "processing_outro_190822_00006.png",
|
||||
"frame": {"x":1,"y":40,"w":31,"h":21},
|
||||
"rotated": false,
|
||||
"trimmed": true,
|
||||
"spriteSourceSize": {"x":59,"y":63,"w":31,"h":21},
|
||||
"sourceSize": {"w":110,"h":110},
|
||||
"pivot": {"x":0.5,"y":0.5}
|
||||
},
|
||||
{
|
||||
"filename": "processing_outro_190822_00007.png",
|
||||
"frame": {"x":34,"y":40,"w":29,"h":19},
|
||||
"rotated": false,
|
||||
"trimmed": true,
|
||||
"spriteSourceSize": {"x":44,"y":65,"w":29,"h":19},
|
||||
"sourceSize": {"w":110,"h":110},
|
||||
"pivot": {"x":0.5,"y":0.5}
|
||||
},
|
||||
{
|
||||
"filename": "processing_outro_190822_00008.png",
|
||||
"frame": {"x":65,"y":37,"w":15,"h":25},
|
||||
"rotated": false,
|
||||
"trimmed": true,
|
||||
"spriteSourceSize": {"x":41,"y":52,"w":15,"h":25},
|
||||
"sourceSize": {"w":110,"h":110},
|
||||
"pivot": {"x":0.5,"y":0.5}
|
||||
},
|
||||
{
|
||||
"filename": "processing_outro_190822_00009.png",
|
||||
"frame": {"x":186,"y":18,"w":19,"h":18},
|
||||
"rotated": false,
|
||||
"trimmed": true,
|
||||
"spriteSourceSize": {"x":43,"y":47,"w":19,"h":18},
|
||||
"sourceSize": {"w":110,"h":110},
|
||||
"pivot": {"x":0.5,"y":0.5}
|
||||
},
|
||||
{
|
||||
"filename": "processing_outro_190822_00010.png",
|
||||
"frame": {"x":186,"y":38,"w":18,"h":18},
|
||||
"rotated": false,
|
||||
"trimmed": true,
|
||||
"spriteSourceSize": {"x":47,"y":46,"w":18,"h":18},
|
||||
"sourceSize": {"w":110,"h":110},
|
||||
"pivot": {"x":0.5,"y":0.5}
|
||||
},
|
||||
{
|
||||
"filename": "processing_outro_190822_00011.png",
|
||||
"frame": {"x":97,"y":37,"w":23,"h":24},
|
||||
"rotated": false,
|
||||
"trimmed": true,
|
||||
"spriteSourceSize": {"x":45,"y":43,"w":23,"h":24},
|
||||
"sourceSize": {"w":110,"h":110},
|
||||
"pivot": {"x":0.5,"y":0.5}
|
||||
},
|
||||
{
|
||||
"filename": "processing_outro_190822_00012.png",
|
||||
"frame": {"x":156,"y":35,"w":27,"h":28},
|
||||
"rotated": true,
|
||||
"trimmed": true,
|
||||
"spriteSourceSize": {"x":43,"y":41,"w":27,"h":28},
|
||||
"sourceSize": {"w":110,"h":110},
|
||||
"pivot": {"x":0.5,"y":0.5}
|
||||
},
|
||||
{
|
||||
"filename": "processing_outro_190822_00013.png",
|
||||
"frame": {"x":142,"y":1,"w":29,"h":32},
|
||||
"rotated": false,
|
||||
"trimmed": true,
|
||||
"spriteSourceSize": {"x":42,"y":39,"w":29,"h":32},
|
||||
"sourceSize": {"w":110,"h":110},
|
||||
"pivot": {"x":0.5,"y":0.5}
|
||||
},
|
||||
{
|
||||
"filename": "processing_outro_190822_00014.png",
|
||||
"frame": {"x":110,"y":1,"w":30,"h":32},
|
||||
"rotated": false,
|
||||
"trimmed": true,
|
||||
"spriteSourceSize": {"x":42,"y":39,"w":30,"h":32},
|
||||
"sourceSize": {"w":110,"h":110},
|
||||
"pivot": {"x":0.5,"y":0.5}
|
||||
},
|
||||
{
|
||||
"filename": "processing_outro_190822_00015.png",
|
||||
"frame": {"x":44,"y":1,"w":31,"h":34},
|
||||
"rotated": false,
|
||||
"trimmed": true,
|
||||
"spriteSourceSize": {"x":42,"y":38,"w":31,"h":34},
|
||||
"sourceSize": {"w":110,"h":110},
|
||||
"pivot": {"x":0.5,"y":0.5}
|
||||
},
|
||||
{
|
||||
"filename": "processing_outro_190822_00016.png",
|
||||
"frame": {"x":77,"y":1,"w":31,"h":34},
|
||||
"rotated": false,
|
||||
"trimmed": true,
|
||||
"spriteSourceSize": {"x":42,"y":38,"w":31,"h":34},
|
||||
"sourceSize": {"w":110,"h":110},
|
||||
"pivot": {"x":0.5,"y":0.5}
|
||||
},
|
||||
{
|
||||
"filename": "processing_outro_190822_00017.png",
|
||||
"frame": {"x":77,"y":1,"w":31,"h":34},
|
||||
"rotated": false,
|
||||
"trimmed": true,
|
||||
"spriteSourceSize": {"x":42,"y":38,"w":31,"h":34},
|
||||
"sourceSize": {"w":110,"h":110},
|
||||
"pivot": {"x":0.5,"y":0.5}
|
||||
},
|
||||
{
|
||||
"filename": "processing_outro_190822_00018.png",
|
||||
"frame": {"x":77,"y":1,"w":31,"h":34},
|
||||
"rotated": false,
|
||||
"trimmed": true,
|
||||
"spriteSourceSize": {"x":42,"y":38,"w":31,"h":34},
|
||||
"sourceSize": {"w":110,"h":110},
|
||||
"pivot": {"x":0.5,"y":0.5}
|
||||
},
|
||||
{
|
||||
"filename": "processing_outro_190822_00019.png",
|
||||
"frame": {"x":77,"y":1,"w":31,"h":34},
|
||||
"rotated": false,
|
||||
"trimmed": true,
|
||||
"spriteSourceSize": {"x":42,"y":38,"w":31,"h":34},
|
||||
"sourceSize": {"w":110,"h":110},
|
||||
"pivot": {"x":0.5,"y":0.5}
|
||||
},
|
||||
{
|
||||
"filename": "processing_outro_190822_00020.png",
|
||||
"frame": {"x":77,"y":1,"w":31,"h":34},
|
||||
"rotated": false,
|
||||
"trimmed": true,
|
||||
"spriteSourceSize": {"x":42,"y":38,"w":31,"h":34},
|
||||
"sourceSize": {"w":110,"h":110},
|
||||
"pivot": {"x":0.5,"y":0.5}
|
||||
},
|
||||
{
|
||||
"filename": "processing_outro_190822_00021.png",
|
||||
"frame": {"x":77,"y":1,"w":31,"h":34},
|
||||
"rotated": false,
|
||||
"trimmed": true,
|
||||
"spriteSourceSize": {"x":42,"y":38,"w":31,"h":34},
|
||||
"sourceSize": {"w":110,"h":110},
|
||||
"pivot": {"x":0.5,"y":0.5}
|
||||
},
|
||||
{
|
||||
"filename": "processing_outro_190822_00022.png",
|
||||
"frame": {"x":77,"y":1,"w":31,"h":34},
|
||||
"rotated": false,
|
||||
"trimmed": true,
|
||||
"spriteSourceSize": {"x":42,"y":38,"w":31,"h":34},
|
||||
"sourceSize": {"w":110,"h":110},
|
||||
"pivot": {"x":0.5,"y":0.5}
|
||||
},
|
||||
{
|
||||
"filename": "processing_outro_190822_00023.png",
|
||||
"frame": {"x":77,"y":1,"w":31,"h":34},
|
||||
"rotated": false,
|
||||
"trimmed": true,
|
||||
"spriteSourceSize": {"x":42,"y":38,"w":31,"h":34},
|
||||
"sourceSize": {"w":110,"h":110},
|
||||
"pivot": {"x":0.5,"y":0.5}
|
||||
},
|
||||
{
|
||||
"filename": "processing_outro_190822_00024.png",
|
||||
"frame": {"x":77,"y":1,"w":31,"h":34},
|
||||
"rotated": false,
|
||||
"trimmed": true,
|
||||
"spriteSourceSize": {"x":42,"y":38,"w":31,"h":34},
|
||||
"sourceSize": {"w":110,"h":110},
|
||||
"pivot": {"x":0.5,"y":0.5}
|
||||
}],
|
||||
"meta": {
|
||||
"app": "https://www.codeandweb.com/texturepacker",
|
||||
"version": "1.0",
|
||||
"image": "play_icon_exit.png",
|
||||
"format": "RGBA8888",
|
||||
"size": {"w":217,"h":63},
|
||||
"scale": "1",
|
||||
"smartupdate": "$TexturePacker:SmartUpdate:c3c530fe6905479b4ca8719a9d079c2e:313834a760df7f23cb3a698e4cd74589:0d545ddd0574d55083449defb59d19dc$"
|
||||
}
|
||||
}
|
||||
BIN
packages/www/src/assets/portal/play_icon_exit.png
Normal file
|
After Width: | Height: | Size: 4.7 KiB |
237
packages/www/src/assets/portal/play_icon_intro.json
Normal file
@@ -0,0 +1,237 @@
|
||||
{"frames": [
|
||||
|
||||
{
|
||||
"filename": "processing-intro_190822_00000.png",
|
||||
"frame": {"x":1,"y":1,"w":35,"h":38},
|
||||
"rotated": false,
|
||||
"trimmed": true,
|
||||
"spriteSourceSize": {"x":40,"y":36,"w":35,"h":38},
|
||||
"sourceSize": {"w":110,"h":110},
|
||||
"pivot": {"x":0.5,"y":0.5}
|
||||
},
|
||||
{
|
||||
"filename": "processing-intro_190822_00001.png",
|
||||
"frame": {"x":38,"y":1,"w":34,"h":38},
|
||||
"rotated": false,
|
||||
"trimmed": true,
|
||||
"spriteSourceSize": {"x":40,"y":36,"w":34,"h":38},
|
||||
"sourceSize": {"w":110,"h":110},
|
||||
"pivot": {"x":0.5,"y":0.5}
|
||||
},
|
||||
{
|
||||
"filename": "processing-intro_190822_00002.png",
|
||||
"frame": {"x":74,"y":1,"w":29,"h":32},
|
||||
"rotated": false,
|
||||
"trimmed": true,
|
||||
"spriteSourceSize": {"x":43,"y":39,"w":29,"h":32},
|
||||
"sourceSize": {"w":110,"h":110},
|
||||
"pivot": {"x":0.5,"y":0.5}
|
||||
},
|
||||
{
|
||||
"filename": "processing-intro_190822_00003.png",
|
||||
"frame": {"x":1,"y":41,"w":22,"h":24},
|
||||
"rotated": true,
|
||||
"trimmed": true,
|
||||
"spriteSourceSize": {"x":46,"y":43,"w":22,"h":24},
|
||||
"sourceSize": {"w":110,"h":110},
|
||||
"pivot": {"x":0.5,"y":0.5}
|
||||
},
|
||||
{
|
||||
"filename": "processing-intro_190822_00004.png",
|
||||
"frame": {"x":142,"y":43,"w":17,"h":18},
|
||||
"rotated": false,
|
||||
"trimmed": true,
|
||||
"spriteSourceSize": {"x":48,"y":46,"w":17,"h":18},
|
||||
"sourceSize": {"w":110,"h":110},
|
||||
"pivot": {"x":0.5,"y":0.5}
|
||||
},
|
||||
{
|
||||
"filename": "processing-intro_190822_00005.png",
|
||||
"frame": {"x":161,"y":40,"w":14,"h":14},
|
||||
"rotated": false,
|
||||
"trimmed": true,
|
||||
"spriteSourceSize": {"x":49,"y":45,"w":14,"h":14},
|
||||
"sourceSize": {"w":110,"h":110},
|
||||
"pivot": {"x":0.5,"y":0.5}
|
||||
},
|
||||
{
|
||||
"filename": "processing-intro_190822_00006.png",
|
||||
"frame": {"x":183,"y":1,"w":10,"h":10},
|
||||
"rotated": false,
|
||||
"trimmed": true,
|
||||
"spriteSourceSize": {"x":54,"y":42,"w":10,"h":10},
|
||||
"sourceSize": {"w":110,"h":110},
|
||||
"pivot": {"x":0.5,"y":0.5}
|
||||
},
|
||||
{
|
||||
"filename": "processing-intro_190822_00007.png",
|
||||
"frame": {"x":183,"y":37,"w":9,"h":9},
|
||||
"rotated": false,
|
||||
"trimmed": true,
|
||||
"spriteSourceSize": {"x":62,"y":38,"w":9,"h":9},
|
||||
"sourceSize": {"w":110,"h":110},
|
||||
"pivot": {"x":0.5,"y":0.5}
|
||||
},
|
||||
{
|
||||
"filename": "processing-intro_190822_00008.png",
|
||||
"frame": {"x":183,"y":25,"w":10,"h":10},
|
||||
"rotated": false,
|
||||
"trimmed": true,
|
||||
"spriteSourceSize": {"x":73,"y":38,"w":10,"h":10},
|
||||
"sourceSize": {"w":110,"h":110},
|
||||
"pivot": {"x":0.5,"y":0.5}
|
||||
},
|
||||
{
|
||||
"filename": "processing-intro_190822_00009.png",
|
||||
"frame": {"x":180,"y":13,"w":10,"h":12},
|
||||
"rotated": true,
|
||||
"trimmed": true,
|
||||
"spriteSourceSize": {"x":82,"y":46,"w":10,"h":12},
|
||||
"sourceSize": {"w":110,"h":110},
|
||||
"pivot": {"x":0.5,"y":0.5}
|
||||
},
|
||||
{
|
||||
"filename": "processing-intro_190822_00010.png",
|
||||
"frame": {"x":168,"y":1,"w":10,"h":13},
|
||||
"rotated": true,
|
||||
"trimmed": true,
|
||||
"spriteSourceSize": {"x":84,"y":59,"w":10,"h":13},
|
||||
"sourceSize": {"w":110,"h":110},
|
||||
"pivot": {"x":0.5,"y":0.5}
|
||||
},
|
||||
{
|
||||
"filename": "processing-intro_190822_00011.png",
|
||||
"frame": {"x":177,"y":48,"w":13,"h":13},
|
||||
"rotated": false,
|
||||
"trimmed": true,
|
||||
"spriteSourceSize": {"x":76,"y":72,"w":13,"h":13},
|
||||
"sourceSize": {"w":110,"h":110},
|
||||
"pivot": {"x":0.5,"y":0.5}
|
||||
},
|
||||
{
|
||||
"filename": "processing-intro_190822_00012.png",
|
||||
"frame": {"x":139,"y":13,"w":17,"h":12},
|
||||
"rotated": false,
|
||||
"trimmed": true,
|
||||
"spriteSourceSize": {"x":63,"y":80,"w":17,"h":12},
|
||||
"sourceSize": {"w":110,"h":110},
|
||||
"pivot": {"x":0.5,"y":0.5}
|
||||
},
|
||||
{
|
||||
"filename": "processing-intro_190822_00013.png",
|
||||
"frame": {"x":158,"y":13,"w":20,"h":10},
|
||||
"rotated": false,
|
||||
"trimmed": true,
|
||||
"spriteSourceSize": {"x":49,"y":84,"w":20,"h":10},
|
||||
"sourceSize": {"w":110,"h":110},
|
||||
"pivot": {"x":0.5,"y":0.5}
|
||||
},
|
||||
{
|
||||
"filename": "processing-intro_190822_00014.png",
|
||||
"frame": {"x":160,"y":25,"w":21,"h":13},
|
||||
"rotated": false,
|
||||
"trimmed": true,
|
||||
"spriteSourceSize": {"x":36,"y":81,"w":21,"h":13},
|
||||
"sourceSize": {"w":110,"h":110},
|
||||
"pivot": {"x":0.5,"y":0.5}
|
||||
},
|
||||
{
|
||||
"filename": "processing-intro_190822_00015.png",
|
||||
"frame": {"x":114,"y":27,"w":21,"h":18},
|
||||
"rotated": true,
|
||||
"trimmed": true,
|
||||
"spriteSourceSize": {"x":26,"y":73,"w":21,"h":18},
|
||||
"sourceSize": {"w":110,"h":110},
|
||||
"pivot": {"x":0.5,"y":0.5}
|
||||
},
|
||||
{
|
||||
"filename": "processing-intro_190822_00016.png",
|
||||
"frame": {"x":94,"y":35,"w":18,"h":22},
|
||||
"rotated": false,
|
||||
"trimmed": true,
|
||||
"spriteSourceSize": {"x":20,"y":63,"w":18,"h":22},
|
||||
"sourceSize": {"w":110,"h":110},
|
||||
"pivot": {"x":0.5,"y":0.5}
|
||||
},
|
||||
{
|
||||
"filename": "processing-intro_190822_00017.png",
|
||||
"frame": {"x":123,"y":1,"w":14,"h":24},
|
||||
"rotated": false,
|
||||
"trimmed": true,
|
||||
"spriteSourceSize": {"x":18,"y":53,"w":14,"h":24},
|
||||
"sourceSize": {"w":110,"h":110},
|
||||
"pivot": {"x":0.5,"y":0.5}
|
||||
},
|
||||
{
|
||||
"filename": "processing-intro_190822_00018.png",
|
||||
"frame": {"x":139,"y":1,"w":10,"h":27},
|
||||
"rotated": true,
|
||||
"trimmed": true,
|
||||
"spriteSourceSize": {"x":18,"y":42,"w":10,"h":27},
|
||||
"sourceSize": {"w":110,"h":110},
|
||||
"pivot": {"x":0.5,"y":0.5}
|
||||
},
|
||||
{
|
||||
"filename": "processing-intro_190822_00019.png",
|
||||
"frame": {"x":114,"y":50,"w":13,"h":26},
|
||||
"rotated": true,
|
||||
"trimmed": true,
|
||||
"spriteSourceSize": {"x":18,"y":34,"w":13,"h":26},
|
||||
"sourceSize": {"w":110,"h":110},
|
||||
"pivot": {"x":0.5,"y":0.5}
|
||||
},
|
||||
{
|
||||
"filename": "processing-intro_190822_00020.png",
|
||||
"frame": {"x":75,"y":35,"w":17,"h":25},
|
||||
"rotated": false,
|
||||
"trimmed": true,
|
||||
"spriteSourceSize": {"x":19,"y":27,"w":17,"h":25},
|
||||
"sourceSize": {"w":110,"h":110},
|
||||
"pivot": {"x":0.5,"y":0.5}
|
||||
},
|
||||
{
|
||||
"filename": "processing-intro_190822_00021.png",
|
||||
"frame": {"x":27,"y":41,"w":21,"h":22},
|
||||
"rotated": false,
|
||||
"trimmed": true,
|
||||
"spriteSourceSize": {"x":21,"y":22,"w":21,"h":22},
|
||||
"sourceSize": {"w":110,"h":110},
|
||||
"pivot": {"x":0.5,"y":0.5}
|
||||
},
|
||||
{
|
||||
"filename": "processing-intro_190822_00022.png",
|
||||
"frame": {"x":50,"y":41,"w":23,"h":19},
|
||||
"rotated": false,
|
||||
"trimmed": true,
|
||||
"spriteSourceSize": {"x":25,"y":19,"w":23,"h":19},
|
||||
"sourceSize": {"w":110,"h":110},
|
||||
"pivot": {"x":0.5,"y":0.5}
|
||||
},
|
||||
{
|
||||
"filename": "processing-intro_190822_00023.png",
|
||||
"frame": {"x":105,"y":1,"w":24,"h":16},
|
||||
"rotated": true,
|
||||
"trimmed": true,
|
||||
"spriteSourceSize": {"x":30,"y":17,"w":24,"h":16},
|
||||
"sourceSize": {"w":110,"h":110},
|
||||
"pivot": {"x":0.5,"y":0.5}
|
||||
},
|
||||
{
|
||||
"filename": "processing-intro_190822_00024.png",
|
||||
"frame": {"x":134,"y":27,"w":24,"h":14},
|
||||
"rotated": false,
|
||||
"trimmed": true,
|
||||
"spriteSourceSize": {"x":35,"y":16,"w":24,"h":14},
|
||||
"sourceSize": {"w":110,"h":110},
|
||||
"pivot": {"x":0.5,"y":0.5}
|
||||
}],
|
||||
"meta": {
|
||||
"app": "https://www.codeandweb.com/texturepacker",
|
||||
"version": "1.0",
|
||||
"image": "play_icon_intro.png",
|
||||
"format": "RGBA8888",
|
||||
"size": {"w":194,"h":64},
|
||||
"scale": "1",
|
||||
"smartupdate": "$TexturePacker:SmartUpdate:a423de98fed1e06cc62611996868a2db:909cd8dbde93c0d2fca4402e7546ffd8:5ea08d4d7caaa5043281f5095baffab1$"
|
||||
}
|
||||
}
|
||||
BIN
packages/www/src/assets/portal/play_icon_intro.png
Normal file
|
After Width: | Height: | Size: 4.5 KiB |
237
packages/www/src/assets/portal/play_icon_loop.json
Normal file
@@ -0,0 +1,237 @@
|
||||
{"frames": [
|
||||
|
||||
{
|
||||
"filename": "processing-loop_190822_00000.png",
|
||||
"frame": {"x":37,"y":97,"w":23,"h":11},
|
||||
"rotated": false,
|
||||
"trimmed": true,
|
||||
"spriteSourceSize": {"x":40,"y":16,"w":23,"h":11},
|
||||
"sourceSize": {"w":110,"h":110},
|
||||
"pivot": {"x":0.5,"y":0.5}
|
||||
},
|
||||
{
|
||||
"filename": "processing-loop_190822_00001.png",
|
||||
"frame": {"x":37,"y":110,"w":23,"h":10},
|
||||
"rotated": false,
|
||||
"trimmed": true,
|
||||
"spriteSourceSize": {"x":46,"y":16,"w":23,"h":10},
|
||||
"sourceSize": {"w":110,"h":110},
|
||||
"pivot": {"x":0.5,"y":0.5}
|
||||
},
|
||||
{
|
||||
"filename": "processing-loop_190822_00002.png",
|
||||
"frame": {"x":30,"y":257,"w":24,"h":12},
|
||||
"rotated": false,
|
||||
"trimmed": true,
|
||||
"spriteSourceSize": {"x":51,"y":16,"w":24,"h":12},
|
||||
"sourceSize": {"w":110,"h":110},
|
||||
"pivot": {"x":0.5,"y":0.5}
|
||||
},
|
||||
{
|
||||
"filename": "processing-loop_190822_00003.png",
|
||||
"frame": {"x":34,"y":225,"w":25,"h":15},
|
||||
"rotated": false,
|
||||
"trimmed": true,
|
||||
"spriteSourceSize": {"x":56,"y":16,"w":25,"h":15},
|
||||
"sourceSize": {"w":110,"h":110},
|
||||
"pivot": {"x":0.5,"y":0.5}
|
||||
},
|
||||
{
|
||||
"filename": "processing-loop_190822_00004.png",
|
||||
"frame": {"x":1,"y":252,"w":27,"h":20},
|
||||
"rotated": false,
|
||||
"trimmed": true,
|
||||
"spriteSourceSize": {"x":61,"y":17,"w":27,"h":20},
|
||||
"sourceSize": {"w":110,"h":110},
|
||||
"pivot": {"x":0.5,"y":0.5}
|
||||
},
|
||||
{
|
||||
"filename": "processing-loop_190822_00005.png",
|
||||
"frame": {"x":35,"y":180,"w":24,"h":25},
|
||||
"rotated": true,
|
||||
"trimmed": true,
|
||||
"spriteSourceSize": {"x":68,"y":19,"w":24,"h":25},
|
||||
"sourceSize": {"w":110,"h":110},
|
||||
"pivot": {"x":0.5,"y":0.5}
|
||||
},
|
||||
{
|
||||
"filename": "processing-loop_190822_00006.png",
|
||||
"frame": {"x":39,"y":39,"w":21,"h":30},
|
||||
"rotated": false,
|
||||
"trimmed": true,
|
||||
"spriteSourceSize": {"x":74,"y":24,"w":21,"h":30},
|
||||
"sourceSize": {"w":110,"h":110},
|
||||
"pivot": {"x":0.5,"y":0.5}
|
||||
},
|
||||
{
|
||||
"filename": "processing-loop_190822_00007.png",
|
||||
"frame": {"x":1,"y":120,"w":16,"h":34},
|
||||
"rotated": true,
|
||||
"trimmed": true,
|
||||
"spriteSourceSize": {"x":80,"y":30,"w":16,"h":34},
|
||||
"sourceSize": {"w":110,"h":110},
|
||||
"pivot": {"x":0.5,"y":0.5}
|
||||
},
|
||||
{
|
||||
"filename": "processing-loop_190822_00008.png",
|
||||
"frame": {"x":1,"y":58,"w":13,"h":36},
|
||||
"rotated": true,
|
||||
"trimmed": true,
|
||||
"spriteSourceSize": {"x":83,"y":38,"w":13,"h":36},
|
||||
"sourceSize": {"w":110,"h":110},
|
||||
"pivot": {"x":0.5,"y":0.5}
|
||||
},
|
||||
{
|
||||
"filename": "processing-loop_190822_00009.png",
|
||||
"frame": {"x":41,"y":1,"w":19,"h":36},
|
||||
"rotated": false,
|
||||
"trimmed": true,
|
||||
"spriteSourceSize": {"x":77,"y":47,"w":19,"h":36},
|
||||
"sourceSize": {"w":110,"h":110},
|
||||
"pivot": {"x":0.5,"y":0.5}
|
||||
},
|
||||
{
|
||||
"filename": "processing-loop_190822_00010.png",
|
||||
"frame": {"x":1,"y":151,"w":27,"h":33},
|
||||
"rotated": true,
|
||||
"trimmed": true,
|
||||
"spriteSourceSize": {"x":68,"y":57,"w":27,"h":33},
|
||||
"sourceSize": {"w":110,"h":110},
|
||||
"pivot": {"x":0.5,"y":0.5}
|
||||
},
|
||||
{
|
||||
"filename": "processing-loop_190822_00011.png",
|
||||
"frame": {"x":1,"y":73,"w":34,"h":26},
|
||||
"rotated": false,
|
||||
"trimmed": true,
|
||||
"spriteSourceSize": {"x":57,"y":68,"w":34,"h":26},
|
||||
"sourceSize": {"w":110,"h":110},
|
||||
"pivot": {"x":0.5,"y":0.5}
|
||||
},
|
||||
{
|
||||
"filename": "processing-loop_190822_00012.png",
|
||||
"frame": {"x":1,"y":1,"w":38,"h":18},
|
||||
"rotated": false,
|
||||
"trimmed": true,
|
||||
"spriteSourceSize": {"x":46,"y":76,"w":38,"h":18},
|
||||
"sourceSize": {"w":110,"h":110},
|
||||
"pivot": {"x":0.5,"y":0.5}
|
||||
},
|
||||
{
|
||||
"filename": "processing-loop_190822_00013.png",
|
||||
"frame": {"x":1,"y":21,"w":38,"h":13},
|
||||
"rotated": false,
|
||||
"trimmed": true,
|
||||
"spriteSourceSize": {"x":36,"y":81,"w":38,"h":13},
|
||||
"sourceSize": {"w":110,"h":110},
|
||||
"pivot": {"x":0.5,"y":0.5}
|
||||
},
|
||||
{
|
||||
"filename": "processing-loop_190822_00014.png",
|
||||
"frame": {"x":1,"y":36,"w":36,"h":20},
|
||||
"rotated": false,
|
||||
"trimmed": true,
|
||||
"spriteSourceSize": {"x":27,"y":74,"w":36,"h":20},
|
||||
"sourceSize": {"w":110,"h":110},
|
||||
"pivot": {"x":0.5,"y":0.5}
|
||||
},
|
||||
{
|
||||
"filename": "processing-loop_190822_00015.png",
|
||||
"frame": {"x":1,"y":197,"w":31,"h":27},
|
||||
"rotated": false,
|
||||
"trimmed": true,
|
||||
"spriteSourceSize": {"x":21,"y":66,"w":31,"h":27},
|
||||
"sourceSize": {"w":110,"h":110},
|
||||
"pivot": {"x":0.5,"y":0.5}
|
||||
},
|
||||
{
|
||||
"filename": "processing-loop_190822_00016.png",
|
||||
"frame": {"x":1,"y":226,"w":24,"h":31},
|
||||
"rotated": true,
|
||||
"trimmed": true,
|
||||
"spriteSourceSize": {"x":18,"y":57,"w":24,"h":31},
|
||||
"sourceSize": {"w":110,"h":110},
|
||||
"pivot": {"x":0.5,"y":0.5}
|
||||
},
|
||||
{
|
||||
"filename": "processing-loop_190822_00017.png",
|
||||
"frame": {"x":1,"y":101,"w":17,"h":34},
|
||||
"rotated": true,
|
||||
"trimmed": true,
|
||||
"spriteSourceSize": {"x":18,"y":47,"w":17,"h":34},
|
||||
"sourceSize": {"w":110,"h":110},
|
||||
"pivot": {"x":0.5,"y":0.5}
|
||||
},
|
||||
{
|
||||
"filename": "processing-loop_190822_00018.png",
|
||||
"frame": {"x":1,"y":138,"w":11,"h":34},
|
||||
"rotated": true,
|
||||
"trimmed": true,
|
||||
"spriteSourceSize": {"x":18,"y":39,"w":11,"h":34},
|
||||
"sourceSize": {"w":110,"h":110},
|
||||
"pivot": {"x":0.5,"y":0.5}
|
||||
},
|
||||
{
|
||||
"filename": "processing-loop_190822_00019.png",
|
||||
"frame": {"x":1,"y":180,"w":15,"h":32},
|
||||
"rotated": true,
|
||||
"trimmed": true,
|
||||
"spriteSourceSize": {"x":18,"y":31,"w":15,"h":32},
|
||||
"sourceSize": {"w":110,"h":110},
|
||||
"pivot": {"x":0.5,"y":0.5}
|
||||
},
|
||||
{
|
||||
"filename": "processing-loop_190822_00020.png",
|
||||
"frame": {"x":37,"y":149,"w":20,"h":29},
|
||||
"rotated": false,
|
||||
"trimmed": true,
|
||||
"spriteSourceSize": {"x":18,"y":25,"w":20,"h":29},
|
||||
"sourceSize": {"w":110,"h":110},
|
||||
"pivot": {"x":0.5,"y":0.5}
|
||||
},
|
||||
{
|
||||
"filename": "processing-loop_190822_00021.png",
|
||||
"frame": {"x":37,"y":122,"w":22,"h":25},
|
||||
"rotated": false,
|
||||
"trimmed": true,
|
||||
"spriteSourceSize": {"x":21,"y":21,"w":22,"h":25},
|
||||
"sourceSize": {"w":110,"h":110},
|
||||
"pivot": {"x":0.5,"y":0.5}
|
||||
},
|
||||
{
|
||||
"filename": "processing-loop_190822_00022.png",
|
||||
"frame": {"x":39,"y":71,"w":24,"h":21},
|
||||
"rotated": true,
|
||||
"trimmed": true,
|
||||
"spriteSourceSize": {"x":25,"y":18,"w":24,"h":21},
|
||||
"sourceSize": {"w":110,"h":110},
|
||||
"pivot": {"x":0.5,"y":0.5}
|
||||
},
|
||||
{
|
||||
"filename": "processing-loop_190822_00023.png",
|
||||
"frame": {"x":34,"y":206,"w":25,"h":17},
|
||||
"rotated": false,
|
||||
"trimmed": true,
|
||||
"spriteSourceSize": {"x":30,"y":16,"w":25,"h":17},
|
||||
"sourceSize": {"w":110,"h":110},
|
||||
"pivot": {"x":0.5,"y":0.5}
|
||||
},
|
||||
{
|
||||
"filename": "processing-loop_190822_00024.png",
|
||||
"frame": {"x":34,"y":242,"w":25,"h":13},
|
||||
"rotated": false,
|
||||
"trimmed": true,
|
||||
"spriteSourceSize": {"x":36,"y":16,"w":25,"h":13},
|
||||
"sourceSize": {"w":110,"h":110},
|
||||
"pivot": {"x":0.5,"y":0.5}
|
||||
}],
|
||||
"meta": {
|
||||
"app": "https://www.codeandweb.com/texturepacker",
|
||||
"version": "1.0",
|
||||
"image": "play_icon_loop.png",
|
||||
"format": "RGBA8888",
|
||||
"size": {"w":61,"h":273},
|
||||
"scale": "1",
|
||||
"smartupdate": "$TexturePacker:SmartUpdate:4d92719faa27cbc50926429a66fc808c:25af8a045fca3b76dba8d8d26ed4ccf7:9b86d3b10829102d2ccf1bd5942144f2$"
|
||||
}
|
||||
}
|
||||
BIN
packages/www/src/assets/portal/play_icon_loop.png
Normal file
|
After Width: | Height: | Size: 6.1 KiB |
BIN
packages/www/src/assets/portal/portal_background_placeholder.png
Normal file
|
After Width: | Height: | Size: 14 KiB |
2316
packages/www/src/common/portal/assets/play_button_idle.json
Normal file
588
packages/www/src/common/portal/assets/play_button_intro.json
Normal file
@@ -0,0 +1,588 @@
|
||||
{"frames": [
|
||||
|
||||
{
|
||||
"filename": "intro_00000.png",
|
||||
"frame": {"x":1,"y":1,"w":3,"h":3},
|
||||
"rotated": false,
|
||||
"trimmed": true,
|
||||
"spriteSourceSize": {"x":0,"y":0,"w":3,"h":3},
|
||||
"sourceSize": {"w":150,"h":150},
|
||||
"pivot": {"x":0.5,"y":0.5}
|
||||
},
|
||||
{
|
||||
"filename": "intro_00001.png",
|
||||
"frame": {"x":911,"y":364,"w":150,"h":150},
|
||||
"rotated": false,
|
||||
"trimmed": false,
|
||||
"spriteSourceSize": {"x":0,"y":0,"w":150,"h":150},
|
||||
"sourceSize": {"w":150,"h":150},
|
||||
"pivot": {"x":0.5,"y":0.5}
|
||||
},
|
||||
{
|
||||
"filename": "intro_00002.png",
|
||||
"frame": {"x":1063,"y":367,"w":150,"h":150},
|
||||
"rotated": false,
|
||||
"trimmed": false,
|
||||
"spriteSourceSize": {"x":0,"y":0,"w":150,"h":150},
|
||||
"sourceSize": {"w":150,"h":150},
|
||||
"pivot": {"x":0.5,"y":0.5}
|
||||
},
|
||||
{
|
||||
"filename": "intro_00003.png",
|
||||
"frame": {"x":1215,"y":372,"w":150,"h":150},
|
||||
"rotated": false,
|
||||
"trimmed": false,
|
||||
"spriteSourceSize": {"x":0,"y":0,"w":150,"h":150},
|
||||
"sourceSize": {"w":150,"h":150},
|
||||
"pivot": {"x":0.5,"y":0.5}
|
||||
},
|
||||
{
|
||||
"filename": "intro_00004.png",
|
||||
"frame": {"x":1367,"y":374,"w":150,"h":150},
|
||||
"rotated": false,
|
||||
"trimmed": false,
|
||||
"spriteSourceSize": {"x":0,"y":0,"w":150,"h":150},
|
||||
"sourceSize": {"w":150,"h":150},
|
||||
"pivot": {"x":0.5,"y":0.5}
|
||||
},
|
||||
{
|
||||
"filename": "intro_00005.png",
|
||||
"frame": {"x":1519,"y":375,"w":150,"h":150},
|
||||
"rotated": false,
|
||||
"trimmed": false,
|
||||
"spriteSourceSize": {"x":0,"y":0,"w":150,"h":150},
|
||||
"sourceSize": {"w":150,"h":150},
|
||||
"pivot": {"x":0.5,"y":0.5}
|
||||
},
|
||||
{
|
||||
"filename": "intro_00006.png",
|
||||
"frame": {"x":1671,"y":379,"w":150,"h":150},
|
||||
"rotated": false,
|
||||
"trimmed": false,
|
||||
"spriteSourceSize": {"x":0,"y":0,"w":150,"h":150},
|
||||
"sourceSize": {"w":150,"h":150},
|
||||
"pivot": {"x":0.5,"y":0.5}
|
||||
},
|
||||
{
|
||||
"filename": "intro_00007.png",
|
||||
"frame": {"x":1823,"y":381,"w":150,"h":150},
|
||||
"rotated": false,
|
||||
"trimmed": false,
|
||||
"spriteSourceSize": {"x":0,"y":0,"w":150,"h":150},
|
||||
"sourceSize": {"w":150,"h":150},
|
||||
"pivot": {"x":0.5,"y":0.5}
|
||||
},
|
||||
{
|
||||
"filename": "intro_00008.png",
|
||||
"frame": {"x":759,"y":362,"w":150,"h":149},
|
||||
"rotated": false,
|
||||
"trimmed": true,
|
||||
"spriteSourceSize": {"x":0,"y":0,"w":150,"h":149},
|
||||
"sourceSize": {"w":150,"h":150},
|
||||
"pivot": {"x":0.5,"y":0.5}
|
||||
},
|
||||
{
|
||||
"filename": "intro_00009.png",
|
||||
"frame": {"x":456,"y":355,"w":148,"h":149},
|
||||
"rotated": true,
|
||||
"trimmed": true,
|
||||
"spriteSourceSize": {"x":2,"y":0,"w":148,"h":149},
|
||||
"sourceSize": {"w":150,"h":150},
|
||||
"pivot": {"x":0.5,"y":0.5}
|
||||
},
|
||||
{
|
||||
"filename": "intro_00010.png",
|
||||
"frame": {"x":607,"y":360,"w":148,"h":150},
|
||||
"rotated": true,
|
||||
"trimmed": true,
|
||||
"spriteSourceSize": {"x":2,"y":0,"w":148,"h":150},
|
||||
"sourceSize": {"w":150,"h":150},
|
||||
"pivot": {"x":0.5,"y":0.5}
|
||||
},
|
||||
{
|
||||
"filename": "intro_00011.png",
|
||||
"frame": {"x":1,"y":349,"w":147,"h":150},
|
||||
"rotated": true,
|
||||
"trimmed": true,
|
||||
"spriteSourceSize": {"x":3,"y":0,"w":147,"h":150},
|
||||
"sourceSize": {"w":150,"h":150},
|
||||
"pivot": {"x":0.5,"y":0.5}
|
||||
},
|
||||
{
|
||||
"filename": "intro_00012.png",
|
||||
"frame": {"x":153,"y":351,"w":147,"h":150},
|
||||
"rotated": true,
|
||||
"trimmed": true,
|
||||
"spriteSourceSize": {"x":3,"y":0,"w":147,"h":150},
|
||||
"sourceSize": {"w":150,"h":150},
|
||||
"pivot": {"x":0.5,"y":0.5}
|
||||
},
|
||||
{
|
||||
"filename": "intro_00013.png",
|
||||
"frame": {"x":305,"y":353,"w":147,"h":149},
|
||||
"rotated": true,
|
||||
"trimmed": true,
|
||||
"spriteSourceSize": {"x":2,"y":1,"w":147,"h":149},
|
||||
"sourceSize": {"w":150,"h":150},
|
||||
"pivot": {"x":0.5,"y":0.5}
|
||||
},
|
||||
{
|
||||
"filename": "intro_00014.png",
|
||||
"frame": {"x":1867,"y":235,"w":144,"h":148},
|
||||
"rotated": true,
|
||||
"trimmed": true,
|
||||
"spriteSourceSize": {"x":4,"y":2,"w":144,"h":148},
|
||||
"sourceSize": {"w":150,"h":150},
|
||||
"pivot": {"x":0.5,"y":0.5}
|
||||
},
|
||||
{
|
||||
"filename": "intro_00015.png",
|
||||
"frame": {"x":1719,"y":235,"w":142,"h":146},
|
||||
"rotated": true,
|
||||
"trimmed": true,
|
||||
"spriteSourceSize": {"x":5,"y":3,"w":142,"h":146},
|
||||
"sourceSize": {"w":150,"h":150},
|
||||
"pivot": {"x":0.5,"y":0.5}
|
||||
},
|
||||
{
|
||||
"filename": "intro_00016.png",
|
||||
"frame": {"x":1574,"y":233,"w":140,"h":143},
|
||||
"rotated": true,
|
||||
"trimmed": true,
|
||||
"spriteSourceSize": {"x":6,"y":4,"w":140,"h":143},
|
||||
"sourceSize": {"w":150,"h":150},
|
||||
"pivot": {"x":0.5,"y":0.5}
|
||||
},
|
||||
{
|
||||
"filename": "intro_00017.png",
|
||||
"frame": {"x":1432,"y":233,"w":139,"h":140},
|
||||
"rotated": true,
|
||||
"trimmed": true,
|
||||
"spriteSourceSize": {"x":6,"y":6,"w":139,"h":140},
|
||||
"sourceSize": {"w":150,"h":150},
|
||||
"pivot": {"x":0.5,"y":0.5}
|
||||
},
|
||||
{
|
||||
"filename": "intro_00018.png",
|
||||
"frame": {"x":1292,"y":233,"w":138,"h":137},
|
||||
"rotated": false,
|
||||
"trimmed": true,
|
||||
"spriteSourceSize": {"x":6,"y":7,"w":138,"h":137},
|
||||
"sourceSize": {"w":150,"h":150},
|
||||
"pivot": {"x":0.5,"y":0.5}
|
||||
},
|
||||
{
|
||||
"filename": "intro_00019.png",
|
||||
"frame": {"x":1154,"y":231,"w":136,"h":134},
|
||||
"rotated": false,
|
||||
"trimmed": true,
|
||||
"spriteSourceSize": {"x":7,"y":9,"w":136,"h":134},
|
||||
"sourceSize": {"w":150,"h":150},
|
||||
"pivot": {"x":0.5,"y":0.5}
|
||||
},
|
||||
{
|
||||
"filename": "intro_00020.png",
|
||||
"frame": {"x":1018,"y":229,"w":134,"h":133},
|
||||
"rotated": false,
|
||||
"trimmed": true,
|
||||
"spriteSourceSize": {"x":8,"y":9,"w":134,"h":133},
|
||||
"sourceSize": {"w":150,"h":150},
|
||||
"pivot": {"x":0.5,"y":0.5}
|
||||
},
|
||||
{
|
||||
"filename": "intro_00021.png",
|
||||
"frame": {"x":885,"y":229,"w":131,"h":131},
|
||||
"rotated": false,
|
||||
"trimmed": true,
|
||||
"spriteSourceSize": {"x":9,"y":10,"w":131,"h":131},
|
||||
"sourceSize": {"w":150,"h":150},
|
||||
"pivot": {"x":0.5,"y":0.5}
|
||||
},
|
||||
{
|
||||
"filename": "intro_00022.png",
|
||||
"frame": {"x":754,"y":229,"w":129,"h":129},
|
||||
"rotated": false,
|
||||
"trimmed": true,
|
||||
"spriteSourceSize": {"x":10,"y":11,"w":129,"h":129},
|
||||
"sourceSize": {"w":150,"h":150},
|
||||
"pivot": {"x":0.5,"y":0.5}
|
||||
},
|
||||
{
|
||||
"filename": "intro_00023.png",
|
||||
"frame": {"x":624,"y":229,"w":128,"h":127},
|
||||
"rotated": false,
|
||||
"trimmed": true,
|
||||
"spriteSourceSize": {"x":11,"y":12,"w":128,"h":127},
|
||||
"sourceSize": {"w":150,"h":150},
|
||||
"pivot": {"x":0.5,"y":0.5}
|
||||
},
|
||||
{
|
||||
"filename": "intro_00024.png",
|
||||
"frame": {"x":496,"y":227,"w":126,"h":126},
|
||||
"rotated": false,
|
||||
"trimmed": true,
|
||||
"spriteSourceSize": {"x":12,"y":12,"w":126,"h":126},
|
||||
"sourceSize": {"w":150,"h":150},
|
||||
"pivot": {"x":0.5,"y":0.5}
|
||||
},
|
||||
{
|
||||
"filename": "intro_00025.png",
|
||||
"frame": {"x":370,"y":227,"w":124,"h":124},
|
||||
"rotated": false,
|
||||
"trimmed": true,
|
||||
"spriteSourceSize": {"x":13,"y":13,"w":124,"h":124},
|
||||
"sourceSize": {"w":150,"h":150},
|
||||
"pivot": {"x":0.5,"y":0.5}
|
||||
},
|
||||
{
|
||||
"filename": "intro_00026.png",
|
||||
"frame": {"x":246,"y":227,"w":122,"h":122},
|
||||
"rotated": false,
|
||||
"trimmed": true,
|
||||
"spriteSourceSize": {"x":14,"y":14,"w":122,"h":122},
|
||||
"sourceSize": {"w":150,"h":150},
|
||||
"pivot": {"x":0.5,"y":0.5}
|
||||
},
|
||||
{
|
||||
"filename": "intro_00027.png",
|
||||
"frame": {"x":1,"y":227,"w":121,"h":120},
|
||||
"rotated": false,
|
||||
"trimmed": true,
|
||||
"spriteSourceSize": {"x":14,"y":15,"w":121,"h":120},
|
||||
"sourceSize": {"w":150,"h":150},
|
||||
"pivot": {"x":0.5,"y":0.5}
|
||||
},
|
||||
{
|
||||
"filename": "intro_00028.png",
|
||||
"frame": {"x":124,"y":227,"w":120,"h":120},
|
||||
"rotated": false,
|
||||
"trimmed": true,
|
||||
"spriteSourceSize": {"x":15,"y":15,"w":120,"h":120},
|
||||
"sourceSize": {"w":150,"h":150},
|
||||
"pivot": {"x":0.5,"y":0.5}
|
||||
},
|
||||
{
|
||||
"filename": "intro_00029.png",
|
||||
"frame": {"x":1855,"y":115,"w":118,"h":118},
|
||||
"rotated": false,
|
||||
"trimmed": true,
|
||||
"spriteSourceSize": {"x":16,"y":16,"w":118,"h":118},
|
||||
"sourceSize": {"w":150,"h":150},
|
||||
"pivot": {"x":0.5,"y":0.5}
|
||||
},
|
||||
{
|
||||
"filename": "intro_00030.png",
|
||||
"frame": {"x":1735,"y":115,"w":117,"h":118},
|
||||
"rotated": true,
|
||||
"trimmed": true,
|
||||
"spriteSourceSize": {"x":16,"y":16,"w":117,"h":118},
|
||||
"sourceSize": {"w":150,"h":150},
|
||||
"pivot": {"x":0.5,"y":0.5}
|
||||
},
|
||||
{
|
||||
"filename": "intro_00031.png",
|
||||
"frame": {"x":1381,"y":115,"w":116,"h":116},
|
||||
"rotated": false,
|
||||
"trimmed": true,
|
||||
"spriteSourceSize": {"x":17,"y":17,"w":116,"h":116},
|
||||
"sourceSize": {"w":150,"h":150},
|
||||
"pivot": {"x":0.5,"y":0.5}
|
||||
},
|
||||
{
|
||||
"filename": "intro_00032.png",
|
||||
"frame": {"x":1499,"y":115,"w":116,"h":116},
|
||||
"rotated": false,
|
||||
"trimmed": true,
|
||||
"spriteSourceSize": {"x":17,"y":17,"w":116,"h":116},
|
||||
"sourceSize": {"w":150,"h":150},
|
||||
"pivot": {"x":0.5,"y":0.5}
|
||||
},
|
||||
{
|
||||
"filename": "intro_00033.png",
|
||||
"frame": {"x":1617,"y":115,"w":116,"h":116},
|
||||
"rotated": false,
|
||||
"trimmed": true,
|
||||
"spriteSourceSize": {"x":17,"y":17,"w":116,"h":116},
|
||||
"sourceSize": {"w":150,"h":150},
|
||||
"pivot": {"x":0.5,"y":0.5}
|
||||
},
|
||||
{
|
||||
"filename": "intro_00034.png",
|
||||
"frame": {"x":685,"y":113,"w":114,"h":114},
|
||||
"rotated": false,
|
||||
"trimmed": true,
|
||||
"spriteSourceSize": {"x":18,"y":18,"w":114,"h":114},
|
||||
"sourceSize": {"w":150,"h":150},
|
||||
"pivot": {"x":0.5,"y":0.5}
|
||||
},
|
||||
{
|
||||
"filename": "intro_00035.png",
|
||||
"frame": {"x":801,"y":113,"w":114,"h":114},
|
||||
"rotated": false,
|
||||
"trimmed": true,
|
||||
"spriteSourceSize": {"x":18,"y":18,"w":114,"h":114},
|
||||
"sourceSize": {"w":150,"h":150},
|
||||
"pivot": {"x":0.5,"y":0.5}
|
||||
},
|
||||
{
|
||||
"filename": "intro_00036.png",
|
||||
"frame": {"x":917,"y":113,"w":114,"h":114},
|
||||
"rotated": false,
|
||||
"trimmed": true,
|
||||
"spriteSourceSize": {"x":18,"y":18,"w":114,"h":114},
|
||||
"sourceSize": {"w":150,"h":150},
|
||||
"pivot": {"x":0.5,"y":0.5}
|
||||
},
|
||||
{
|
||||
"filename": "intro_00037.png",
|
||||
"frame": {"x":1033,"y":113,"w":114,"h":114},
|
||||
"rotated": false,
|
||||
"trimmed": true,
|
||||
"spriteSourceSize": {"x":18,"y":18,"w":114,"h":114},
|
||||
"sourceSize": {"w":150,"h":150},
|
||||
"pivot": {"x":0.5,"y":0.5}
|
||||
},
|
||||
{
|
||||
"filename": "intro_00038.png",
|
||||
"frame": {"x":1149,"y":113,"w":114,"h":114},
|
||||
"rotated": false,
|
||||
"trimmed": true,
|
||||
"spriteSourceSize": {"x":18,"y":18,"w":114,"h":114},
|
||||
"sourceSize": {"w":150,"h":150},
|
||||
"pivot": {"x":0.5,"y":0.5}
|
||||
},
|
||||
{
|
||||
"filename": "intro_00039.png",
|
||||
"frame": {"x":1265,"y":115,"w":114,"h":114},
|
||||
"rotated": false,
|
||||
"trimmed": true,
|
||||
"spriteSourceSize": {"x":18,"y":18,"w":114,"h":114},
|
||||
"sourceSize": {"w":150,"h":150},
|
||||
"pivot": {"x":0.5,"y":0.5}
|
||||
},
|
||||
{
|
||||
"filename": "intro_00040.png",
|
||||
"frame": {"x":1350,"y":1,"w":112,"h":112},
|
||||
"rotated": false,
|
||||
"trimmed": true,
|
||||
"spriteSourceSize": {"x":19,"y":19,"w":112,"h":112},
|
||||
"sourceSize": {"w":150,"h":150},
|
||||
"pivot": {"x":0.5,"y":0.5}
|
||||
},
|
||||
{
|
||||
"filename": "intro_00041.png",
|
||||
"frame": {"x":1464,"y":1,"w":112,"h":112},
|
||||
"rotated": false,
|
||||
"trimmed": true,
|
||||
"spriteSourceSize": {"x":19,"y":19,"w":112,"h":112},
|
||||
"sourceSize": {"w":150,"h":150},
|
||||
"pivot": {"x":0.5,"y":0.5}
|
||||
},
|
||||
{
|
||||
"filename": "intro_00042.png",
|
||||
"frame": {"x":1578,"y":1,"w":112,"h":112},
|
||||
"rotated": false,
|
||||
"trimmed": true,
|
||||
"spriteSourceSize": {"x":19,"y":19,"w":112,"h":112},
|
||||
"sourceSize": {"w":150,"h":150},
|
||||
"pivot": {"x":0.5,"y":0.5}
|
||||
},
|
||||
{
|
||||
"filename": "intro_00043.png",
|
||||
"frame": {"x":1692,"y":1,"w":112,"h":112},
|
||||
"rotated": false,
|
||||
"trimmed": true,
|
||||
"spriteSourceSize": {"x":19,"y":19,"w":112,"h":112},
|
||||
"sourceSize": {"w":150,"h":150},
|
||||
"pivot": {"x":0.5,"y":0.5}
|
||||
},
|
||||
{
|
||||
"filename": "intro_00044.png",
|
||||
"frame": {"x":1806,"y":1,"w":112,"h":112},
|
||||
"rotated": false,
|
||||
"trimmed": true,
|
||||
"spriteSourceSize": {"x":19,"y":19,"w":112,"h":112},
|
||||
"sourceSize": {"w":150,"h":150},
|
||||
"pivot": {"x":0.5,"y":0.5}
|
||||
},
|
||||
{
|
||||
"filename": "intro_00045.png",
|
||||
"frame": {"x":1920,"y":1,"w":112,"h":112},
|
||||
"rotated": false,
|
||||
"trimmed": true,
|
||||
"spriteSourceSize": {"x":19,"y":19,"w":112,"h":112},
|
||||
"sourceSize": {"w":150,"h":150},
|
||||
"pivot": {"x":0.5,"y":0.5}
|
||||
},
|
||||
{
|
||||
"filename": "intro_00046.png",
|
||||
"frame": {"x":1,"y":113,"w":112,"h":112},
|
||||
"rotated": false,
|
||||
"trimmed": true,
|
||||
"spriteSourceSize": {"x":19,"y":19,"w":112,"h":112},
|
||||
"sourceSize": {"w":150,"h":150},
|
||||
"pivot": {"x":0.5,"y":0.5}
|
||||
},
|
||||
{
|
||||
"filename": "intro_00047.png",
|
||||
"frame": {"x":115,"y":113,"w":112,"h":112},
|
||||
"rotated": false,
|
||||
"trimmed": true,
|
||||
"spriteSourceSize": {"x":19,"y":19,"w":112,"h":112},
|
||||
"sourceSize": {"w":150,"h":150},
|
||||
"pivot": {"x":0.5,"y":0.5}
|
||||
},
|
||||
{
|
||||
"filename": "intro_00048.png",
|
||||
"frame": {"x":229,"y":113,"w":112,"h":112},
|
||||
"rotated": false,
|
||||
"trimmed": true,
|
||||
"spriteSourceSize": {"x":19,"y":19,"w":112,"h":112},
|
||||
"sourceSize": {"w":150,"h":150},
|
||||
"pivot": {"x":0.5,"y":0.5}
|
||||
},
|
||||
{
|
||||
"filename": "intro_00049.png",
|
||||
"frame": {"x":343,"y":113,"w":112,"h":112},
|
||||
"rotated": false,
|
||||
"trimmed": true,
|
||||
"spriteSourceSize": {"x":19,"y":19,"w":112,"h":112},
|
||||
"sourceSize": {"w":150,"h":150},
|
||||
"pivot": {"x":0.5,"y":0.5}
|
||||
},
|
||||
{
|
||||
"filename": "intro_00050.png",
|
||||
"frame": {"x":457,"y":113,"w":112,"h":112},
|
||||
"rotated": false,
|
||||
"trimmed": true,
|
||||
"spriteSourceSize": {"x":19,"y":19,"w":112,"h":112},
|
||||
"sourceSize": {"w":150,"h":150},
|
||||
"pivot": {"x":0.5,"y":0.5}
|
||||
},
|
||||
{
|
||||
"filename": "intro_00051.png",
|
||||
"frame": {"x":571,"y":113,"w":112,"h":112},
|
||||
"rotated": false,
|
||||
"trimmed": true,
|
||||
"spriteSourceSize": {"x":19,"y":19,"w":112,"h":112},
|
||||
"sourceSize": {"w":150,"h":150},
|
||||
"pivot": {"x":0.5,"y":0.5}
|
||||
},
|
||||
{
|
||||
"filename": "intro_00052.png",
|
||||
"frame": {"x":6,"y":1,"w":110,"h":110},
|
||||
"rotated": false,
|
||||
"trimmed": true,
|
||||
"spriteSourceSize": {"x":20,"y":20,"w":110,"h":110},
|
||||
"sourceSize": {"w":150,"h":150},
|
||||
"pivot": {"x":0.5,"y":0.5}
|
||||
},
|
||||
{
|
||||
"filename": "intro_00053.png",
|
||||
"frame": {"x":118,"y":1,"w":110,"h":110},
|
||||
"rotated": false,
|
||||
"trimmed": true,
|
||||
"spriteSourceSize": {"x":20,"y":20,"w":110,"h":110},
|
||||
"sourceSize": {"w":150,"h":150},
|
||||
"pivot": {"x":0.5,"y":0.5}
|
||||
},
|
||||
{
|
||||
"filename": "intro_00054.png",
|
||||
"frame": {"x":230,"y":1,"w":110,"h":110},
|
||||
"rotated": false,
|
||||
"trimmed": true,
|
||||
"spriteSourceSize": {"x":20,"y":20,"w":110,"h":110},
|
||||
"sourceSize": {"w":150,"h":150},
|
||||
"pivot": {"x":0.5,"y":0.5}
|
||||
},
|
||||
{
|
||||
"filename": "intro_00055.png",
|
||||
"frame": {"x":342,"y":1,"w":110,"h":110},
|
||||
"rotated": false,
|
||||
"trimmed": true,
|
||||
"spriteSourceSize": {"x":20,"y":20,"w":110,"h":110},
|
||||
"sourceSize": {"w":150,"h":150},
|
||||
"pivot": {"x":0.5,"y":0.5}
|
||||
},
|
||||
{
|
||||
"filename": "intro_00056.png",
|
||||
"frame": {"x":454,"y":1,"w":110,"h":110},
|
||||
"rotated": false,
|
||||
"trimmed": true,
|
||||
"spriteSourceSize": {"x":20,"y":20,"w":110,"h":110},
|
||||
"sourceSize": {"w":150,"h":150},
|
||||
"pivot": {"x":0.5,"y":0.5}
|
||||
},
|
||||
{
|
||||
"filename": "intro_00057.png",
|
||||
"frame": {"x":566,"y":1,"w":110,"h":110},
|
||||
"rotated": false,
|
||||
"trimmed": true,
|
||||
"spriteSourceSize": {"x":20,"y":20,"w":110,"h":110},
|
||||
"sourceSize": {"w":150,"h":150},
|
||||
"pivot": {"x":0.5,"y":0.5}
|
||||
},
|
||||
{
|
||||
"filename": "intro_00058.png",
|
||||
"frame": {"x":678,"y":1,"w":110,"h":110},
|
||||
"rotated": false,
|
||||
"trimmed": true,
|
||||
"spriteSourceSize": {"x":20,"y":20,"w":110,"h":110},
|
||||
"sourceSize": {"w":150,"h":150},
|
||||
"pivot": {"x":0.5,"y":0.5}
|
||||
},
|
||||
{
|
||||
"filename": "intro_00059.png",
|
||||
"frame": {"x":790,"y":1,"w":110,"h":110},
|
||||
"rotated": false,
|
||||
"trimmed": true,
|
||||
"spriteSourceSize": {"x":20,"y":20,"w":110,"h":110},
|
||||
"sourceSize": {"w":150,"h":150},
|
||||
"pivot": {"x":0.5,"y":0.5}
|
||||
},
|
||||
{
|
||||
"filename": "intro_00060.png",
|
||||
"frame": {"x":902,"y":1,"w":110,"h":110},
|
||||
"rotated": false,
|
||||
"trimmed": true,
|
||||
"spriteSourceSize": {"x":20,"y":20,"w":110,"h":110},
|
||||
"sourceSize": {"w":150,"h":150},
|
||||
"pivot": {"x":0.5,"y":0.5}
|
||||
},
|
||||
{
|
||||
"filename": "intro_00061.png",
|
||||
"frame": {"x":1014,"y":1,"w":110,"h":110},
|
||||
"rotated": false,
|
||||
"trimmed": true,
|
||||
"spriteSourceSize": {"x":20,"y":20,"w":110,"h":110},
|
||||
"sourceSize": {"w":150,"h":150},
|
||||
"pivot": {"x":0.5,"y":0.5}
|
||||
},
|
||||
{
|
||||
"filename": "intro_00062.png",
|
||||
"frame": {"x":1126,"y":1,"w":110,"h":110},
|
||||
"rotated": false,
|
||||
"trimmed": true,
|
||||
"spriteSourceSize": {"x":20,"y":20,"w":110,"h":110},
|
||||
"sourceSize": {"w":150,"h":150},
|
||||
"pivot": {"x":0.5,"y":0.5}
|
||||
},
|
||||
{
|
||||
"filename": "intro_00063.png",
|
||||
"frame": {"x":1238,"y":1,"w":110,"h":110},
|
||||
"rotated": false,
|
||||
"trimmed": true,
|
||||
"spriteSourceSize": {"x":20,"y":20,"w":110,"h":110},
|
||||
"sourceSize": {"w":150,"h":150},
|
||||
"pivot": {"x":0.5,"y":0.5}
|
||||
}],
|
||||
"meta": {
|
||||
"app": "https://www.codeandweb.com/texturepacker",
|
||||
"version": "1.0",
|
||||
"image": "play_button_intro.png",
|
||||
"format": "RGBA8888",
|
||||
"size": {"w":2033,"h":532},
|
||||
"scale": "1",
|
||||
"smartupdate": "$TexturePacker:SmartUpdate:c182f7ef1f119bbfd7cc0a11dba8aad6:0f1ccfe8fb1cdc864d87b7e8f6fb3197:356e3de40db0da0fa679697918a56687$"
|
||||
}
|
||||
}
|
||||
237
packages/www/src/common/portal/assets/play_icon_exit.json
Normal file
@@ -0,0 +1,237 @@
|
||||
{"frames": [
|
||||
|
||||
{
|
||||
"filename": "processing_outro_190822_00000.png",
|
||||
"frame": {"x":82,"y":37,"w":25,"h":13},
|
||||
"rotated": true,
|
||||
"trimmed": true,
|
||||
"spriteSourceSize": {"x":36,"y":16,"w":25,"h":13},
|
||||
"sourceSize": {"w":110,"h":110},
|
||||
"pivot": {"x":0.5,"y":0.5}
|
||||
},
|
||||
{
|
||||
"filename": "processing_outro_190822_00001.png",
|
||||
"frame": {"x":173,"y":1,"w":27,"h":11},
|
||||
"rotated": true,
|
||||
"trimmed": true,
|
||||
"spriteSourceSize": {"x":42,"y":16,"w":27,"h":11},
|
||||
"sourceSize": {"w":110,"h":110},
|
||||
"pivot": {"x":0.5,"y":0.5}
|
||||
},
|
||||
{
|
||||
"filename": "processing_outro_190822_00002.png",
|
||||
"frame": {"x":186,"y":1,"w":30,"h":15},
|
||||
"rotated": false,
|
||||
"trimmed": true,
|
||||
"spriteSourceSize": {"x":50,"y":16,"w":30,"h":15},
|
||||
"sourceSize": {"w":110,"h":110},
|
||||
"pivot": {"x":0.5,"y":0.5}
|
||||
},
|
||||
{
|
||||
"filename": "processing_outro_190822_00003.png",
|
||||
"frame": {"x":122,"y":35,"w":32,"h":27},
|
||||
"rotated": false,
|
||||
"trimmed": true,
|
||||
"spriteSourceSize": {"x":58,"y":16,"w":32,"h":27},
|
||||
"sourceSize": {"w":110,"h":110},
|
||||
"pivot": {"x":0.5,"y":0.5}
|
||||
},
|
||||
{
|
||||
"filename": "processing_outro_190822_00004.png",
|
||||
"frame": {"x":1,"y":1,"w":22,"h":37},
|
||||
"rotated": false,
|
||||
"trimmed": true,
|
||||
"spriteSourceSize": {"x":72,"y":23,"w":22,"h":37},
|
||||
"sourceSize": {"w":110,"h":110},
|
||||
"pivot": {"x":0.5,"y":0.5}
|
||||
},
|
||||
{
|
||||
"filename": "processing_outro_190822_00005.png",
|
||||
"frame": {"x":25,"y":1,"w":17,"h":37},
|
||||
"rotated": false,
|
||||
"trimmed": true,
|
||||
"spriteSourceSize": {"x":77,"y":40,"w":17,"h":37},
|
||||
"sourceSize": {"w":110,"h":110},
|
||||
"pivot": {"x":0.5,"y":0.5}
|
||||
},
|
||||
{
|
||||
"filename": "processing_outro_190822_00006.png",
|
||||
"frame": {"x":1,"y":40,"w":31,"h":21},
|
||||
"rotated": false,
|
||||
"trimmed": true,
|
||||
"spriteSourceSize": {"x":59,"y":63,"w":31,"h":21},
|
||||
"sourceSize": {"w":110,"h":110},
|
||||
"pivot": {"x":0.5,"y":0.5}
|
||||
},
|
||||
{
|
||||
"filename": "processing_outro_190822_00007.png",
|
||||
"frame": {"x":34,"y":40,"w":29,"h":19},
|
||||
"rotated": false,
|
||||
"trimmed": true,
|
||||
"spriteSourceSize": {"x":44,"y":65,"w":29,"h":19},
|
||||
"sourceSize": {"w":110,"h":110},
|
||||
"pivot": {"x":0.5,"y":0.5}
|
||||
},
|
||||
{
|
||||
"filename": "processing_outro_190822_00008.png",
|
||||
"frame": {"x":65,"y":37,"w":15,"h":25},
|
||||
"rotated": false,
|
||||
"trimmed": true,
|
||||
"spriteSourceSize": {"x":41,"y":52,"w":15,"h":25},
|
||||
"sourceSize": {"w":110,"h":110},
|
||||
"pivot": {"x":0.5,"y":0.5}
|
||||
},
|
||||
{
|
||||
"filename": "processing_outro_190822_00009.png",
|
||||
"frame": {"x":186,"y":18,"w":19,"h":18},
|
||||
"rotated": false,
|
||||
"trimmed": true,
|
||||
"spriteSourceSize": {"x":43,"y":47,"w":19,"h":18},
|
||||
"sourceSize": {"w":110,"h":110},
|
||||
"pivot": {"x":0.5,"y":0.5}
|
||||
},
|
||||
{
|
||||
"filename": "processing_outro_190822_00010.png",
|
||||
"frame": {"x":186,"y":38,"w":18,"h":18},
|
||||
"rotated": false,
|
||||
"trimmed": true,
|
||||
"spriteSourceSize": {"x":47,"y":46,"w":18,"h":18},
|
||||
"sourceSize": {"w":110,"h":110},
|
||||
"pivot": {"x":0.5,"y":0.5}
|
||||
},
|
||||
{
|
||||
"filename": "processing_outro_190822_00011.png",
|
||||
"frame": {"x":97,"y":37,"w":23,"h":24},
|
||||
"rotated": false,
|
||||
"trimmed": true,
|
||||
"spriteSourceSize": {"x":45,"y":43,"w":23,"h":24},
|
||||
"sourceSize": {"w":110,"h":110},
|
||||
"pivot": {"x":0.5,"y":0.5}
|
||||
},
|
||||
{
|
||||
"filename": "processing_outro_190822_00012.png",
|
||||
"frame": {"x":156,"y":35,"w":27,"h":28},
|
||||
"rotated": true,
|
||||
"trimmed": true,
|
||||
"spriteSourceSize": {"x":43,"y":41,"w":27,"h":28},
|
||||
"sourceSize": {"w":110,"h":110},
|
||||
"pivot": {"x":0.5,"y":0.5}
|
||||
},
|
||||
{
|
||||
"filename": "processing_outro_190822_00013.png",
|
||||
"frame": {"x":142,"y":1,"w":29,"h":32},
|
||||
"rotated": false,
|
||||
"trimmed": true,
|
||||
"spriteSourceSize": {"x":42,"y":39,"w":29,"h":32},
|
||||
"sourceSize": {"w":110,"h":110},
|
||||
"pivot": {"x":0.5,"y":0.5}
|
||||
},
|
||||
{
|
||||
"filename": "processing_outro_190822_00014.png",
|
||||
"frame": {"x":110,"y":1,"w":30,"h":32},
|
||||
"rotated": false,
|
||||
"trimmed": true,
|
||||
"spriteSourceSize": {"x":42,"y":39,"w":30,"h":32},
|
||||
"sourceSize": {"w":110,"h":110},
|
||||
"pivot": {"x":0.5,"y":0.5}
|
||||
},
|
||||
{
|
||||
"filename": "processing_outro_190822_00015.png",
|
||||
"frame": {"x":44,"y":1,"w":31,"h":34},
|
||||
"rotated": false,
|
||||
"trimmed": true,
|
||||
"spriteSourceSize": {"x":42,"y":38,"w":31,"h":34},
|
||||
"sourceSize": {"w":110,"h":110},
|
||||
"pivot": {"x":0.5,"y":0.5}
|
||||
},
|
||||
{
|
||||
"filename": "processing_outro_190822_00016.png",
|
||||
"frame": {"x":77,"y":1,"w":31,"h":34},
|
||||
"rotated": false,
|
||||
"trimmed": true,
|
||||
"spriteSourceSize": {"x":42,"y":38,"w":31,"h":34},
|
||||
"sourceSize": {"w":110,"h":110},
|
||||
"pivot": {"x":0.5,"y":0.5}
|
||||
},
|
||||
{
|
||||
"filename": "processing_outro_190822_00017.png",
|
||||
"frame": {"x":77,"y":1,"w":31,"h":34},
|
||||
"rotated": false,
|
||||
"trimmed": true,
|
||||
"spriteSourceSize": {"x":42,"y":38,"w":31,"h":34},
|
||||
"sourceSize": {"w":110,"h":110},
|
||||
"pivot": {"x":0.5,"y":0.5}
|
||||
},
|
||||
{
|
||||
"filename": "processing_outro_190822_00018.png",
|
||||
"frame": {"x":77,"y":1,"w":31,"h":34},
|
||||
"rotated": false,
|
||||
"trimmed": true,
|
||||
"spriteSourceSize": {"x":42,"y":38,"w":31,"h":34},
|
||||
"sourceSize": {"w":110,"h":110},
|
||||
"pivot": {"x":0.5,"y":0.5}
|
||||
},
|
||||
{
|
||||
"filename": "processing_outro_190822_00019.png",
|
||||
"frame": {"x":77,"y":1,"w":31,"h":34},
|
||||
"rotated": false,
|
||||
"trimmed": true,
|
||||
"spriteSourceSize": {"x":42,"y":38,"w":31,"h":34},
|
||||
"sourceSize": {"w":110,"h":110},
|
||||
"pivot": {"x":0.5,"y":0.5}
|
||||
},
|
||||
{
|
||||
"filename": "processing_outro_190822_00020.png",
|
||||
"frame": {"x":77,"y":1,"w":31,"h":34},
|
||||
"rotated": false,
|
||||
"trimmed": true,
|
||||
"spriteSourceSize": {"x":42,"y":38,"w":31,"h":34},
|
||||
"sourceSize": {"w":110,"h":110},
|
||||
"pivot": {"x":0.5,"y":0.5}
|
||||
},
|
||||
{
|
||||
"filename": "processing_outro_190822_00021.png",
|
||||
"frame": {"x":77,"y":1,"w":31,"h":34},
|
||||
"rotated": false,
|
||||
"trimmed": true,
|
||||
"spriteSourceSize": {"x":42,"y":38,"w":31,"h":34},
|
||||
"sourceSize": {"w":110,"h":110},
|
||||
"pivot": {"x":0.5,"y":0.5}
|
||||
},
|
||||
{
|
||||
"filename": "processing_outro_190822_00022.png",
|
||||
"frame": {"x":77,"y":1,"w":31,"h":34},
|
||||
"rotated": false,
|
||||
"trimmed": true,
|
||||
"spriteSourceSize": {"x":42,"y":38,"w":31,"h":34},
|
||||
"sourceSize": {"w":110,"h":110},
|
||||
"pivot": {"x":0.5,"y":0.5}
|
||||
},
|
||||
{
|
||||
"filename": "processing_outro_190822_00023.png",
|
||||
"frame": {"x":77,"y":1,"w":31,"h":34},
|
||||
"rotated": false,
|
||||
"trimmed": true,
|
||||
"spriteSourceSize": {"x":42,"y":38,"w":31,"h":34},
|
||||
"sourceSize": {"w":110,"h":110},
|
||||
"pivot": {"x":0.5,"y":0.5}
|
||||
},
|
||||
{
|
||||
"filename": "processing_outro_190822_00024.png",
|
||||
"frame": {"x":77,"y":1,"w":31,"h":34},
|
||||
"rotated": false,
|
||||
"trimmed": true,
|
||||
"spriteSourceSize": {"x":42,"y":38,"w":31,"h":34},
|
||||
"sourceSize": {"w":110,"h":110},
|
||||
"pivot": {"x":0.5,"y":0.5}
|
||||
}],
|
||||
"meta": {
|
||||
"app": "https://www.codeandweb.com/texturepacker",
|
||||
"version": "1.0",
|
||||
"image": "play_icon_exit.png",
|
||||
"format": "RGBA8888",
|
||||
"size": {"w":217,"h":63},
|
||||
"scale": "1",
|
||||
"smartupdate": "$TexturePacker:SmartUpdate:c3c530fe6905479b4ca8719a9d079c2e:313834a760df7f23cb3a698e4cd74589:0d545ddd0574d55083449defb59d19dc$"
|
||||
}
|
||||
}
|
||||
237
packages/www/src/common/portal/assets/play_icon_intro.json
Normal file
@@ -0,0 +1,237 @@
|
||||
{"frames": [
|
||||
|
||||
{
|
||||
"filename": "processing-intro_190822_00000.png",
|
||||
"frame": {"x":1,"y":1,"w":35,"h":38},
|
||||
"rotated": false,
|
||||
"trimmed": true,
|
||||
"spriteSourceSize": {"x":40,"y":36,"w":35,"h":38},
|
||||
"sourceSize": {"w":110,"h":110},
|
||||
"pivot": {"x":0.5,"y":0.5}
|
||||
},
|
||||
{
|
||||
"filename": "processing-intro_190822_00001.png",
|
||||
"frame": {"x":38,"y":1,"w":34,"h":38},
|
||||
"rotated": false,
|
||||
"trimmed": true,
|
||||
"spriteSourceSize": {"x":40,"y":36,"w":34,"h":38},
|
||||
"sourceSize": {"w":110,"h":110},
|
||||
"pivot": {"x":0.5,"y":0.5}
|
||||
},
|
||||
{
|
||||
"filename": "processing-intro_190822_00002.png",
|
||||
"frame": {"x":74,"y":1,"w":29,"h":32},
|
||||
"rotated": false,
|
||||
"trimmed": true,
|
||||
"spriteSourceSize": {"x":43,"y":39,"w":29,"h":32},
|
||||
"sourceSize": {"w":110,"h":110},
|
||||
"pivot": {"x":0.5,"y":0.5}
|
||||
},
|
||||
{
|
||||
"filename": "processing-intro_190822_00003.png",
|
||||
"frame": {"x":1,"y":41,"w":22,"h":24},
|
||||
"rotated": true,
|
||||
"trimmed": true,
|
||||
"spriteSourceSize": {"x":46,"y":43,"w":22,"h":24},
|
||||
"sourceSize": {"w":110,"h":110},
|
||||
"pivot": {"x":0.5,"y":0.5}
|
||||
},
|
||||
{
|
||||
"filename": "processing-intro_190822_00004.png",
|
||||
"frame": {"x":142,"y":43,"w":17,"h":18},
|
||||
"rotated": false,
|
||||
"trimmed": true,
|
||||
"spriteSourceSize": {"x":48,"y":46,"w":17,"h":18},
|
||||
"sourceSize": {"w":110,"h":110},
|
||||
"pivot": {"x":0.5,"y":0.5}
|
||||
},
|
||||
{
|
||||
"filename": "processing-intro_190822_00005.png",
|
||||
"frame": {"x":161,"y":40,"w":14,"h":14},
|
||||
"rotated": false,
|
||||
"trimmed": true,
|
||||
"spriteSourceSize": {"x":49,"y":45,"w":14,"h":14},
|
||||
"sourceSize": {"w":110,"h":110},
|
||||
"pivot": {"x":0.5,"y":0.5}
|
||||
},
|
||||
{
|
||||
"filename": "processing-intro_190822_00006.png",
|
||||
"frame": {"x":183,"y":1,"w":10,"h":10},
|
||||
"rotated": false,
|
||||
"trimmed": true,
|
||||
"spriteSourceSize": {"x":54,"y":42,"w":10,"h":10},
|
||||
"sourceSize": {"w":110,"h":110},
|
||||
"pivot": {"x":0.5,"y":0.5}
|
||||
},
|
||||
{
|
||||
"filename": "processing-intro_190822_00007.png",
|
||||
"frame": {"x":183,"y":37,"w":9,"h":9},
|
||||
"rotated": false,
|
||||
"trimmed": true,
|
||||
"spriteSourceSize": {"x":62,"y":38,"w":9,"h":9},
|
||||
"sourceSize": {"w":110,"h":110},
|
||||
"pivot": {"x":0.5,"y":0.5}
|
||||
},
|
||||
{
|
||||
"filename": "processing-intro_190822_00008.png",
|
||||
"frame": {"x":183,"y":25,"w":10,"h":10},
|
||||
"rotated": false,
|
||||
"trimmed": true,
|
||||
"spriteSourceSize": {"x":73,"y":38,"w":10,"h":10},
|
||||
"sourceSize": {"w":110,"h":110},
|
||||
"pivot": {"x":0.5,"y":0.5}
|
||||
},
|
||||
{
|
||||
"filename": "processing-intro_190822_00009.png",
|
||||
"frame": {"x":180,"y":13,"w":10,"h":12},
|
||||
"rotated": true,
|
||||
"trimmed": true,
|
||||
"spriteSourceSize": {"x":82,"y":46,"w":10,"h":12},
|
||||
"sourceSize": {"w":110,"h":110},
|
||||
"pivot": {"x":0.5,"y":0.5}
|
||||
},
|
||||
{
|
||||
"filename": "processing-intro_190822_00010.png",
|
||||
"frame": {"x":168,"y":1,"w":10,"h":13},
|
||||
"rotated": true,
|
||||
"trimmed": true,
|
||||
"spriteSourceSize": {"x":84,"y":59,"w":10,"h":13},
|
||||
"sourceSize": {"w":110,"h":110},
|
||||
"pivot": {"x":0.5,"y":0.5}
|
||||
},
|
||||
{
|
||||
"filename": "processing-intro_190822_00011.png",
|
||||
"frame": {"x":177,"y":48,"w":13,"h":13},
|
||||
"rotated": false,
|
||||
"trimmed": true,
|
||||
"spriteSourceSize": {"x":76,"y":72,"w":13,"h":13},
|
||||
"sourceSize": {"w":110,"h":110},
|
||||
"pivot": {"x":0.5,"y":0.5}
|
||||
},
|
||||
{
|
||||
"filename": "processing-intro_190822_00012.png",
|
||||
"frame": {"x":139,"y":13,"w":17,"h":12},
|
||||
"rotated": false,
|
||||
"trimmed": true,
|
||||
"spriteSourceSize": {"x":63,"y":80,"w":17,"h":12},
|
||||
"sourceSize": {"w":110,"h":110},
|
||||
"pivot": {"x":0.5,"y":0.5}
|
||||
},
|
||||
{
|
||||
"filename": "processing-intro_190822_00013.png",
|
||||
"frame": {"x":158,"y":13,"w":20,"h":10},
|
||||
"rotated": false,
|
||||
"trimmed": true,
|
||||
"spriteSourceSize": {"x":49,"y":84,"w":20,"h":10},
|
||||
"sourceSize": {"w":110,"h":110},
|
||||
"pivot": {"x":0.5,"y":0.5}
|
||||
},
|
||||
{
|
||||
"filename": "processing-intro_190822_00014.png",
|
||||
"frame": {"x":160,"y":25,"w":21,"h":13},
|
||||
"rotated": false,
|
||||
"trimmed": true,
|
||||
"spriteSourceSize": {"x":36,"y":81,"w":21,"h":13},
|
||||
"sourceSize": {"w":110,"h":110},
|
||||
"pivot": {"x":0.5,"y":0.5}
|
||||
},
|
||||
{
|
||||
"filename": "processing-intro_190822_00015.png",
|
||||
"frame": {"x":114,"y":27,"w":21,"h":18},
|
||||
"rotated": true,
|
||||
"trimmed": true,
|
||||
"spriteSourceSize": {"x":26,"y":73,"w":21,"h":18},
|
||||
"sourceSize": {"w":110,"h":110},
|
||||
"pivot": {"x":0.5,"y":0.5}
|
||||
},
|
||||
{
|
||||
"filename": "processing-intro_190822_00016.png",
|
||||
"frame": {"x":94,"y":35,"w":18,"h":22},
|
||||
"rotated": false,
|
||||
"trimmed": true,
|
||||
"spriteSourceSize": {"x":20,"y":63,"w":18,"h":22},
|
||||
"sourceSize": {"w":110,"h":110},
|
||||
"pivot": {"x":0.5,"y":0.5}
|
||||
},
|
||||
{
|
||||
"filename": "processing-intro_190822_00017.png",
|
||||
"frame": {"x":123,"y":1,"w":14,"h":24},
|
||||
"rotated": false,
|
||||
"trimmed": true,
|
||||
"spriteSourceSize": {"x":18,"y":53,"w":14,"h":24},
|
||||
"sourceSize": {"w":110,"h":110},
|
||||
"pivot": {"x":0.5,"y":0.5}
|
||||
},
|
||||
{
|
||||
"filename": "processing-intro_190822_00018.png",
|
||||
"frame": {"x":139,"y":1,"w":10,"h":27},
|
||||
"rotated": true,
|
||||
"trimmed": true,
|
||||
"spriteSourceSize": {"x":18,"y":42,"w":10,"h":27},
|
||||
"sourceSize": {"w":110,"h":110},
|
||||
"pivot": {"x":0.5,"y":0.5}
|
||||
},
|
||||
{
|
||||
"filename": "processing-intro_190822_00019.png",
|
||||
"frame": {"x":114,"y":50,"w":13,"h":26},
|
||||
"rotated": true,
|
||||
"trimmed": true,
|
||||
"spriteSourceSize": {"x":18,"y":34,"w":13,"h":26},
|
||||
"sourceSize": {"w":110,"h":110},
|
||||
"pivot": {"x":0.5,"y":0.5}
|
||||
},
|
||||
{
|
||||
"filename": "processing-intro_190822_00020.png",
|
||||
"frame": {"x":75,"y":35,"w":17,"h":25},
|
||||
"rotated": false,
|
||||
"trimmed": true,
|
||||
"spriteSourceSize": {"x":19,"y":27,"w":17,"h":25},
|
||||
"sourceSize": {"w":110,"h":110},
|
||||
"pivot": {"x":0.5,"y":0.5}
|
||||
},
|
||||
{
|
||||
"filename": "processing-intro_190822_00021.png",
|
||||
"frame": {"x":27,"y":41,"w":21,"h":22},
|
||||
"rotated": false,
|
||||
"trimmed": true,
|
||||
"spriteSourceSize": {"x":21,"y":22,"w":21,"h":22},
|
||||
"sourceSize": {"w":110,"h":110},
|
||||
"pivot": {"x":0.5,"y":0.5}
|
||||
},
|
||||
{
|
||||
"filename": "processing-intro_190822_00022.png",
|
||||
"frame": {"x":50,"y":41,"w":23,"h":19},
|
||||
"rotated": false,
|
||||
"trimmed": true,
|
||||
"spriteSourceSize": {"x":25,"y":19,"w":23,"h":19},
|
||||
"sourceSize": {"w":110,"h":110},
|
||||
"pivot": {"x":0.5,"y":0.5}
|
||||
},
|
||||
{
|
||||
"filename": "processing-intro_190822_00023.png",
|
||||
"frame": {"x":105,"y":1,"w":24,"h":16},
|
||||
"rotated": true,
|
||||
"trimmed": true,
|
||||
"spriteSourceSize": {"x":30,"y":17,"w":24,"h":16},
|
||||
"sourceSize": {"w":110,"h":110},
|
||||
"pivot": {"x":0.5,"y":0.5}
|
||||
},
|
||||
{
|
||||
"filename": "processing-intro_190822_00024.png",
|
||||
"frame": {"x":134,"y":27,"w":24,"h":14},
|
||||
"rotated": false,
|
||||
"trimmed": true,
|
||||
"spriteSourceSize": {"x":35,"y":16,"w":24,"h":14},
|
||||
"sourceSize": {"w":110,"h":110},
|
||||
"pivot": {"x":0.5,"y":0.5}
|
||||
}],
|
||||
"meta": {
|
||||
"app": "https://www.codeandweb.com/texturepacker",
|
||||
"version": "1.0",
|
||||
"image": "play_icon_intro.png",
|
||||
"format": "RGBA8888",
|
||||
"size": {"w":194,"h":64},
|
||||
"scale": "1",
|
||||
"smartupdate": "$TexturePacker:SmartUpdate:a423de98fed1e06cc62611996868a2db:909cd8dbde93c0d2fca4402e7546ffd8:5ea08d4d7caaa5043281f5095baffab1$"
|
||||
}
|
||||
}
|
||||
237
packages/www/src/common/portal/assets/play_icon_loop.json
Normal file
@@ -0,0 +1,237 @@
|
||||
{"frames": [
|
||||
|
||||
{
|
||||
"filename": "processing-loop_190822_00000.png",
|
||||
"frame": {"x":37,"y":97,"w":23,"h":11},
|
||||
"rotated": false,
|
||||
"trimmed": true,
|
||||
"spriteSourceSize": {"x":40,"y":16,"w":23,"h":11},
|
||||
"sourceSize": {"w":110,"h":110},
|
||||
"pivot": {"x":0.5,"y":0.5}
|
||||
},
|
||||
{
|
||||
"filename": "processing-loop_190822_00001.png",
|
||||
"frame": {"x":37,"y":110,"w":23,"h":10},
|
||||
"rotated": false,
|
||||
"trimmed": true,
|
||||
"spriteSourceSize": {"x":46,"y":16,"w":23,"h":10},
|
||||
"sourceSize": {"w":110,"h":110},
|
||||
"pivot": {"x":0.5,"y":0.5}
|
||||
},
|
||||
{
|
||||
"filename": "processing-loop_190822_00002.png",
|
||||
"frame": {"x":30,"y":257,"w":24,"h":12},
|
||||
"rotated": false,
|
||||
"trimmed": true,
|
||||
"spriteSourceSize": {"x":51,"y":16,"w":24,"h":12},
|
||||
"sourceSize": {"w":110,"h":110},
|
||||
"pivot": {"x":0.5,"y":0.5}
|
||||
},
|
||||
{
|
||||
"filename": "processing-loop_190822_00003.png",
|
||||
"frame": {"x":34,"y":225,"w":25,"h":15},
|
||||
"rotated": false,
|
||||
"trimmed": true,
|
||||
"spriteSourceSize": {"x":56,"y":16,"w":25,"h":15},
|
||||
"sourceSize": {"w":110,"h":110},
|
||||
"pivot": {"x":0.5,"y":0.5}
|
||||
},
|
||||
{
|
||||
"filename": "processing-loop_190822_00004.png",
|
||||
"frame": {"x":1,"y":252,"w":27,"h":20},
|
||||
"rotated": false,
|
||||
"trimmed": true,
|
||||
"spriteSourceSize": {"x":61,"y":17,"w":27,"h":20},
|
||||
"sourceSize": {"w":110,"h":110},
|
||||
"pivot": {"x":0.5,"y":0.5}
|
||||
},
|
||||
{
|
||||
"filename": "processing-loop_190822_00005.png",
|
||||
"frame": {"x":35,"y":180,"w":24,"h":25},
|
||||
"rotated": true,
|
||||
"trimmed": true,
|
||||
"spriteSourceSize": {"x":68,"y":19,"w":24,"h":25},
|
||||
"sourceSize": {"w":110,"h":110},
|
||||
"pivot": {"x":0.5,"y":0.5}
|
||||
},
|
||||
{
|
||||
"filename": "processing-loop_190822_00006.png",
|
||||
"frame": {"x":39,"y":39,"w":21,"h":30},
|
||||
"rotated": false,
|
||||
"trimmed": true,
|
||||
"spriteSourceSize": {"x":74,"y":24,"w":21,"h":30},
|
||||
"sourceSize": {"w":110,"h":110},
|
||||
"pivot": {"x":0.5,"y":0.5}
|
||||
},
|
||||
{
|
||||
"filename": "processing-loop_190822_00007.png",
|
||||
"frame": {"x":1,"y":120,"w":16,"h":34},
|
||||
"rotated": true,
|
||||
"trimmed": true,
|
||||
"spriteSourceSize": {"x":80,"y":30,"w":16,"h":34},
|
||||
"sourceSize": {"w":110,"h":110},
|
||||
"pivot": {"x":0.5,"y":0.5}
|
||||
},
|
||||
{
|
||||
"filename": "processing-loop_190822_00008.png",
|
||||
"frame": {"x":1,"y":58,"w":13,"h":36},
|
||||
"rotated": true,
|
||||
"trimmed": true,
|
||||
"spriteSourceSize": {"x":83,"y":38,"w":13,"h":36},
|
||||
"sourceSize": {"w":110,"h":110},
|
||||
"pivot": {"x":0.5,"y":0.5}
|
||||
},
|
||||
{
|
||||
"filename": "processing-loop_190822_00009.png",
|
||||
"frame": {"x":41,"y":1,"w":19,"h":36},
|
||||
"rotated": false,
|
||||
"trimmed": true,
|
||||
"spriteSourceSize": {"x":77,"y":47,"w":19,"h":36},
|
||||
"sourceSize": {"w":110,"h":110},
|
||||
"pivot": {"x":0.5,"y":0.5}
|
||||
},
|
||||
{
|
||||
"filename": "processing-loop_190822_00010.png",
|
||||
"frame": {"x":1,"y":151,"w":27,"h":33},
|
||||
"rotated": true,
|
||||
"trimmed": true,
|
||||
"spriteSourceSize": {"x":68,"y":57,"w":27,"h":33},
|
||||
"sourceSize": {"w":110,"h":110},
|
||||
"pivot": {"x":0.5,"y":0.5}
|
||||
},
|
||||
{
|
||||
"filename": "processing-loop_190822_00011.png",
|
||||
"frame": {"x":1,"y":73,"w":34,"h":26},
|
||||
"rotated": false,
|
||||
"trimmed": true,
|
||||
"spriteSourceSize": {"x":57,"y":68,"w":34,"h":26},
|
||||
"sourceSize": {"w":110,"h":110},
|
||||
"pivot": {"x":0.5,"y":0.5}
|
||||
},
|
||||
{
|
||||
"filename": "processing-loop_190822_00012.png",
|
||||
"frame": {"x":1,"y":1,"w":38,"h":18},
|
||||
"rotated": false,
|
||||
"trimmed": true,
|
||||
"spriteSourceSize": {"x":46,"y":76,"w":38,"h":18},
|
||||
"sourceSize": {"w":110,"h":110},
|
||||
"pivot": {"x":0.5,"y":0.5}
|
||||
},
|
||||
{
|
||||
"filename": "processing-loop_190822_00013.png",
|
||||
"frame": {"x":1,"y":21,"w":38,"h":13},
|
||||
"rotated": false,
|
||||
"trimmed": true,
|
||||
"spriteSourceSize": {"x":36,"y":81,"w":38,"h":13},
|
||||
"sourceSize": {"w":110,"h":110},
|
||||
"pivot": {"x":0.5,"y":0.5}
|
||||
},
|
||||
{
|
||||
"filename": "processing-loop_190822_00014.png",
|
||||
"frame": {"x":1,"y":36,"w":36,"h":20},
|
||||
"rotated": false,
|
||||
"trimmed": true,
|
||||
"spriteSourceSize": {"x":27,"y":74,"w":36,"h":20},
|
||||
"sourceSize": {"w":110,"h":110},
|
||||
"pivot": {"x":0.5,"y":0.5}
|
||||
},
|
||||
{
|
||||
"filename": "processing-loop_190822_00015.png",
|
||||
"frame": {"x":1,"y":197,"w":31,"h":27},
|
||||
"rotated": false,
|
||||
"trimmed": true,
|
||||
"spriteSourceSize": {"x":21,"y":66,"w":31,"h":27},
|
||||
"sourceSize": {"w":110,"h":110},
|
||||
"pivot": {"x":0.5,"y":0.5}
|
||||
},
|
||||
{
|
||||
"filename": "processing-loop_190822_00016.png",
|
||||
"frame": {"x":1,"y":226,"w":24,"h":31},
|
||||
"rotated": true,
|
||||
"trimmed": true,
|
||||
"spriteSourceSize": {"x":18,"y":57,"w":24,"h":31},
|
||||
"sourceSize": {"w":110,"h":110},
|
||||
"pivot": {"x":0.5,"y":0.5}
|
||||
},
|
||||
{
|
||||
"filename": "processing-loop_190822_00017.png",
|
||||
"frame": {"x":1,"y":101,"w":17,"h":34},
|
||||
"rotated": true,
|
||||
"trimmed": true,
|
||||
"spriteSourceSize": {"x":18,"y":47,"w":17,"h":34},
|
||||
"sourceSize": {"w":110,"h":110},
|
||||
"pivot": {"x":0.5,"y":0.5}
|
||||
},
|
||||
{
|
||||
"filename": "processing-loop_190822_00018.png",
|
||||
"frame": {"x":1,"y":138,"w":11,"h":34},
|
||||
"rotated": true,
|
||||
"trimmed": true,
|
||||
"spriteSourceSize": {"x":18,"y":39,"w":11,"h":34},
|
||||
"sourceSize": {"w":110,"h":110},
|
||||
"pivot": {"x":0.5,"y":0.5}
|
||||
},
|
||||
{
|
||||
"filename": "processing-loop_190822_00019.png",
|
||||
"frame": {"x":1,"y":180,"w":15,"h":32},
|
||||
"rotated": true,
|
||||
"trimmed": true,
|
||||
"spriteSourceSize": {"x":18,"y":31,"w":15,"h":32},
|
||||
"sourceSize": {"w":110,"h":110},
|
||||
"pivot": {"x":0.5,"y":0.5}
|
||||
},
|
||||
{
|
||||
"filename": "processing-loop_190822_00020.png",
|
||||
"frame": {"x":37,"y":149,"w":20,"h":29},
|
||||
"rotated": false,
|
||||
"trimmed": true,
|
||||
"spriteSourceSize": {"x":18,"y":25,"w":20,"h":29},
|
||||
"sourceSize": {"w":110,"h":110},
|
||||
"pivot": {"x":0.5,"y":0.5}
|
||||
},
|
||||
{
|
||||
"filename": "processing-loop_190822_00021.png",
|
||||
"frame": {"x":37,"y":122,"w":22,"h":25},
|
||||
"rotated": false,
|
||||
"trimmed": true,
|
||||
"spriteSourceSize": {"x":21,"y":21,"w":22,"h":25},
|
||||
"sourceSize": {"w":110,"h":110},
|
||||
"pivot": {"x":0.5,"y":0.5}
|
||||
},
|
||||
{
|
||||
"filename": "processing-loop_190822_00022.png",
|
||||
"frame": {"x":39,"y":71,"w":24,"h":21},
|
||||
"rotated": true,
|
||||
"trimmed": true,
|
||||
"spriteSourceSize": {"x":25,"y":18,"w":24,"h":21},
|
||||
"sourceSize": {"w":110,"h":110},
|
||||
"pivot": {"x":0.5,"y":0.5}
|
||||
},
|
||||
{
|
||||
"filename": "processing-loop_190822_00023.png",
|
||||
"frame": {"x":34,"y":206,"w":25,"h":17},
|
||||
"rotated": false,
|
||||
"trimmed": true,
|
||||
"spriteSourceSize": {"x":30,"y":16,"w":25,"h":17},
|
||||
"sourceSize": {"w":110,"h":110},
|
||||
"pivot": {"x":0.5,"y":0.5}
|
||||
},
|
||||
{
|
||||
"filename": "processing-loop_190822_00024.png",
|
||||
"frame": {"x":34,"y":242,"w":25,"h":13},
|
||||
"rotated": false,
|
||||
"trimmed": true,
|
||||
"spriteSourceSize": {"x":36,"y":16,"w":25,"h":13},
|
||||
"sourceSize": {"w":110,"h":110},
|
||||
"pivot": {"x":0.5,"y":0.5}
|
||||
}],
|
||||
"meta": {
|
||||
"app": "https://www.codeandweb.com/texturepacker",
|
||||
"version": "1.0",
|
||||
"image": "play_icon_loop.png",
|
||||
"format": "RGBA8888",
|
||||
"size": {"w":61,"h":273},
|
||||
"scale": "1",
|
||||
"smartupdate": "$TexturePacker:SmartUpdate:4d92719faa27cbc50926429a66fc808c:25af8a045fca3b76dba8d8d26ed4ccf7:9b86d3b10829102d2ccf1bd5942144f2$"
|
||||
}
|
||||
}
|
||||
309
packages/www/src/common/portal/button.ts
Normal file
@@ -0,0 +1,309 @@
|
||||
import PlayIconLoop from "./assets/play_icon_loop.json"
|
||||
import PlayIconExit from "./assets/play_icon_exit.json"
|
||||
import PlayIconIntro from "./assets/play_icon_intro.json"
|
||||
import PlayButtonIdle from "./assets/play_button_idle.json"
|
||||
import PlayButtonIntro from "./assets/play_button_intro.json"
|
||||
|
||||
const button_assets = {
|
||||
intro: {
|
||||
image: "/src/assets/portal/play_button_intro.png",
|
||||
json: PlayButtonIntro
|
||||
},
|
||||
idle: {
|
||||
image: "/src/assets/portal/play_button_idle.png",
|
||||
json: PlayButtonIdle
|
||||
}
|
||||
}
|
||||
|
||||
const icon_assets = {
|
||||
intro: {
|
||||
image: "/src/assets/portal/play_icon_intro.png",
|
||||
json: PlayIconIntro
|
||||
},
|
||||
loop: {
|
||||
image: "/src/assets/portal/play_icon_loop.png",
|
||||
json: PlayIconLoop
|
||||
},
|
||||
exit: {
|
||||
image: "/src/assets/portal/play_icon_exit.png",
|
||||
json: PlayIconExit
|
||||
}
|
||||
}
|
||||
|
||||
export class PortalButton {
|
||||
private canvas: HTMLCanvasElement;
|
||||
private currentFrame: number;
|
||||
private index: number;
|
||||
private buttonQueue: (() => void)[];
|
||||
private isButtonRunning: boolean;
|
||||
private animationSpeed: number;
|
||||
|
||||
constructor(canvas: HTMLCanvasElement) {
|
||||
this.canvas = canvas;
|
||||
this.currentFrame = 0;
|
||||
this.index = 0;
|
||||
this.buttonQueue = [];
|
||||
this.isButtonRunning = false;
|
||||
this.animationSpeed = 50;
|
||||
}
|
||||
|
||||
render(type: "intro" | "idle", loop: boolean, image: HTMLImageElement, index?: number) {
|
||||
if (index) this.index = index
|
||||
return new Promise<void>((resolve) => {
|
||||
const buttonTask = () => {
|
||||
// Get the canvas element
|
||||
const ctx = this.canvas.getContext('2d');
|
||||
|
||||
// Load the JSON data
|
||||
const animationData = button_assets[type].json;
|
||||
|
||||
// Play the animation
|
||||
const frames = animationData.frames;
|
||||
const totalFrames = frames.length;
|
||||
|
||||
if (this.index) this.currentFrame = this.index;
|
||||
|
||||
const targetDim = 100 //target dimensions of the output image (height, width)
|
||||
|
||||
// Start the animation
|
||||
const updateFrame = () => {
|
||||
|
||||
// Check if we have reached the last frame
|
||||
if (!loop && this.currentFrame === totalFrames - 1) {
|
||||
// Animation has reached the last frame, stop playing
|
||||
this.isButtonRunning = false;
|
||||
|
||||
// Resolve the Promise to indicate completion
|
||||
resolve();
|
||||
return null;
|
||||
}
|
||||
|
||||
// Clear the canvas
|
||||
ctx?.clearRect(0, 0, this.canvas.width, this.canvas.height);
|
||||
|
||||
// Get the current frame details
|
||||
const singleFrame = frames[this.currentFrame];
|
||||
const { frame, sourceSize: ss, rotated, spriteSourceSize: sss, trimmed } = singleFrame;
|
||||
|
||||
this.canvas.width = targetDim;
|
||||
this.canvas.height = targetDim;
|
||||
this.canvas.style.borderRadius = `${ss.h / 2}px`
|
||||
|
||||
const newSize = {
|
||||
w: frame.w,
|
||||
h: frame.h
|
||||
};
|
||||
|
||||
const newPosition = {
|
||||
x: 0,
|
||||
y: 0
|
||||
};
|
||||
|
||||
if (rotated) {
|
||||
ctx?.save()
|
||||
ctx?.translate(this.canvas.width / 2, this.canvas.height / 2)
|
||||
ctx?.rotate(-Math.PI / 2);
|
||||
ctx?.translate(-this.canvas.height / 2, -this.canvas.width / 2);
|
||||
|
||||
newSize.w = frame.h;
|
||||
newSize.h = frame.w;
|
||||
}
|
||||
|
||||
if (trimmed) {
|
||||
newPosition.x = sss.x;
|
||||
newPosition.y = sss.y;
|
||||
|
||||
if (rotated) {
|
||||
newPosition.x = this.canvas.height - sss.h - sss.y;
|
||||
newPosition.y = sss.x;
|
||||
}
|
||||
}
|
||||
|
||||
const scaleFactor = Math.min(targetDim / newSize.w, targetDim / newSize.h);
|
||||
const scaledWidth = newSize.w * scaleFactor;
|
||||
const scaledHeight = newSize.h * scaleFactor;
|
||||
|
||||
// Calculate the center position to draw the resized image
|
||||
const x = (targetDim - scaledWidth) / 2;
|
||||
const y = (targetDim - scaledHeight) / 2;
|
||||
|
||||
ctx?.drawImage(
|
||||
image,
|
||||
frame.x,
|
||||
frame.y,
|
||||
newSize.w,
|
||||
newSize.h,
|
||||
x,
|
||||
y,
|
||||
scaledWidth,
|
||||
scaledHeight
|
||||
)
|
||||
|
||||
|
||||
if (rotated) {
|
||||
ctx?.restore()
|
||||
}
|
||||
// Increment the frame index
|
||||
this.currentFrame = (this.currentFrame + 1) % totalFrames
|
||||
|
||||
// Schedule the next frame update
|
||||
setTimeout(updateFrame, this.animationSpeed);
|
||||
};
|
||||
|
||||
return updateFrame()
|
||||
}
|
||||
// Check if the button function is already running
|
||||
if (this.isButtonRunning) {
|
||||
// If running, add the button task to the queue
|
||||
this.buttonQueue.push(buttonTask);
|
||||
|
||||
} else {
|
||||
// If not running, set the flag and execute the button task immediately
|
||||
this.isButtonRunning = true;
|
||||
buttonTask();
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
export class PortalIcon {
|
||||
private canvas: HTMLCanvasElement;
|
||||
private currentFrame: number;
|
||||
private index: number;
|
||||
private iconQueue: (() => void)[];
|
||||
private isIconRunning: boolean;
|
||||
private animationSpeed: number;
|
||||
|
||||
constructor(canvas: HTMLCanvasElement) {
|
||||
this.canvas = canvas;
|
||||
this.currentFrame = 0;
|
||||
this.index = 0;
|
||||
this.iconQueue = [];
|
||||
this.isIconRunning = false;
|
||||
this.animationSpeed = 50;
|
||||
}
|
||||
|
||||
render(type: "loop" | "intro" | "exit", loop: boolean, image: HTMLImageElement, play: boolean) {
|
||||
return new Promise<void>((resolve) => {
|
||||
const iconTask = () => {
|
||||
// Get the canvas element
|
||||
const ctx = this.canvas.getContext('2d');
|
||||
|
||||
// Load the JSON data
|
||||
const animationData = icon_assets[type].json;
|
||||
|
||||
// Load the image
|
||||
// const image = new Image();
|
||||
image.src = icon_assets[type].image; // Path to the sprite sheet image
|
||||
|
||||
// Play the animation
|
||||
const frames = animationData.frames;
|
||||
const totalFrames = frames.length;
|
||||
|
||||
if (!play) {
|
||||
this.currentFrame = totalFrames - 3
|
||||
} else { this.currentFrame = 0 }
|
||||
|
||||
// Start the animation
|
||||
const updateFrame = () => {
|
||||
|
||||
// Check if we have reached the last frame
|
||||
if (!loop && this.currentFrame === totalFrames - 1) {
|
||||
// Animation has reached the last frame, stop playing
|
||||
this.isIconRunning = false;
|
||||
|
||||
// Resolve the Promise to indicate completion
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
|
||||
// Clear the canvas
|
||||
ctx?.clearRect(0, 0, this.canvas.width, this.canvas.height);
|
||||
|
||||
// Get the current frame details
|
||||
const singleFrame = frames[this.currentFrame];
|
||||
const { frame, sourceSize: ss, rotated, spriteSourceSize: sss, trimmed } = singleFrame;
|
||||
|
||||
this.canvas.width = ss.w;
|
||||
this.canvas.height = ss.h
|
||||
this.canvas.style.borderRadius = `${ss.h / 2}px`
|
||||
|
||||
const newSize = {
|
||||
w: frame.w,
|
||||
h: frame.h
|
||||
};
|
||||
|
||||
const newPosition = {
|
||||
x: 0,
|
||||
y: 0
|
||||
};
|
||||
|
||||
if (rotated) {
|
||||
ctx?.save()
|
||||
ctx?.translate(this.canvas.width / 2, this.canvas.height / 2)
|
||||
ctx?.rotate(-Math.PI / 2);
|
||||
ctx?.translate(-this.canvas.height / 2, -this.canvas.width / 2);
|
||||
|
||||
newSize.w = frame.h;
|
||||
newSize.h = frame.w;
|
||||
}
|
||||
|
||||
if (trimmed) {
|
||||
newPosition.x = sss.x;
|
||||
newPosition.y = sss.y;
|
||||
|
||||
if (rotated) {
|
||||
newPosition.x = this.canvas.height - sss.h - sss.y;
|
||||
newPosition.y = sss.x;
|
||||
}
|
||||
}
|
||||
|
||||
ctx?.drawImage(
|
||||
image,
|
||||
frame.x,
|
||||
frame.y,
|
||||
newSize.w,
|
||||
newSize.h,
|
||||
newPosition.x,
|
||||
newPosition.y,
|
||||
newSize.w,
|
||||
newSize.h
|
||||
)
|
||||
|
||||
|
||||
if (rotated) {
|
||||
ctx?.restore()
|
||||
}
|
||||
// Increment the frame index
|
||||
this.currentFrame = (this.currentFrame + 1) % totalFrames
|
||||
|
||||
|
||||
// Schedule the next frame update
|
||||
if (!play) {
|
||||
this.isIconRunning = false;
|
||||
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
|
||||
setTimeout(updateFrame, this.animationSpeed)
|
||||
};
|
||||
|
||||
return updateFrame();
|
||||
}
|
||||
// Check if the icon function is already running
|
||||
if (this.isIconRunning) {
|
||||
// If running, add the button icon to the queue
|
||||
this.iconQueue.push(iconTask);
|
||||
} else {
|
||||
// If not running, set the flag and execute the button task immediately
|
||||
this.isIconRunning = true;
|
||||
iconTask();
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
const portal = { assets: { button_assets, icon_assets } }
|
||||
export default portal;
|
||||
123
packages/www/src/common/portal/index.tsx
Normal file
@@ -0,0 +1,123 @@
|
||||
import { createEffect, createSignal, onCleanup } from "solid-js";
|
||||
import portalbtn, { PortalButton, PortalIcon } from "./button";
|
||||
import { styled } from "@macaron-css/solid";
|
||||
import { theme } from "@nestri/www/ui";
|
||||
|
||||
|
||||
const PlayBtn = styled("button", {
|
||||
base: {
|
||||
position: "relative",
|
||||
backgroundColor: "transparent",
|
||||
outline: "none",
|
||||
border: "none",
|
||||
padding: 0,
|
||||
margin: 0,
|
||||
height: 100,
|
||||
borderRadius: 999,
|
||||
":focus": {
|
||||
outline: `3px solid ${theme.color.brand}`
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
const CanvasOne = styled("canvas", {
|
||||
base: {
|
||||
position: "absolute",
|
||||
inset: 0,
|
||||
height: "100%",
|
||||
width: "100%",
|
||||
borderRadius: 999,
|
||||
}
|
||||
})
|
||||
|
||||
const CanvasTwo = styled("canvas", {
|
||||
base: {
|
||||
position: "relative",
|
||||
inset: 0,
|
||||
zIndex: 1,
|
||||
height: "100%",
|
||||
width: "100%",
|
||||
borderRadius: 999,
|
||||
}
|
||||
})
|
||||
/**
|
||||
* Renders a portal play button with animated canvas icons.
|
||||
*
|
||||
* This Solid.js component manages two canvas elements that display an animated portal button and its icon. It asynchronously loads a set of image assets and uses instances of PortalButton and PortalIcon to render various animation states—including intro, idle, exit, and loop—on the canvases. Image loading errors are logged to the console.
|
||||
*
|
||||
* @returns A JSX element containing a styled button with two canvases for rendering animations.
|
||||
*/
|
||||
export function Portal() {
|
||||
const [iconRef, setIconRef] = createSignal<HTMLCanvasElement | undefined>();
|
||||
const [buttonRef, setButtonRef] = createSignal<HTMLCanvasElement | undefined>();
|
||||
|
||||
const imageUrls = [
|
||||
portalbtn.assets.button_assets["intro"].image,
|
||||
portalbtn.assets.button_assets["idle"].image,
|
||||
portalbtn.assets.icon_assets["exit"].image,
|
||||
portalbtn.assets.icon_assets["intro"].image,
|
||||
portalbtn.assets.icon_assets["loop"].image
|
||||
];
|
||||
|
||||
const loadImages = () => {
|
||||
return Promise.all(imageUrls.map(url => {
|
||||
return new Promise((resolve, reject) => {
|
||||
const img = new Image();
|
||||
img.onload = () => resolve(img);
|
||||
img.onerror = (e) => {
|
||||
console.error(`Failed to load image from ${url}:`, e);
|
||||
reject(new Error(`Failed to load image from ${url}`));
|
||||
};
|
||||
img.src = url;
|
||||
});
|
||||
}));
|
||||
}
|
||||
|
||||
createEffect(() => {
|
||||
(async () => {
|
||||
const btnRef = buttonRef()
|
||||
const icnRef = iconRef()
|
||||
let isActive = true;
|
||||
|
||||
if (icnRef && btnRef) {
|
||||
try {
|
||||
|
||||
// Destructure images for each animation type - skipping introIconImg at index 3
|
||||
const [introImg, idleImg, exitImg, , loopImg] = await loadImages();
|
||||
|
||||
const button = new PortalButton(btnRef);
|
||||
const icon = new PortalIcon(icnRef)
|
||||
if (!isActive) return;
|
||||
|
||||
await button.render("intro", false, introImg as HTMLImageElement);
|
||||
await icon.render("exit", false, exitImg as HTMLImageElement, false);
|
||||
await button.render("idle", true, idleImg as HTMLImageElement, 3);
|
||||
|
||||
// Intro and loop animation
|
||||
await Promise.all([
|
||||
(async () => {
|
||||
if (icnRef) {
|
||||
await icon.render("loop", false, loopImg as HTMLImageElement, true);
|
||||
await icon.render("loop", false, loopImg as HTMLImageElement, true);
|
||||
await icon.render("exit", false, exitImg as HTMLImageElement, true);
|
||||
}
|
||||
})(),
|
||||
button.render("idle", true, idleImg as HTMLImageElement, 2),
|
||||
]);
|
||||
} catch (err) {
|
||||
console.error("Failed to load animation images:", err);
|
||||
}
|
||||
}
|
||||
onCleanup(() => {
|
||||
isActive = false;
|
||||
});
|
||||
})()
|
||||
});
|
||||
|
||||
return (
|
||||
<PlayBtn autofocus>
|
||||
<CanvasOne height={100} width={100} ref={setButtonRef} />
|
||||
<CanvasTwo height={100} width={100} ref={setIconRef} />
|
||||
</PlayBtn>
|
||||
)
|
||||
}
|
||||
1
packages/www/src/components/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from "./qr-code"
|
||||
378
packages/www/src/components/qr-code.tsx
Normal file
@@ -0,0 +1,378 @@
|
||||
import { theme } from "@nestri/www/ui";
|
||||
import { A } from "@solidjs/router";
|
||||
import { styled } from "@macaron-css/solid";
|
||||
import { useSteam } from "../providers/steam";
|
||||
import { keyframes } from "@macaron-css/core";
|
||||
import { QRCode } from "@nestri/www/ui/custom-qr";
|
||||
import { createEffect, createSignal, onCleanup, Show } from "solid-js";
|
||||
|
||||
const EmptyState = styled("div", {
|
||||
base: {
|
||||
padding: "0 40px",
|
||||
display: "flex",
|
||||
height: "100dvh",
|
||||
gap: 10,
|
||||
flexDirection: "column",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
margin: "auto"
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
const EmptyStateHeader = styled("h2", {
|
||||
base: {
|
||||
textAlign: "center",
|
||||
fontSize: theme.font.size["2xl"],
|
||||
fontFamily: theme.font.family.heading,
|
||||
fontWeight: theme.font.weight.semibold,
|
||||
letterSpacing: -0.5,
|
||||
}
|
||||
})
|
||||
|
||||
const EmptyStateSubHeader = styled("p", {
|
||||
base: {
|
||||
fontWeight: theme.font.weight.regular,
|
||||
color: theme.color.gray.d900,
|
||||
fontSize: theme.font.size["base"],
|
||||
textAlign: "center",
|
||||
maxWidth: 380,
|
||||
letterSpacing: -0.4,
|
||||
lineHeight: 1.1,
|
||||
}
|
||||
})
|
||||
|
||||
const bgRotate = keyframes({
|
||||
'to': { transform: 'rotate(1turn)' },
|
||||
});
|
||||
|
||||
const QRContainer = styled("div", {
|
||||
base: {
|
||||
position: "relative",
|
||||
display: "flex",
|
||||
overflow: "hidden",
|
||||
marginBottom: 20,
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
borderRadius: 25,
|
||||
padding: 5,
|
||||
isolation: "isolate",
|
||||
":after": {
|
||||
content: "",
|
||||
zIndex: -1,
|
||||
inset: 5,
|
||||
backgroundColor: theme.color.background.d100,
|
||||
borderRadius: 22,
|
||||
position: "absolute"
|
||||
}
|
||||
},
|
||||
variants: {
|
||||
login: {
|
||||
true: {
|
||||
":before": {
|
||||
content: "",
|
||||
backgroundImage: `conic-gradient(from 0deg,transparent 0,${theme.color.blue.d600} 10%,${theme.color.blue.d600} 25%,transparent 35%)`,
|
||||
animation: `${bgRotate} 2.25s linear infinite`,
|
||||
width: "200%",
|
||||
height: "200%",
|
||||
zIndex: -2,
|
||||
top: "-50%",
|
||||
left: "-50%",
|
||||
position: "absolute"
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
const QRWrapper = styled("div", {
|
||||
base: {
|
||||
backgroundColor: theme.color.background.d100,
|
||||
position: "relative",
|
||||
textWrap: "balance",
|
||||
border: `1px solid ${theme.color.gray.d400}`,
|
||||
display: "flex",
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
overflow: "hidden",
|
||||
borderRadius: 22,
|
||||
padding: 20,
|
||||
}
|
||||
})
|
||||
|
||||
const QRBg = styled("div", {
|
||||
base: {
|
||||
backgroundColor: theme.color.background.d200,
|
||||
position: "absolute",
|
||||
inset: 0,
|
||||
margin: 5,
|
||||
borderRadius: 20
|
||||
}
|
||||
})
|
||||
|
||||
const QRReloadBtn = styled("button", {
|
||||
base: {
|
||||
background: "none",
|
||||
border: "none",
|
||||
width: 50,
|
||||
height: 50,
|
||||
position: "absolute",
|
||||
borderRadius: 25,
|
||||
zIndex: 5,
|
||||
right: 2,
|
||||
bottom: 2,
|
||||
cursor: "pointer",
|
||||
color: theme.color.blue.d700,
|
||||
transition: "color 200ms",
|
||||
overflow: "hidden",
|
||||
display: "flex",
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
":before": {
|
||||
zIndex: 3,
|
||||
content: "",
|
||||
position: "absolute",
|
||||
inset: 0,
|
||||
opacity: 0,
|
||||
transition: "opacity 200ms",
|
||||
background: "#FFF"
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
const QRRealoadContainer = styled("div", {
|
||||
base: {
|
||||
position: "absolute",
|
||||
inset: 0,
|
||||
isolation: "isolate",
|
||||
":before": {
|
||||
background: `conic-gradient( from 90deg, currentColor 10%, #FFF 80% )`,
|
||||
inset: 3,
|
||||
borderRadius: 16,
|
||||
position: "absolute",
|
||||
content: "",
|
||||
zIndex: 1
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
const QRReloadSvg = styled("svg", {
|
||||
base: {
|
||||
zIndex: 2,
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
position: "relative",
|
||||
display: "block"
|
||||
}
|
||||
})
|
||||
|
||||
const LogoContainer = styled("div", {
|
||||
base: {
|
||||
position: "absolute",
|
||||
top: 0,
|
||||
left: 0,
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
}
|
||||
})
|
||||
|
||||
const LogoIcon = styled("svg", {
|
||||
base: {
|
||||
zIndex: 6,
|
||||
position: "absolute",
|
||||
left: "50%",
|
||||
top: "50%",
|
||||
transform: "translate(-50%,-50%)",
|
||||
overflow: "hidden",
|
||||
// width: "21%",
|
||||
// height: "21%",
|
||||
borderRadius: 17,
|
||||
// ":before": {
|
||||
// pointerEvents: "none",
|
||||
// zIndex: 2,
|
||||
// content: '',
|
||||
// position: "absolute",
|
||||
// inset: 0,
|
||||
// borderRadius: "inherit",
|
||||
// boxShadow: "inset 0 0 0 1px rgba(0, 0, 0, 0.02)",
|
||||
// }
|
||||
}
|
||||
})
|
||||
|
||||
const SteamMobileLink = styled(A, {
|
||||
base: {
|
||||
textUnderlineOffset: 2,
|
||||
textDecoration: "none",
|
||||
color: theme.color.blue.d900,
|
||||
display: "inline-flex",
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
gap: 1,
|
||||
width: "max-content",
|
||||
textTransform: "capitalize",
|
||||
":hover": {
|
||||
textDecoration: "underline"
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
/**
|
||||
* Manages the Steam authentication flow via a reactive hook.
|
||||
*
|
||||
* This hook connects to Steam's login stream for QR code authentication, updating the internal state through reactive signals. It sets up event listeners to capture authentication challenges (setting the login URL) and errors (flagging login errors), and it provides methods to initiate and re-establish the connection.
|
||||
*
|
||||
* The returned object includes:
|
||||
* - loginError: A signal that indicates whether an authentication error has occurred.
|
||||
* - loginUrl: A signal that holds the URL received on a successful authentication challenge.
|
||||
* - isConnecting: A signal that reflects whether the authentication process is currently in progress.
|
||||
* - authenticateSteam: A function that initiates the authentication process, sets up event listeners, and returns cleanup and reset functions.
|
||||
* - reconnect: A function that cleans up any existing connection and initiates a new authentication attempt.
|
||||
*
|
||||
* @returns An object with authentication state signals and functions to manage the connection.
|
||||
*/
|
||||
export function useSteamAuth() {
|
||||
const [loginError, setLoginError] = createSignal<boolean>(false);
|
||||
const [loginUrl, setLoginUrl] = createSignal<string | undefined>();
|
||||
const [isConnecting, setIsConnecting] = createSignal<boolean>(false);
|
||||
const [disconnectFn, setDisconnectFn] = createSignal<(() => void) | null>(null);
|
||||
const steam = useSteam()
|
||||
// Function to authenticate with Steam
|
||||
const authenticateSteam = async () => {
|
||||
try {
|
||||
setIsConnecting(true);
|
||||
setLoginError(false);
|
||||
|
||||
// Connect to the Steam login stream
|
||||
const steamConnection = await steam.client.login.connect();
|
||||
|
||||
// Set up event listeners for different event types
|
||||
const urlUnsubscribe = steamConnection.addEventListener('challenge', (data) => {
|
||||
setLoginUrl(data.url);
|
||||
});
|
||||
|
||||
const loginUnsuccessfulUnsubscribe = steamConnection.addEventListener('error', (data) => {
|
||||
setLoginError(true);
|
||||
});
|
||||
|
||||
// Store the disconnect function for later use
|
||||
const cleanupConnection = () => {
|
||||
urlUnsubscribe();
|
||||
loginUnsuccessfulUnsubscribe();
|
||||
steamConnection.disconnect();
|
||||
};
|
||||
|
||||
setDisconnectFn(() => cleanupConnection);
|
||||
setIsConnecting(false);
|
||||
|
||||
return {
|
||||
cleanup: cleanupConnection,
|
||||
resetConnection: () => {
|
||||
cleanupConnection();
|
||||
authenticateSteam();
|
||||
}
|
||||
};
|
||||
} catch (error) {
|
||||
setLoginError(true);
|
||||
setIsConnecting(false);
|
||||
console.error("Steam authentication error:", error);
|
||||
return {
|
||||
cleanup: () => { },
|
||||
resetConnection: () => authenticateSteam()
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
// Function to reconnect
|
||||
const reconnect = async () => {
|
||||
// Clean up existing connection if any
|
||||
const currentDisconnectFn = disconnectFn();
|
||||
if (currentDisconnectFn) {
|
||||
currentDisconnectFn();
|
||||
}
|
||||
|
||||
// Start a new connection
|
||||
return authenticateSteam();
|
||||
};
|
||||
|
||||
return {
|
||||
loginError,
|
||||
loginUrl,
|
||||
isConnecting,
|
||||
authenticateSteam,
|
||||
reconnect
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders a Steam QR code authentication interface.
|
||||
*
|
||||
* On mount, the component initiates the Steam authentication process using a custom hook and sets up a cleanup routine upon unmounting. It conditionally displays a QR code for signing in when a valid login URL is available, a reload button if an error occurs, or a timeout message if the request times out.
|
||||
*
|
||||
* @example
|
||||
* <QrCodeComponent />
|
||||
*
|
||||
* @returns A Solid.js component that provides a QR code authentication UI for Steam.
|
||||
*/
|
||||
export function QrCodeComponent() {
|
||||
const { loginError, loginUrl, isConnecting, authenticateSteam, reconnect } = useSteamAuth();
|
||||
|
||||
createEffect(async () => {
|
||||
const { cleanup } = await authenticateSteam();
|
||||
onCleanup(() => cleanup());
|
||||
});
|
||||
|
||||
|
||||
return (
|
||||
<EmptyState
|
||||
style={{
|
||||
"--nestri-qr-dot-color": theme.color.d1000.gray,
|
||||
"--nestri-body-background": theme.color.gray.d100
|
||||
}}
|
||||
>
|
||||
<QRContainer login={typeof loginUrl() === "string" && !loginError()}>
|
||||
<QRBg />
|
||||
<QRWrapper>
|
||||
<LogoContainer>
|
||||
<LogoIcon
|
||||
xmlns="http://www.w3.org/2000/svg" width={!loginError() && loginUrl() ? 32 : 60} height={!loginError() && loginUrl() ? 32 : 60} viewBox="0 0 16 16">
|
||||
<g fill="currentColor">
|
||||
<path d="M.329 10.333A8.01 8.01 0 0 0 7.99 16C12.414 16 16 12.418 16 8s-3.586-8-8.009-8A8.006 8.006 0 0 0 0 7.468l.003.006l4.304 1.769A2.2 2.2 0 0 1 5.62 8.88l1.96-2.844l-.001-.04a3.046 3.046 0 0 1 3.042-3.043a3.046 3.046 0 0 1 3.042 3.043a3.047 3.047 0 0 1-3.111 3.044l-2.804 2a2.223 2.223 0 0 1-3.075 2.11a2.22 2.22 0 0 1-1.312-1.568L.33 10.333Z" /><path d="M4.868 12.683a1.715 1.715 0 0 0 1.318-3.165a1.7 1.7 0 0 0-1.263-.02l1.023.424a1.261 1.261 0 1 1-.97 2.33l-.99-.41a1.7 1.7 0 0 0 .882.84Zm3.726-6.687a2.03 2.03 0 0 0 2.027 2.029a2.03 2.03 0 0 0 2.027-2.029a2.03 2.03 0 0 0-2.027-2.027a2.03 2.03 0 0 0-2.027 2.027m2.03-1.527a1.524 1.524 0 1 1-.002 3.048a1.524 1.524 0 0 1 .002-3.048" />
|
||||
</g>
|
||||
</LogoIcon>
|
||||
</LogoContainer>
|
||||
<Show
|
||||
when={!loginError() && loginUrl()}
|
||||
fallback={
|
||||
<div style={{ height: "220px", width: "220px" }} />
|
||||
}
|
||||
>
|
||||
<QRCode
|
||||
uri={loginUrl()!}
|
||||
size={240}
|
||||
ecl="M"
|
||||
clearArea={true}
|
||||
/>
|
||||
</Show>
|
||||
</QRWrapper>
|
||||
<Show when={loginError()}>
|
||||
<QRReloadBtn onClick={() => reconnect()} disabled={isConnecting()}>
|
||||
<QRRealoadContainer>
|
||||
<QRReloadSvg aria-hidden="true" width="32" height="32" viewBox="0 0 32 32" fill="currentColor" xmlns="http://www.w3.org/2000/svg"><path fill-rule="evenodd" clip-rule="evenodd" d="M32 16C32 24.8366 24.8366 32 16 32C7.16344 32 0 24.8366 0 16C0 7.16344 7.16344 0 16 0C24.8366 0 32 7.16344 32 16ZM24.5001 8.74263C25.0834 8.74263 25.5563 9.21551 25.5563 9.79883V14.5997C25.5563 15.183 25.0834 15.6559 24.5001 15.6559H19.6992C19.1159 15.6559 18.643 15.183 18.643 14.5997C18.643 14.0164 19.1159 13.5435 19.6992 13.5435H21.8378L20.071 11.8798C20.0632 11.8724 20.0555 11.865 20.048 11.8574C19.1061 10.915 17.8835 10.3042 16.5643 10.1171C15.2452 9.92999 13.9009 10.1767 12.7341 10.82C11.5674 11.4634 10.6413 12.4685 10.0955 13.684C9.54968 14.8994 9.41368 16.2593 9.70801 17.5588C10.0023 18.8583 10.711 20.0269 11.7273 20.8885C12.7436 21.7502 14.0124 22.2582 15.3425 22.336C16.6726 22.4138 17.9919 22.0572 19.1017 21.3199C19.5088 21.0495 19.8795 20.7333 20.2078 20.3793C20.6043 19.9515 21.2726 19.9262 21.7004 20.3228C22.1282 20.7194 22.1534 21.3876 21.7569 21.8154C21.3158 22.2912 20.8176 22.7161 20.2706 23.0795C18.7793 24.0702 17.0064 24.5493 15.2191 24.4448C13.4318 24.3402 11.7268 23.6576 10.3612 22.4998C8.9956 21.3419 8.0433 19.7716 7.6478 18.0254C7.2523 16.2793 7.43504 14.4519 8.16848 12.8186C8.90192 11.1854 10.1463 9.83471 11.7142 8.97021C13.282 8.10572 15.0884 7.77421 16.861 8.02565C18.6282 8.27631 20.2664 9.09278 21.5304 10.3525L23.4439 12.1544V9.79883C23.4439 9.21551 23.9168 8.74263 24.5001 8.74263Z" fill="currentColor"></path></QRReloadSvg>
|
||||
</QRRealoadContainer>
|
||||
</QRReloadBtn>
|
||||
</Show>
|
||||
</QRContainer>
|
||||
<Show
|
||||
fallback={
|
||||
<>
|
||||
<EmptyStateHeader>Request Timed Out</EmptyStateHeader>
|
||||
<EmptyStateSubHeader>Click above to try again.</EmptyStateSubHeader>
|
||||
</>
|
||||
}
|
||||
when={!loginError() && loginUrl()} >
|
||||
<EmptyStateHeader>Sign in to your Steam account</EmptyStateHeader>
|
||||
<EmptyStateSubHeader>Use your Steam Mobile App to sign in via QR code. <SteamMobileLink href="https://store.steampowered.com/mobile" target="_blank">Learn More<svg data-testid="geist-icon" height="20" stroke-linejoin="round" viewBox="0 0 16 16" width="20" style="color: currentcolor;"><path fill-rule="evenodd" clip-rule="evenodd" d="M11.5 9.75V11.25C11.5 11.3881 11.3881 11.5 11.25 11.5H4.75C4.61193 11.5 4.5 11.3881 4.5 11.25L4.5 4.75C4.5 4.61193 4.61193 4.5 4.75 4.5H6.25H7V3H6.25H4.75C3.7835 3 3 3.7835 3 4.75V11.25C3 12.2165 3.7835 13 4.75 13H11.25C12.2165 13 13 12.2165 13 11.25V9.75V9H11.5V9.75ZM8.5 3H9.25H12.2495C12.6637 3 12.9995 3.33579 12.9995 3.75V6.75V7.5H11.4995V6.75V5.56066L8.53033 8.52978L8 9.06011L6.93934 7.99945L7.46967 7.46912L10.4388 4.5H9.25H8.5V3Z" fill="currentColor"></path></svg></SteamMobileLink></EmptyStateSubHeader>
|
||||
</Show>
|
||||
</EmptyState>
|
||||
)
|
||||
}
|
||||
@@ -8,7 +8,7 @@ import { useNavigate } from "@solidjs/router";
|
||||
import { useOpenAuth } from "@openauthjs/solid";
|
||||
import { utility } from "@nestri/www/ui/utility";
|
||||
import { useAccount } from "../providers/account";
|
||||
import { Container, FullScreen } from "@nestri/www/ui/layout";
|
||||
import { Container, Screen as FullScreen } from "@nestri/www/ui/layout";
|
||||
import { FormField, Input, Select } from "@nestri/www/ui/form";
|
||||
import { createForm, getValue, setError, valiForm } from "@modular-forms/solid";
|
||||
|
||||
@@ -191,7 +191,6 @@ export function CreateTeamComponent() {
|
||||
</UrlTitle>
|
||||
<Input
|
||||
{...props}
|
||||
autofocus
|
||||
placeholder={
|
||||
getValue(form, "name")?.toString()
|
||||
.split(" ").join("-")
|
||||
|
||||
@@ -4,7 +4,7 @@ import { Text } from "@nestri/www/ui/text";
|
||||
import { styled } from "@macaron-css/solid";
|
||||
import { theme } from "@nestri/www/ui/theme";
|
||||
import { Header } from "@nestri/www/pages/team/header";
|
||||
import { FullScreen, Container } from "@nestri/www/ui/layout";
|
||||
import { Screen as FullScreen, Container } from "@nestri/www/ui/layout";
|
||||
|
||||
const NotAllowedDesc = styled("div", {
|
||||
base: {
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { animate, scroll } from "motion"
|
||||
import { A } from "@solidjs/router";
|
||||
import { Container } from "@nestri/www/ui";
|
||||
import Avatar from "@nestri/www/ui/avatar";
|
||||
@@ -5,12 +6,11 @@ import { styled } from "@macaron-css/solid";
|
||||
import { theme } from "@nestri/www/ui/theme";
|
||||
import { useAccount } from "@nestri/www/providers/account";
|
||||
import { TeamContext } from "@nestri/www/providers/context";
|
||||
import { Match, ParentProps, Show, Switch, useContext } from "solid-js";
|
||||
import { createEffect, createSignal, Match, onCleanup, ParentProps, Show, Switch, useContext } from "solid-js";
|
||||
|
||||
const PageWrapper = styled("div", {
|
||||
base: {
|
||||
minHeight: "100dvh",
|
||||
// paddingBottom: "4rem",
|
||||
backgroundColor: theme.color.background.d200
|
||||
}
|
||||
})
|
||||
@@ -139,7 +139,7 @@ const NavRoot = styled("div", {
|
||||
|
||||
const NavLink = styled(A, {
|
||||
base: {
|
||||
color: "#FFF",
|
||||
color: theme.color.d1000.gray,
|
||||
textDecoration: "none",
|
||||
height: 32,
|
||||
padding: "0 8px",
|
||||
@@ -160,14 +160,19 @@ const NavLink = styled(A, {
|
||||
|
||||
const NavWrapper = styled("div", {
|
||||
base: {
|
||||
// borderBottom: "1px solid white",
|
||||
zIndex: 10,
|
||||
zIndex: 100,
|
||||
position: "fixed",
|
||||
// backdropFilter: "saturate(60%) blur(3px)",
|
||||
height: theme.headerHeight.root,
|
||||
transition: "all 0.3s cubic-bezier(0.4,0,0.2,1)",
|
||||
width: "100%",
|
||||
backgroundColor: "transparent"
|
||||
},
|
||||
variants: {
|
||||
scrolled: {
|
||||
true: {
|
||||
backgroundColor: theme.color.background.d200,
|
||||
boxShadow: `0 2px 20px 1px ${theme.color.gray.d300}`
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
@@ -195,16 +200,45 @@ const Nav = styled("nav", {
|
||||
}
|
||||
})
|
||||
|
||||
export function Header(props: { whiteColor?: boolean } & ParentProps) {
|
||||
const team = useContext(TeamContext)
|
||||
const account = useAccount()
|
||||
/**
|
||||
* Renders the application's header, featuring navigation, branding, and team details.
|
||||
*
|
||||
* This component displays a navigation bar that includes the logo, team avatar, team name, a badge
|
||||
* reflecting the team's plan type, and navigation links. It adjusts its styling based on the scroll
|
||||
* position by toggling visual effects on the navigation wrapper. A scroll event listener is added
|
||||
* on mount to update the header's appearance when the user scrolls and is removed on unmount.
|
||||
*
|
||||
* @param props.children - Optional child elements rendered below the header component.
|
||||
* @returns The header component element.
|
||||
*/
|
||||
export function Header(props: ParentProps) {
|
||||
// const team = useContext(TeamContext)
|
||||
const [hasScrolled, setHasScrolled] = createSignal(false)
|
||||
const [team,] = createSignal({
|
||||
id: "tea_01JPACSPYWTTJ66F32X3AWWFWE",
|
||||
slug: "wanjohiryan",
|
||||
name: "Wanjohi",
|
||||
planType: "BYOG"
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
const handleScroll = () => { setHasScrolled(window.scrollY > 0); }
|
||||
|
||||
document.addEventListener("scroll", handleScroll);
|
||||
|
||||
onCleanup(() => {
|
||||
document.removeEventListener("scroll", handleScroll);
|
||||
});
|
||||
|
||||
})
|
||||
|
||||
// const account = useAccount()
|
||||
return (
|
||||
<PageWrapper>
|
||||
<NavWrapper style={{ color: props.whiteColor ? "#FFF" : theme.color.d1000.gray }} >
|
||||
{/* <Background /> */}
|
||||
<NavWrapper scrolled={hasScrolled()}>
|
||||
<Nav>
|
||||
<Container space="4" vertical="center">
|
||||
<Show when={team}
|
||||
{/* <Show when={team}
|
||||
fallback={
|
||||
<Link href="/">
|
||||
<NestriLogoBig
|
||||
@@ -228,70 +262,67 @@ export function Header(props: { whiteColor?: boolean } & ParentProps) {
|
||||
</LogoName>
|
||||
</Link>
|
||||
}
|
||||
>
|
||||
<LogoRoot>
|
||||
<A href={`/${team!().slug}`} >
|
||||
<NestriLogo
|
||||
width={32}
|
||||
height={32}
|
||||
viewBox="0 0 12.8778 9.7377253"
|
||||
version="1.1"
|
||||
id="svg1"
|
||||
xmlns="http://www.w3.org/2000/svg">
|
||||
<path
|
||||
d="m 2.093439,1.7855532 h 8.690922 V 2.2639978 H 2.093439 Z m 0,2.8440874 h 8.690922 V 5.1080848 H 2.093439 Z m 0,2.8440866 h 8.690922 V 7.952172 H 2.093439 Z"
|
||||
style="font-size:12px;fill:#ff4f01;fill-opacity:1;fill-rule:evenodd;stroke:#ff4f01;stroke-width:1.66201;stroke-linecap:round;stroke-dasharray:none;stroke-opacity:1" />
|
||||
</NestriLogo>
|
||||
</A>
|
||||
<LineSvg
|
||||
height="16"
|
||||
stroke-linejoin="round"
|
||||
viewBox="0 0 16 16"
|
||||
width="16">
|
||||
> */}
|
||||
<LogoRoot>
|
||||
<A href={`/${team!().slug}`} >
|
||||
<NestriLogo
|
||||
width={32}
|
||||
height={32}
|
||||
viewBox="0 0 12.8778 9.7377253"
|
||||
version="1.1"
|
||||
id="svg1"
|
||||
xmlns="http://www.w3.org/2000/svg">
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
clip-rule="evenodd"
|
||||
d="M4.01526 15.3939L4.3107 14.7046L10.3107 0.704556L10.6061 0.0151978L11.9849 0.606077L11.6894 1.29544L5.68942 15.2954L5.39398 15.9848L4.01526 15.3939Z" fill="currentColor"></path>
|
||||
</LineSvg>
|
||||
<TeamRoot>
|
||||
<Avatar size={21} name={team!().slug} />
|
||||
<TeamLabel style={{ color: props.whiteColor ? "#FFF" : theme.color.d1000.gray }}>{team!().name}</TeamLabel>
|
||||
<Switch>
|
||||
<Match when={team!().planType === "BYOG"}>
|
||||
<Badge style={{ "background-color": theme.color.purple.d700 }}>
|
||||
<span style={{ "line-height": 0 }} >BYOG</span>
|
||||
</Badge>
|
||||
</Match>
|
||||
<Match when={team!().planType === "Hosted"}>
|
||||
<Badge style={{ "background-color": theme.color.blue.d700 }}>
|
||||
<span style={{ "line-height": 0 }}>Hosted</span>
|
||||
</Badge>
|
||||
</Match>
|
||||
</Switch>
|
||||
<DropIcon
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="32"
|
||||
height="32"
|
||||
viewBox="0 0 256 256">
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="M72.61 83.06a8 8 0 0 1 1.73-8.72l48-48a8 8 0 0 1 11.32 0l48 48A8 8 0 0 1 176 88H80a8 8 0 0 1-7.39-4.94M176 168H80a8 8 0 0 0-5.66 13.66l48 48a8 8 0 0 0 11.32 0l48-48A8 8 0 0 0 176 168" />
|
||||
</DropIcon>
|
||||
</TeamRoot>
|
||||
</LogoRoot>
|
||||
</Show>
|
||||
d="m 2.093439,1.7855532 h 8.690922 V 2.2639978 H 2.093439 Z m 0,2.8440874 h 8.690922 V 5.1080848 H 2.093439 Z m 0,2.8440866 h 8.690922 V 7.952172 H 2.093439 Z"
|
||||
style="font-size:12px;fill:#ff4f01;fill-opacity:1;fill-rule:evenodd;stroke:#ff4f01;stroke-width:1.66201;stroke-linecap:round;stroke-dasharray:none;stroke-opacity:1" />
|
||||
</NestriLogo>
|
||||
</A>
|
||||
<LineSvg
|
||||
height="16"
|
||||
stroke-linejoin="round"
|
||||
viewBox="0 0 16 16"
|
||||
width="16">
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
clip-rule="evenodd"
|
||||
d="M4.01526 15.3939L4.3107 14.7046L10.3107 0.704556L10.6061 0.0151978L11.9849 0.606077L11.6894 1.29544L5.68942 15.2954L5.39398 15.9848L4.01526 15.3939Z" fill="currentColor"></path>
|
||||
</LineSvg>
|
||||
<TeamRoot>
|
||||
<Avatar
|
||||
size={21}
|
||||
name={team!().slug}
|
||||
/>
|
||||
<TeamLabel style={{ color: theme.color.d1000.gray }}>{team!().name}</TeamLabel>
|
||||
<Switch>
|
||||
<Match when={team!().planType === "BYOG"}>
|
||||
<Badge style={{ "background-color": theme.color.purple.d700 }}>
|
||||
<span style={{ "line-height": 0 }} >BYOG</span>
|
||||
</Badge>
|
||||
</Match>
|
||||
<Match when={team!().planType === "Hosted"}>
|
||||
<Badge style={{ "background-color": theme.color.blue.d700 }}>
|
||||
<span style={{ "line-height": 0 }}>Hosted</span>
|
||||
</Badge>
|
||||
</Match>
|
||||
</Switch>
|
||||
<DropIcon
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="32"
|
||||
height="32"
|
||||
viewBox="0 0 256 256">
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="M72.61 83.06a8 8 0 0 1 1.73-8.72l48-48a8 8 0 0 1 11.32 0l48 48A8 8 0 0 1 176 88H80a8 8 0 0 1-7.39-4.94M176 168H80a8 8 0 0 0-5.66 13.66l48 48a8 8 0 0 0 11.32 0l48-48A8 8 0 0 0 176 168" />
|
||||
</DropIcon>
|
||||
</TeamRoot>
|
||||
</LogoRoot>
|
||||
{/* </Show> */}
|
||||
</Container>
|
||||
<RightRoot>
|
||||
<Show when={team}>
|
||||
<NavRoot>
|
||||
<NavLink href={`/${team!().slug}/machines`}>
|
||||
{/* <svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24">
|
||||
<path fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M17.5 17.5L22 22m-2-11a9 9 0 1 0-18 0a9 9 0 0 0 18 0" color="currentColor" />
|
||||
</svg> */}
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24">
|
||||
<path fill="currentColor" d="M3.441 9.956a4.926 4.926 0 0 0 6.233 7.571l4.256 4.257a.773.773 0 0 0 1.169-1.007l-.075-.087l-4.217-4.218A4.927 4.927 0 0 0 3.44 9.956m13.213 6.545c-.225 1.287-.548 2.456-.952 3.454l.03.028l.124.14c.22.295.344.624.378.952a10.03 10.03 0 0 0 4.726-4.574zM12.25 16.5l2.284 2.287c.202-.6.381-1.268.53-1.992l.057-.294zm-2.936-5.45a3.38 3.38 0 1 1-4.78 4.779a3.38 3.38 0 0 1 4.78-4.78M15.45 10h-3.7a5.94 5.94 0 0 1 .892 5h2.71a26 26 0 0 0 .132-4.512zm1.507 0a28 28 0 0 1-.033 4.42l-.057.58h4.703a10.05 10.05 0 0 0 .258-5zm-2.095-7.593c.881 1.35 1.536 3.329 1.883 5.654l.062.44h4.59a10.03 10.03 0 0 0-6.109-5.958l-.304-.1zm-2.836-.405c-1.277 0-2.561 2.382-3.158 5.839c.465.16.912.38 1.331.658l5.088.001c-.54-3.809-1.905-6.498-3.261-6.498m-2.837.405A10.03 10.03 0 0 0 2.654 8.5h.995a5.92 5.92 0 0 1 3.743-.968c.322-1.858.846-3.47 1.527-4.68l.162-.275z" />
|
||||
</svg>
|
||||
{/* Machines */}
|
||||
<NavLink style={{ "margin-right": "-8px" }} href={`/${team!().slug}/machines`}>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24"><path fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="m21 21l-4.343-4.343m0 0A8 8 0 1 0 5.343 5.343a8 8 0 0 0 11.314 11.314" /></svg>
|
||||
</NavLink>
|
||||
<NavLink href={`/${team!().slug}/machines`}>
|
||||
<svg style={{ "margin-bottom": "1px" }} xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 16 16">
|
||||
@@ -304,14 +335,15 @@ export function Header(props: { whiteColor?: boolean } & ParentProps) {
|
||||
</NavRoot>
|
||||
</Show>
|
||||
<div style={{ "margin-bottom": "2px" }} >
|
||||
<Switch>
|
||||
<AvatarImg src={"https://avatars.githubusercontent.com/u/71614375?v=4"} alt={`Wanjohi's avatar`} />
|
||||
{/* <Switch>
|
||||
<Match when={account.current.avatarUrl} >
|
||||
<AvatarImg src={account.current.avatarUrl} alt={`${account.current.name}'s avatar`} />
|
||||
<AvatarImg src={account.current.avatarUrl} alt={`${account.current.name}'s avatar`} />
|
||||
</Match>
|
||||
<Match when={!account.current.avatarUrl}>
|
||||
<Avatar size={32} name={`${account.current.name}#${account.current.discriminator}`} />
|
||||
</Match>
|
||||
</Switch>
|
||||
</Switch> */}
|
||||
</div>
|
||||
</RightRoot>
|
||||
</Nav>
|
||||
|
||||
@@ -3,111 +3,14 @@ import { styled } from "@macaron-css/solid";
|
||||
import { Header } from "@nestri/www/pages/team/header";
|
||||
import { useSteam } from "@nestri/www/providers/steam";
|
||||
import { Modal } from "@nestri/www/ui/modal";
|
||||
import { createEffect, createSignal, onCleanup } from "solid-js";
|
||||
import { createEffect, createSignal, Match, onCleanup, Switch } from "solid-js";
|
||||
import { Text } from "@nestri/www/ui/text"
|
||||
import { QRCode } from "@nestri/www/ui/custom-qr";
|
||||
import { globalStyle, keyframes } from "@macaron-css/core";
|
||||
import { A } from "@solidjs/router";
|
||||
import Avatar from "@nestri/www/ui/avatar";
|
||||
import { Portal } from "@nestri/www/common/portal";
|
||||
import { QrCodeComponent } from "@nestri/www/components"
|
||||
|
||||
const EmptyState = styled("div", {
|
||||
base: {
|
||||
padding: "0 40px",
|
||||
display: "flex",
|
||||
gap: 10,
|
||||
flexDirection: "column",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
margin: "auto"
|
||||
}
|
||||
})
|
||||
|
||||
const EmptyStateHeader = styled("h2", {
|
||||
base: {
|
||||
textAlign: "center",
|
||||
fontSize: theme.font.size["2xl"],
|
||||
fontFamily: theme.font.family.heading,
|
||||
fontWeight: theme.font.weight.semibold,
|
||||
letterSpacing: -0.5,
|
||||
}
|
||||
})
|
||||
|
||||
const EmptyStateSubHeader = styled("p", {
|
||||
base: {
|
||||
fontWeight: theme.font.weight.regular,
|
||||
color: theme.color.gray.d900,
|
||||
fontSize: theme.font.size["lg"],
|
||||
textAlign: "center",
|
||||
maxWidth: 380,
|
||||
letterSpacing: -0.4,
|
||||
lineHeight: 1.1,
|
||||
}
|
||||
})
|
||||
|
||||
const QRWrapper = styled("div", {
|
||||
base: {
|
||||
backgroundColor: theme.color.background.d100,
|
||||
position: "relative",
|
||||
marginBottom: 20,
|
||||
textWrap: "balance",
|
||||
border: `1px solid ${theme.color.gray.d400}`,
|
||||
display: "flex",
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
overflow: "hidden",
|
||||
borderRadius: 22,
|
||||
padding: 20,
|
||||
}
|
||||
})
|
||||
|
||||
const SteamMobileLink = styled(A, {
|
||||
base: {
|
||||
textUnderlineOffset: 2,
|
||||
textDecoration: "none",
|
||||
color: theme.color.blue.d900,
|
||||
display: "inline-flex",
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
gap: 1,
|
||||
width: "max-content",
|
||||
textTransform: "capitalize",
|
||||
":hover": {
|
||||
textDecoration: "underline"
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
const LogoContainer = styled("div", {
|
||||
base: {
|
||||
position: "absolute",
|
||||
top: 0,
|
||||
left: 0,
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
}
|
||||
})
|
||||
|
||||
const LogoIcon = styled("svg", {
|
||||
base: {
|
||||
zIndex: 6,
|
||||
position: "absolute",
|
||||
left: "50%",
|
||||
top: "50%",
|
||||
transform: "translate(-50%,-50%)",
|
||||
overflow: "hidden",
|
||||
// width: "21%",
|
||||
// height: "21%",
|
||||
borderRadius: 17,
|
||||
// ":before": {
|
||||
// pointerEvents: "none",
|
||||
// zIndex: 2,
|
||||
// content: '',
|
||||
// position: "absolute",
|
||||
// inset: 0,
|
||||
// borderRadius: "inherit",
|
||||
// boxShadow: "inset 0 0 0 1px rgba(0, 0, 0, 0.02)",
|
||||
// }
|
||||
}
|
||||
})
|
||||
|
||||
const LastPlayedWrapper = styled("div", {
|
||||
base: {
|
||||
@@ -230,7 +133,8 @@ const GamesContainer = styled("div", {
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
flexDirection: "column",
|
||||
zIndex: 3,
|
||||
zIndex: 10,
|
||||
isolation: "isolate",
|
||||
backgroundColor: theme.color.background.d200,
|
||||
}
|
||||
})
|
||||
@@ -293,13 +197,13 @@ const SteamLibrary = styled("div", {
|
||||
display: "grid",
|
||||
// backgroundColor: "red",
|
||||
maxWidth: "70vw",
|
||||
gridTemplateColumns: "repeat(4, minmax(0, 1fr))",
|
||||
columnGap: 12,
|
||||
gridTemplateColumns: "repeat(2, minmax(0, 1fr))",
|
||||
columnGap: 20,
|
||||
rowGap: 10,
|
||||
}
|
||||
})
|
||||
|
||||
const SteamLibraryTitle = styled("h3", {
|
||||
const Title = styled("h3", {
|
||||
base: {
|
||||
textAlign: "left",
|
||||
fontFamily: theme.font.family.heading,
|
||||
@@ -311,104 +215,311 @@ const SteamLibraryTitle = styled("h3", {
|
||||
}
|
||||
})
|
||||
|
||||
const SteamGameTitle = styled("h3", {
|
||||
base: {
|
||||
textAlign: "left",
|
||||
fontFamily: theme.font.family.heading,
|
||||
fontWeight: theme.font.weight.medium,
|
||||
fontSize: theme.font.size["xl"],
|
||||
letterSpacing: -0.7,
|
||||
}
|
||||
})
|
||||
|
||||
const SteamGameSubTitle = styled("span", {
|
||||
base: {
|
||||
textAlign: "left",
|
||||
fontWeight: theme.font.weight.regular,
|
||||
color: theme.color.gray.d900,
|
||||
fontSize: theme.font.size["base"],
|
||||
letterSpacing: -0.4,
|
||||
}
|
||||
})
|
||||
|
||||
const SubTitle = styled("span", {
|
||||
base: {
|
||||
textAlign: "left",
|
||||
fontWeight: theme.font.weight.regular,
|
||||
color: theme.color.gray.d900,
|
||||
fontSize: theme.font.size["base"],
|
||||
letterSpacing: -0.4,
|
||||
gridColumn: "1/-1",
|
||||
marginTop: -20,
|
||||
marginBottom: 20,
|
||||
}
|
||||
})
|
||||
|
||||
const FriendsList = styled("div", {
|
||||
base: {
|
||||
borderTop: `1px solid ${theme.color.gray.d400}`,
|
||||
padding: "20px 0",
|
||||
margin: "20px auto",
|
||||
width: "100%",
|
||||
display: "grid",
|
||||
maxWidth: "70vw",
|
||||
gridTemplateColumns: "repeat(5, minmax(0, 1fr))",
|
||||
columnGap: 12,
|
||||
rowGap: 10,
|
||||
}
|
||||
})
|
||||
|
||||
const FriendContainer = styled("div", {
|
||||
base: {
|
||||
width: "100%",
|
||||
display: "flex",
|
||||
minHeight: "calc(100% + 20px)",
|
||||
aspectRatio: "300/380",
|
||||
borderRadius: 15,
|
||||
position: "relative",
|
||||
padding: "35px 17px",
|
||||
border: `1px solid ${theme.color.gray.d500}`,
|
||||
backgroundColor: theme.color.background.d100,
|
||||
flexDirection: "column",
|
||||
alignItems: "center",
|
||||
}
|
||||
})
|
||||
|
||||
const FriendsSubText = styled("span", {
|
||||
base: {
|
||||
color: theme.color.gray.d900,
|
||||
fontSize: theme.font.size.sm,
|
||||
marginTop: 10,
|
||||
}
|
||||
})
|
||||
const FriendsText = styled("h3", {
|
||||
base: {
|
||||
fontSize: theme.font.size["lg"],
|
||||
fontFamily: theme.font.family.heading,
|
||||
marginTop: 20,
|
||||
}
|
||||
})
|
||||
|
||||
const FriendsInviteButton = styled("button", {
|
||||
base: {
|
||||
minWidth: 48,
|
||||
borderRadius: 9999,
|
||||
textAlign: "center",
|
||||
padding: "0px 24px",
|
||||
fontSize: theme.font.size["base"],
|
||||
lineHeight: "1.75",
|
||||
marginTop: 20,
|
||||
cursor: "pointer",
|
||||
fontWeight: theme.font.weight.bold,
|
||||
fontFamily: theme.font.family.heading,
|
||||
border: `1px solid ${theme.color.gray.d100}`,
|
||||
backgroundColor: theme.color.blue.d700,
|
||||
transition: "all 0.2s cubic-bezier(0.4,0,0.2,1)",
|
||||
":hover": {
|
||||
transform: "scale(1.05)"
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
const SteamGameContainer = styled("div", {
|
||||
base: {
|
||||
padding: "20px 0",
|
||||
width: "100%",
|
||||
minHeight: 72,
|
||||
display: "flex",
|
||||
flexDirection: "row",
|
||||
selectors: {
|
||||
"&:not(:last-of-type)": {
|
||||
borderBottom: `1px solid ${theme.color.gray.d400}`
|
||||
},
|
||||
"&:not(:first-of-type)": {
|
||||
borderTop: `1px solid ${theme.color.gray.d400}`
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
const SteamGameImg = styled("img", {
|
||||
base: {
|
||||
border: "none",
|
||||
outline: "none",
|
||||
aspectRatio: "1/1",
|
||||
height: 80,
|
||||
borderRadius: 8
|
||||
}
|
||||
})
|
||||
|
||||
const SteamGameText = styled("div", {
|
||||
base: {
|
||||
paddingRight: "3em",
|
||||
marginLeft: 30,
|
||||
display: "flex",
|
||||
gap: 8,
|
||||
flexDirection: "column",
|
||||
alignSelf: "center",
|
||||
}
|
||||
})
|
||||
const SteamGameBtn = styled("button", {
|
||||
base: {
|
||||
minWidth: 48,
|
||||
borderRadius: 9999,
|
||||
textAlign: "center",
|
||||
padding: "0px 24px",
|
||||
fontSize: theme.font.size["base"],
|
||||
lineHeight: "1.75",
|
||||
// marginTop: 20,
|
||||
// marginRight: 1,
|
||||
margin: "0 1px 0 auto",
|
||||
cursor: "pointer",
|
||||
alignSelf: "center",
|
||||
fontWeight: theme.font.weight.bold,
|
||||
fontFamily: theme.font.family.heading,
|
||||
color: theme.color.blue.d900,
|
||||
border: `1px solid ${theme.color.gray.d100}`,
|
||||
backgroundColor: theme.color.blue.d300,
|
||||
transition: "all 0.2s cubic-bezier(0.4,0,0.2,1)",
|
||||
":hover": {
|
||||
transform: "scale(1.05)"
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
const PortalContainer = styled("div", {
|
||||
base: {
|
||||
zIndex: 4,
|
||||
isolation: "isolate",
|
||||
position: "fixed",
|
||||
bottom: "20vh",
|
||||
left: "50%",
|
||||
transform: "translateX(-50%)"
|
||||
}
|
||||
})
|
||||
|
||||
/**
|
||||
* Renders the home page layout for the gaming platform.
|
||||
*
|
||||
* This component wraps its content within a header and a full-screen container,
|
||||
* currently displaying a QR code component. Commented sections indicate planned
|
||||
* enhancements such as game previews, team mate suggestions, and a Steam library.
|
||||
*/
|
||||
export function HomeRoute() {
|
||||
|
||||
// const steam = useSteam();
|
||||
// const [loginUrl, setLoginUrl] = createSignal<string | null>(null);
|
||||
// const [loginStatus, setLoginStatus] = createSignal<string | null>("Not connected");
|
||||
// const [userData, setUserData] = createSignal<{ username?: string, steamId?: string } | null>(null);
|
||||
|
||||
// createEffect(async () => {
|
||||
// // Connect to the Steam login stream
|
||||
// const steamConnection = await steam.client.login.connect();
|
||||
|
||||
// // Set up event listeners for different event types
|
||||
// const urlUnsubscribe = steamConnection.addEventListener('url', (url) => {
|
||||
// setLoginUrl(url);
|
||||
// setLoginStatus('Scan QR code with Steam mobile app');
|
||||
// });
|
||||
|
||||
// const loginAttemptUnsubscribe = steamConnection.addEventListener('login-attempt', (data) => {
|
||||
// setLoginStatus(`Logging in as ${data.username}...`);
|
||||
// });
|
||||
|
||||
// const loginSuccessUnsubscribe = steamConnection.addEventListener('login-success', (data) => {
|
||||
// setUserData(data);
|
||||
// setLoginStatus(`Successfully logged in as ${data.username}`);
|
||||
// });
|
||||
|
||||
// const loginUnsuccessfulUnsubscribe = steamConnection.addEventListener('login-unsuccessful', (data) => {
|
||||
// setLoginStatus(`Login failed: ${data.error}`);
|
||||
// });
|
||||
|
||||
// const loggedOffUnsubscribe = steamConnection.addEventListener('logged-off', (data) => {
|
||||
// setLoginStatus(`Logged out of Steam: ${data.reason}`);
|
||||
// setUserData(null);
|
||||
// });
|
||||
|
||||
// onCleanup(() => {
|
||||
// urlUnsubscribe();
|
||||
// loginAttemptUnsubscribe();
|
||||
// loginSuccessUnsubscribe();
|
||||
// loginUnsuccessfulUnsubscribe();
|
||||
// loggedOffUnsubscribe();
|
||||
// steamConnection.disconnect();
|
||||
// });
|
||||
// })
|
||||
|
||||
return (
|
||||
<>
|
||||
<Header whiteColor>
|
||||
<Header>
|
||||
<FullScreen >
|
||||
<EmptyState
|
||||
style={{
|
||||
"--nestri-qr-dot-color": theme.color.d1000.gray,
|
||||
"--nestri-body-background": theme.color.gray.d100
|
||||
}}
|
||||
>
|
||||
<QRWrapper>
|
||||
<LogoContainer>
|
||||
<LogoIcon
|
||||
xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 16 16">
|
||||
<g fill="currentColor">
|
||||
<path d="M.329 10.333A8.01 8.01 0 0 0 7.99 16C12.414 16 16 12.418 16 8s-3.586-8-8.009-8A8.006 8.006 0 0 0 0 7.468l.003.006l4.304 1.769A2.2 2.2 0 0 1 5.62 8.88l1.96-2.844l-.001-.04a3.046 3.046 0 0 1 3.042-3.043a3.046 3.046 0 0 1 3.042 3.043a3.047 3.047 0 0 1-3.111 3.044l-2.804 2a2.223 2.223 0 0 1-3.075 2.11a2.22 2.22 0 0 1-1.312-1.568L.33 10.333Z" /><path d="M4.868 12.683a1.715 1.715 0 0 0 1.318-3.165a1.7 1.7 0 0 0-1.263-.02l1.023.424a1.261 1.261 0 1 1-.97 2.33l-.99-.41a1.7 1.7 0 0 0 .882.84Zm3.726-6.687a2.03 2.03 0 0 0 2.027 2.029a2.03 2.03 0 0 0 2.027-2.029a2.03 2.03 0 0 0-2.027-2.027a2.03 2.03 0 0 0-2.027 2.027m2.03-1.527a1.524 1.524 0 1 1-.002 3.048a1.524 1.524 0 0 1 .002-3.048" />
|
||||
</g>
|
||||
</LogoIcon>
|
||||
</LogoContainer>
|
||||
<QRCode
|
||||
uri={"https://github.com/family/connectkit/blob/9a3c16c781d8a60853eff0c4988e22926a3f91ce"}
|
||||
size={180}
|
||||
ecl="M"
|
||||
clearArea={true}
|
||||
/>
|
||||
</QRWrapper>
|
||||
<EmptyStateHeader>Sign in to your Steam account</EmptyStateHeader>
|
||||
<EmptyStateSubHeader>Use your Steam Mobile App to sign in via QR code. <SteamMobileLink href="https://store.steampowered.com/mobile" target="_blank">Learn More<svg data-testid="geist-icon" height="20" stroke-linejoin="round" viewBox="0 0 16 16" width="20" style="color: currentcolor;"><path fill-rule="evenodd" clip-rule="evenodd" d="M11.5 9.75V11.25C11.5 11.3881 11.3881 11.5 11.25 11.5H4.75C4.61193 11.5 4.5 11.3881 4.5 11.25L4.5 4.75C4.5 4.61193 4.61193 4.5 4.75 4.5H6.25H7V3H6.25H4.75C3.7835 3 3 3.7835 3 4.75V11.25C3 12.2165 3.7835 13 4.75 13H11.25C12.2165 13 13 12.2165 13 11.25V9.75V9H11.5V9.75ZM8.5 3H9.25H12.2495C12.6637 3 12.9995 3.33579 12.9995 3.75V6.75V7.5H11.4995V6.75V5.56066L8.53033 8.52978L8 9.06011L6.93934 7.99945L7.46967 7.46912L10.4388 4.5H9.25H8.5V3Z" fill="currentColor"></path></svg></SteamMobileLink></EmptyStateSubHeader>
|
||||
</EmptyState>
|
||||
<QrCodeComponent />
|
||||
{/* <LastPlayedWrapper>
|
||||
<LastPlayedFader />
|
||||
<LogoBackgroundImage />
|
||||
<BackgroundImage />
|
||||
<Material />
|
||||
<JoeColor />
|
||||
</LastPlayedWrapper> */}
|
||||
{/* <GamesContainer>
|
||||
<PortalContainer>
|
||||
<Portal />
|
||||
</PortalContainer>
|
||||
</LastPlayedWrapper>
|
||||
<GamesContainer>
|
||||
<GamesWrapper>
|
||||
<GameSquareImage alt="Assasin's Creed Shadows" src="https://assets-prd.ignimgs.com/2024/05/15/acshadows-1715789601294.jpg" />
|
||||
<GameSquareImage alt="Assasin's Creed Shadows" src="https://assets-prd.ignimgs.com/2022/09/22/slime-rancher-2-button-02-1663890048548.jpg" />
|
||||
<GameSquareImage alt="Assasin's Creed Shadows" src="https://assets-prd.ignimgs.com/2023/05/19/cataclismo-button-1684532710313.jpg" />
|
||||
<GameSquareImage alt="Assasin's Creed Shadows" src="https://assets-prd.ignimgs.com/2024/03/27/marvelrivals-1711557092104.jpg" />
|
||||
<GameSquareImage draggable={false} alt="Assasin's Creed Shadows" src="https://assets-prd.ignimgs.com/2024/05/15/acshadows-1715789601294.jpg" />
|
||||
<GameSquareImage draggable={false} alt="Assasin's Creed Shadows" src="https://assets-prd.ignimgs.com/2022/09/22/slime-rancher-2-button-02-1663890048548.jpg" />
|
||||
<GameSquareImage draggable={false} alt="Assasin's Creed Shadows" src="https://assets-prd.ignimgs.com/2023/05/19/cataclismo-button-1684532710313.jpg" />
|
||||
<GameSquareImage draggable={false} alt="Assasin's Creed Shadows" src="https://assets-prd.ignimgs.com/2024/03/27/marvelrivals-1711557092104.jpg" />
|
||||
</GamesWrapper>
|
||||
<FriendsList>
|
||||
<Title>Team Mate Suggestions</Title>
|
||||
<SubTitle>Invite people to join your team and play together</SubTitle>
|
||||
<FriendContainer>
|
||||
<Avatar size={100} name="Wanjohi Ryan" />
|
||||
<FriendsText>Wanjohi Ryan</FriendsText>
|
||||
<FriendsSubText>From your Steam Friends</FriendsSubText>
|
||||
<FriendsInviteButton>Invite</FriendsInviteButton>
|
||||
</FriendContainer>
|
||||
<FriendContainer>
|
||||
<Avatar size={100} name="Tracy Jones" />
|
||||
<FriendsText>Tracy Jones</FriendsText>
|
||||
<FriendsSubText>From your Steam Friends</FriendsSubText>
|
||||
<FriendsInviteButton>Invite</FriendsInviteButton>
|
||||
</FriendContainer>
|
||||
<FriendContainer>
|
||||
<Avatar size={100} name="The65th" />
|
||||
<FriendsText>The65th</FriendsText>
|
||||
<FriendsSubText>From your Steam Friends</FriendsSubText>
|
||||
<FriendsInviteButton>Invite</FriendsInviteButton>
|
||||
</FriendContainer>
|
||||
<FriendContainer>
|
||||
<Avatar size={100} name="Menstral" />
|
||||
<FriendsText>Menstral</FriendsText>
|
||||
<FriendsSubText>From your Steam Friends</FriendsSubText>
|
||||
<FriendsInviteButton>Invite</FriendsInviteButton>
|
||||
</FriendContainer>
|
||||
<FriendContainer>
|
||||
<Avatar size={100} name="AstroHot" />
|
||||
<FriendsText>AstroHot</FriendsText>
|
||||
<FriendsSubText>From your Steam Friends</FriendsSubText>
|
||||
<FriendsInviteButton>Invite</FriendsInviteButton>
|
||||
</FriendContainer>
|
||||
</FriendsList>
|
||||
<SteamLibrary>
|
||||
<SteamLibraryTitle>Games we think you will like</SteamLibraryTitle>
|
||||
<GameImageCapsule alt="Assasin's Creed Shadows" src="https://shared.cloudflare.steamstatic.com/store_item_assets/steam/apps/2625420/hero_capsule.jpg?t=1742853642" />
|
||||
<GameImageCapsule alt="Assasin's Creed Shadows" src="https://shared.cloudflare.steamstatic.com/store_item_assets/steam/apps/2486740/hero_capsule.jpg?t=1742596243" />
|
||||
<GameImageCapsule alt="Assasin's Creed Shadows" src="https://shared.cloudflare.steamstatic.com/store_item_assets/steam/apps/870780/hero_capsule.jpg?t=1737800535" />
|
||||
<GameImageCapsule alt="Assasin's Creed Shadows" src="https://shared.cloudflare.steamstatic.com/store_item_assets/steam/apps/2050650/hero_capsule.jpg?t=1737800535" />
|
||||
<Title>Your Steam library</Title>
|
||||
<SubTitle>These titles from your Steam Library are fully functional on Nestri</SubTitle>
|
||||
<div>
|
||||
<SteamGameContainer>
|
||||
<SteamGameImg draggable={false} src="https://assets-prd.ignimgs.com/2023/05/27/alanwake2-1685200534966.jpg" />
|
||||
<SteamGameText>
|
||||
<SteamGameTitle>Alan Wake II</SteamGameTitle>
|
||||
<SteamGameSubTitle>Action, Adventure, Horror</SteamGameSubTitle>
|
||||
</SteamGameText>
|
||||
<SteamGameBtn>Install</SteamGameBtn>
|
||||
</SteamGameContainer>
|
||||
<SteamGameContainer>
|
||||
<SteamGameImg draggable={false} src="https://assets-prd.ignimgs.com/2022/09/22/slime-rancher-2-button-02-1663890048548.jpg" />
|
||||
<SteamGameText>
|
||||
<SteamGameTitle>Slime Rancher 2</SteamGameTitle>
|
||||
<SteamGameSubTitle>Action, Adventure, Casual, Indie</SteamGameSubTitle>
|
||||
</SteamGameText>
|
||||
<SteamGameBtn>Install</SteamGameBtn>
|
||||
</SteamGameContainer>
|
||||
<SteamGameContainer>
|
||||
<SteamGameImg draggable={false} src="https://assets1.ignimgs.com/2019/07/17/doom-eternal---button-fin-1563400339680.jpg" />
|
||||
<SteamGameText>
|
||||
<SteamGameTitle>Doom Eternal</SteamGameTitle>
|
||||
<SteamGameSubTitle>Action</SteamGameSubTitle>
|
||||
</SteamGameText>
|
||||
<SteamGameBtn>Install</SteamGameBtn>
|
||||
</SteamGameContainer>
|
||||
</div>
|
||||
<div>
|
||||
<SteamGameContainer>
|
||||
<SteamGameImg draggable={false} src="https://assets-prd.ignimgs.com/2022/10/12/dead-space-2023-button-3-1665603079064.jpg" />
|
||||
<SteamGameText>
|
||||
<SteamGameTitle>Dead Space</SteamGameTitle>
|
||||
<SteamGameSubTitle>Action, Adventure</SteamGameSubTitle>
|
||||
</SteamGameText>
|
||||
<SteamGameBtn>Update</SteamGameBtn>
|
||||
</SteamGameContainer>
|
||||
<SteamGameContainer>
|
||||
<SteamGameImg draggable={false} src="https://assets-prd.ignimgs.com/2023/01/25/hifirush-1674680068070.jpg" />
|
||||
<SteamGameText>
|
||||
<SteamGameTitle>Hi-Fi Rush</SteamGameTitle>
|
||||
<SteamGameSubTitle>Action</SteamGameSubTitle>
|
||||
</SteamGameText>
|
||||
<SteamGameBtn>Install</SteamGameBtn>
|
||||
</SteamGameContainer>
|
||||
<SteamGameContainer>
|
||||
<SteamGameImg draggable={false} src="https://assets-prd.ignimgs.com/2023/08/24/baldursg3-1692894717196.jpeg" />
|
||||
<SteamGameText>
|
||||
<SteamGameTitle>Baldur's Gate 3</SteamGameTitle>
|
||||
<SteamGameSubTitle>Adventure, RPG, Strategy</SteamGameSubTitle>
|
||||
</SteamGameText>
|
||||
<SteamGameBtn>Install</SteamGameBtn>
|
||||
</SteamGameContainer>
|
||||
</div>
|
||||
</SteamLibrary>
|
||||
</GamesContainer> */}
|
||||
</GamesContainer>*/}
|
||||
</FullScreen>
|
||||
</Header>
|
||||
</>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
<GameImageCapsule alt="Assasin's Creed Shadows" src="https://shared.cloudflare.steamstatic.com/store_item_assets/steam/apps/2625420/hero_capsule.jpg?t=1742853642" />
|
||||
<GameImageCapsule alt="Assasin's Creed Shadows" src="https://shared.cloudflare.steamstatic.com/store_item_assets/steam/apps/2486740/hero_capsule.jpg?t=1742596243" />
|
||||
<GameImageCapsule alt="Assasin's Creed Shadows" src="https://shared.cloudflare.steamstatic.com/store_item_assets/steam/apps/870780/hero_capsule.jpg?t=1737800535" />
|
||||
<GameImageCapsule alt="Assasin's Creed Shadows" src="https://shared.cloudflare.steamstatic.com/store_item_assets/steam/apps/2050650/hero_capsule.jpg?t=1737800535" />
|
||||
*/
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ import { createInitializedContext } from "@nestri/www/common/context";
|
||||
|
||||
|
||||
export const { use: useApi, provider: ApiProvider } = createInitializedContext(
|
||||
"Api",
|
||||
"ApiContext",
|
||||
() => {
|
||||
const team = useTeam();
|
||||
const auth = useOpenAuth();
|
||||
|
||||
@@ -4,23 +4,31 @@ import { useOpenAuth } from "@openauthjs/solid";
|
||||
import { createSignal, onCleanup } from "solid-js";
|
||||
import { createInitializedContext } from "../common/context";
|
||||
|
||||
// Global connection state to prevent multiple instances
|
||||
let globalEventSource: EventSource | null = null;
|
||||
let globalReconnectAttempts = 0;
|
||||
const MAX_RECONNECT_ATTEMPTS = 1;
|
||||
let isConnecting = false;
|
||||
let activeConnection: SteamConnection | null = null;
|
||||
|
||||
// FIXME: The redo button is not working as expected... it does not reinitialise the connection
|
||||
|
||||
// Type definitions for the events
|
||||
interface SteamEventTypes {
|
||||
'url': string;
|
||||
'login-attempt': { username: string };
|
||||
'login-success': { username: string; steamId: string };
|
||||
'login-unsuccessful': { error: string };
|
||||
'logged-off': { reason: string };
|
||||
'connected': { sessionID: string };
|
||||
'challenge': { sessionID: string; url: string };
|
||||
'error': { message: string };
|
||||
'completed': { sessionID: string };
|
||||
}
|
||||
|
||||
// Type for the connection
|
||||
type SteamConnection = {
|
||||
addEventListener: <T extends keyof SteamEventTypes>(
|
||||
event: T,
|
||||
event: T,
|
||||
callback: (data: SteamEventTypes[T]) => void
|
||||
) => () => void;
|
||||
removeEventListener: <T extends keyof SteamEventTypes>(
|
||||
event: T,
|
||||
event: T,
|
||||
callback: (data: SteamEventTypes[T]) => void
|
||||
) => void;
|
||||
disconnect: () => void;
|
||||
@@ -30,74 +38,67 @@ type SteamConnection = {
|
||||
interface SteamContext {
|
||||
ready: boolean;
|
||||
client: {
|
||||
// Regular API endpoints
|
||||
whoami: () => Promise<any>;
|
||||
games: () => Promise<any>;
|
||||
// SSE connection for login
|
||||
login: {
|
||||
connect: () => SteamConnection;
|
||||
connect: () => Promise<SteamConnection>;
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
// Create the initialized context
|
||||
export const { use: useSteam, provider: SteamProvider } = createInitializedContext(
|
||||
"Steam",
|
||||
"SteamContext",
|
||||
() => {
|
||||
const team = useTeam();
|
||||
const auth = useOpenAuth();
|
||||
|
||||
// Create the HTTP client for regular endpoints
|
||||
const client = {
|
||||
// Regular HTTP endpoints
|
||||
whoami: async () => {
|
||||
const token = await auth.access();
|
||||
const response = await fetch(`${import.meta.env.VITE_STEAM_URL}/whoami`, {
|
||||
headers: {
|
||||
'Authorization': `Bearer ${token}`,
|
||||
'x-nestri-team': team().id
|
||||
}
|
||||
});
|
||||
return response.json();
|
||||
},
|
||||
|
||||
games: async () => {
|
||||
const token = await auth.access();
|
||||
const response = await fetch(`${import.meta.env.VITE_STEAM_URL}/games`, {
|
||||
headers: {
|
||||
'Authorization': `Bearer ${token}`,
|
||||
'x-nestri-team': team().id
|
||||
}
|
||||
});
|
||||
return response.json();
|
||||
},
|
||||
|
||||
// SSE connection factory for login
|
||||
login: {
|
||||
connect: async (): Promise<SteamConnection> => {
|
||||
let eventSource: EventSource | null = null;
|
||||
// Return existing connection if active
|
||||
if (activeConnection && globalEventSource && globalEventSource.readyState !== 2) {
|
||||
return activeConnection;
|
||||
}
|
||||
|
||||
// Prevent multiple simultaneous connection attempts
|
||||
if (isConnecting) {
|
||||
console.log("Connection attempt already in progress, waiting...");
|
||||
// Wait for existing connection attempt to finish
|
||||
return new Promise((resolve) => {
|
||||
const checkInterval = setInterval(() => {
|
||||
if (!isConnecting && activeConnection) {
|
||||
clearInterval(checkInterval);
|
||||
resolve(activeConnection);
|
||||
}
|
||||
}, 100);
|
||||
});
|
||||
}
|
||||
|
||||
isConnecting = true;
|
||||
|
||||
const [isConnected, setIsConnected] = createSignal(false);
|
||||
|
||||
|
||||
// Store event listeners
|
||||
const listeners: Record<string, Array<(data: any) => void>> = {
|
||||
'url': [],
|
||||
'login-attempt': [],
|
||||
'login-success': [],
|
||||
'login-unsuccessful': [],
|
||||
'logged-off': []
|
||||
'connected': [],
|
||||
'challenge': [],
|
||||
'error': [],
|
||||
'completed': []
|
||||
};
|
||||
|
||||
// Method to add event listeners
|
||||
const addEventListener = <T extends keyof SteamEventTypes>(
|
||||
event: T,
|
||||
event: T,
|
||||
callback: (data: SteamEventTypes[T]) => void
|
||||
) => {
|
||||
if (!listeners[event]) {
|
||||
listeners[event] = [];
|
||||
}
|
||||
|
||||
|
||||
listeners[event].push(callback as any);
|
||||
|
||||
|
||||
// Return a function to remove this specific listener
|
||||
return () => {
|
||||
removeEventListener(event, callback);
|
||||
@@ -106,7 +107,7 @@ export const { use: useSteam, provider: SteamProvider } = createInitializedConte
|
||||
|
||||
// Method to remove event listeners
|
||||
const removeEventListener = <T extends keyof SteamEventTypes>(
|
||||
event: T,
|
||||
event: T,
|
||||
callback: (data: SteamEventTypes[T]) => void
|
||||
) => {
|
||||
if (listeners[event]) {
|
||||
@@ -117,16 +118,39 @@ export const { use: useSteam, provider: SteamProvider } = createInitializedConte
|
||||
}
|
||||
};
|
||||
|
||||
// Handle notifying listeners safely
|
||||
const notifyListeners = (eventType: string, data: any) => {
|
||||
if (listeners[eventType]) {
|
||||
listeners[eventType].forEach(callback => {
|
||||
try {
|
||||
callback(data);
|
||||
} catch (error) {
|
||||
console.error(`Error in ${eventType} event handler:`, error);
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// Initialize connection
|
||||
const initConnection = async () => {
|
||||
if (eventSource) {
|
||||
eventSource.close();
|
||||
if (globalReconnectAttempts >= MAX_RECONNECT_ATTEMPTS) {
|
||||
console.log(`Maximum reconnection attempts (${MAX_RECONNECT_ATTEMPTS}) reached. Giving up.`);
|
||||
notifyListeners('error', { message: 'Connection to Steam authentication failed after multiple attempts' });
|
||||
isConnecting = false;
|
||||
disconnect()
|
||||
return;
|
||||
}
|
||||
|
||||
if (globalEventSource) {
|
||||
globalEventSource.close();
|
||||
globalEventSource = null;
|
||||
}
|
||||
|
||||
try {
|
||||
const token = await auth.access();
|
||||
|
||||
eventSource = new EventSource(`${import.meta.env.VITE_STEAM_URL}/login`, {
|
||||
// Create new EventSource connection
|
||||
globalEventSource = new EventSource(`${import.meta.env.VITE_API_URL}/steam/login`, {
|
||||
fetch: (input, init) =>
|
||||
fetch(input, {
|
||||
...init,
|
||||
@@ -138,59 +162,74 @@ export const { use: useSteam, provider: SteamProvider } = createInitializedConte
|
||||
}),
|
||||
});
|
||||
|
||||
eventSource.onopen = () => {
|
||||
globalEventSource.onopen = () => {
|
||||
console.log('Connected to Steam login stream');
|
||||
setIsConnected(true);
|
||||
globalReconnectAttempts = 0; // Reset reconnect counter on successful connection
|
||||
isConnecting = false;
|
||||
};
|
||||
|
||||
// Set up event handlers for all specific events
|
||||
['url', 'login-attempt', 'login-success', 'login-unsuccessful', 'logged-off'].forEach((eventType) => {
|
||||
eventSource!.addEventListener(eventType, (event) => {
|
||||
['connected', 'challenge', 'completed'].forEach((eventType) => {
|
||||
globalEventSource!.addEventListener(eventType, (event) => {
|
||||
try {
|
||||
const data = JSON.parse(event.data);
|
||||
console.log(`Received ${eventType} event:`, data);
|
||||
|
||||
// Notify all registered listeners for this event type
|
||||
if (listeners[eventType]) {
|
||||
listeners[eventType].forEach(callback => {
|
||||
callback(data);
|
||||
});
|
||||
}
|
||||
notifyListeners(eventType, data);
|
||||
} catch (error) {
|
||||
console.error(`Error parsing ${eventType} event data:`, error);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Handle generic messages (fallback)
|
||||
eventSource.onmessage = (event) => {
|
||||
console.log('Received generic message:', event.data);
|
||||
};
|
||||
|
||||
eventSource.onerror = (error) => {
|
||||
console.error('Steam login stream error:', error);
|
||||
// Handle connection errors (this is different from server-sent 'error' events)
|
||||
globalEventSource.onerror = (error) => {
|
||||
console.error('Steam login stream connection error:', error);
|
||||
setIsConnected(false);
|
||||
// Attempt to reconnect after a delay
|
||||
setTimeout(initConnection, 5000);
|
||||
|
||||
// Close the connection to prevent automatic browser reconnect
|
||||
if (globalEventSource) {
|
||||
globalEventSource.close();
|
||||
}
|
||||
|
||||
// Check if we should attempt to reconnect
|
||||
if (globalReconnectAttempts <= MAX_RECONNECT_ATTEMPTS) {
|
||||
const currentAttempt = globalReconnectAttempts + 1;
|
||||
console.log(`Reconnecting (attempt ${currentAttempt}/${MAX_RECONNECT_ATTEMPTS})...`);
|
||||
globalReconnectAttempts = currentAttempt;
|
||||
|
||||
// Exponential backoff for reconnection
|
||||
const delay = Math.min(1000 * Math.pow(2, globalReconnectAttempts), 30000);
|
||||
setTimeout(initConnection, delay);
|
||||
} else {
|
||||
console.error(`Maximum reconnection attempts (${MAX_RECONNECT_ATTEMPTS}) reached`);
|
||||
// Notify listeners about connection failure
|
||||
notifyListeners('error', { message: 'Connection to Steam authentication failed after multiple attempts' });
|
||||
disconnect();
|
||||
isConnecting = false;
|
||||
}
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Failed to connect to Steam login stream:', error);
|
||||
setIsConnected(false);
|
||||
isConnecting = false;
|
||||
}
|
||||
};
|
||||
|
||||
// Disconnection function
|
||||
const disconnect = () => {
|
||||
if (eventSource) {
|
||||
eventSource.close();
|
||||
eventSource = null;
|
||||
if (globalEventSource) {
|
||||
globalEventSource.close();
|
||||
globalEventSource = null;
|
||||
setIsConnected(false);
|
||||
console.log('Disconnected from Steam login stream');
|
||||
|
||||
|
||||
// Clear all listeners
|
||||
Object.keys(listeners).forEach(key => {
|
||||
listeners[key] = [];
|
||||
});
|
||||
|
||||
activeConnection = null;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -205,9 +244,17 @@ export const { use: useSteam, provider: SteamProvider } = createInitializedConte
|
||||
isConnected: () => isConnected()
|
||||
};
|
||||
|
||||
// Store the active connection
|
||||
activeConnection = connection;
|
||||
|
||||
// Clean up on context destruction
|
||||
onCleanup(() => {
|
||||
disconnect();
|
||||
// Instead of disconnecting on cleanup, we'll leave the connection
|
||||
// active for other components to use
|
||||
// Only disconnect if no components are using it
|
||||
if (!isConnected()) {
|
||||
disconnect();
|
||||
}
|
||||
});
|
||||
|
||||
return connection;
|
||||
|
||||
1
packages/www/src/sst-env.d.ts
vendored
@@ -7,7 +7,6 @@ interface ImportMetaEnv {
|
||||
readonly VITE_STAGE: string
|
||||
readonly VITE_AUTH_URL: string
|
||||
readonly VITE_ZERO_URL: string
|
||||
readonly VITE_STEAM_URL: string
|
||||
}
|
||||
interface ImportMeta {
|
||||
readonly env: ImportMetaEnv
|
||||
|
||||
@@ -28,6 +28,22 @@ type Props = {
|
||||
imageBackground?: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Renders an SVG element displaying a QR code generated from a URI.
|
||||
*
|
||||
* This component creates a QR code matrix based on the provided URI and error correction level, then renders
|
||||
* the QR code using SVG elements. It highlights finder patterns and conditionally renders QR code dots,
|
||||
* while optionally embedding a logo in the center with a specified background and an adjustable clear area.
|
||||
*
|
||||
* @param ecl - The error correction level for the QR code (defaults to 'M').
|
||||
* @param size - The overall size (in pixels) of the QR code, including margins (defaults to 200).
|
||||
* @param uri - The URI to encode into the QR code.
|
||||
* @param clearArea - When true, reserves extra space in the QR code for an embedded logo.
|
||||
* @param image - An optional JSX element to render as a central logo within the QR code.
|
||||
* @param imageBackground - The background color for the logo area (defaults to 'transparent').
|
||||
*
|
||||
* @returns An SVG element representing the generated QR code.
|
||||
*/
|
||||
export function QRCode({
|
||||
ecl = 'M',
|
||||
size: sizeProp = 200,
|
||||
@@ -36,7 +52,7 @@ export function QRCode({
|
||||
image,
|
||||
imageBackground = 'transparent',
|
||||
}: Props) {
|
||||
const logoSize = clearArea ? 32 : 0;
|
||||
const logoSize = clearArea ? 38 : 0;
|
||||
const size = sizeProp - 10 * 2;
|
||||
|
||||
const dots = createMemo(() => {
|
||||
@@ -115,7 +131,6 @@ export function QRCode({
|
||||
(i < 7 && j > matrix.length - 8)
|
||||
)
|
||||
) {
|
||||
//if (image && i > matrix.length - 9 && j > matrix.length - 9) return;
|
||||
if (
|
||||
image ||
|
||||
!(
|
||||
|
||||