diff --git a/.gitignore b/.gitignore index b84cea885..f97753a02 100644 --- a/.gitignore +++ b/.gitignore @@ -13,3 +13,8 @@ CLAUDE.md .idea/ # this is autogenerated by script src/assets/ + +# Debug / Log Outputs +*.log +*debug*.txt +eslint_out.txt diff --git a/.vscode/settings.json b/.vscode/settings.json index c0c69aab9..8d6f5a203 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,3 +1,5 @@ { - "html.validate.scripts": false + "html.validate.scripts": false, + "tailwindCSS.lint.suggestCanonicalClasses": "ignore", + "lit-plugin.rules.suggestCanonicalClasses": "off" } diff --git a/src/client/GoogleAdElement.ts b/src/client/GoogleAdElement.ts index 6d9419974..f0d234847 100644 --- a/src/client/GoogleAdElement.ts +++ b/src/client/GoogleAdElement.ts @@ -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; diff --git a/src/client/HelpModal.ts b/src/client/HelpModal.ts index 21fba5424..1bab55f1a 100644 --- a/src/client/HelpModal.ts +++ b/src/client/HelpModal.ts @@ -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", diff --git a/src/client/InputHandler.ts b/src/client/InputHandler.ts index 1d8a21d77..5c16bb931 100644 --- a/src/client/InputHandler.ts +++ b/src/client/InputHandler.ts @@ -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", diff --git a/src/client/Layout.ts b/src/client/Layout.ts index e138f9706..f3cf03e5b 100644 --- a/src/client/Layout.ts +++ b/src/client/Layout.ts @@ -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(); } diff --git a/src/client/Platform.ts b/src/client/Platform.ts new file mode 100644 index 000000000..e33f89ccf --- /dev/null +++ b/src/client/Platform.ts @@ -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; + }, + }; +})(); diff --git a/src/client/UserSettingModal.ts b/src/client/UserSettingModal.ts index 7a6025a1c..7bf6612d3 100644 --- a/src/client/UserSettingModal.ts +++ b/src/client/UserSettingModal.ts @@ -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 = { toggleView: "Space", diff --git a/src/client/Utils.ts b/src/client/Utils.ts index d7ce0f60a..eceba18c9 100644 --- a/src/client/Utils.ts +++ b/src/client/Utils.ts @@ -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 { diff --git a/src/client/graphics/layers/GameLeftSidebar.ts b/src/client/graphics/layers/GameLeftSidebar.ts index 05f8aa80a..35dfb6328 100644 --- a/src/client/graphics/layers/GameLeftSidebar.ts +++ b/src/client/graphics/layers/GameLeftSidebar.ts @@ -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; } diff --git a/src/client/graphics/layers/SpawnVideoReward.ts b/src/client/graphics/layers/SpawnVideoReward.ts index adbc51354..028ec89bc 100644 --- a/src/client/graphics/layers/SpawnVideoReward.ts +++ b/src/client/graphics/layers/SpawnVideoReward.ts @@ -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 diff --git a/src/client/graphics/layers/WinModal.ts b/src/client/graphics/layers/WinModal.ts index 1479d09e0..e4fcd3286 100644 --- a/src/client/graphics/layers/WinModal.ts +++ b/src/client/graphics/layers/WinModal.ts @@ -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), diff --git a/src/client/utilities/Diagnostic.ts b/src/client/utilities/Diagnostic.ts index dc6553071..ca5c34ca9 100644 --- a/src/client/utilities/Diagnostic.ts +++ b/src/client/utilities/Diagnostic.ts @@ -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"; -} diff --git a/tests/client/Platform.test.ts b/tests/client/Platform.test.ts new file mode 100644 index 000000000..2991bcadb --- /dev/null +++ b/tests/client/Platform.test.ts @@ -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); + }); +});