diff --git a/index.html b/index.html index 96ecbd3a4..b9f9d8883 100644 --- a/index.html +++ b/index.html @@ -207,6 +207,7 @@ + diff --git a/resources/lang/en.json b/resources/lang/en.json index 25c99b363..b530e1fdb 100644 --- a/resources/lang/en.json +++ b/resources/lang/en.json @@ -370,6 +370,7 @@ "duration_minute_short": "min", "duration_second_short": "s", "enabled": "Enabled", + "got_it": "Got it", "map_default": "Map default", "month_apr": "April", "month_aug": "August", @@ -1406,6 +1407,9 @@ "toggle_achievements": "Toggle achievements", "water_nukes": "Water nukes" }, + "steam": { + "link_signpost": "Have an existing OpenFront account? Account linking is coming in a later update." + }, "store": { "already_subscribed": "Already subscribed.", "change_tier_failed": "Couldn't update your subscription. Please try again.", diff --git a/src/client/Api.ts b/src/client/Api.ts index 5f6177b96..880407f18 100644 --- a/src/client/Api.ts +++ b/src/client/Api.ts @@ -690,9 +690,9 @@ export function getApiBase() { } export function getAudience() { - const { hostname } = new URL(window.location.href); - const domainname = hostname.split(".").slice(-2).join("."); - return domainname; + // Sourced from BOOTSTRAP_CONFIG (server/desktop-injected) rather than + // window.location, so the desktop app (app://openfront) targets real infra. + return ClientEnv.jwtAudience(); } // Check if the user's account is linked to a Discord, Google, or email account. diff --git a/src/client/Auth.ts b/src/client/Auth.ts index 52fdd1662..f37537cfb 100644 --- a/src/client/Auth.ts +++ b/src/client/Auth.ts @@ -5,6 +5,7 @@ import { TokenPayload, TokenPayloadSchema } from "../core/ApiSchemas"; import { base64urlToUuid } from "../core/Base64"; import { getApiBase, getAudience } from "./Api"; import { crazyGamesSDK } from "./CrazyGamesSDK"; +import { steamSDK } from "./SteamSDK"; import { generateCryptoRandomUUID } from "./Utils"; export type UserAuth = { jwt: string; claims: TokenPayload } | false; @@ -190,6 +191,14 @@ async function refreshJwt(): Promise { } async function doRefreshJwt(): Promise { + if (steamSDK.isOnSteam()) { + const ticket = await steamSDK.getTicket(); + if (ticket) { + // On Steam, we exchange a Steam Web-API ticket for our session. No + // ticket (Steam unavailable) falls through to the guest flow below. + return doSteamLogin(ticket); + } + } if (crazyGamesSDK.isOnCrazyGames()) { const token = await crazyGamesSDK.getUserToken(); if (token) { @@ -250,6 +259,33 @@ async function doCrazyGamesLogin(token: string): Promise { } } +// Exchange a Steam Web-API ticket for our session. Like CrazyGames, the +// refresh cookie isn't usable from app://openfront (cross-site), so we +// re-exchange a fresh ticket on expiry rather than hitting /auth/refresh. +async function doSteamLogin(ticket: string): Promise { + try { + console.log("Logging in with Steam"); + const response = await fetch(getApiBase() + "/auth/steam", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ ticket }), + }); + if (response.status !== 200) { + console.error("Steam login failed", response); + __jwt = null; + return; + } + const json = await response.json(); + const { jwt, expiresIn } = json; + __expiresAt = Date.now() + expiresIn * 1000; + console.log("Steam login succeeded"); + __jwt = jwt; + } catch (e) { + console.error("Steam login failed", e); + __jwt = null; + } +} + // Called when the CrazyGames auth state changes mid-session (e.g. the player // signs in): drop the cached session so userAuth() re-exchanges the new token. // Single-flight: Main's auth listener and the account modal's sign-in handler diff --git a/src/client/Main.ts b/src/client/Main.ts index dc40cc090..a3f36c1b1 100644 --- a/src/client/Main.ts +++ b/src/client/Main.ts @@ -54,6 +54,7 @@ import "./NewsModal"; import "./PlayerProfileModal"; import { RewardsModal } from "./RewardsModal"; import "./SinglePlayerModal"; +import "./SteamLinkSignpost"; import { StoreModal } from "./Store"; import { TokenLoginModal } from "./TokenLoginModal"; import { @@ -937,6 +938,11 @@ class Client { } const auth = await userAuth(); const playerRole = auth !== false ? (auth.claims.role ?? null) : null; + // Ensure the one-shot Steam name-seed has settled before reading + // getUsername(), mirroring how getClanCheck() runs in parallel with the + // handshake. whenSeeded() always resolves (falling back to the generated + // anon name on failure/timeout), so this can only delay, never block. + await this.usernameInput?.whenSeeded(); const newLobbyHandle = joinLobby(this.eventBus, { gameID: lobby.gameID, cosmetics: await getPlayerCosmeticsRefs(), diff --git a/src/client/SinglePlayerModal.ts b/src/client/SinglePlayerModal.ts index 75506e937..c9ee6c89f 100644 --- a/src/client/SinglePlayerModal.ts +++ b/src/client/SinglePlayerModal.ts @@ -830,6 +830,11 @@ export class SinglePlayerModal extends BaseModal { "username-input", ) as UsernameInput; + // Wait for the one-shot Steam name-seed to settle before reading + // getUsername(), so a fast single-player start uses the Steam persona + // rather than the interim generated anon name. Always resolves. + await usernameInput?.whenSeeded(); + await crazyGamesSDK.requestMidgameAd(); this.dispatchEvent( diff --git a/src/client/SteamLinkSignpost.ts b/src/client/SteamLinkSignpost.ts new file mode 100644 index 000000000..06270161f --- /dev/null +++ b/src/client/SteamLinkSignpost.ts @@ -0,0 +1,55 @@ +import { html, LitElement, nothing } from "lit"; +import { customElement } from "lit/decorators.js"; +import { steamSDK } from "./SteamSDK"; +import { translateText } from "./Utils"; + +const SEEN_KEY = "steam_link_signpost_seen"; + +/** + * One-time first-launch signpost for the Steam desktop build. Account + * linking (matching a Steam player to an existing web OpenFront account) + * isn't implemented yet — a fresh install on Steam always creates a new + * account. This tells the player that once, so they aren't surprised, and + * never shows again once dismissed (or once seen, since `dismiss()` is the + * only way the seen-flag gets set). + * + * Self-hides off Steam, so mounting it unconditionally (web + desktop) is + * safe. + */ +@customElement("steam-link-signpost") +export class SteamLinkSignpost extends LitElement { + createRenderRoot() { + return this; + } + + shouldShow(): boolean { + return steamSDK.isOnSteam() && localStorage.getItem(SEEN_KEY) !== "1"; + } + + dismiss(): void { + localStorage.setItem(SEEN_KEY, "1"); + this.requestUpdate(); + } + + render() { + if (!this.shouldShow()) return nothing; + + return html` + + `; + } +} diff --git a/src/client/SteamSDK.ts b/src/client/SteamSDK.ts new file mode 100644 index 000000000..16b5b83f7 --- /dev/null +++ b/src/client/SteamSDK.ts @@ -0,0 +1,45 @@ +interface SteamBridge { + getAuthTicket(): Promise; + getUser(): Promise<{ steamId: string; name: string } | null>; +} + +// window.openfrontDesktop is declared `unknown` by DesktopShell.ts (kept loose +// there on purpose). We know the shape the Electron preload exposes, so narrow +// it locally rather than re-declaring the global (a second `declare global` +// with a different type triggers TS2717). +function steamBridge(): SteamBridge | undefined { + const desktop = window.openfrontDesktop as + | { steam?: SteamBridge } + | undefined; + return desktop?.steam; +} + +// Thin renderer wrapper over the desktop shell's Steam bridge. Mirrors +// CrazyGamesSDK; the native work lives in the Electron main process. +class SteamSDK { + isOnSteam(): boolean { + return steamBridge() !== undefined; + } + + async getTicket(): Promise { + const bridge = steamBridge(); + if (!bridge) return null; + try { + return await bridge.getAuthTicket(); + } catch { + return null; + } + } + + async getUser(): Promise<{ steamId: string; name: string } | null> { + const bridge = steamBridge(); + if (!bridge) return null; + try { + return await bridge.getUser(); + } catch { + return null; + } + } +} + +export const steamSDK = new SteamSDK(); diff --git a/src/client/UsernameInput.ts b/src/client/UsernameInput.ts index 18f2bbf0c..5a75e790d 100644 --- a/src/client/UsernameInput.ts +++ b/src/client/UsernameInput.ts @@ -15,6 +15,7 @@ import { import { checkClanTagOwnership } from "./ClanApi"; import { crazyGamesSDK } from "./CrazyGamesSDK"; import { showInGameConfirm } from "./InGameModal"; +import { steamSDK } from "./SteamSDK"; interface LangSelectorLike { currentLang?: string; @@ -37,6 +38,9 @@ export class UsernameInput extends LitElement { // Clans aren't supported on CrazyGames — hide the tag input and never submit one. private readonly onCrazyGames = crazyGamesSDK.isOnCrazyGames(); + // Steam identity is fixed for the session (no login/logout events like + // CrazyGames), so it's only used to seed the name once in connectedCallback. + private readonly onSteam = steamSDK.isOnSteam(); @property({ type: String }) validationError: string = ""; // Ownership-check feedback (i18n key) shown inline beneath the tag input. Only @@ -51,6 +55,13 @@ export class UsernameInput extends LitElement { private clanCheckGen = 0; private clanCheck: Promise = Promise.resolve(null); + // Resolves once the one-shot Steam name-seed has settled (or immediately for + // non-Steam players). The join flow awaits this before reading getUsername() + // so a fast join can't start the game under the generated anon name before + // the Steam persona lands. Always resolves — never rejects — so a failed or + // slow getUser() falls back to the generated name instead of blocking. + private steamSeedReady: Promise = Promise.resolve(); + // Remove static styles since we're using Tailwind createRenderRoot() { @@ -155,6 +166,15 @@ export class UsernameInput extends LitElement { return this.clanCheck; } + // Resolves once the one-shot Steam name-seed has settled (immediately for + // non-Steam players, or once nothing was left to seed). The join flow awaits + // this before reading getUsername() so a fast join reads the Steam persona + // rather than the interim generated anon name. Never blocks: the underlying + // chain always resolves, even when getUser() fails or the persona is invalid. + public whenSeeded(): Promise { + return this.steamSeedReady; + } + private startClanCheck() { const gen = ++this.clanCheckGen; const tag = this.clanTag; @@ -178,6 +198,10 @@ export class UsernameInput extends LitElement { connectedCallback() { super.connectedCallback(); + // Captured before loadStoredUsername(), which — when nothing is stored — + // fills in a fresh anon username AND persists it immediately. Checking + // localStorage afterwards would therefore never see it as empty. + const noStoredUsername = this.onSteam && !localStorage.getItem(usernameKey); this.loadStoredUsername(); // On CrazyGames the account username is applied here but never persisted // (see loadStoredUsername / validateAndStore), so logging out — which @@ -196,6 +220,36 @@ export class UsernameInput extends LitElement { this.validateAndStore(); } }); + // Seed the in-game name from the Steam persona, once, only when nothing + // is stored yet. Unlike CrazyGames, Steam persists normally (see + // validateAndStore's onCrazyGames guard), and there's no logout event to + // handle since the Steam identity is fixed for the session. + if (noStoredUsername) { + // The anon name loadStoredUsername() just generated. Only overwrite it if + // the player hasn't typed their own name while getUser() was in flight, + // so a late Steam result never clobbers a name they entered. + const generated = this.baseUsername; + // Store the seeding promise so the join path can await it (see + // whenSeeded). The chain never rejects — on any failure we keep the + // generated name — so awaiting it can only delay, never block, a join. + this.steamSeedReady = steamSDK + .getUser() + .then((user) => { + if (this.baseUsername !== generated) return; + // Steam personas can contain characters our usernames disallow (e.g. + // brackets) or exceed the length limit; strip brackets, trim, and only + // accept the persona if it validates — otherwise keep the generated + // name so the player can always start a game. + const candidate = user?.name?.replace(/[[\]]/g, "").trim(); + if (candidate && validateUsername(candidate).isValid) { + this.baseUsername = candidate; + this.validateAndStore(); + } + }) + .catch(() => { + // Swallow: keep the generated name so the player can always play. + }); + } } protected updated(): void { diff --git a/tests/Api.test.ts b/tests/Api.test.ts index b5bf87ebf..a469fe277 100644 --- a/tests/Api.test.ts +++ b/tests/Api.test.ts @@ -1,9 +1,32 @@ -import { beforeEach, describe, expect, it } from "vitest"; -import { getApiBase } from "../src/client/Api"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { getApiBase, getAudience } from "../src/client/Api"; +import { ClientEnv } from "../src/client/ClientEnv"; -describe("getApiBase", () => { +function setConfig(jwtAudience: string) { + (window as any).BOOTSTRAP_CONFIG = { + gameEnv: "prod", + numWorkers: 1, + turnstileSiteKey: "x", + jwtAudience, + instanceId: "desktop", + gitCommit: "test", + }; + ClientEnv.reset(); +} + +// setConfig sets window.BOOTSTRAP_CONFIG; clear it (and the cached env) after +// every test so a stray value can't leak into a later test. +afterEach(() => { + delete (window as any).BOOTSTRAP_CONFIG; + ClientEnv.reset(); +}); + +describe("getApiBase localhost fallback", () => { beforeEach(() => { localStorage.clear(); + // getAudience() now reads the audience from BOOTSTRAP_CONFIG, so the + // localhost branch is reached via jwtAudience "localhost". + setConfig("localhost"); }); // API_DOMAIN is forced empty under vitest via the vite.config `define`, so this @@ -13,3 +36,19 @@ describe("getApiBase", () => { expect(getApiBase()).toBe("http://localhost:8787"); }); }); + +describe("getAudience / getApiBase from BOOTSTRAP_CONFIG", () => { + beforeEach(() => ClientEnv.reset()); + + it("returns the configured audience (desktop staging)", () => { + setConfig("openfront.dev"); + expect(getAudience()).toBe("openfront.dev"); + expect(getApiBase()).toBe("https://api.openfront.dev"); + }); + + it("returns the configured audience (prod)", () => { + setConfig("openfront.io"); + expect(getAudience()).toBe("openfront.io"); + expect(getApiBase()).toBe("https://api.openfront.io"); + }); +}); diff --git a/tests/SteamSDK.test.ts b/tests/SteamSDK.test.ts new file mode 100644 index 000000000..9eed6dda0 --- /dev/null +++ b/tests/SteamSDK.test.ts @@ -0,0 +1,41 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { steamSDK } from "../src/client/SteamSDK"; + +beforeEach(() => { + delete (window as any).openfrontDesktop; +}); + +describe("SteamSDK", () => { + it("isOnSteam is false without the bridge", () => { + expect(steamSDK.isOnSteam()).toBe(false); + }); + it("isOnSteam true and passes through ticket/user with the bridge", async () => { + (window as any).openfrontDesktop = { + steam: { + getAuthTicket: vi.fn().mockResolvedValue("deadbeef"), + getUser: vi.fn().mockResolvedValue({ steamId: "77", name: "Ada" }), + }, + }; + expect(steamSDK.isOnSteam()).toBe(true); + expect(await steamSDK.getTicket()).toBe("deadbeef"); + expect(await steamSDK.getUser()).toEqual({ steamId: "77", name: "Ada" }); + }); + it("getTicket degrades to null when bridge rejects", async () => { + (window as any).openfrontDesktop = { + steam: { + getAuthTicket: vi.fn().mockRejectedValue(new Error("boom")), + getUser: vi.fn(), + }, + }; + expect(await steamSDK.getTicket()).toBeNull(); + }); + it("getUser degrades to null when bridge rejects", async () => { + (window as any).openfrontDesktop = { + steam: { + getAuthTicket: vi.fn(), + getUser: vi.fn().mockRejectedValue(new Error("boom")), + }, + }; + expect(await steamSDK.getUser()).toBeNull(); + }); +}); diff --git a/tests/client/Auth.steam.test.ts b/tests/client/Auth.steam.test.ts new file mode 100644 index 000000000..ffafa303f --- /dev/null +++ b/tests/client/Auth.steam.test.ts @@ -0,0 +1,71 @@ +import { UnsecuredJWT } from "jose"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { getAuthHeader, logOut } from "../../src/client/Auth"; +import { ClientEnv } from "../../src/client/ClientEnv"; +import { steamSDK } from "../../src/client/SteamSDK"; + +function setBootstrapConfig() { + (window as any).BOOTSTRAP_CONFIG = { + gameEnv: "prod", + numWorkers: 1, + turnstileSiteKey: "x", + jwtAudience: "openfront.dev", + instanceId: "d", + gitCommit: "t", + }; + ClientEnv.reset(); +} + +beforeEach(async () => { + setBootstrapConfig(); + await logOut(); + vi.restoreAllMocks(); +}); + +describe("Steam login", () => { + it("exchanges a Steam ticket for a session JWT via POST /auth/steam", async () => { + vi.spyOn(steamSDK, "isOnSteam").mockReturnValue(true); + vi.spyOn(steamSDK, "getTicket").mockResolvedValue("ticket123"); + + const jwt = new UnsecuredJWT({ + jti: "some-id", + sub: "AAAAAAAAAAAAAAAAAAAAAA", + iat: Math.floor(Date.now() / 1000), + iss: "https://api.openfront.dev", + aud: "openfront.dev", + exp: Math.floor(Date.now() / 1000) + 3600, + }).encode(); + + const fetchMock = vi.spyOn(globalThis, "fetch").mockResolvedValue( + new Response(JSON.stringify({ jwt, expiresIn: 900 }), { + status: 200, + }), + ); + + const header = await getAuthHeader(); + + expect(fetchMock).toHaveBeenCalledTimes(1); + const [url, init] = fetchMock.mock.calls[0]; + expect(String(url)).toContain("/auth/steam"); + expect(JSON.parse((init as RequestInit).body as string)).toEqual({ + ticket: "ticket123", + }); + expect(header).toBe(`Bearer ${jwt}`); + }); + + it("falls through to the guest/refresh flow when no Steam ticket is available", async () => { + vi.spyOn(steamSDK, "isOnSteam").mockReturnValue(true); + vi.spyOn(steamSDK, "getTicket").mockResolvedValue(null); + + const fetchMock = vi + .spyOn(globalThis, "fetch") + .mockResolvedValue(new Response(null, { status: 401 })); + + await getAuthHeader(); + + expect(fetchMock).toHaveBeenCalled(); + for (const call of fetchMock.mock.calls) { + expect(String(call[0])).not.toContain("/auth/steam"); + } + }); +}); diff --git a/tests/client/SteamLinkSignpost.test.ts b/tests/client/SteamLinkSignpost.test.ts new file mode 100644 index 000000000..6da943553 --- /dev/null +++ b/tests/client/SteamLinkSignpost.test.ts @@ -0,0 +1,24 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { SteamLinkSignpost } from "../../src/client/SteamLinkSignpost"; +import { steamSDK } from "../../src/client/SteamSDK"; + +beforeEach(() => { + localStorage.clear(); + vi.restoreAllMocks(); +}); + +describe("SteamLinkSignpost", () => { + it("is visible on first Steam launch, hidden after dismiss", () => { + vi.spyOn(steamSDK, "isOnSteam").mockReturnValue(true); + const el = new SteamLinkSignpost(); + expect(el.shouldShow()).toBe(true); + el.dismiss(); + expect(localStorage.getItem("steam_link_signpost_seen")).toBe("1"); + expect(el.shouldShow()).toBe(false); + }); + + it("never shows off Steam", () => { + vi.spyOn(steamSDK, "isOnSteam").mockReturnValue(false); + expect(new SteamLinkSignpost().shouldShow()).toBe(false); + }); +}); diff --git a/tests/client/UsernameInput.steam.test.ts b/tests/client/UsernameInput.steam.test.ts new file mode 100644 index 000000000..4f6dfe34b --- /dev/null +++ b/tests/client/UsernameInput.steam.test.ts @@ -0,0 +1,67 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { steamSDK } from "../../src/client/SteamSDK"; +import { UsernameInput } from "../../src/client/UsernameInput"; + +beforeEach(() => { + localStorage.clear(); + vi.restoreAllMocks(); +}); + +describe("UsernameInput Steam seeding", () => { + it("seeds and persists the Steam persona when nothing is stored", async () => { + vi.spyOn(steamSDK, "isOnSteam").mockReturnValue(true); + vi.spyOn(steamSDK, "getUser").mockResolvedValue({ + steamId: "77", + name: "Ada", + }); + const el = new UsernameInput(); + el.connectedCallback(); + await new Promise((r) => setTimeout(r, 0)); + expect(el.getUsername()).toBe("Ada"); + expect(localStorage.getItem("username")).toBe("Ada"); // usernameKey + }); + + it("keeps an already-stored username", async () => { + localStorage.setItem("username", "MyName"); + vi.spyOn(steamSDK, "isOnSteam").mockReturnValue(true); + vi.spyOn(steamSDK, "getUser").mockResolvedValue({ + steamId: "77", + name: "Ada", + }); + const el = new UsernameInput(); + el.connectedCallback(); + await new Promise((r) => setTimeout(r, 0)); + expect(el.getUsername()).toBe("MyName"); + }); + + it("strips brackets from the Steam persona", async () => { + vi.spyOn(steamSDK, "isOnSteam").mockReturnValue(true); + vi.spyOn(steamSDK, "getUser").mockResolvedValue({ + steamId: "77", + name: "[Ada]", + }); + const el = new UsernameInput(); + el.connectedCallback(); + await new Promise((r) => setTimeout(r, 0)); + expect(el.getUsername()).toBe("Ada"); + }); + + it("keeps a valid generated name when the persona is invalid", async () => { + vi.spyOn(steamSDK, "isOnSteam").mockReturnValue(true); + vi.spyOn(steamSDK, "getUser").mockResolvedValue({ + steamId: "77", + name: "x".repeat(100), + }); + const el = new UsernameInput(); + el.connectedCallback(); + // Captured synchronously, before the async getUser() seed resolves: this is + // the generated anon name loadStoredUsername() just produced. + const generated = el.getUsername(); + await new Promise((r) => setTimeout(r, 0)); + // The invalid persona must be rejected and the exact generated name kept — + // not merely replaced by some other valid name. + expect(el.getUsername()).toBe(generated); + expect(generated).not.toBe("x".repeat(100)); + expect(generated.length).toBeGreaterThan(0); + }); +}); diff --git a/tests/client/components/FeaturedStream.test.ts b/tests/client/components/FeaturedStream.test.ts index e49bbc41b..c47d49b4e 100644 --- a/tests/client/components/FeaturedStream.test.ts +++ b/tests/client/components/FeaturedStream.test.ts @@ -1,6 +1,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import featuredStream from "../../../resources/featured-stream.json"; import { getFeaturedStream } from "../../../src/client/Api"; +import { ClientEnv } from "../../../src/client/ClientEnv"; import { cornerFromCenter } from "../../../src/client/FeaturedStream"; import { FeaturedStreamSchema } from "../../../src/core/ApiSchemas"; @@ -65,10 +66,25 @@ describe("FeaturedStream", () => { beforeEach(() => { vi.unstubAllGlobals(); vi.spyOn(console, "warn").mockImplementation(() => {}); + // getApiBase() → getAudience() now reads the JWT audience from + // BOOTSTRAP_CONFIG (ClientEnv), so getFeaturedStream needs it present or + // it throws before fetch and silently falls back. The value is irrelevant + // here since fetch is stubbed; we only need a well-formed config. + (window as any).BOOTSTRAP_CONFIG = { + gameEnv: "prod", + numWorkers: 1, + turnstileSiteKey: "x", + jwtAudience: "openfront.io", + instanceId: "desktop", + gitCommit: "test", + }; + ClientEnv.reset(); }); afterEach(() => { vi.restoreAllMocks(); vi.unstubAllGlobals(); + delete (window as any).BOOTSTRAP_CONFIG; + ClientEnv.reset(); }); const stubFetch = (impl: () => unknown) =>