Add portable Windows desktop downloads and release uploads

This commit is contained in:
2026-10-11 11:44:27 +05:00
parent fc6da6756d
commit a54a3e7705
24 changed files with 4159 additions and 5 deletions

BIN
desktop/assets/icon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

BIN
desktop/assets/icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

175
desktop/main.cjs Normal file
View File

@@ -0,0 +1,175 @@
const { app, BrowserWindow, Menu, dialog, shell } = require("electron");
const path = require("node:path");
const { SITE_URL, isInternal, isExternal } = require("./navigation.cjs");
let mainWindow;
const smokeTest = process.argv.includes("--smoke-test");
app.setAppUserModelId("app.minegeo.desktop");
if (!app.requestSingleInstanceLock()) app.quit();
else {
app.on("second-instance", () => {
if (!mainWindow) return;
if (mainWindow.isMinimized()) mainWindow.restore();
mainWindow.show();
mainWindow.focus();
});
app.whenReady().then(createWindow);
}
function openExternal(url) {
if (isExternal(url)) shell.openExternal(url).catch(() => {});
}
function createWindow() {
mainWindow = new BrowserWindow({
width: 1360,
height: 900,
minWidth: 800,
minHeight: 600,
title: "MineGeo",
backgroundColor: "#14201b",
show: false,
icon: path.join(__dirname, "assets/icon.png"),
webPreferences: {
nodeIntegration: false,
contextIsolation: true,
sandbox: true,
webSecurity: true,
allowRunningInsecureContent: false,
partition: "persist:minegeo",
},
});
const contents = mainWindow.webContents;
contents.session.setPermissionRequestHandler((_wc, _permission, callback) =>
callback(false),
);
contents.session.setPermissionCheckHandler(() => false);
contents.on("will-attach-webview", (event) => event.preventDefault());
for (const eventName of ["will-navigate", "will-redirect"]) {
contents.on(eventName, (event, url) => {
if (!isInternal(url)) {
event.preventDefault();
openExternal(url);
}
});
}
contents.setWindowOpenHandler(({ url }) => {
if (isInternal(url)) contents.loadURL(url).catch(() => {});
else openExternal(url);
return { action: "deny" };
});
contents.on(
"did-fail-load",
(_event, code, _description, _url, isMainFrame) => {
if (!isMainFrame || code === -3) return;
if (smokeTest) {
console.error("MineGeo smoke test: load failed", code);
app.exit(1);
return;
}
const html =
'<!doctype html><html lang="en"><meta charset="utf-8"><meta http-equiv="Content-Security-Policy" content="default-src \'none\'; style-src \'unsafe-inline\'"><title>MineGeo · Connection interrupted</title><body style="background:#14201b;color:#f4f1e6;font:18px system-ui;padding:10vh 10vw"><h1>Let’s get you back to MineGeo.</h1><p>Check your internet connection, then choose Game → Retry connection (Ctrl+R).</p><p>Your account and saved games will be here when you reconnect.</p></body></html>';
contents
.loadURL("data:text/html;charset=utf-8," + encodeURIComponent(html))
.catch(() => {});
mainWindow.show();
},
);
mainWindow.once("ready-to-show", () => mainWindow.show());
contents.on("did-finish-load", () => {
if (smokeTest && isInternal(contents.getURL())) {
console.log(
"MineGeo smoke test: production site loaded with sandbox enabled",
);
app.exit(0);
}
});
contents.session.on("will-download", (_event, item) => {
item.once("done", (_downloadEvent, state) => {
if (state === "interrupted" && !mainWindow.isDestroyed())
dialog.showMessageBox(mainWindow, {
type: "error",
title: "Download interrupted",
message: "Please check your connection and try downloading again.",
});
});
});
Menu.setApplicationMenu(
Menu.buildFromTemplate([
{
label: "Game",
submenu: [
{
label: "Home",
accelerator: "Alt+Home",
click: () => contents.loadURL(SITE_URL).catch(() => {}),
},
{
label: "Back",
accelerator: "Alt+Left",
click: () => {
if (contents.navigationHistory.canGoBack())
contents.navigationHistory.goBack();
},
},
{
label: "Retry connection",
accelerator: "CmdOrCtrl+R",
click: () => {
if (isInternal(contents.getURL())) contents.reload();
else contents.loadURL(SITE_URL).catch(() => {});
},
},
{ type: "separator" },
{ role: "quit" },
],
},
{
label: "Edit",
submenu: [
{ role: "undo" },
{ role: "redo" },
{ type: "separator" },
{ role: "cut" },
{ role: "copy" },
{ role: "paste" },
{ role: "selectAll" },
],
},
{
label: "View",
submenu: [
{ role: "resetZoom" },
{ role: "zoomIn" },
{ role: "zoomOut" },
{ type: "separator" },
{ role: "togglefullscreen" },
],
},
{
label: "Help",
submenu: [
{ label: "Open in browser", click: () => openExternal(SITE_URL) },
{
label: "About MineGeo",
click: () =>
dialog.showMessageBox(mainWindow, {
title: "MineGeo",
message: `MineGeo Desktop ${app.getVersion()}`,
detail:
"Play at minegeo.vercel.app. Requires an internet connection.",
}),
},
],
},
]),
);
contents.loadURL(SITE_URL).catch(() => {});
if (smokeTest) setTimeout(() => app.exit(1), 60000).unref();
mainWindow.on("closed", () => {
mainWindow = null;
});
}
app.on("window-all-closed", () => {
if (process.platform !== "darwin") app.quit();
});
app.on("activate", () => {
if (BrowserWindow.getAllWindows().length === 0) createWindow();
});

