Skip to content
Open
Show file tree
Hide file tree
Changes from 7 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
48 changes: 48 additions & 0 deletions .github/workflows/electron-build.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
name: Electron Windows Build

on:
workflow_dispatch:
push:
branches:
- main
paths:
- 'client/**'

jobs:
build-windows:
runs-on: windows-latest
steps:
- uses: actions/checkout@v4

- uses: pnpm/action-setup@v4
with:
version: 10

- uses: actions/setup-node@v4
with:
node-version: 20
cache: pnpm
cache-dependency-path: client/pnpm-lock.yaml

- name: Install dependencies
working-directory: client
run: pnpm install

- name: Build Electron TypeScript
working-directory: client
run: npx tsc -p electron/tsconfig.json

- name: Build Vite
working-directory: client
run: npx vite build

- name: Build Windows executable
working-directory: client
run: npx electron-builder --win --config.win.target=dir

- name: Upload Windows build
uses: actions/upload-artifact@v4
with:
name: loot-survivor-2-win-x64
path: client/release/win-unpacked/
retention-days: 30
4 changes: 4 additions & 0 deletions client/.gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -22,3 +22,7 @@ tsconfig.tsbuildinfo
npm-debug.log*
yarn-debug.log*
yarn-error.log*

# electron
/electron-dist
/release
117 changes: 117 additions & 0 deletions client/electron/main.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
import { app, BrowserWindow } from "electron";
import http from "http";
import fs from "fs";
import path from "path";

const ALLOWED_PATH_PREFIX = "/trials";

function getMimeType(filePath: string): string {
const ext = path.extname(filePath).toLowerCase();
const types: Record<string, string> = {
".html": "text/html",
".js": "application/javascript",
".css": "text/css",
".json": "application/json",
".png": "image/png",
".jpg": "image/jpeg",
".jpeg": "image/jpeg",
".gif": "image/gif",
".svg": "image/svg+xml",
".ico": "image/x-icon",
".wasm": "application/wasm",
".woff": "font/woff",
".woff2": "font/woff2",
".ttf": "font/ttf",
".mp3": "audio/mpeg",
".ogg": "audio/ogg",
".wav": "audio/wav",
".webp": "image/webp",
".webm": "video/webm",
".mp4": "video/mp4",
};
return types[ext] || "application/octet-stream";
}

function startLocalServer(distPath: string): Promise<number> {
return new Promise((resolve) => {
const server = http.createServer((req, res) => {
const url = new URL(req.url || "/", "http://localhost");
let filePath = path.join(distPath, url.pathname);

// Serve index.html for SPA routes
if (!fs.existsSync(filePath) || fs.statSync(filePath).isDirectory()) {
filePath = path.join(distPath, "index.html");
}

try {
const data = fs.readFileSync(filePath);
res.writeHead(200, { "Content-Type": getMimeType(filePath) });
res.end(data);
} catch {
res.writeHead(404);
res.end("Not found");
}
});
Comment on lines +37 to +54

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The local server is using synchronous file system calls (existsSync, statSync, readFileSync) within the request handler. This will block the Electron main process for every file request, which can lead to unresponsiveness of the application, especially when loading many assets. It's better to use the asynchronous versions of these methods from fs.promises and make the request handler async.

    const server = http.createServer(async (req, res) => {
      const url = new URL(req.url || "/", "http://localhost");
      let filePath = path.join(distPath, url.pathname);

      // Serve index.html for SPA routes
      try {
        const stats = await fs.promises.stat(filePath);
        if (stats.isDirectory()) {
          filePath = path.join(distPath, "index.html");
        }
      } catch {
        // If stat fails, file likely doesn't exist, so serve index.html for SPA.
        filePath = path.join(distPath, "index.html");
      }

      try {
        const data = await fs.promises.readFile(filePath);
        res.writeHead(200, { "Content-Type": getMimeType(filePath) });
        res.end(data);
      } catch (err) {
        console.error(`Failed to serve file ${filePath}:`, err);
        res.writeHead(404);
        res.end("Not found");
      }
    });


server.listen(0, "127.0.0.1", () => {
const addr = server.address();
const port = typeof addr === "object" && addr ? addr.port : 0;
resolve(port);
});
});
}

async function createWindow() {
const win = new BrowserWindow({
width: 1440,
height: 900,
title: "Loot Survivor 2",
webPreferences: {
contextIsolation: true,
nodeIntegration: false,
preload: path.join(__dirname, "preload.js"),
},
});

// Block navigation to routes outside /trials
win.webContents.on("will-navigate", (event, url) => {
try {
const parsed = new URL(url);
if (
!parsed.pathname.startsWith(ALLOWED_PATH_PREFIX) &&
parsed.pathname !== "/"
) {
event.preventDefault();
}
} catch {
event.preventDefault();
}
Comment on lines +86 to +88

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

This catch block silently prevents navigation if parsing the URL fails. For easier debugging, it's a good practice to log the error that was caught.

    } catch (error) {
      console.error(`Failed to parse navigation URL: ${url}`, error);
      event.preventDefault();
    }

});

