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

1
.gitignore vendored
View File

@@ -1,4 +1,5 @@
node_modules/ node_modules/
desktop/dist/
.next/ .next/
.env* .env*
!.env.example !.env.example

View File

@@ -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>

View File

@@ -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;

View File

@@ -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

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})`);

22
docs/desktop.md Normal file
View 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.

View File

@@ -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`,

View File

@@ -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",

View File

@@ -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)$).*)",
], ],
}; };

Binary file not shown.

View File

@@ -0,0 +1 @@
6cae2a0b8ddf37083377b2f68bbba690cbaafa933c64eb7a5e797da43294059f MineGeo-1.0.0-windows-x64.exe

View 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)), ".."));
}

View File

@@ -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…`,

View File

@@ -0,0 +1,6 @@
{
"version": "1.0.0",
"file": "/desktop/MineGeo-1.0.0-windows-x64.exe",
"bytes": 100277533,
"sha256": "6cae2a0b8ddf37083377b2f68bbba690cbaafa933c64eb7a5e797da43294059f"
}

View File

@@ -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 может показать предупреждение об издателе."
} }

View File

@@ -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
View 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);
}
});