17
desktop/navigation.cjs Normal file
View File

@@ -0,0 +1,17 @@
const SITE_URL = "https://minegeo.vercel.app";
function isInternal(value) {
try {
return new URL(value).origin === SITE_URL;
} catch {
return false;
}
}
function isExternal(value) {
try {
const url = new URL(value);
return url.protocol === "https:" && !url.username && !url.password;
} catch {
return false;
}
}
module.exports = { SITE_URL, isInternal, isExternal };

3600
desktop/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

39
desktop/package.json Normal file
View File

@@ -0,0 +1,39 @@
{
"name": "minegeo-desktop",
"version": "1.0.0",
"private": true,
"description": "MineGeo desktop client",
"author": "MineGeo",
"main": "main.cjs",
"scripts": {
"start": "electron .",
"build": "node prepare-icon.mjs && electron-builder --win portable --x64"
},
"devDependencies": {
"electron": "44.7.0",
"electron-builder": "26.15.3"
},
"build": {
"appId": "app.minegeo.desktop",
"productName": "MineGeo",
"asar": true,
"directories": {
"output": "dist",
"buildResources": "assets"
},
"files": [
"main.cjs",
"navigation.cjs",
"assets/icon.png",
"assets/icon.ico"
],
"win": {
"target": "portable",
"icon": "assets/icon.ico"
},
"portable": {
"artifactName": "MineGeo-${version}-windows-x64.exe",
"requestExecutionLevel": "user"
}
}
}

34
desktop/prepare-icon.mjs Normal file
View File

@@ -0,0 +1,34 @@
import sharp from "sharp";
import { mkdir, writeFile } from "node:fs/promises";
import { fileURLToPath } from "node:url";
const assets = new URL("./assets/", import.meta.url);
await mkdir(assets, { recursive: true });
const source = fileURLToPath(new URL("../app/icon.png", import.meta.url));
await sharp(source)
.resize(512, 512, { kernel: "nearest" })
.png()
.toFile(fileURLToPath(new URL("icon.png", assets)));
const sizes = [16, 24, 32, 48, 64, 128, 256];
const images = await Promise.all(
sizes.map((size) =>
sharp(source).resize(size, size, { kernel: "nearest" }).png().toBuffer(),
),
);
const header = Buffer.alloc(6 + 16 * sizes.length);
header.writeUInt16LE(1, 2);
header.writeUInt16LE(sizes.length, 4);
let offset = header.length;
sizes.forEach((size, index) => {
const entry = 6 + index * 16;
header[entry] = size === 256 ? 0 : size;
header[entry + 1] = header[entry];
header.writeUInt16LE(1, entry + 4);
header.writeUInt16LE(32, entry + 6);
header.writeUInt32LE(images[index].length, entry + 8);
header.writeUInt32LE(offset, entry + 12);
offset += images[index].length;
});
await writeFile(
new URL("icon.ico", assets),
Buffer.concat([header, ...images]),
);

65
desktop/publish.mjs Normal file
View File

@@ -0,0 +1,65 @@
import { copyFile, mkdir, readFile, writeFile, stat } from "node:fs/promises";
import { createHash } from "node:crypto";
import { execFileSync } from "node:child_process";
import { createRequire } from "node:module";
import { fileURLToPath } from "node:url";
const require = createRequire(import.meta.url);
const asar = require("@electron/asar");
const archive = fileURLToPath(
new URL("./dist/win-unpacked/resources/app.asar", import.meta.url),
);
let privateTerms = [];
try {
const remote = execFileSync("git", ["remote", "get-url", "origin"], {
cwd: fileURLToPath(new URL("..", import.meta.url)),
encoding: "utf8",
stdio: ["ignore", "pipe", "ignore"],
windowsHide: true,
}).trim();
const hostname = remote.includes("://")
? new URL(remote).hostname
: remote.split("@").pop().split(":")[0];
privateTerms = [remote, hostname]
.filter(Boolean)
.map((term) => term.toLowerCase());
} catch {
/* Source snapshots need no Git metadata. */
}
for (const entry of asar.listPackage(archive)) {
const name = entry.replace(/^[/\\]+/, "");
if (!asar.statFile(archive, name).files) {
const content = asar
.extractFile(archive, name)
.toString("utf8")
.toLowerCase();
if (privateTerms.some((term) => content.includes(term)))
throw new Error(`Private Git reference in desktop archive: ${name}`);
}
}
console.log("Desktop archive privacy scan passed.");
const pkg = JSON.parse(
await readFile(new URL("./package.json", import.meta.url), "utf8"),
);
const file = `MineGeo-${pkg.version}-windows-x64.exe`;
const source = new URL(`./dist/${file}`, import.meta.url);
const directory = new URL("../public/desktop/", import.meta.url);
await mkdir(directory, { recursive: true });
await copyFile(source, new URL(file, directory));
const sha256 = createHash("sha256")
.update(await readFile(source))
.digest("hex");
await writeFile(new URL(`${file}.sha256`, directory), `${sha256} ${file}\n`);
await writeFile(
new URL("../shared/desktop-release.json", import.meta.url),
JSON.stringify(
{
version: pkg.version,
file: `/desktop/${file}`,
bytes: (await stat(source)).size,
sha256,
},
null,
2,
) + "\n",
);
console.log(`Published ${file} (${sha256})`);