mirror of
https://github.com/openfrontio/OpenFrontIO.git
synced 2026-06-21 06:10:42 +00:00
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:
@@ -13,3 +13,8 @@ CLAUDE.md
|
||||
.idea/
|
||||
# this is autogenerated by script
|
||||
src/assets/
|
||||
|
||||
# Debug / Log Outputs
|
||||
*.log
|
||||
*debug*.txt
|
||||
eslint_out.txt
|
||||
|
||||
Vendored
+3
-1
@@ -1,3 +1,5 @@
|
||||
{
|
||||
"html.validate.scripts": false
|
||||
"html.validate.scripts": false,
|
||||
"tailwindCSS.lint.suggestCanonicalClasses": "ignore",
|
||||
"lit-plugin.rules.suggestCanonicalClasses": "off"
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
},
|
||||
};
|
||||
})();
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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";
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user