refactor: consolidate platform detection across client components (#3325)

## Description:

This PR consolidates ad hoc platform/environment/viewport detection into
a single shared utility. It is scoped to this refactor only, and serves
as groundwork for the mobile-focused feature work planned for the v31
milestone.

### What changed
- Introduced a shared `Platform` utility centralising:
  - OS detection (with `userAgentData` + UA fallback)
  - Electron environment detection
- Viewport breakpoint helpers (`isMobileWidth`, `isTabletWidth`,
`isDesktopWidth`)
- Replaced duplicated inline checks across client files with the shared
API.
- Normalised Mac detection to derive from the consolidated OS logic
rather than a separate regex.

### Why
- Multiple client files each independently ran `navigator.userAgent`
regexes or copy-pasted `isElectron` logic — this unifies all of that.
- Puts a stable, tested abstraction in place before v31 mobile work
lands, so mobile feature branches have a consistent surface to build
against.

## Please complete the following:

- [x] I have added screenshots for all UI updates (N/A: refactor only,
no visible UI changes)
- [x] I process any text displayed to the user through translateText()
and I've added it to the en.json file (N/A: no new user-facing strings)
- [x] I have added relevant tests to the test directory (N/A: refactor
only)
- [x] I confirm I have thoroughly tested these changes and take full
responsibility for any bugs introduced

## Please put your Discord username so you can be contacted if a bug or
regression is found:

skigim
This commit is contained in:
Skigim
2026-03-02 12:12:48 -06:00
committed by GitHub
parent 15f4f5e20a
commit f7598369ed
14 changed files with 283 additions and 68 deletions
+5
View File
@@ -13,3 +13,8 @@ CLAUDE.md
.idea/
# this is autogenerated by script
src/assets/
# Debug / Log Outputs
*.log
*debug*.txt
eslint_out.txt
+3 -1
View File
@@ -1,3 +1,5 @@
{
"html.validate.scripts": false
"html.validate.scripts": false,
"tailwindCSS.lint.suggestCanonicalClasses": "ignore",
"lit-plugin.rules.suggestCanonicalClasses": "off"
}
+6 -34
View File
@@ -1,5 +1,6 @@
import { LitElement, html } from "lit";
import { customElement, property } from "lit/decorators.js";
import { Platform } from "./Platform";
declare global {
interface Window {
@@ -28,7 +29,7 @@ export class GoogleAdElement extends LitElement {
}
render() {
if (isElectron()) {
if (Platform.isElectron) {
return html``;
}
return html`
@@ -48,6 +49,10 @@ export class GoogleAdElement extends LitElement {
connectedCallback() {
super.connectedCallback();
if (Platform.isElectron) {
return;
}
// Wait for the component to be fully rendered
setTimeout(() => {
try {
@@ -61,37 +66,4 @@ export class GoogleAdElement extends LitElement {
}
}
// Check if running in Electron
const isElectron = () => {
// Renderer process
if (
window !== undefined &&
typeof window.process === "object" &&
// @ts-expect-error hidden
window.process.type === "renderer"
) {
return true;
}
// Main process
if (
process !== undefined &&
typeof process.versions === "object" &&
!!process.versions.electron
) {
return true;
}
// Detect the user agent when the `nodeIntegration` option is set to false
if (
typeof navigator === "object" &&
typeof navigator.userAgent === "string" &&
navigator.userAgent.indexOf("Electron") >= 0
) {
return true;
}
return false;
};
export default GoogleAdElement;
+2 -1
View File
@@ -4,6 +4,7 @@ import { translateText, TUTORIAL_VIDEO_URL } from "../client/Utils";
import { BaseModal } from "./components/BaseModal";
import "./components/Difficulties";
import { modalHeader } from "./components/ui/ModalHeader";
import { Platform } from "./Platform";
import { TroubleshootingModal } from "./TroubleshootingModal";
@customElement("help-modal")
@@ -39,7 +40,7 @@ export class HelpModal extends BaseModal {
console.warn("Invalid keybinds JSON:", e);
}
const isMac = /Mac/.test(navigator.userAgent);
const isMac = Platform.isMac;
return {
toggleView: "Space",
coordinateGrid: "KeyM",
+2 -1
View File
@@ -3,6 +3,7 @@ import { UnitType } from "../core/game/Game";
import { UnitView } from "../core/game/GameView";
import { UserSettings } from "../core/game/UserSettings";
import { UIState } from "./graphics/UIState";
import { Platform } from "./Platform";
import { ReplaySpeedMultiplier } from "./utilities/ReplaySpeedMultiplier";
export class MouseUpEvent implements GameEvent {
@@ -202,7 +203,7 @@ export class InputHandler {
}
// Mac users might have different keybinds
const isMac = /Mac/.test(navigator.userAgent);
const isMac = Platform.isMac;
this.keybinds = {
toggleView: "Space",
+5 -3
View File
@@ -1,3 +1,5 @@
import { Platform } from "./Platform";
export function initLayout() {
// Wait for play-page component to render before setting up hamburger menu
customElements.whenDefined("play-page").then(() => {
@@ -6,7 +8,7 @@ export function initLayout() {
const backdrop = document.getElementById("mobile-menu-backdrop");
// Force sidebar visibility style to ensure it's not hidden by other CSS
if (sidebar && window.innerWidth < 768) {
if (sidebar && Platform.isMobileWidth) {
sidebar.style.display = "flex";
}
@@ -59,7 +61,7 @@ export function initLayout() {
// Close menu when clicking a menu link or button (Mobile only)
sidebar.addEventListener("click", (e) => {
// On desktop, we want the menu to stay open unless explicitly toggled
if (window.innerWidth >= 768) return;
if (!Platform.isMobileWidth) return;
// If the click happened on or inside an anchor/button/menu item, close the menu
const clickedElement = (e.target as Element).closest
@@ -75,7 +77,7 @@ export function initLayout() {
// Close on Escape (Mobile only)
document.addEventListener("keydown", (e) => {
if (window.innerWidth >= 768) return;
if (!Platform.isMobileWidth) return;
if (e.key === "Escape" && sidebar.classList.contains("open")) {
closeMenu();
}
+112
View File
@@ -0,0 +1,112 @@
export const Platform = (() => {
const isBrowser =
typeof window !== "undefined" && typeof navigator !== "undefined";
const normalizePlatform = (platform: string): string => {
const normalized = platform.toLowerCase();
if (normalized.includes("windows")) return "Windows";
if (
normalized.includes("iphone") ||
normalized.includes("ipad") ||
normalized.includes("ipod") ||
normalized.includes("ios")
) {
return "iOS";
}
if (
normalized.includes("mac") ||
normalized.includes("macintosh") ||
normalized.includes("macos")
) {
return "macOS";
}
if (normalized.includes("android")) return "Android";
if (normalized.includes("chrome os")) return "Linux";
if (normalized.includes("linux")) return "Linux";
return "Unknown";
};
// OS Extraction
const extractOS = (): string => {
if (!isBrowser) return "Unknown";
const uaData = (navigator as any).userAgentData;
if (uaData?.platform) {
return normalizePlatform(uaData.platform);
}
const ua = navigator.userAgent;
if (/windows nt/i.test(ua)) return "Windows";
if (/iphone|ipad|ipod/i.test(ua)) return "iOS";
if (
/mac os x/i.test(ua) &&
((navigator.maxTouchPoints ?? 0) > 1 || /ipad/i.test(ua))
) {
return "iOS";
}
if (/mac os x/i.test(ua)) return "macOS";
if (/android/i.test(ua)) return "Android";
if (/linux/i.test(ua)) return "Linux";
return "Unknown";
};
const currentOS = extractOS();
// Environment Extraction
const performElectronCheck = (): boolean => {
// Renderer process
if (
typeof window !== "undefined" &&
typeof (window as any).process === "object" &&
(window as any).process.type === "renderer"
) {
return true;
}
// Main process
if (
typeof process !== "undefined" &&
typeof process.versions === "object" &&
!!process.versions.electron
) {
return true;
}
// Detect the user agent when the `nodeIntegration` option is set to false
if (
isBrowser &&
typeof navigator.userAgent === "string" &&
navigator.userAgent.indexOf("Electron") >= 0
) {
return true;
}
return false;
};
const isMac = currentOS === "macOS";
return {
os: currentOS,
isMac,
isWindows: currentOS === "Windows",
isIOS: currentOS === "iOS",
isAndroid: currentOS === "Android",
isLinux: currentOS === "Linux",
isElectron: performElectronCheck(),
get isMobileWidth(): boolean {
return isBrowser ? window.innerWidth < 768 : false;
},
get isTabletWidth(): boolean {
return isBrowser
? window.innerWidth >= 768 && window.innerWidth < 1024
: false;
},
get isDesktopWidth(): boolean {
return isBrowser ? window.innerWidth >= 1024 : false;
},
};
})();
+2 -2
View File
@@ -11,14 +11,14 @@ import "./components/baseComponents/setting/SettingToggle";
import { BaseModal } from "./components/BaseModal";
import { modalHeader } from "./components/ui/ModalHeader";
import "./FlagInputModal";
import { Platform } from "./Platform";
interface FlagInputModalElement extends HTMLElement {
open(): void;
returnTo?: string;
}
const isMac =
typeof navigator !== "undefined" && /Mac/.test(navigator.userAgent);
const isMac = Platform.isMac;
const DefaultKeybinds: Record<string, string> = {
toggleView: "Space",
+3 -12
View File
@@ -10,6 +10,7 @@ import {
} from "../core/game/Game";
import { GameConfig } from "../core/Schemas";
import type { LangSelector } from "./LangSelector";
import { Platform } from "./Platform";
export const TUTORIAL_VIDEO_URL = "https://www.youtube.com/embed/EN2oOog3pSs";
@@ -463,21 +464,11 @@ export function getMessageTypeClasses(type: MessageType): string {
}
export function getModifierKey(): string {
const isMac = /Mac/.test(navigator.userAgent);
if (isMac) {
return "⌘"; // Command key
} else {
return "Ctrl";
}
return Platform.isMac ? "⌘" : "Ctrl";
}
export function getAltKey(): string {
const isMac = /Mac/.test(navigator.userAgent);
if (isMac) {
return "⌥"; // Option key
} else {
return "Alt";
}
return Platform.isMac ? "⌥" : "Alt";
}
export function getGamesPlayed(): number {
@@ -4,6 +4,7 @@ import { customElement, state } from "lit/decorators.js";
import { EventBus } from "../../../core/EventBus";
import { GameMode } from "../../../core/game/Game";
import { GameView } from "../../../core/game/GameView";
import { Platform } from "../../Platform";
import { translateText } from "../../Utils";
import { ImmunityBarVisibleEvent } from "./ImmunityTimer";
import { Layer } from "./Layer";
@@ -51,7 +52,7 @@ export class GameLeftSidebar extends LitElement implements Layer {
this.isPlayerTeamLabelVisible = true;
}
// Make it visible by default on large screens
if (window.innerWidth >= 1024) {
if (Platform.isDesktopWidth) {
// lg breakpoint
this._shownOnInit = true;
}
@@ -1,6 +1,7 @@
import { LitElement, html } from "lit";
import { customElement, state } from "lit/decorators.js";
import { crazyGamesSDK } from "src/client/CrazyGamesSDK";
import { Platform } from "src/client/Platform";
import { getGamesPlayed } from "src/client/Utils";
import { GameType } from "src/core/game/Game";
import { GameView } from "../../../core/game/GameView";
@@ -21,7 +22,7 @@ export class SpawnVideoAd extends LitElement implements Layer {
init() {
if (
!window.adsEnabled ||
window.innerWidth < 768 ||
Platform.isMobileWidth ||
crazyGamesSDK.isOnCrazyGames() ||
this.game.config().gameConfig().gameType === GameType.Singleplayer ||
getGamesPlayed() < 3 // Don't show to new players
+2 -2
View File
@@ -19,6 +19,7 @@ import {
patternRelationship,
} from "../../Cosmetics";
import { crazyGamesSDK } from "../../CrazyGamesSDK";
import { Platform } from "../../Platform";
import { SendWinnerEvent } from "../../Transport";
import { Layer } from "./Layer";
@@ -186,8 +187,7 @@ export class WinModal extends LitElement implements Layer {
// Shuffle the array and take patterns based on screen size
const shuffled = [...purchasablePatterns].sort(() => Math.random() - 0.5);
const isMobile = window.innerWidth < 768; // md breakpoint
const maxPatterns = isMobile ? 1 : 3;
const maxPatterns = Platform.isMobileWidth ? 1 : 3;
const selectedPatterns = shuffled.slice(
0,
Math.min(maxPatterns, shuffled.length),
+3 -10
View File
@@ -1,3 +1,5 @@
import { Platform } from "../Platform";
export type RendererType = "Canvas2D" | "WebGL1" | "WebGL2";
export interface BrowserInfo {
@@ -48,7 +50,7 @@ export async function collectGraphicsDiagnostics(
const uaData = (navigator as any).userAgentData;
const os = uaData?.platform ?? detectOS(navigator.userAgent);
const os = Platform.os;
const browser: BrowserInfo = {
engine: uaData?.brands
@@ -130,12 +132,3 @@ export async function collectGraphicsDiagnostics(
power,
};
}
function detectOS(ua: string): string {
if (/windows nt/i.test(ua)) return "Windows";
if (/mac os x/i.test(ua)) return "macOS";
if (/android/i.test(ua)) return "Android";
if (/iphone|ipad|ipod/i.test(ua)) return "iOS";
if (/linux/i.test(ua)) return "Linux";
return "Unknown";
}
+134
View File
@@ -0,0 +1,134 @@
import { afterEach, describe, expect, it, vi } from "vitest";
type NavigatorOverride = {
userAgent: string;
userAgentData?: { platform?: string };
maxTouchPoints?: number;
};
const setInnerWidth = (value: number) => {
Object.defineProperty(window, "innerWidth", {
configurable: true,
writable: true,
value,
});
};
const loadPlatform = async ({
userAgent,
userAgentData,
maxTouchPoints,
}: NavigatorOverride) => {
vi.resetModules();
vi.stubGlobal("navigator", {
userAgent,
userAgentData,
maxTouchPoints,
});
const { Platform } = await import("../../src/client/Platform");
return Platform;
};
afterEach(() => {
vi.unstubAllGlobals();
vi.clearAllMocks();
});
describe("Platform", () => {
it("detects iOS before macOS for iPhone-like user agents", async () => {
const platform = await loadPlatform({
userAgent:
"Mozilla/5.0 (iPhone; CPU iPhone OS 17_5 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.5 Mobile/15E148 Safari/604.1",
});
expect(platform.os).toBe("iOS");
expect(platform.isIOS).toBe(true);
expect(platform.isMac).toBe(false);
});
it("detects macOS for Macintosh user agents", async () => {
const platform = await loadPlatform({
userAgent:
"Mozilla/5.0 (Macintosh; Intel Mac OS X 14_5) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.5 Safari/605.1.15",
});
expect(platform.os).toBe("macOS");
expect(platform.isMac).toBe(true);
expect(platform.isIOS).toBe(false);
});
it("detects iOS for iPad desktop-mode user agents with touch support", async () => {
const platform = await loadPlatform({
userAgent:
"Mozilla/5.0 (Macintosh; Intel Mac OS X 14_5) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.5 Safari/605.1.15",
maxTouchPoints: 5,
});
expect(platform.os).toBe("iOS");
expect(platform.isIOS).toBe(true);
expect(platform.isMac).toBe(false);
});
it("uses userAgentData platform when available", async () => {
const platform = await loadPlatform({
userAgent:
"Mozilla/5.0 (iPhone; CPU iPhone OS 17_5 like Mac OS X) AppleWebKit/605.1.15",
userAgentData: { platform: "Android" },
});
expect(platform.os).toBe("Android");
expect(platform.isAndroid).toBe(true);
});
it("normalizes non-canonical userAgentData platform values", async () => {
const macPlatform = await loadPlatform({
userAgent: "Mozilla/5.0",
userAgentData: { platform: "Macintosh" },
});
expect(macPlatform.os).toBe("macOS");
expect(macPlatform.isMac).toBe(true);
const chromeOsPlatform = await loadPlatform({
userAgent: "Mozilla/5.0",
userAgentData: { platform: "Chrome OS" },
});
expect(chromeOsPlatform.os).toBe("Linux");
expect(chromeOsPlatform.isLinux).toBe(true);
const unknownPlatform = await loadPlatform({
userAgent: "Mozilla/5.0",
userAgentData: { platform: "PlayStation" },
});
expect(unknownPlatform.os).toBe("Unknown");
expect(unknownPlatform.isMac).toBe(false);
expect(unknownPlatform.isWindows).toBe(false);
expect(unknownPlatform.isIOS).toBe(false);
expect(unknownPlatform.isAndroid).toBe(false);
expect(unknownPlatform.isLinux).toBe(false);
});
it("reports viewport breakpoint helpers from window.innerWidth", async () => {
const platform = await loadPlatform({
userAgent:
"Mozilla/5.0 (Macintosh; Intel Mac OS X 14_5) AppleWebKit/605.1.15",
});
setInnerWidth(767);
expect(platform.isMobileWidth).toBe(true);
expect(platform.isTabletWidth).toBe(false);
expect(platform.isDesktopWidth).toBe(false);
setInnerWidth(768);
expect(platform.isMobileWidth).toBe(false);
expect(platform.isTabletWidth).toBe(true);
expect(platform.isDesktopWidth).toBe(false);
setInnerWidth(1024);
expect(platform.isMobileWidth).toBe(false);
expect(platform.isTabletWidth).toBe(false);
expect(platform.isDesktopWidth).toBe(true);
});
});