Add portable Windows desktop downloads and release uploads
This commit is contained in:
1
.gitignore
vendored
1
.gitignore
vendored
@@ -1,4 +1,5 @@
|
|||||||
node_modules/
|
node_modules/
|
||||||
|
desktop/dist/
|
||||||
.next/
|
.next/
|
||||||
.env*
|
.env*
|
||||||
!.env.example
|
!.env.example
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { Header, Footer } from "@/components/Shell";
|
|||||||
import { Blocks, Server, Download, ExternalLink } from "lucide-react";
|
import { Blocks, Server, Download, ExternalLink } from "lucide-react";
|
||||||
import { existsSync } from "node:fs";
|
import { existsSync } from "node:fs";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
|
import desktopRelease from "@/shared/desktop-release.json";
|
||||||
const release = "0.1.3";
|
const release = "0.1.3";
|
||||||
const versions = ["1.21.11", "26.1", "26.2"];
|
const versions = ["1.21.11", "26.1", "26.2"];
|
||||||
export const dynamic = "force-dynamic";
|
export const dynamic = "force-dynamic";
|
||||||
@@ -14,6 +15,34 @@ export default async function Downloads() {
|
|||||||
<>
|
<>
|
||||||
<Header />
|
<Header />
|
||||||
<main className="section page-content">
|
<main className="section page-content">
|
||||||
|
<article className="tool-card" style={{ marginBottom: 40 }}>
|
||||||
|
<span className="eyebrow">{t("MINEGEO DESKTOP")}</span>
|
||||||
|
<h2>{t("Your next expedition, on your desktop.")}</h2>
|
||||||
|
<p className="muted">
|
||||||
|
{t(
|
||||||
|
"Download the portable app, run the EXE, and start exploring. No installation needed.",
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
<a className="button primary" href={desktopRelease.file} download>
|
||||||
|
<Download size={16} /> {t("Download for Windows")}
|
||||||
|
</a>
|
||||||
|
<p className="muted" style={{ marginTop: 16 }}>
|
||||||
|
{t(
|
||||||
|
"Windows 10/11 · 64-bit · Version {version} · Internet required",
|
||||||
|
{ version: desktopRelease.version },
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
<p className="muted">
|
||||||
|
{t(
|
||||||
|
"The app connects to minegeo.vercel.app and keeps you signed in on this device.",
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
<p className="muted">
|
||||||
|
{t(
|
||||||
|
"This release is unsigned. Windows may show a publisher warning when you open it.",
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
</article>
|
||||||
<span className="eyebrow">
|
<span className="eyebrow">
|
||||||
{t("MADE FOR THE PEOPLE WHO KNOW THEIR WORLDS")}
|
{t("MADE FOR THE PEOPLE WHO KNOW THEIR WORLDS")}
|
||||||
</span>
|
</span>
|
||||||
|
|||||||
@@ -263,6 +263,7 @@ nav a:hover,
|
|||||||
}
|
}
|
||||||
.hero-actions {
|
.hero-actions {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 26px;
|
gap: 26px;
|
||||||
margin-top: 30px;
|
margin-top: 30px;
|
||||||
|
|||||||
@@ -8,12 +8,14 @@ import {
|
|||||||
Timer,
|
Timer,
|
||||||
Blocks,
|
Blocks,
|
||||||
Upload,
|
Upload,
|
||||||
|
Download,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { Header, Footer } from "@/components/Shell";
|
import { Header, Footer } from "@/components/Shell";
|
||||||
import { MapCard } from "@/components/MapCard";
|
import { MapCard } from "@/components/MapCard";
|
||||||
import { catalog } from "@/lib/catalog";
|
import { catalog } from "@/lib/catalog";
|
||||||
import { leaderboard } from "@/lib/leaderboard";
|
import { leaderboard } from "@/lib/leaderboard";
|
||||||
import { LeaderboardPreview } from "@/components/Leaderboard";
|
import { LeaderboardPreview } from "@/components/Leaderboard";
|
||||||
|
import desktopRelease from "@/shared/desktop-release.json";
|
||||||
export const dynamic = "force-dynamic";
|
export const dynamic = "force-dynamic";
|
||||||
export default async function Home() {
|
export default async function Home() {
|
||||||
const { t, formatNumber } = await getI18n();
|
const { t, formatNumber } = await getI18n();
|
||||||
@@ -45,6 +47,9 @@ export default async function Home() {
|
|||||||
<Link href="/servers" className="button primary">
|
<Link href="/servers" className="button primary">
|
||||||
{t("Start exploring")} <ArrowRight size={18} />
|
{t("Start exploring")} <ArrowRight size={18} />
|
||||||
</Link>
|
</Link>
|
||||||
|
<a href={desktopRelease.file} className="button" download>
|
||||||
|
<Download size={18} /> {t("Download for Windows")}
|
||||||
|
</a>
|
||||||
<Link href="/leaderboard" className="text-link">
|
<Link href="/leaderboard" className="text-link">
|
||||||
{t("Meet the top explorers")} <ArrowRight size={14} />
|
{t("Meet the top explorers")} <ArrowRight size={14} />
|
||||||
</Link>
|
</Link>
|
||||||
|
|||||||
BIN
desktop/assets/icon.ico
Normal file
BIN
desktop/assets/icon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 31 KiB |
BIN
desktop/assets/icon.png
Normal file
BIN
desktop/assets/icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 13 KiB |
175
desktop/main.cjs
Normal file
175
desktop/main.cjs
Normal 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
17
desktop/navigation.cjs
Normal 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
3600
desktop/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
39
desktop/package.json
Normal file
39
desktop/package.json
Normal 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
34
desktop/prepare-icon.mjs
Normal 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
65
desktop/publish.mjs
Normal 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})`);
|
||||||
22
docs/desktop.md
Normal file
22
docs/desktop.md
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
# MineGeo Desktop
|
||||||
|
|
||||||
|
The Windows x64 client is a portable Electron EXE. Open it directly; no installation or administrator access is needed. It connects to https://minegeo.vercel.app and requires internet access. Website updates appear automatically on reload; Electron runtime updates require a new EXE.
|
||||||
|
|
||||||
|
The EXE extracts its runtime into a temporary directory while running. Sign-in, cookies, and site preferences persist separately in `%APPDATA%/minegeo-desktop`. Removing the EXE does not clear that profile. External HTTPS links open in the default browser. The client has no Node.js bridge to the remote site, uses Chromium sandboxing and context isolation, and denies device permissions.
|
||||||
|
|
||||||
|
## Build and publish
|
||||||
|
|
||||||
|
From the repository root:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
npm --prefix desktop ci
|
||||||
|
npm run desktop:build
|
||||||
|
npm run desktop:publish
|
||||||
|
npm run deploy
|
||||||
|
```
|
||||||
|
|
||||||
|
`desktop:build` generates the multi-resolution Windows icon from `app/icon.png` and builds `desktop/dist/MineGeo-1.0.0-windows-x64.exe`. `desktop:publish` copies only the portable EXE and SHA-256 checksum into `public/desktop/` and updates the shared release metadata used by both download buttons. Bump `desktop/package.json` before future releases, rebuild, and publish. The desktop toolchain has its own lockfile and is excluded from the Vercel build.
|
||||||
|
|
||||||
|
Run `npm run desktop:dev` during desktop development. Run `desktop/dist/win-unpacked/MineGeo.exe --smoke-test` to verify that the packaged client loads production with its security settings. The smoke test exits with a nonzero status if loading fails or times out.
|
||||||
|
|
||||||
|
This initial release is unsigned and Windows may display an unknown-publisher warning. Signing future releases requires the publisher's code-signing certificate; no signing credentials are bundled. macOS and Linux builds are not included in this release.
|
||||||
@@ -1,5 +1,17 @@
|
|||||||
# Deploy MineGeo
|
# Deploy MineGeo
|
||||||
|
|
||||||
|
The portable Windows Electron client and release process are described in
|
||||||
|
[Desktop client](desktop.md). Build and publish the EXE before deploying changes
|
||||||
|
to desktop download links. Public desktop releases are included in the upload;
|
||||||
|
the local Electron source, build cache, and packaging toolchain are excluded.
|
||||||
|
|
||||||
|
For the Vercel plugin deployment of Desktop 1.0.0, the public installer parts
|
||||||
|
are staged in Supabase Storage's `minegeo-desktop-builds` bucket under the
|
||||||
|
release SHA-256. The deployment snapshot downloads and verifies each part before
|
||||||
|
the existing assembly script verifies the complete EXE. Keep these immutable
|
||||||
|
parts for rebuilding that snapshot. No Storage credentials are included in the
|
||||||
|
snapshot, and players download the assembled EXE from `/desktop/` on MineGeo.
|
||||||
|
|
||||||
Optional Discord map and multiplayer-party announcements use `DISCORD_BOT_TOKEN`,
|
Optional Discord map and multiplayer-party announcements use `DISCORD_BOT_TOKEN`,
|
||||||
`DISCORD_MAPS_CHANNEL_ID`, and `DISCORD_PARTIES_CHANNEL_ID`. All published parties
|
`DISCORD_MAPS_CHANNEL_ID`, and `DISCORD_PARTIES_CHANNEL_ID`. All published parties
|
||||||
use the same configured channel. Follow [Discord bot setup](discord-integration.md)
|
use the same configured channel. Follow [Discord bot setup](discord-integration.md)
|
||||||
@@ -19,7 +31,8 @@ invite links. Blank, malformed, or unsupported URLs hide the corresponding
|
|||||||
button. Each button opens its configured destination in a new tab.
|
button. Each button opens its configured destination in a new tab.
|
||||||
|
|
||||||
Run `npm run deploy` from the project directory. The script collects all web
|
Run `npm run deploy` from the project directory. The script collects all web
|
||||||
source files and public assets, uploads them in one request, waits for the build,
|
source files and public assets, uploads large release files separately and the
|
||||||
|
remaining source in one request, waits for the build,
|
||||||
and prints the deployment URL. It uses the [Vercel deployments API](https://vercel.com/docs/rest-api/deployments/create-a-new-deployment). The database credentials already configured in
|
and prints the deployment URL. It uses the [Vercel deployments API](https://vercel.com/docs/rest-api/deployments/create-a-new-deployment). The database credentials already configured in
|
||||||
Vercel remain there. Mod captures are processed by the deployed Vercel API:
|
Vercel remain there. Mod captures are processed by the deployed Vercel API:
|
||||||
upload finalization starts background image processing with Next.js `after`,
|
upload finalization starts background image processing with Next.js `after`,
|
||||||
|
|||||||
@@ -4,6 +4,9 @@
|
|||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
"desktop:dev": "npm --prefix desktop start",
|
||||||
|
"desktop:build": "npm --prefix desktop run build",
|
||||||
|
"desktop:publish": "node desktop/publish.mjs",
|
||||||
"deploy": "node scripts/deploy-vercel.mjs",
|
"deploy": "node scripts/deploy-vercel.mjs",
|
||||||
"dev": "node scripts/supervise.mjs dev",
|
"dev": "node scripts/supervise.mjs dev",
|
||||||
"dev:web": "next dev --hostname 127.0.0.1",
|
"dev:web": "next dev --hostname 127.0.0.1",
|
||||||
|
|||||||
2
proxy.ts
2
proxy.ts
@@ -33,6 +33,6 @@ export async function proxy(request: NextRequest) {
|
|||||||
}
|
}
|
||||||
export const config = {
|
export const config = {
|
||||||
matcher: [
|
matcher: [
|
||||||
"/((?!_next/static|_next/image|favicon.ico|\\.well-known/workflow/|tools/|fonts/|images/|.*\\.(?:svg|png|jpg|jpeg|gif|webp|ico|ttf|woff|woff2|jar)$).*)",
|
"/((?!_next/static|_next/image|favicon.ico|\\.well-known/workflow/|tools/|desktop/|fonts/|images/|.*\\.(?:svg|png|jpg|jpeg|gif|webp|ico|ttf|woff|woff2|jar)$).*)",
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
|||||||
BIN
public/desktop/MineGeo-1.0.0-windows-x64.exe
Normal file
BIN
public/desktop/MineGeo-1.0.0-windows-x64.exe
Normal file
Binary file not shown.
1
public/desktop/MineGeo-1.0.0-windows-x64.exe.sha256
Normal file
1
public/desktop/MineGeo-1.0.0-windows-x64.exe.sha256
Normal file
@@ -0,0 +1 @@
|
|||||||
|
6cae2a0b8ddf37083377b2f68bbba690cbaafa933c64eb7a5e797da43294059f MineGeo-1.0.0-windows-x64.exe
|
||||||
23
scripts/assemble-desktop-release.mjs
Normal file
23
scripts/assemble-desktop-release.mjs
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
import fs from "node:fs";
|
||||||
|
import path from "node:path";
|
||||||
|
import { createHash } from "node:crypto";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
|
||||||
|
export function assembleDesktopRelease(root) {
|
||||||
|
const directory = path.join(root, ".desktop-release");
|
||||||
|
const manifestPath = path.join(directory, "manifest.json");
|
||||||
|
if (!fs.existsSync(manifestPath)) return;
|
||||||
|
const manifest = JSON.parse(fs.readFileSync(manifestPath, "utf8"));
|
||||||
|
if (!/^MineGeo-[\d.]+-windows-x64\.exe$/.test(manifest.name)) throw new Error("Invalid desktop release name");
|
||||||
|
if (!Number.isInteger(manifest.parts) || manifest.parts < 1 || manifest.parts > 100) throw new Error("Invalid desktop release parts");
|
||||||
|
const binary = Buffer.concat(Array.from({ length: manifest.parts }, (_, i) => fs.readFileSync(path.join(directory, `part-${i}`))));
|
||||||
|
if (binary.length !== manifest.bytes || createHash("sha256").update(binary).digest("hex") !== manifest.sha256) throw new Error("Desktop release checksum mismatch");
|
||||||
|
const destination = path.join(root, "public", "desktop");
|
||||||
|
fs.mkdirSync(destination, { recursive: true });
|
||||||
|
fs.writeFileSync(path.join(destination, manifest.name), binary);
|
||||||
|
console.log(`Verified and assembled desktop release: ${manifest.name}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
|
||||||
|
assembleDesktopRelease(path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."));
|
||||||
|
}
|
||||||
@@ -4,6 +4,7 @@ import os from "node:os";
|
|||||||
import { fileURLToPath } from "node:url";
|
import { fileURLToPath } from "node:url";
|
||||||
import { execFileSync } from "node:child_process";
|
import { execFileSync } from "node:child_process";
|
||||||
import { unzipSync } from "fflate";
|
import { unzipSync } from "fflate";
|
||||||
|
import { createHash } from "node:crypto";
|
||||||
|
|
||||||
const defaultRoot = path.resolve(
|
const defaultRoot = path.resolve(
|
||||||
path.dirname(fileURLToPath(import.meta.url)),
|
path.dirname(fileURLToPath(import.meta.url)),
|
||||||
@@ -52,7 +53,7 @@ export function deploymentFiles(root = defaultRoot) {
|
|||||||
if (relative === "package.json") {
|
if (relative === "package.json") {
|
||||||
const pkg = JSON.parse(data);
|
const pkg = JSON.parse(data);
|
||||||
pkg.scripts = {
|
pkg.scripts = {
|
||||||
prebuild: "node scripts/clean-build.mjs",
|
prebuild: "node scripts/assemble-desktop-release.mjs && node scripts/clean-build.mjs",
|
||||||
build: "next build",
|
build: "next build",
|
||||||
start: "next start",
|
start: "next start",
|
||||||
};
|
};
|
||||||
@@ -89,6 +90,15 @@ export function deploymentFiles(root = defaultRoot) {
|
|||||||
throw new Error(
|
throw new Error(
|
||||||
`Private Git server reference found in ${relative}. Remove it before deploying.`,
|
`Private Git server reference found in ${relative}. Remove it before deploying.`,
|
||||||
);
|
);
|
||||||
|
if (relative.startsWith("public/desktop/") && relative.endsWith(".exe") && data.length > 10 * 1024 * 1024) {
|
||||||
|
const chunkSize = 6 * 1024 * 1024;
|
||||||
|
const parts = Math.ceil(data.length / chunkSize);
|
||||||
|
for (let index = 0; index < parts; index++) {
|
||||||
|
files.push({ file: `.desktop-release/part-${index}`, data: data.subarray(index * chunkSize, (index + 1) * chunkSize).toString("base64"), encoding: "base64" });
|
||||||
|
}
|
||||||
|
files.push({ file: ".desktop-release/manifest.json", data: Buffer.from(JSON.stringify({ name: path.basename(relative), parts, bytes: data.length, sha256: createHash("sha256").update(data).digest("hex") })).toString("base64"), encoding: "base64" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
files.push({
|
files.push({
|
||||||
file: relative,
|
file: relative,
|
||||||
data: data.toString("base64"),
|
data: data.toString("base64"),
|
||||||
@@ -124,6 +134,7 @@ export function deploymentFiles(root = defaultRoot) {
|
|||||||
"postcss.config.mjs",
|
"postcss.config.mjs",
|
||||||
"vercel.json",
|
"vercel.json",
|
||||||
"scripts/clean-build.mjs",
|
"scripts/clean-build.mjs",
|
||||||
|
"scripts/assemble-desktop-release.mjs",
|
||||||
]) {
|
]) {
|
||||||
if (fs.existsSync(path.join(root, file))) add(path.join(root, file), file);
|
if (fs.existsSync(path.join(root, file))) add(path.join(root, file), file);
|
||||||
}
|
}
|
||||||
@@ -220,7 +231,36 @@ export async function deploy(
|
|||||||
);
|
);
|
||||||
return data;
|
return data;
|
||||||
}
|
}
|
||||||
let deployment = await api("/v13/deployments", payload);
|
const files = [];
|
||||||
|
for (const file of payload.files) {
|
||||||
|
const data = Buffer.from(file.data || "", "base64");
|
||||||
|
if (file.encoding !== "base64" || data.length <= 10 * 1024 * 1024) {
|
||||||
|
files.push(file);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const sha = createHash("sha1").update(data).digest("hex");
|
||||||
|
const response = await request(
|
||||||
|
`https://api.vercel.com/v2/files?teamId=${encodeURIComponent(team)}`,
|
||||||
|
{
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${token}`,
|
||||||
|
"Content-Type": "application/octet-stream",
|
||||||
|
"Content-Length": String(data.length),
|
||||||
|
"x-vercel-digest": sha,
|
||||||
|
},
|
||||||
|
body: data,
|
||||||
|
signal: AbortSignal.timeout(300000),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
if (!response.ok)
|
||||||
|
throw new Error(
|
||||||
|
`Vercel file upload failed (${response.status}): ${file.file}`,
|
||||||
|
);
|
||||||
|
files.push({ file: file.file, sha, size: data.length });
|
||||||
|
log(`Uploaded release: ${file.file}`);
|
||||||
|
}
|
||||||
|
let deployment = await api("/v13/deployments", { ...payload, files });
|
||||||
if (!deployment.id) throw new Error("Vercel did not return a deployment ID.");
|
if (!deployment.id) throw new Error("Vercel did not return a deployment ID.");
|
||||||
log(
|
log(
|
||||||
`Uploaded ${payload.files.length} site files in one request. Waiting for Vercel…`,
|
`Uploaded ${payload.files.length} site files in one request. Waiting for Vercel…`,
|
||||||
|
|||||||
6
shared/desktop-release.json
Normal file
6
shared/desktop-release.json
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
{
|
||||||
|
"version": "1.0.0",
|
||||||
|
"file": "/desktop/MineGeo-1.0.0-windows-x64.exe",
|
||||||
|
"bytes": 100277533,
|
||||||
|
"sha256": "6cae2a0b8ddf37083377b2f68bbba690cbaafa933c64eb7a5e797da43294059f"
|
||||||
|
}
|
||||||
@@ -841,5 +841,12 @@
|
|||||||
"This report is closed and will be deleted from MineGeo and Linear after three days.": "Обращение закрыто и будет удалено из MineGeo и Linear через три дня.",
|
"This report is closed and will be deleted from MineGeo and Linear after three days.": "Обращение закрыто и будет удалено из MineGeo и Linear через три дня.",
|
||||||
"Scheduled deletion": "Дата удаления",
|
"Scheduled deletion": "Дата удаления",
|
||||||
"Syncing Done status with Linear…": "Синхронизируем статус «Завершено» с Linear…",
|
"Syncing Done status with Linear…": "Синхронизируем статус «Завершено» с Linear…",
|
||||||
"Support cleanup paused. The support team may need to update Linear permissions.": "Удаление обращения приостановлено. Поддержке может потребоваться обновить права доступа к Linear."
|
"Support cleanup paused. The support team may need to update Linear permissions.": "Удаление обращения приостановлено. Поддержке может потребоваться обновить права доступа к Linear.",
|
||||||
|
"Download for Windows": "Скачать для Windows",
|
||||||
|
"MINEGEO DESKTOP": "MINEGEO ДЛЯ КОМПЬЮТЕРА",
|
||||||
|
"Your next expedition, on your desktop.": "Следующая экспедиция — на вашем компьютере.",
|
||||||
|
"Download the portable app, run the EXE, and start exploring. No installation needed.": "Скачайте портативное приложение, запустите EXE и начните исследование. Установка не нужна.",
|
||||||
|
"Windows 10/11 · 64-bit · Version {version} · Internet required": "Windows 10/11 · 64 бита · Версия {version} · Нужен интернет",
|
||||||
|
"The app connects to minegeo.vercel.app and keeps you signed in on this device.": "Приложение подключается к minegeo.vercel.app и сохраняет вход на этом устройстве.",
|
||||||
|
"This release is unsigned. Windows may show a publisher warning when you open it.": "Эта версия без цифровой подписи. При запуске Windows может показать предупреждение об издателе."
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -188,3 +188,43 @@ test("failed builds and rejected authentication never report success", async ()
|
|||||||
/Vercel 401/,
|
/Vercel 401/,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("large desktop releases upload as binary before deployment and failures stop release", async () => {
|
||||||
|
const binary = Buffer.alloc(10 * 1024 * 1024 + 1, 7);
|
||||||
|
const payload = deploymentPayload([
|
||||||
|
{
|
||||||
|
file: "public/desktop/MineGeo.exe",
|
||||||
|
data: binary.toString("base64"),
|
||||||
|
encoding: "base64",
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
const requests: { url: string; init: RequestInit }[] = [];
|
||||||
|
await deploy(payload, {
|
||||||
|
token: "test",
|
||||||
|
log: () => {},
|
||||||
|
request: async (url, init = {}) => {
|
||||||
|
requests.push({ url: String(url), init });
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify(
|
||||||
|
String(url).includes("/v2/files")
|
||||||
|
? {}
|
||||||
|
: { id: "release", readyState: "READY", url: "example.invalid" },
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
assert.ok(requests[0].url.includes("/v2/files"));
|
||||||
|
assert.deepEqual(requests[0].init.body, binary);
|
||||||
|
const file = JSON.parse(requests[1].init.body as string).files[0];
|
||||||
|
assert.equal(file.size, binary.length);
|
||||||
|
assert.match(file.sha, /^[a-f0-9]{40}$/);
|
||||||
|
assert.equal(file.data, undefined);
|
||||||
|
await assert.rejects(
|
||||||
|
deploy(payload, {
|
||||||
|
token: "test",
|
||||||
|
log: () => {},
|
||||||
|
request: async () => new Response("", { status: 403 }),
|
||||||
|
}),
|
||||||
|
/file upload failed/,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|||||||
33
tests/desktop.test.ts
Normal file
33
tests/desktop.test.ts
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { test } from "node:test";
|
||||||
|
import { createRequire } from "node:module";
|
||||||
|
const require = createRequire(import.meta.url);
|
||||||
|
const { isInternal, isExternal } = require("../desktop/navigation.cjs");
|
||||||
|
|
||||||
|
test("desktop navigation confines remote content to the production origin", () => {
|
||||||
|
assert.equal(isInternal("https://minegeo.vercel.app/play?map=1"), true);
|
||||||
|
for (const url of [
|
||||||
|
"http://minegeo.vercel.app",
|
||||||
|
"https://minegeo.vercel.app.evil.test",
|
||||||
|
"https://minegeo.vercel.app@evil.test",
|
||||||
|
"file:///C:/Windows",
|
||||||
|
"javascript:alert(1)",
|
||||||
|
"not a URL",
|
||||||
|
]) {
|
||||||
|
assert.equal(isInternal(url), false, url);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("desktop only opens credential-free HTTPS links in external apps", () => {
|
||||||
|
assert.equal(isExternal("https://discord.com/invite/example"), true);
|
||||||
|
for (const url of [
|
||||||
|
"file:///C:/Windows",
|
||||||
|
"javascript:alert(1)",
|
||||||
|
"ms-settings:windowsupdate",
|
||||||
|
"http://example.com",
|
||||||
|
"https://user:password@example.com",
|
||||||
|
"not a URL",
|
||||||
|
]) {
|
||||||
|
assert.equal(isExternal(url), false, url);
|
||||||
|
}
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user