Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 4 additions & 2 deletions index.html
Original file line number Diff line number Diff line change
Expand Up @@ -208,9 +208,11 @@
};
// Set here rather than from the bundle so the first paint already has the
// desktop layout. The shell's preload defines openfrontDesktop before
// any page script runs. ?desktop-layout previews it outside prod.
// any page script runs. Steam only (isSteamShell): the Android app
// defines it too, and lays out the way the site does in a mobile browser.
// ?desktop-layout previews it outside prod.
if (
window.openfrontDesktop ||
(window.openfrontDesktop && window.openfrontDesktop.steam) ||
(window.BOOTSTRAP_CONFIG.gameEnv !== "prod" &&
new URLSearchParams(location.search).has("desktop-layout"))
) {
Expand Down
14 changes: 7 additions & 7 deletions src/client/AccountModal.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@ import type { RewardsChangedDetail } from "./components/RewardsPanel";
import { googleLinkButton } from "./components/ui/GoogleLinkButton";
import { modalHeader } from "./components/ui/ModalHeader";
import { steamGlyph, steamLinkButton } from "./components/ui/SteamLinkButton";
import { desktopLinkGate, isDesktopShell } from "./DesktopShell";
import { desktopLinkGate, isSteamShell } from "./DesktopShell";
import { gamePortal, type PortalUser } from "./GamePortal";
import "./GameStatsModal";
import { showInGameAlert } from "./InGameModal";
Expand Down Expand Up @@ -862,7 +862,7 @@ export class AccountModal extends BaseModal {
this.handleLinkGoogle,
// The shell sends the player to the website for this (see linkGoogle
// in Auth.ts); the caption has to say so.
isDesktopShell()
isSteamShell()
? "account_modal.link_google_on_web"
: "account_modal.link_google",
);
Expand All @@ -880,7 +880,7 @@ export class AccountModal extends BaseModal {
// Not shown inside the desktop shell: a shell player already holds this
// identity through the native Steam ticket.
private renderSteamLink(): TemplateResult {
if (isDesktopShell()) return html``;
if (isSteamShell()) return html``;
const steam = this.userMeResponse?.user?.steam;
if (steam) {
// The attached account is NAMED, not merely reported as linked: a wrong
Expand Down Expand Up @@ -1012,14 +1012,14 @@ export class AccountModal extends BaseModal {
}

private renderLoginOptions() {
// On the desktop shell both provider buttons open the shell's browser
// In the Steam shell both provider buttons open the shell's browser
// link flow rather than an in-place OAuth redirect (see discordLogin /
// googleLogin in Auth.ts), and the captions say so. Keyed on the shell
// itself, not on the link-flow bridge: Auth.ts never builds the redirect
// on ANY desktop shell (a shell too old to expose showLinkGate gets an
// on ANY Steam shell (a shell too old to expose showLinkGate gets an
// update prompt instead), so the web caption would be wrong on every one
// of them.
const viaBrowser = isDesktopShell();
// of them. The Android shell signs in as the web does.
const viaBrowser = isSteamShell();
return html`
<div class="flex items-center justify-center p-6 min-h-full">
<div
Expand Down
13 changes: 7 additions & 6 deletions src/client/Auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import { z } from "zod";
import { getApiBase, getAudience } from "./Api";
import { ClientEnv } from "./ClientEnv";
import type { DesktopSessionState, SessionFailureKind } from "./DesktopShell";
import { desktopLinkGate, isDesktopShell } from "./DesktopShell";
import { desktopLinkGate, isSteamShell } from "./DesktopShell";
import { gamePortal, type GamePortalName } from "./GamePortal";
import { showInGameAlert } from "./InGameModal";
import type { SteamTicketResult } from "./SteamSDK";
Expand Down Expand Up @@ -65,9 +65,10 @@ function setSessionState(state: DesktopSessionState): void {
// server refuses that as `steam_has_progress`, and the website says so);
// that is the same rule the first-launch gate lives under.
//
// Returns true whenever this is the desktop shell at all -- the caller must
// not build the redirect there under any circumstances -- and false only on
// the web, where the redirect is the right thing.
// Returns true whenever this is the Steam shell at all -- the caller must
// not build the redirect there under any circumstances -- and false on the
// web and in the Android shell, which is served at the site's own origin, so
// the redirect comes back to it as it does in a browser.
//
// A shell that exists but has no callable showLinkGate is a real case, not a
// hypothetical: this client updates at runtime while the shell ships in the
Expand All @@ -86,7 +87,7 @@ function startDesktopLinkFlow(): boolean {
});
return true;
}
if (isDesktopShell()) {
if (isSteamShell()) {
void showInGameAlert(
translateText("account_modal.desktop_login_needs_update"),
);
Expand Down Expand Up @@ -148,7 +149,7 @@ function desktopWebAccountSettingsUrl(): string {
// the same button runs the real OAuth flow. The player signs in there with the
// account they use here; the copy on the button says as much.
export async function linkGoogle(): Promise<boolean> {
if (isDesktopShell()) {
if (isSteamShell()) {
// Routed to the system browser by the shell's window-open policy
// (openfront-desktop's navigationPolicy.ts), like every https link.
window.open(
Expand Down
9 changes: 5 additions & 4 deletions src/client/ClientPlatform.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,14 +2,15 @@ import type {
ClientDevice,
ClientPlatform,
} from "@openfront/shared/WireSchemas";
import { isDesktopShell } from "./DesktopShell";
import { isSteamShell } from "./DesktopShell";
import { gamePortal } from "./GamePortal";
import { Platform } from "./Platform";

// The desktop shell is only distributed through Steam, so the shell itself
// (not a working Steam client, which may be absent) is the signal.
// The Steam shell's bridge carries `steam` whether or not a Steam client is
// running, so the shell itself (not a working Steam client, which may be
// absent) is the signal. The Android shell has no `steam` and reports "web".
export function clientPlatform(): ClientPlatform {
if (isDesktopShell()) return "steam";
if (isSteamShell()) return "steam";
if (typeof window !== "undefined") {
const portal = gamePortal.name();
if (portal !== null) return portal;
Expand Down
12 changes: 10 additions & 2 deletions src/client/DesktopPresence.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,11 @@
// Thin renderer wrapper over the desktop shell's presence bridge. Mirrors
// SteamSDK.ts; all Steam-specific work lives in the Electron main process.
//
// This module deliberately knows NOTHING about Steam. It reports game state;
// the shell decides what Steam and Discord make of it.
// This module deliberately knows NOTHING about Steam beyond running only in
// the Steam shell (see isAvailable). It reports game state; the shell decides
// what Steam and Discord make of it.

import { isSteamShell } from "./DesktopShell";

export type PresenceState = "menu" | "lobby" | "game" | "spectating";

Expand Down Expand Up @@ -51,7 +54,12 @@ class DesktopPresence {
// be present" instead of "call what the shell declares it supports". A shell
// that ever exposed half the surface without bumping api would be silently
// half-driven; this refuses it instead.
//
// Steam only, as well. shell.api is one number space shared with the
// Android shell, which is past 2 but has no Steam presence or invites, so
// the level alone would show it an Invite Friends button that does nothing.
isAvailable(): boolean {
if (!isSteamShell()) return false;
const api = bridge()?.shell?.api;
return typeof api === "number" && api >= 2;
}
Expand Down
10 changes: 5 additions & 5 deletions src/client/DesktopSessionRecovery.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { getDesktopSessionState } from "./Auth";
import { isDesktopShell, type SessionFailureKind } from "./DesktopShell";
import { isSteamShell, type SessionFailureKind } from "./DesktopShell";
import {
backendReachable,
retryServerList,
Expand Down Expand Up @@ -65,12 +65,12 @@ function worthAutoRetrying(): boolean {
*
* The manual `desktop-session-retry` listener is registered on EVERY boot,
* desktop or web. The status bar dispatches that event and nothing else
* listens for it, so gating it behind `isDesktopShell()` would leave the
* listens for it, so gating it behind `isSteamShell()` would leave the
* event with no handler at all on a web boot.
*
* Only the automatic triggers — `online` and `backend-reachability` — are
* desktop-only, because only the desktop shell has a Steam session to
* recover.
* Steam-only, because only the Steam shell has a Steam session to recover.
* The Android shell signs in the way the web does.
*
* @param signIn Re-runs Steam sign-in and refreshes the profile.
* @returns A cleanup function that removes every listener registered here.
Expand Down Expand Up @@ -98,7 +98,7 @@ export function subscribeDesktopSessionRecovery(
const onRetry = () => void retry(true);
document.addEventListener("desktop-session-retry", onRetry);

if (!isDesktopShell()) {
if (!isSteamShell()) {
return () => {
document.removeEventListener("desktop-session-retry", onRetry);
};
Expand Down
33 changes: 28 additions & 5 deletions src/client/DesktopShell.ts
Original file line number Diff line number Diff line change
@@ -1,17 +1,40 @@
// The Electron desktop (Steam) shell exposes this global via a contextBridge
// preload script — see openfront-desktop's src/preload/preload.ts. Its mere
// presence is a reliable signal we're running inside that shell, since only
// the desktop build's preload script ever sets it.
// preload script — see openfront-desktop's src/preload/preload.ts. The Android
// app defines it too, from its own shell script. Its mere presence is a
// reliable signal we're running inside one of those shells, since nothing
// else ever sets it; isSteamShell() below says which.
declare global {
interface Window {
openfrontDesktop?: unknown;
}
}

// True inside ANY native app shell: the Electron (Steam) shell, and the
// Android app, which defines the same global with a smaller surface (no
// `steam`, `linkGate`, `presence`, `invite`, `display` or `quit`). Use it for
// what every shell shares -- no ads, the shell's own updater, a document the
// shell serves -- and isSteamShell() below for anything that goes through
// Steam.
export function isDesktopShell(): boolean {
return typeof window !== "undefined" && window.openfrontDesktop !== undefined;
}

/**
* True inside the Steam (Electron) shell only.
*
* The Steam preload exposes the `steam` namespace unconditionally, whether or
* not a Steam client is running, so on Steam this is exactly isDesktopShell().
* The Android shell has no `steam` key, which is what tells the two apart: it
* signs in, pays and shows presence the way the web does, not through Steam.
*
* The same test SteamSDK.isOnSteam() and Payments' steamBridge() make.
*/
export function isSteamShell(): boolean {
if (typeof window === "undefined") return false;
const desktop = window.openfrontDesktop as { steam?: unknown } | undefined;
return desktop?.steam !== undefined;
}

// The shell's own version, distinct from the game version this client was
// built from. Narrowed locally rather than re-declaring the global -- see
// SteamSDK.ts for why (TS2717).
Expand Down Expand Up @@ -374,8 +397,8 @@ export interface DesktopSessionState {
*
* So every signed-out reason gates, including the ones the player cannot fix.
*
* A null state means no desktop shell -- the web build -- where a logged-out
* player passes Turnstile normally and must not be gated.
* A null state means no Steam shell -- the web build, or the Android shell --
* where a logged-out player passes Turnstile normally and must not be gated.
*/
export function multiplayerAllowedForSession(
state: DesktopSessionState | null,
Expand Down
17 changes: 8 additions & 9 deletions src/client/GameModeSelector.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import {
import {
getDesktopUpdateState,
isDesktopShell,
isSteamShell,
multiplayerAllowed,
multiplayerAllowedForSession,
type DesktopSessionState,
Expand Down Expand Up @@ -405,15 +406,13 @@ export class GameModeSelector extends LitElement {
// play-page renders this after Main may already have broadcast.
const last = lastUserMeResponse();
if (last !== null) this.applyUserMe(last.response);
if (isDesktopShell()) {
// Seed BOTH from their current values. This element is rendered by
// <play-page> on a Lit microtask, so it cannot exist yet when the status
// bar dispatches the update bridge's synchronous replay -- without the
// seed the update half of the gate stays null and silently never
// applies (OPE-396).
this.desktopUpdateState = getDesktopUpdateState();
this.desktopSessionState = getDesktopSessionState();
}
// Seed BOTH from their current values. This element is rendered by
// <play-page> on a Lit microtask, so it cannot exist yet when the status
// bar dispatches the update bridge's synchronous replay -- without the
// seed the update half of the gate stays null and silently never applies
// (OPE-396). The session is the Steam sign-in, so only Steam seeds it.
if (isDesktopShell()) this.desktopUpdateState = getDesktopUpdateState();
if (isSteamShell()) this.desktopSessionState = getDesktopSessionState();
// After the session seed above: a shell that already knows it has no
// session must not open a feed it will close on the next tick.
this.start();
Expand Down
28 changes: 16 additions & 12 deletions src/client/Main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,7 @@ import {
desktopUpdate,
getDesktopUpdateState,
isDesktopShell,
isSteamShell,
type DesktopUpdateState,
} from "./DesktopShell";
import "./FeaturedStream";
Expand Down Expand Up @@ -562,14 +563,16 @@ class Client {
startServerListPolling();

// Prefetch turnstile token so it is available when the user joins a lobby.
// Desktop (Steam) has no Turnstile script and is server-side exempt, so
// skip it — otherwise getTurnstileToken() throws "Failed to load Turnstile
// script" after its load wait. Also skip on the versioned replay shells:
// the replay host may not be on the Turnstile site key's domain allowlist,
// so rendering the widget there just fails — and replays never
// send a token anyway (see getTurnstileToken below).
// Steam has no Turnstile script and is server-side exempt (a
// provider="steam" JWT, see planJoinVerify), so skip it — otherwise
// getTurnstileToken() throws "Failed to load Turnstile script" after its
// load wait. Not every shell: the Android app has no Steam session, so the
// server wants a token from it like any browser. Also skip on the
// versioned replay shells: the replay host may not be on the Turnstile
// site key's domain allowlist, so rendering the widget there just fails —
// and replays never send a token anyway (see getTurnstileToken below).
const turnstilePrefetch =
isDesktopShell() || isReplayShellHost(window.location.hostname)
isSteamShell() || isReplayShellHost(window.location.hostname)
? null
: getTurnstileToken();
// A prefetch that fails is not an error anyone has asked about yet: the
Expand Down Expand Up @@ -951,7 +954,7 @@ class Client {
);
const steamNotice = steamNoticeDue(
userMeResponse,
isDesktopShell(),
isSteamShell(),
Date.now(),
);
const paid = paidFactsOf(userMeResponse);
Expand Down Expand Up @@ -1766,12 +1769,13 @@ class Client {
* do.
*/
private blockedJoin(lobby: JoinLobbyEvent): boolean {
const desktop = isDesktopShell();
// The update half applies in every shell; the session half is the Steam
// sign-in, which only the Steam shell has.
if (
!shouldBlockJoin(
lobby,
desktop ? this.desktopUpdateState : null,
desktop ? getDesktopSessionState() : null,
isDesktopShell() ? this.desktopUpdateState : null,
isSteamShell() ? getDesktopSessionState() : null,
)
) {
return false;
Expand Down Expand Up @@ -2343,7 +2347,7 @@ class Client {
): Promise<string | null> {
if (
ClientEnv.env() === GameEnv.Dev ||
isDesktopShell() ||
isSteamShell() ||
// Single-player and replays: no server to verify a token against (and
// on the CDN replay shells Turnstile cannot load at all). Shared with
// the desktop gate so the exemption has one definition.
Expand Down
4 changes: 2 additions & 2 deletions src/client/components/AccountSettingsPanel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ import {
type SetVisibilityResult,
} from "../Api";
import { clearLocalSession, linkGoogle, sendMagicLink } from "../Auth";
import { isDesktopShell } from "../DesktopShell";
import { isSteamShell } from "../DesktopShell";
import { gamePortal } from "../GamePortal";
import { showInGameAlert } from "../InGameModal";
import { refreshOwnHiddenLevelBadge } from "../OwnLevelBadge";
Expand Down Expand Up @@ -321,7 +321,7 @@ export class AccountSettingsPanel extends LitElement {
this.handleLinkGoogle,
// The shell sends the player to the website for this (see
// linkGoogle in Auth.ts); the caption has to say so.
isDesktopShell()
isSteamShell()
? "account_modal.link_google_on_web"
: "account_modal.link_google",
)
Expand Down
3 changes: 2 additions & 1 deletion src/client/components/DesktopStatusBar.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import {
desktopLinkGate,
desktopUpdate,
isDesktopShell,
isSteamShell,
multiplayerAllowedForSession,
publishDesktopUpdateState,
type DesktopSessionState,
Expand Down Expand Up @@ -217,7 +218,7 @@ export class DesktopStatusBar extends LitElement {

// Seed from the current value: Auth publishes its first transition during
// startup, quite possibly before this element upgrades.
if (isDesktopShell()) this.sessionState = getDesktopSessionState();
if (isSteamShell()) this.sessionState = getDesktopSessionState();
document.addEventListener("desktop-session-state", this.onSessionState);
document.addEventListener("join-lobby", this.onJoinLobby);
document.addEventListener("leave-lobby", this.onLeaveLobby);
Expand Down
14 changes: 7 additions & 7 deletions src/client/components/DetailedGameViewModal.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { getDesktopSessionState } from "../Auth";
import {
getDesktopUpdateState,
isDesktopShell,
isSteamShell,
type DesktopSessionState,
type DesktopUpdateState,
} from "../DesktopShell";
Expand Down Expand Up @@ -214,13 +215,12 @@ export class DetailedGameViewModal extends BaseModal {
// went out.
const last = lastUserMeResponse();
if (last !== null) this.applyUserMe(last.response);
if (isDesktopShell()) {
// Seed BOTH from their current values -- this modal mounts well after
// the update bridge's synchronous replay has already been dispatched
// and lost, so without the seed its join() gate sees null (OPE-396).
this.desktopUpdateState = getDesktopUpdateState();
this.desktopSessionState = getDesktopSessionState();
}
// Seed BOTH from their current values -- this modal mounts well after
// the update bridge's synchronous replay has already been dispatched and
// lost, so without the seed its join() gate sees null (OPE-396). The
// session is the Steam sign-in, so only Steam seeds it.
if (isDesktopShell()) this.desktopUpdateState = getDesktopUpdateState();
if (isSteamShell()) this.desktopSessionState = getDesktopSessionState();
document.addEventListener(
"desktop-session-state",
this.onDesktopSessionState,
Expand Down
Loading
Loading