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
1 change: 1 addition & 0 deletions index.html
Original file line number Diff line number Diff line change
Expand Up @@ -309,6 +309,7 @@
</div>

<!-- Global Modals -->
<steam-link-modal></steam-link-modal>
</div>

<!-- Game components -->
Expand Down
22 changes: 22 additions & 0 deletions resources/lang/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@
"get_magic_link": "Get Magic Link",
"google_alt": "Google",
"link_discord": "Link Discord Account",
"link_existing_account": "Link an existing account",
"link_google": "Link Google Account",
"link_google_already_linked": "That Google account is already linked to another player.",
"link_google_error": "Couldn't link your Google account. Please make sure you're signed in and try again.",
Expand Down Expand Up @@ -1444,6 +1445,27 @@
"toggle_achievements": "Toggle achievements",
"water_nukes": "Water nukes"
},
"steam_link_modal": {
"code_placeholder": "XXXX-XXXX",
"code_prompt": "Enter the code shown on your Steam desktop app.",
"code_submit": "Continue",
"confirm": "Link Account",
"confirm_prompt": "Link Steam {persona} with account {username}?",
"confirm_prompt_no_persona": "Link your Steam account with account {username}?",
"invalid_code": "That doesn't look like a valid code. Check it and try again.",
"linking": "Linking…",
"load_error": "Couldn't load your account details. Please try again from Steam.",
"load_error_code": "Couldn't load your account details. Please try again.",
"loading_placeholder": "…",
"reason_account_already_has_steam": "Your account is already linked to a different Steam account.",
"reason_expired": "This link has expired. Please try again from Steam.",
"reason_failed": "Something went wrong. Please try again.",
"reason_rate_limited": "Too many attempts. Please wait {seconds, plural, =0 {a moment} one {# second} other {# seconds}} and try again.",
"reason_steam_has_progress": "This Steam account already has its own progress and can't be merged.",
"reason_steam_linked_elsewhere": "This Steam account is already linked to a different OpenFront account.",
"success": "Your Steam account is now linked.",
"title": "Link Steam Account"
},
"steam_user_header": {
"avatar_alt": "Steam avatar",
"default_name": "Steam player"
Expand Down
55 changes: 54 additions & 1 deletion src/client/AccountModal.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,26 @@ import { crazyGamesSDK, type CrazyGamesUser } from "./CrazyGamesSDK";
import { playerProfileUrl } from "./PlayerProfileModal";
import { translateText } from "./Utils";

// window.openfrontDesktop is declared `unknown` by DesktopShell.ts (kept loose
// there on purpose). We know the one function we need, so narrow it locally
// rather than re-declaring the global (a second `declare global` with a
// different type triggers TS2717) — mirrors SteamSDK.ts's steamBridge().
//
// Guard on `showLinkGate` specifically, the function actually invoked below —
// not on a sibling property like `linkGate` (a separate namespace used by the
// gate page itself) — so a rename of one can't silently leave this button
// wired to nothing.
function desktopLinkGateBridge():
| { showLinkGate: () => Promise<void> }
| undefined {
const desktop = window.openfrontDesktop as
| { showLinkGate?: unknown }
| undefined;
return typeof desktop?.showLinkGate === "function"
? (desktop as { showLinkGate: () => Promise<void> })
: undefined;
}

@customElement("account-modal")
export class AccountModal extends BaseModal {
protected routerName = "account";
Expand Down Expand Up @@ -354,11 +374,44 @@ export class AccountModal extends BaseModal {
</div>
</div>
${this.renderUsernamePanel()} ${this.renderRewardsPanel()}
${this.renderSubscriptionPanel()}
${this.renderSubscriptionPanel()} ${this.renderDesktopLinkGateAction()}
</div>
`;
}

// Re-entry to the desktop shell's account-linking gate shown at first
// launch. Absent entirely on plain web (no window.openfrontDesktop there),
// present whenever the desktop bridge exposes a callable showLinkGate —
// see desktopLinkGateBridge() above for why the guard is scoped that way.
// Needed because the desktop app's menu bar will eventually be hidden and
// the game runs fullscreen borderless, so a dismissed or since-linked
// player needs another way back to that gate.
private renderDesktopLinkGateAction(): TemplateResult | typeof nothing {
if (!desktopLinkGateBridge()) return nothing;
return html`
<o-button
variant="secondary"
width="block"
size="md"
translationKey="account_modal.link_existing_account"
@click=${this.handleShowLinkGate}
></o-button>
`;
}

private handleShowLinkGate(): void {
// The bare `void` form swallowed a rejection into an unhandled promise.
// This is an IPC round trip to the Electron main process, so it can
// genuinely reject (no window, a main-process throw); catching keeps the
// failure visible in the console instead of surfacing as a button that
// silently does nothing.
desktopLinkGateBridge()
?.showLinkGate()
.catch((err) => {
console.error("AccountModal: showLinkGate failed", err);
});
}

// CrazyGames "connected as" view: avatar + username from the SDK, plus
// currency/subscription. No Discord/Google/email link or logout (CrazyGames
// owns the account and its logout).
Expand Down
55 changes: 55 additions & 0 deletions src/client/Main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,13 @@ import "./NewsModal";
import "./PlayerProfileModal";
import { RewardsModal } from "./RewardsModal";
import "./SinglePlayerModal";
import {
isSteamLinkHash,
parseSteamLinkToken,
resumePendingSteamLink,
} from "./SteamLink";
import "./SteamLinkModal";
import { SteamLinkModal } from "./SteamLinkModal";
import { StoreModal } from "./Store";
import { TokenLoginModal } from "./TokenLoginModal";
import {
Expand Down Expand Up @@ -174,6 +181,7 @@ class Client {
private tokenLoginModal: TokenLoginModal;
private matchmakingModal: MatchmakingModal;
private rewardsModal: RewardsModal;
private steamLinkModal: SteamLinkModal;
private mostRecentJoinEvent: number;

private turnstileTokenPromise: Promise<{
Expand Down Expand Up @@ -404,6 +412,16 @@ class Client {
console.warn("Rewards modal element not found");
}

this.steamLinkModal = document.querySelector(
"steam-link-modal",
) as SteamLinkModal;
if (
!this.steamLinkModal ||
!(this.steamLinkModal instanceof SteamLinkModal)
) {
console.warn("Steam link modal element not found");
}

const onUserMe = async (userMeResponse: UserMeResponse | false) => {
if (crazyGamesSDK.isOnCrazyGames()) {
void updateCrazyGamesNavButton();
Expand Down Expand Up @@ -445,6 +463,19 @@ class Client {
"Sharing this ID will allow others to view your game history and stats.",
);

// Resume a Steam-link flow that was interrupted by a login redirect
// (Discord/Google OAuth, magic link): the modal stashed either a
// token or a bare code-entry intent and sent the player to log in
// via #modal=account, so a login redirect commonly lands back there
// rather than on a clean "/" — this must NOT be gated on
// cleanHomepage below. Only resume once login is confirmed:
// resumePendingSteamLink() consumes the stash on read, so a
// speculative call while logged out would burn an entry that a
// *later* successful login should still get to resume.
if (resumePendingSteamLink(this.steamLinkModal)) {
return;
}

// Popups below only on a clean homepage load, never over a deep link
// (join URL, #modal=..., #purchase-completed, ...).
const cleanHomepage =
Expand Down Expand Up @@ -750,6 +781,29 @@ class Client {
return;
}

// The desktop Electron shell's account-linking gate opens the browser
// here (see SteamLink.ts for the full handoff). Checked against the raw
// hash, not decodedHash — parseSteamLinkToken's prefix match is exact
// and the token itself is opaque, so no decoding is needed or expected.
const steamLinkToken = parseSteamLinkToken(hash);
if (steamLinkToken) {
strip();
void this.steamLinkModal?.openWithToken(steamLinkToken);
return;
}

// Fallback: the gate's browser handoff itself can fail (wrong default
// browser, an odd Linux setup, Steam's overlay browser), in which case it
// shows an 8-character code instead and tells the player to enter it on
// the website. There's no token in that case, so parseSteamLinkToken
// above returns null — this is the bare `#steam-link` hash the code path
// lands on instead (see SteamLink.ts's isSteamLinkHash).
if (isSteamLinkHash(hash)) {
strip();
void this.steamLinkModal?.openForCodeEntry();
return;
}

const pathMatch = window.location.pathname.match(
/^\/(?:w\d+\/)?game\/([^/]+)/,
);
Expand Down Expand Up @@ -911,6 +965,7 @@ class Client {
"account-button",
"leaderboard-button",
"token-login",
"steam-link-modal",
"matchmaking-modal",
"clan-modal",
"lang-selector",
Expand Down
Loading
Loading