const isDev = !app.isPackaged;

if (isDev) {
await win.loadURL(`http://localhost:5173${ALLOWED_PATH_PREFIX}`);
} else {
const distPath = path.join(app.getAppPath(), "dist");
const port = await startLocalServer(distPath);
await win.loadURL(`http://127.0.0.1:${port}${ALLOWED_PATH_PREFIX}`);
}
}

app.whenReady().then(createWindow).catch((err) => {
console.error("Failed to create window:", err);
app.quit();
});

app.on("window-all-closed", () => {
if (process.platform !== "darwin") {
app.quit();
}
});

app.on("activate", () => {
if (BrowserWindow.getAllWindows().length === 0) {
createWindow();
}
});
5 changes: 5 additions & 0 deletions client/electron/preload.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import { contextBridge } from "electron";

contextBridge.exposeInMainWorld("electronAPI", {
isElectron: true,
});
15 changes: 15 additions & 0 deletions client/electron/tsconfig.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
{
"compilerOptions": {
"target": "es2020",
"module": "commonjs",
"outDir": "../electron-dist",
"rootDir": ".",
"strict": true,
"typeRoots": ["../node_modules/@types"],
"types": ["node"],
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true
},
"include": ["./**/*.ts"]
}
42 changes: 41 additions & 1 deletion client/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,40 @@
"private": true,
"version": "0.0.0",
"type": "module",
"main": "electron-dist/main.js",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"lint": "eslint .",
"preview": "vite preview",
"serve": "vite preview"
"serve": "vite preview",
"electron:dev": "concurrently \"vite\" \"wait-on http://localhost:5173 && NODE_ENV=development electron .\"",

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Cross-platform compatibility issue with NODE_ENV=development.

The inline environment variable syntax NODE_ENV=development electron . works on Unix-like systems but not on Windows CMD/PowerShell natively. Since this PR targets Windows builds, consider using cross-env for cross-platform compatibility.

Suggested fix using cross-env

Add cross-env to devDependencies and update the script:

-"electron:dev": "concurrently \"vite\" \"wait-on http://localhost:5173 && NODE_ENV=development electron .\""
+"electron:dev": "concurrently \"vite\" \"wait-on http://localhost:5173 && cross-env NODE_ENV=development electron .\""
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@client/package.json` at line 13, The "electron:dev" npm script uses POSIX env
syntax ("NODE_ENV=development electron .") which breaks on Windows; add
cross-env as a devDependency and update the "electron:dev" script to prefix the
command with cross-env so the environment variable is set cross-platform
(install cross-env in devDependencies and change the script value referenced as
"electron:dev" to use cross-env NODE_ENV=development electron .).

"electron:build": "tsc -p electron/tsconfig.json && vite build && electron-builder",
"electron:preview": "tsc -p electron/tsconfig.json && vite build && electron ."
},
"build": {
"appId": "io.lootsurvivor.desktop",
"productName": "Loot Survivor 2",
"directories": {
"output": "release"
},
"files": [
"dist/**/*",
"electron-dist/**/*"
],
"win": {
"target": [
{
"target": "dir",
"arch": [
"x64"
]
}
]
},
"extraMetadata": {
"type": "commonjs"
}
},
"dependencies": {
"@cartridge/connector": "^0.10.1",
Expand Down Expand Up @@ -53,17 +81,29 @@
"vite-plugin-wasm": "^3.4.1"
},
"devDependencies": {
"@types/node": "^25.5.0",

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

The specified version ^25.5.0 for @types/node does not exist. The latest major version is 20. This will likely cause dependency installation to fail. Please use a valid and existing version.

Suggested change
"@types/node": "^25.5.0",
"@types/node": "^20.14.0",

"@types/react": "^18.2.0",
"@types/react-dom": "^18.2.0",
"@types/react-lazy-load-image-component": "^1.6.4",
"@vitejs/plugin-react": "^4.3.4",
"concurrently": "^9.1.2",
"electron": "^34.2.0",
"electron-builder": "^25.1.8",
"eslint": "^9.24.0",
"eslint-plugin-react-hooks": "^5.2.0",
"eslint-plugin-react-refresh": "^0.4.19",
"typescript": "^5.8.3",
"typescript-eslint": "^8.30.1",
"vite": "^5.4.18",
"vite-plugin-top-level-await": "^1.5.0",
"wait-on": "^8.0.3",
"zustand": "^4.5.6"
},
"pnpm": {
"onlyBuiltDependencies": [
"electron",
"esbuild",
"@swc/core"
]
}
}
Loading
Loading