Integrated capacitor, adjusted the fetch urls and UI elements position to fit the safe area on mobile

This commit is contained in:
oleksandr-shysh
2025-06-12 11:18:32 +03:00
parent 0cd663df02
commit c5fde1d287
19 changed files with 212 additions and 76 deletions
+25 -1
View File
@@ -29,6 +29,7 @@
"binary-loader": "^0.0.1",
"colord": "^2.9.3",
"copy-webpack-plugin": "^13.0.0",
"cors": "^2.8.5",
"d3": "^7.9.0",
"dompurify": "^3.1.7",
"dotenv": "^16.5.0",
@@ -69,6 +70,7 @@
"@eslint/compat": "^1.2.7",
"@eslint/js": "^9.21.0",
"@types/chai": "^4.3.17",
"@types/cors": "^2.8.19",
"@types/d3": "^7.4.3",
"@types/jest": "^30.0.0",
"@types/jquery": "^3.5.31",
@@ -8986,6 +8988,16 @@
"@types/node": "*"
}
},
"node_modules/@types/cors": {
"version": "2.8.19",
"resolved": "https://registry.npmjs.org/@types/cors/-/cors-2.8.19.tgz",
"integrity": "sha512-mFNylyeyqN93lfe/9CSxOGREz8cpzAhH+E93xJ4xWQf62V8sQ/24reV2nyzUWM6H6Xji+GGHpkbLe7pVoUEskg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/node": "*"
}
},
"node_modules/@types/d3": {
"version": "7.4.3",
"resolved": "https://registry.npmjs.org/@types/d3/-/d3-7.4.3.tgz",
@@ -12005,6 +12017,19 @@
"integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==",
"license": "MIT"
},
"node_modules/cors": {
"version": "2.8.5",
"resolved": "https://registry.npmjs.org/cors/-/cors-2.8.5.tgz",
"integrity": "sha512-KIHbLJqu73RGr/hnbrO9uBeixNGuvSQjul/jdFvS/KFSIH1hWVd1ng7zOHx+YrEfInLG7q4n6GHQ9cDtxv/P6g==",
"license": "MIT",
"dependencies": {
"object-assign": "^4",
"vary": "^1"
},
"engines": {
"node": ">= 0.10"
}
},
"node_modules/cosmiconfig": {
"version": "9.0.0",
"resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-9.0.0.tgz",
@@ -18887,7 +18912,6 @@
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
"integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=0.10.0"
+2
View File
@@ -29,6 +29,7 @@
"@eslint/compat": "^1.2.7",
"@eslint/js": "^9.21.0",
"@types/chai": "^4.3.17",
"@types/cors": "^2.8.19",
"@types/d3": "^7.4.3",
"@types/jest": "^30.0.0",
"@types/jquery": "^3.5.31",
@@ -102,6 +103,7 @@
"binary-loader": "^0.0.1",
"colord": "^2.9.3",
"copy-webpack-plugin": "^13.0.0",
"cors": "^2.8.5",
"d3": "^7.9.0",
"dompurify": "^3.1.7",
"dotenv": "^16.5.0",
+1 -1
View File
@@ -20,7 +20,7 @@ export class DarkModeButton extends LitElement {
return html`
<button
title="Toggle Dark Mode"
class="absolute top-0 right-0 md:top-[10px] md:right-[10px] border-none bg-none cursor-pointer text-2xl"
class="absolute top-0 safe-top right-0 md:top-[10px] md:right-[10px] border-none bg-none cursor-pointer text-2xl"
@click=${() => this.toggleDarkMode()}
>
${this.darkMode ? "☀️" : "🌙"}
+45 -43
View File
@@ -342,7 +342,7 @@ export class HostLobbyModal extends LitElement {
</div>
</div>
<div class="start-game-button-container">
<div class="start-game-button-container">
<button
@click=${this.startGame}
?disabled=${this.players.length < 2}
@@ -471,28 +471,25 @@ export class HostLobbyModal extends LitElement {
}
private async putGameConfig() {
const config = await getServerConfigFromClient();
const response = await fetch(
`${window.location.origin}/${config.workerPath(this.lobbyId)}/api/game/${this.lobbyId}`,
{
method: "PUT",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
gameMap: this.selectedMap,
difficulty: this.selectedDifficulty,
disableNPCs: this.disableNPCs,
bots: this.bots,
infiniteGold: this.infiniteGold,
infiniteTroops: this.infiniteTroops,
instantBuild: this.instantBuild,
gameMode: this.gameMode,
disabledUnits: this.disabledUnits,
playerTeams: this.teamCount,
} satisfies Partial<GameConfig>),
const url = await buildGameUrl(this.lobbyId, "game");
const response = await fetch(url, {
method: "PUT",
headers: {
"Content-Type": "application/json",
},
);
body: JSON.stringify({
gameMap: this.selectedMap,
difficulty: this.selectedDifficulty,
disableNPCs: this.disableNPCs,
bots: this.bots,
infiniteGold: this.infiniteGold,
infiniteTroops: this.infiniteTroops,
instantBuild: this.instantBuild,
gameMode: this.gameMode,
disabledUnits: this.disabledUnits,
playerTeams: this.teamCount,
} satisfies Partial<GameConfig>),
});
return response;
}
@@ -521,16 +518,13 @@ export class HostLobbyModal extends LitElement {
`Starting private game with map: ${GameMapType[this.selectedMap]} ${this.useRandomMap ? " (Randomly selected)" : ""}`,
);
this.close();
const config = await getServerConfigFromClient();
const response = await fetch(
`${window.location.origin}/${config.workerPath(this.lobbyId)}/api/start_game/${this.lobbyId}`,
{
method: "POST",
headers: {
"Content-Type": "application/json",
},
const url = await buildGameUrl(this.lobbyId, "start_game");
const response = await fetch(url, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
);
});
return response;
}
@@ -550,8 +544,8 @@ export class HostLobbyModal extends LitElement {
}
private async pollPlayers() {
const config = await getServerConfigFromClient();
fetch(`/${config.workerPath(this.lobbyId)}/api/game/${this.lobbyId}`, {
const url = await buildGameUrl(this.lobbyId, "game");
fetch(url, {
method: "GET",
headers: {
"Content-Type": "application/json",
@@ -566,19 +560,16 @@ export class HostLobbyModal extends LitElement {
}
async function createLobby(): Promise<GameInfo> {
const config = await getServerConfigFromClient();
try {
const id = generateID();
const response = await fetch(
`/${config.workerPath(id)}/api/create_game/${id}`,
{
method: "POST",
headers: {
"Content-Type": "application/json",
},
// body: JSON.stringify(data), // Include this if you need to send data
const url = await buildGameUrl(id, "create_game");
const response = await fetch(url, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
);
// body: JSON.stringify(data), // Include this if you need to send data
});
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
@@ -593,3 +584,14 @@ async function createLobby(): Promise<GameInfo> {
throw error; // Re-throw the error so the caller can handle it
}
}
export async function buildGameUrl(
gameID: string,
path: string,
): Promise<string> {
const config = await getServerConfigFromClient();
const apiPath = `/api/${path}/${gameID}`;
const baseUrl = process.env.API_BASE_URL || "/";
return `${baseUrl}${config.workerPath(gameID)}${apiPath}`;
}
+12 -16
View File
@@ -3,10 +3,11 @@ import { customElement, query, state } from "lit/decorators.js";
import { translateText } from "../client/Utils";
import { GameInfo, GameRecord } from "../core/Schemas";
import { generateID } from "../core/Util";
import { getServerConfigFromClient } from "../core/configuration/ConfigLoader";
import { JoinLobbyEvent } from "./Main";
import "./components/baseComponents/Button";
import "./components/baseComponents/Modal";
import { buildGameUrl } from "./HostLobbyModal";
import { JoinLobbyEvent } from "./Main";
@customElement("join-private-lobby-modal")
export class JoinPrivateLobbyModal extends LitElement {
@query("o-modal") private modalEl!: HTMLElement & {
@@ -170,8 +171,7 @@ export class JoinPrivateLobbyModal extends LitElement {
}
private async checkActiveLobby(lobbyId: string): Promise<boolean> {
const config = await getServerConfigFromClient();
const url = `/${config.workerPath(lobbyId)}/api/game/${lobbyId}/exists`;
const url = await buildGameUrl(lobbyId, "game/exists");
const response = await fetch(url, {
method: "GET",
@@ -203,10 +203,9 @@ export class JoinPrivateLobbyModal extends LitElement {
}
private async checkArchivedGame(lobbyId: string): Promise<boolean> {
const config = await getServerConfigFromClient();
const archiveUrl = `/${config.workerPath(lobbyId)}/api/archived_game/${lobbyId}`;
const url = await buildGameUrl(lobbyId, "archived_game");
const archiveResponse = await fetch(archiveUrl, {
const archiveResponse = await fetch(url, {
method: "GET",
headers: { "Content-Type": "application/json" },
});
@@ -249,17 +248,14 @@ export class JoinPrivateLobbyModal extends LitElement {
private async pollPlayers() {
if (!this.lobbyIdInput?.value) return;
const config = await getServerConfigFromClient();
fetch(
`/${config.workerPath(this.lobbyIdInput.value)}/api/game/${this.lobbyIdInput.value}`,
{
method: "GET",
headers: {
"Content-Type": "application/json",
},
const url = await buildGameUrl(this.lobbyIdInput.value, "game");
fetch(url, {
method: "GET",
headers: {
"Content-Type": "application/json",
},
)
})
.then((response) => response.json())
.then((data: GameInfo) => {
this.players = data.clients?.map((p) => p.username) ?? [];
+3 -1
View File
@@ -56,7 +56,9 @@ export class PublicLobby extends LitElement {
async fetchLobbies(): Promise<GameInfo[]> {
try {
const response = await fetch(`/api/public_lobbies`);
const response = await fetch(
`${process.env.API_BASE_URL}/api/public_lobbies`,
);
if (!response.ok)
throw new Error(`HTTP error! status: ${response.status}`);
const data = await response.json();
+1 -1
View File
@@ -204,7 +204,7 @@ export class ControlPanel extends LitElement implements Layer {
</style>
<div
class="${this._isVisible
? "w-[320px] text-sm lg:text-m bg-gray-800/70 p-2 pr-3 lg:p-4 shadow-lg lg:rounded-lg backdrop-blur"
? "w-[320px] text-sm lg:text-m bg-gray-800/70 p-2 pr-3 lg:p-4 shadow-lg lg:rounded-lg backdrop-blur safe-pb"
: "hidden"}"
@contextmenu=${(e) => e.preventDefault()}
>
+1 -1
View File
@@ -153,7 +153,7 @@ export class OptionsMenu extends LitElement implements Layer {
}
return html`
<div
class="top-0 lg:top-4 right-0 lg:right-4 z-50 pointer-events-auto"
class="top-0 lg:top-4 right-0 lg:right-4 z-50 pointer-events-auto safe-pt"
@contextmenu=${(e) => e.preventDefault()}
>
<div
+1 -1
View File
@@ -54,7 +54,7 @@ export class TopBar extends LitElement implements Layer {
return html`
<div
class="fixed top-0 z-50 bg-slate-800/40 backdrop-blur-sm shadow-xs text-white text-sm p-1 rounded-ee-sm lg:rounded grid grid-cols-1 sm:grid-cols-2 w-1/2 sm:w-2/3 md:w-1/2 lg:hidden"
class="fixed top-0 safe-top z-50 bg-slate-800/40 backdrop-blur-sm shadow-xs text-white text-sm p-1 rounded-ee-sm lg:rounded grid grid-cols-1 sm:grid-cols-2 w-1/2 sm:w-2/3 md:w-1/2 lg:hidden"
>
<!-- Pop section (takes 2 columns on desktop) -->
<div
+12 -9
View File
@@ -2,7 +2,10 @@
<html lang="en" class="h-full">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta
name="viewport"
content="width=device-width, initial-scale=1.0, viewport-fit=cover"
/>
<title>OpenFront (ALPHA)</title>
<link rel="manifest" href="../../resources/manifest.json" />
@@ -97,7 +100,7 @@
<body
class="h-full select-none font-sans min-h-screen bg-opacity-0 bg-cover bg-center bg-fixed transition-opacity duration-300 ease-in-out flex flex-col"
>
<header class="l-header">
<header class="l-header safe-pt">
<div class="l-header__content">
<svg
xmlns="http://www.w3.org/2000/svg"
@@ -209,11 +212,11 @@
secondary
></o-button>
<!-- <o-button
id="chat-button"
title="Chat Test"
block
secondary
></o-button> -->
id="chat-button"
title="Chat Test"
block
secondary
></o-button> -->
</div>
<o-button
@@ -240,7 +243,7 @@
<button
id="settings-button"
title="Settings"
class="fixed bottom-4 right-4 z-50 rounded-full p-2 shadow-lg transition-colors duration-300 flex items-center justify-center"
class="fixed bottom-4 right-4 z-50 rounded-full p-2 shadow-lg transition-colors duration-300 flex items-center justify-center safe-mb"
style="width: 80px; height: 80px; background-color: #0075ff"
>
<img
@@ -287,7 +290,7 @@
</div>
<!-- Footer section -->
<footer class="l-footer">
<footer class="l-footer safe-pb">
<div class="l-footer__content">
<div class="l-footer__col">
<a
+16
View File
@@ -17,6 +17,22 @@
box-sizing: border-box;
}
/* Add safe-area offsets for iOS native mobile device */
@supports (-webkit-touch-callout: none) {
.safe-pt {
padding-top: env(safe-area-inset-top);
}
.safe-pb {
padding-bottom: env(safe-area-inset-bottom);
}
.safe-top {
top: env(safe-area-inset-top);
}
.safe-mb {
margin-bottom: env(safe-area-inset-bottom);
}
}
/* Add custom scrollbar styles */
::-webkit-scrollbar {
width: 8px;
+1
View File
@@ -62,6 +62,7 @@ export interface ServerConfig {
cloudflareApiToken(): string;
cloudflareConfigPath(): string;
cloudflareCredsPath(): string;
origin(): string;
}
export interface NukeMagnitude {
+1 -1
View File
@@ -29,7 +29,7 @@ export async function getServerConfigFromClient(): Promise<ServerConfig> {
if (cachedSC) {
return cachedSC;
}
const response = await fetch("/api/env");
const response = await fetch(`${process.env.API_BASE_URL}/api/env`);
if (!response.ok) {
throw new Error(
+12
View File
@@ -85,6 +85,18 @@ export abstract class DefaultServerConfig implements ServerConfig {
return process.env.CF_CREDS_PATH ?? "";
}
origin(): string {
const audience = this.jwtAudience();
const subdomain = this.subdomain();
if (audience === "localhost") {
return "http://localhost:9000";
}
if (subdomain === "") {
return `https://${audience}`;
}
return `https://${subdomain}.${audience}`;
}
private publicKey: JWK;
abstract jwtAudience(): string;
jwtIssuer(): string {
+3 -1
View File
@@ -7,6 +7,7 @@ import { fileURLToPath } from "url";
import { getServerConfigFromServer } from "../core/configuration/ConfigLoader";
import { GameInfo } from "../core/Schemas";
import { generateID } from "../core/Util";
import { corsMiddleware } from "./cors";
import { gatekeeper, LimiterType } from "./Gatekeeper";
import { logger } from "./Logger";
import { MapPlaylist } from "./MapPlaylist";
@@ -22,7 +23,8 @@ const log = logger.child({ comp: "m" });
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
app.use(express.json());
app.use(corsMiddleware);
app.use(
express.static(path.join(__dirname, "../../static"), {
maxAge: "1y", // Set max-age to 1 year for all static assets
+2
View File
@@ -18,6 +18,7 @@ import {
import { CreateGameInputSchema, GameInputSchema } from "../core/WorkerSchemas";
import { archive, readGameRecord } from "./Archive";
import { Client } from "./Client";
import { corsMiddleware } from "./cors";
import { GameManager } from "./GameManager";
import { gatekeeper, LimiterType } from "./Gatekeeper";
import { getUserMe, verifyClientToken } from "./jwt";
@@ -71,6 +72,7 @@ export function startWorker() {
next();
});
app.use(corsMiddleware);
app.set("trust proxy", 3);
app.use(express.json());
app.use(express.static(path.join(__dirname, "../../out")));
+38
View File
@@ -0,0 +1,38 @@
import cors from "cors";
import { getLocalIP } from "../../webpack.config";
import { GameEnv } from "../core/configuration/Config";
import { getServerConfigFromServer } from "../core/configuration/ConfigLoader";
const config = getServerConfigFromServer();
const origin = config.origin();
const allowedOrigins = [
origin,
"capacitor://openfront.io",
"https://openfront.io",
];
if (config.env() === GameEnv.Dev) {
const localIp = getLocalIP();
if (localIp) {
allowedOrigins.push(`http://${localIp}:9000`);
}
}
const corsOptions = {
origin: (
origin: string | undefined,
callback: (err: Error | null, allow?: boolean) => void,
) => {
// allow requests with no origin (like mobile apps or curl requests)
if (!origin) return callback(null, true);
if (allowedOrigins.indexOf(origin) !== -1) {
callback(null, true);
} else {
callback(new Error("Not allowed by CORS"));
}
},
credentials: true,
};
export const corsMiddleware = cors(corsOptions);
+3
View File
@@ -4,6 +4,9 @@ import { GameMapType } from "../../src/core/game/Game";
import { GameID } from "../../src/core/Schemas";
export class TestServerConfig implements ServerConfig {
origin(): string {
return "unused";
}
cloudflareConfigPath(): string {
throw new Error("Method not implemented.");
}
+33
View File
@@ -2,10 +2,25 @@ import { execSync } from "child_process";
import CopyPlugin from "copy-webpack-plugin";
import ESLintPlugin from "eslint-webpack-plugin";
import HtmlWebpackPlugin from "html-webpack-plugin";
import os from "os";
import path from "path";
import { fileURLToPath } from "url";
import webpack from "webpack";
export function getLocalIP() {
const interfaces = os.networkInterfaces();
for (const interfaceName in interfaces) {
const networkInterface = interfaces[interfaceName];
if (!networkInterface) continue;
for (const address of networkInterface) {
if (address.family === "IPv4" && !address.internal) {
return address.address;
}
}
}
return "localhost";
}
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
@@ -15,6 +30,12 @@ const gitCommit =
export default async (env, argv) => {
const isProduction = argv.mode === "production";
const apiBaseUrl = process.env.CAPACITOR_BUILD
? isProduction && process.env.CAPACITOR_PRODUCTION_HOSTNAME
? `https://${process.env.CAPACITOR_PRODUCTION_HOSTNAME}`
: `http://${getLocalIP()}:9000`
: "";
return {
entry: "./src/client/Main.ts",
output: {
@@ -126,6 +147,10 @@ export default async (env, argv) => {
),
"process.env.GAME_ENV": JSON.stringify(isProduction ? "prod" : "dev"),
"process.env.GIT_COMMIT": JSON.stringify(gitCommit),
"process.env.API_BASE_URL": JSON.stringify(apiBaseUrl),
"process.env.CAPACITOR_PRODUCTION_HOSTNAME": JSON.stringify(
process.env.CAPACITOR_PRODUCTION_HOSTNAME,
),
}),
new CopyPlugin({
patterns: [
@@ -134,6 +159,14 @@ export default async (env, argv) => {
to: path.resolve(__dirname, "static"),
noErrorOnMissing: true,
},
{
from: path.resolve(
__dirname,
"node_modules/@capacitor/android/capacitor.js",
),
to: path.resolve(__dirname, "static/capacitor.js"),
noErrorOnMissing: true,
},
],
options: { concurrency: 100 },
}),