699 lines
28 KiB
JavaScript
699 lines
28 KiB
JavaScript
// version: v9
|
|
// v9 — F-24 (Security wave 2): three new IPC handlers for encrypted
|
|
// credential storage via Electron safeStorage. Replaces the
|
|
// consumer-side plaintext password in localStorage (the audit's
|
|
// F-24 vulnerability). safeStorage uses the OS keychain — DPAPI on
|
|
// Windows, Keychain on macOS, libsecret on Linux. Encrypted blob
|
|
// written to `app.getPath('userData')/mk_credentials.json` (same
|
|
// Electron userData dir as session storage). LoginScreen.jsx v50
|
|
// consumes these via window.mkElectron.secureSavePassword/
|
|
// secureLoadPassword/secureClearPassword and runs a one-time
|
|
// migration that reads any legacy localStorage.mk_saved_pass,
|
|
// encrypts it, then deletes the plaintext. Bus thread:
|
|
// security-audit-2026-05-09 + terminal_server_vps msg #166.
|
|
// v8 — Ctrl+= / Ctrl+- / Ctrl+0 zoom shortcuts + external-URL routing
|
|
// (2026-05-08). Two operator-requested changes:
|
|
// (1) Restored browser-style font zoom that v1 had natively (browser
|
|
// mode). Wired via webContents.setZoomLevel; Ctrl+0 resets to
|
|
// 100%. Range clamped to [-8, +9] (V8's safe zone). Applied to
|
|
// BOTH the main window and detached windows. Existing F12 dev-
|
|
// tools toggle stays dev-only; zoom is always-on.
|
|
// (2) Added setWindowOpenHandler to BrowserWindows so window.open()
|
|
// calls and <a target="_blank"> clicks route to the OS default
|
|
// browser via shell.openExternal instead of spawning an embedded
|
|
// Electron child window. Specific motivator: the "OPEN FREE
|
|
// DEMO ACCOUNT" button in SettingsPanel was opening Tickmill
|
|
// inside a stripped-down Electron sub-window.
|
|
// v7 — Drop the /S NSIS silent flag from the installer spawn. Electron-
|
|
// builder's oneClick:true installer is already near-silent (small
|
|
// progress window only) AND its .onInstSuccess auto-launches the
|
|
// new exe. The /S flag put NSIS into truly-silent mode which
|
|
// ALSO suppressed the auto-launch hook — so the update finished
|
|
// with no terminal running, requiring the user to click the
|
|
// shortcut. With /S dropped, oneClick handles both: silent UX
|
|
// (small NSIS progress only) + auto-relaunch.
|
|
// v6 — Silent NSIS install. The installer is now spawned with /S and
|
|
// windowsHide: true so no NSIS GUI flashes during update. Combined
|
|
// with oneClick:true in electron-builder NSIS config, the installer
|
|
// runs silently and auto-launches the new exe on completion.
|
|
// v5 — IPC: mk:download-and-install. Replaces the old "open download URL
|
|
// in a new window" flow which spawned a blank Electron child window
|
|
// and then chained Chromium's download dialog. Now the URL is fetched
|
|
// directly via https/http (with redirects), streamed to %TEMP%, the
|
|
// installer is spawned detached, and the running terminal quits so
|
|
// NSIS can replace the install dir without "in use" conflicts.
|
|
// Quantum Terminal Consumer — Electron main process
|
|
// =============================================================================
|
|
// Approach: minimal-risk, maximum-reuse.
|
|
//
|
|
// 1. Spawn the existing Python backend (PyInstaller-built data_server.exe in
|
|
// production, or `python data_server.py` from venv_consumer in dev) as a
|
|
// hidden child process — `windowsHide: true` so the user sees no console.
|
|
//
|
|
// 2. Wait for /api/health to come up.
|
|
//
|
|
// 3. Open a single BrowserWindow pointing at http://127.0.0.1:8502/ — same
|
|
// URL the browser opens today. All React fetch('/api/...') calls resolve
|
|
// relative to that host, no frontend code changes required.
|
|
//
|
|
// 4. On window close: kill the backend child cleanly so no zombie process.
|
|
//
|
|
// This is intentionally a thin shell. The whole React/LWC/data pipeline is
|
|
// untouched — same code as production. If this proves stable, follow-ups can
|
|
// switch the renderer to load from a local file:// build for full offline /
|
|
// no-localhost-port operation.
|
|
|
|
const { app, BrowserWindow, dialog, Menu, ipcMain, shell, safeStorage } = require("electron"); // v9: safeStorage for F-24 credential encryption
|
|
const { spawn } = require("child_process");
|
|
const path = require("path");
|
|
const fs = require("fs");
|
|
const http = require("http");
|
|
const https = require("https");
|
|
const os = require("os");
|
|
const { URL } = require("url");
|
|
|
|
// v2 uses port 8503 (v1 uses 8502) so both can run simultaneously without
|
|
// conflict. Explicitly passed to the backend via --port so server_config
|
|
// defaults can't override.
|
|
const BACKEND_PORT = 8503;
|
|
const BACKEND_URL = `http://127.0.0.1:${BACKEND_PORT}`;
|
|
const HEALTH_URL = `${BACKEND_URL}/api/health`;
|
|
const READY_TIMEOUT_MS = 60_000; // give the backend up to 60s to come up
|
|
const HEALTH_POLL_MS = 500;
|
|
|
|
let backendProc = null;
|
|
let mainWindow = null;
|
|
let isQuitting = false;
|
|
|
|
// Registry of detached child windows (right-click "Open in New Window" → page).
|
|
// `parent: mainWindow` causes Electron to auto-close these when main closes,
|
|
// so we just need to track membership for cleanup + future broadcast features.
|
|
const detachedWindows = new Set();
|
|
|
|
// Allowlist must match the renderer's VALID_PAGES in useDetachedMode.js.
|
|
const VALID_DETACHED_PAGES = new Set(["quant", "fundamental", "flow", "options", "stress", "learn"]);
|
|
|
|
// ── 1. Locate the backend ─────────────────────────────────────────────────
|
|
// Production (after `npm run dist`):
|
|
// extraResources copies `source code/backend/` to `<resourcesDir>/backend/`
|
|
// PyInstaller exe is expected at `<resourcesDir>/backend/dist/data_server/data_server.exe`
|
|
// Falling back to `<resourcesDir>/backend/data_server.exe` if the user
|
|
// copied the PyInstaller output one level up.
|
|
// Development (`npm start` or `npm run start:dev`):
|
|
// Look for venv_consumer + data_server.py beside the project tree.
|
|
function findBackendCommand() {
|
|
const isDev = !app.isPackaged;
|
|
const projectRoot = isDev
|
|
? path.resolve(__dirname, "..") // v2/ folder
|
|
: process.resourcesPath; // electron resources/ in installed app
|
|
|
|
const candidates = [];
|
|
|
|
// Spawn launcher.py — it imports data_server.app, mounts the React build
|
|
// at /, and runs uvicorn. Spawning data_server.py directly only serves
|
|
// /api/* and 404s on the root URL.
|
|
// Port + no-browser are env vars (MK_PORT, MK_NO_BROWSER), set in startBackend().
|
|
|
|
// Production: prebuilt PyInstaller exe inside extraResources
|
|
if (!isDev) {
|
|
candidates.push({
|
|
cmd: path.join(projectRoot, "backend", "dist", "data_server", "data_server.exe"),
|
|
args: [],
|
|
cwd: path.join(projectRoot, "backend", "dist", "data_server"),
|
|
label: "packaged: extraResources/backend/dist/data_server/",
|
|
});
|
|
candidates.push({
|
|
cmd: path.join(projectRoot, "backend", "data_server.exe"),
|
|
args: [],
|
|
cwd: path.join(projectRoot, "backend"),
|
|
label: "packaged: extraResources/backend/",
|
|
});
|
|
}
|
|
|
|
// Dev: run launcher.py via v2's own venv. Independent of v1 — no fallbacks.
|
|
if (isDev) {
|
|
const v2Venv = path.join(projectRoot, "source code", "venv_consumer", "Scripts", "python.exe");
|
|
const launcherPy = path.join(projectRoot, "source code", "backend", "launcher.py");
|
|
if (fs.existsSync(v2Venv) && fs.existsSync(launcherPy)) {
|
|
candidates.push({
|
|
cmd: v2Venv, args: [launcherPy],
|
|
cwd: path.dirname(launcherPy),
|
|
label: "dev: v2 venv + launcher.py",
|
|
});
|
|
}
|
|
// System python only if v2 venv isn't built yet — useful for the
|
|
// "I cloned the repo, haven't run the venv setup" first-run case.
|
|
if (fs.existsSync(launcherPy)) {
|
|
candidates.push({
|
|
cmd: "python", args: [launcherPy],
|
|
cwd: path.dirname(launcherPy),
|
|
label: "dev: system python (no venv detected, may fail without deps installed)",
|
|
});
|
|
}
|
|
}
|
|
|
|
for (const c of candidates) {
|
|
if (c.cmd === "python" || (c.cmd && fs.existsSync(c.cmd))) {
|
|
console.log(`[electron] backend: ${c.label}`);
|
|
return c;
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
// ── 2. Spawn backend ──────────────────────────────────────────────────────
|
|
function startBackend() {
|
|
const target = findBackendCommand();
|
|
if (!target) {
|
|
dialog.showErrorBox(
|
|
"Backend not found",
|
|
"Could not locate the Python backend. Expected a PyInstaller-built data_server.exe inside extraResources/backend/, or a dev venv_consumer with data_server.py."
|
|
);
|
|
app.exit(1);
|
|
return;
|
|
}
|
|
|
|
console.log(`[electron] spawning: ${target.cmd} ${target.args.join(" ")}`);
|
|
backendProc = spawn(target.cmd, target.args, {
|
|
cwd: target.cwd,
|
|
windowsHide: true,
|
|
detached: false,
|
|
stdio: ["ignore", "pipe", "pipe"],
|
|
env: {
|
|
...process.env,
|
|
PYTHONUNBUFFERED: "1",
|
|
MK_PORT: String(BACKEND_PORT), // launcher.py reads this
|
|
MK_NO_BROWSER: "1", // skip auto-launch of system browser
|
|
MK_APP_DIR_NAME: "QuantumTerminal-v2", // isolate AppData from v1 (auth, cache, account state)
|
|
},
|
|
});
|
|
|
|
backendProc.stdout.on("data", (d) => console.log(`[backend] ${d.toString().trimEnd()}`));
|
|
backendProc.stderr.on("data", (d) => console.error(`[backend ERR] ${d.toString().trimEnd()}`));
|
|
backendProc.on("exit", (code, signal) => {
|
|
console.log(`[electron] backend exited code=${code} signal=${signal}`);
|
|
if (!isQuitting) {
|
|
// Backend died unexpectedly — close the window to make it visible.
|
|
if (mainWindow) mainWindow.close();
|
|
}
|
|
});
|
|
backendProc.on("error", (err) => {
|
|
console.error("[electron] backend spawn error:", err);
|
|
dialog.showErrorBox("Backend spawn failed", String(err));
|
|
});
|
|
}
|
|
|
|
// ── 3. Poll /api/health ───────────────────────────────────────────────────
|
|
function waitForBackend() {
|
|
return new Promise((resolve, reject) => {
|
|
const startTs = Date.now();
|
|
const tick = () => {
|
|
const req = http.get(HEALTH_URL, (res) => {
|
|
if (res.statusCode === 200) return resolve();
|
|
retry();
|
|
});
|
|
req.on("error", retry);
|
|
req.setTimeout(2000, () => { req.destroy(); retry(); });
|
|
};
|
|
const retry = () => {
|
|
if (Date.now() - startTs > READY_TIMEOUT_MS) return reject(new Error("backend health check timed out"));
|
|
setTimeout(tick, HEALTH_POLL_MS);
|
|
};
|
|
tick();
|
|
});
|
|
}
|
|
|
|
// ── 4. Create the window ──────────────────────────────────────────────────
|
|
function createWindow() {
|
|
mainWindow = new BrowserWindow({
|
|
width: 1600,
|
|
height: 1000,
|
|
minWidth: 1024,
|
|
minHeight: 720,
|
|
icon: path.join(app.isPackaged ? process.resourcesPath : __dirname, "..", "icon.ico"),
|
|
backgroundColor: "#000000",
|
|
show: false,
|
|
autoHideMenuBar: true,
|
|
webPreferences: {
|
|
contextIsolation: true,
|
|
nodeIntegration: false,
|
|
preload: path.join(__dirname, "preload.js"),
|
|
// Allow the renderer to talk to http://127.0.0.1:8502 — no mixed-content
|
|
// headache because Electron's default security model already permits
|
|
// localhost http when the page itself is loaded over http.
|
|
},
|
|
});
|
|
|
|
// Strip the default File/Edit/View menu — keeps the chrome looking like an app.
|
|
// Comment this out if you want devtools/menu access during testing.
|
|
Menu.setApplicationMenu(null);
|
|
|
|
mainWindow.loadURL(BACKEND_URL);
|
|
mainWindow.once("ready-to-show", () => mainWindow.show());
|
|
mainWindow.on("closed", () => { mainWindow = null; });
|
|
|
|
// v8: route window.open() and target=_blank links to OS default browser
|
|
// instead of spawning an embedded Electron sub-window.
|
|
mainWindow.webContents.setWindowOpenHandler(({ url }) => {
|
|
if (/^https?:\/\//.test(url)) {
|
|
shell.openExternal(url).catch(err =>
|
|
console.error("[electron] shell.openExternal failed:", err));
|
|
}
|
|
return { action: "deny" };
|
|
});
|
|
|
|
// v8: keyboard shortcuts. Ctrl+= / Ctrl+- / Ctrl+0 zoom (always on);
|
|
// F12 devtools (dev mode only). Single listener so we don't
|
|
// register two before-input-event handlers on the same wc.
|
|
mainWindow.webContents.on("before-input-event", (event, input) => {
|
|
if (input.type !== "keyDown") return;
|
|
// F12 dev tools — gated to dev / unpackaged builds.
|
|
if (input.key === "F12" &&
|
|
(process.env.ELECTRON_DEV === "1" || !app.isPackaged)) {
|
|
mainWindow.webContents.toggleDevTools();
|
|
return;
|
|
}
|
|
// Ctrl+= / Ctrl+- / Ctrl+0 zoom — always on, production included.
|
|
if (input.control || input.meta) {
|
|
const wc = mainWindow.webContents;
|
|
if (input.key === "=" || input.key === "+") {
|
|
wc.setZoomLevel(Math.min(wc.getZoomLevel() + 0.5, 9));
|
|
event.preventDefault();
|
|
} else if (input.key === "-") {
|
|
wc.setZoomLevel(Math.max(wc.getZoomLevel() - 0.5, -8));
|
|
event.preventDefault();
|
|
} else if (input.key === "0") {
|
|
wc.setZoomLevel(0);
|
|
event.preventDefault();
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
// ── 5. Lifecycle ──────────────────────────────────────────────────────────
|
|
app.whenReady().then(async () => {
|
|
startBackend();
|
|
try {
|
|
await waitForBackend();
|
|
console.log("[electron] backend healthy — opening window");
|
|
createWindow();
|
|
} catch (e) {
|
|
console.error("[electron] giving up:", e);
|
|
dialog.showErrorBox("Backend failed to start", String(e));
|
|
app.exit(1);
|
|
}
|
|
});
|
|
|
|
app.on("window-all-closed", () => {
|
|
isQuitting = true;
|
|
app.quit();
|
|
});
|
|
|
|
app.on("before-quit", () => {
|
|
isQuitting = true;
|
|
if (backendProc && !backendProc.killed) {
|
|
try { backendProc.kill("SIGTERM"); } catch {}
|
|
setTimeout(() => {
|
|
if (backendProc && !backendProc.killed) { try { backendProc.kill("SIGKILL"); } catch {} }
|
|
}, 3000);
|
|
}
|
|
});
|
|
|
|
// ── IPC: secure credential storage (F-24, v9) ──────────────────────────
|
|
// safeStorage encrypts via OS keychain (DPAPI on Windows, Keychain on
|
|
// macOS, libsecret on Linux). Encrypted blob stored at:
|
|
// path.join(app.getPath('userData'), 'mk_credentials.json')
|
|
// File shape: { email: <plain>, passwordEncryptedB64: <base64 of encrypted Buffer> }
|
|
// Email kept plain (not sensitive — visible on screen during entry).
|
|
// Password encrypted at rest. LoginScreen.jsx v50 calls these via
|
|
// window.mkElectron.secureSavePassword/secureLoadPassword/secureClearPassword.
|
|
|
|
function _credsFilePath() {
|
|
return path.join(app.getPath("userData"), "mk_credentials.json");
|
|
}
|
|
|
|
ipcMain.handle("mk:secure-save-password", async (_event, { email, password }) => {
|
|
try {
|
|
if (!safeStorage.isEncryptionAvailable()) {
|
|
return { ok: false, error: "encryption_unavailable" };
|
|
}
|
|
if (typeof password !== "string" || password.length === 0) {
|
|
return { ok: false, error: "empty_password" };
|
|
}
|
|
const encrypted = safeStorage.encryptString(password);
|
|
const payload = {
|
|
email: typeof email === "string" ? email : "",
|
|
passwordEncryptedB64: encrypted.toString("base64"),
|
|
savedAt: new Date().toISOString(),
|
|
};
|
|
await fs.promises.writeFile(_credsFilePath(), JSON.stringify(payload), "utf8");
|
|
return { ok: true };
|
|
} catch (e) {
|
|
console.error("[electron] secure-save-password failed:", e);
|
|
return { ok: false, error: String(e?.message || e) };
|
|
}
|
|
});
|
|
|
|
ipcMain.handle("mk:secure-load-password", async () => {
|
|
try {
|
|
const file = _credsFilePath();
|
|
if (!fs.existsSync(file)) return { ok: true, email: "", password: "" };
|
|
if (!safeStorage.isEncryptionAvailable()) {
|
|
return { ok: false, error: "encryption_unavailable" };
|
|
}
|
|
const raw = await fs.promises.readFile(file, "utf8");
|
|
const data = JSON.parse(raw);
|
|
if (!data?.passwordEncryptedB64) {
|
|
return { ok: true, email: data?.email || "", password: "" };
|
|
}
|
|
const encrypted = Buffer.from(data.passwordEncryptedB64, "base64");
|
|
const password = safeStorage.decryptString(encrypted);
|
|
return { ok: true, email: data.email || "", password };
|
|
} catch (e) {
|
|
console.error("[electron] secure-load-password failed:", e);
|
|
return { ok: false, error: String(e?.message || e) };
|
|
}
|
|
});
|
|
|
|
ipcMain.handle("mk:secure-clear-password", async () => {
|
|
try {
|
|
const file = _credsFilePath();
|
|
if (fs.existsSync(file)) await fs.promises.unlink(file);
|
|
return { ok: true };
|
|
} catch (e) {
|
|
console.error("[electron] secure-clear-password failed:", e);
|
|
return { ok: false, error: String(e?.message || e) };
|
|
}
|
|
});
|
|
|
|
// ── IPC: save screenshot via native dialog ─────────────────────────────
|
|
// Renderer sends raw PNG bytes; main pops a native dialog + writes the file.
|
|
// Bypasses Chromium's download manager → instant dialog, no base64 hop.
|
|
ipcMain.handle("mk:save-screenshot", async (_event, { suggestedName, bytes }) => {
|
|
try {
|
|
const win = BrowserWindow.fromWebContents(_event.sender) || mainWindow;
|
|
const { canceled, filePath } = await dialog.showSaveDialog(win, {
|
|
title: "Save chart screenshot",
|
|
defaultPath: suggestedName || "chart.png",
|
|
filters: [{ name: "PNG image", extensions: ["png"] }],
|
|
});
|
|
if (canceled || !filePath) return { saved: false };
|
|
// bytes arrives as Uint8Array (preload wraps ArrayBuffer). Buffer.from
|
|
// accepts Uint8Array and writes raw bytes — no base64 conversion.
|
|
await fs.promises.writeFile(filePath, Buffer.from(bytes));
|
|
return { saved: true, path: filePath };
|
|
} catch (e) {
|
|
console.error("[electron] save-screenshot failed:", e);
|
|
return { saved: false, error: String(e) };
|
|
}
|
|
});
|
|
|
|
// ── IPC: fetch Trump-related news from Google News RSS ───────────────
|
|
// Renderer's `fetch` can't reach news.google.com (no CORS). Main process
|
|
// fetches the RSS, parses items with regex (no XML lib dep), caches for
|
|
// 5 minutes so repeated toast-opens don't spam the upstream.
|
|
let trumpNewsCache = { items: null, fetchedAt: 0 };
|
|
const TRUMP_CACHE_MS = 5 * 60 * 1000;
|
|
|
|
function decodeHtml(s) {
|
|
return String(s || "")
|
|
.replace(/&/g, "&")
|
|
.replace(/</g, "<")
|
|
.replace(/>/g, ">")
|
|
.replace(/"/g, '"')
|
|
.replace(/'/g, "'")
|
|
.replace(/'/g, "'");
|
|
}
|
|
function parseRssItems(xml) {
|
|
const items = [];
|
|
const itemRe = /<item>([\s\S]*?)<\/item>/g;
|
|
let m;
|
|
while ((m = itemRe.exec(xml)) !== null) {
|
|
const block = m[1];
|
|
const titleMatch = block.match(/<title>(?:<!\[CDATA\[)?([\s\S]*?)(?:\]\]>)?<\/title>/);
|
|
const linkMatch = block.match(/<link>([\s\S]*?)<\/link>/);
|
|
const dateMatch = block.match(/<pubDate>([\s\S]*?)<\/pubDate>/);
|
|
const sourceMatch = block.match(/<source[^>]*>([\s\S]*?)<\/source>/);
|
|
items.push({
|
|
title: decodeHtml((titleMatch ? titleMatch[1] : "").trim()),
|
|
link: (linkMatch ? linkMatch[1] : "").trim(),
|
|
pubDate: (dateMatch ? dateMatch[1] : "").trim(),
|
|
source: decodeHtml((sourceMatch ? sourceMatch[1] : "").trim()),
|
|
});
|
|
}
|
|
return items;
|
|
}
|
|
|
|
ipcMain.handle("mk:fetch-trump-news", async (_event, opts) => {
|
|
const force = opts && opts.force;
|
|
const now = Date.now();
|
|
if (!force && trumpNewsCache.items && now - trumpNewsCache.fetchedAt < TRUMP_CACHE_MS) {
|
|
return {
|
|
ok: true, items: trumpNewsCache.items,
|
|
fetchedAt: trumpNewsCache.fetchedAt, cached: true,
|
|
};
|
|
}
|
|
try {
|
|
const url = "https://news.google.com/rss/search?q=Trump&hl=en-US&gl=US&ceid=US:en";
|
|
const res = await fetch(url, {
|
|
headers: { "User-Agent": "Mozilla/5.0 Quantum Terminal Consumer Terminal" },
|
|
});
|
|
if (!res.ok) {
|
|
return { ok: false, error: `HTTP ${res.status} from Google News` };
|
|
}
|
|
const xml = await res.text();
|
|
// v4: sort by pubDate descending (latest first) BEFORE slicing so we
|
|
// never drop fresher items just because Google's RSS shipped them
|
|
// out of order. Items without a parseable date sink to the bottom.
|
|
const all = parseRssItems(xml);
|
|
all.sort((a, b) => {
|
|
const ta = new Date(a.pubDate).getTime();
|
|
const tb = new Date(b.pubDate).getTime();
|
|
const sa = isNaN(ta) ? -Infinity : ta;
|
|
const sb = isNaN(tb) ? -Infinity : tb;
|
|
return sb - sa;
|
|
});
|
|
const items = all.slice(0, 30);
|
|
trumpNewsCache = { items, fetchedAt: now };
|
|
return { ok: true, items, fetchedAt: now, cached: false };
|
|
} catch (e) {
|
|
console.error("[electron] fetch-trump-news failed:", e);
|
|
return { ok: false, error: String(e) };
|
|
}
|
|
});
|
|
|
|
// ── IPC: open URL in user's default browser ──────────────────────────
|
|
ipcMain.handle("mk:open-external", async (_event, url) => {
|
|
try {
|
|
if (typeof url !== "string" || !/^https?:\/\//.test(url)) {
|
|
return { ok: false, error: "invalid url" };
|
|
}
|
|
await shell.openExternal(url);
|
|
return { ok: true };
|
|
} catch (e) {
|
|
return { ok: false, error: String(e) };
|
|
}
|
|
});
|
|
|
|
// ── v5: IPC — download installer + auto-launch + quit terminal ────────
|
|
// Renderer (UpdateAvailableModal) calls this with the installer URL.
|
|
// We follow redirects (max 5 hops), stream to %TEMP%\mk-trades-update.exe
|
|
// emitting `mk:installer-progress` events to the renderer for the progress
|
|
// UI, then spawn the .exe detached and quit the terminal so NSIS can
|
|
// replace the install directory without file-in-use errors.
|
|
function _httpGet(url, redirectsLeft = 5) {
|
|
return new Promise((resolve, reject) => {
|
|
let parsed;
|
|
try { parsed = new URL(url); } catch (e) { return reject(e); }
|
|
const lib = parsed.protocol === "https:" ? https : http;
|
|
const req = lib.get(url, { headers: { "User-Agent": "MK-TRADES-Updater" } }, (res) => {
|
|
const code = res.statusCode || 0;
|
|
if ([301, 302, 303, 307, 308].includes(code)) {
|
|
if (redirectsLeft <= 0) return reject(new Error("too many redirects"));
|
|
const loc = res.headers.location;
|
|
res.resume();
|
|
if (!loc) return reject(new Error("redirect without Location"));
|
|
const next = loc.startsWith("http") ? loc : new URL(loc, parsed).toString();
|
|
return _httpGet(next, redirectsLeft - 1).then(resolve, reject);
|
|
}
|
|
if (code < 200 || code >= 300) {
|
|
res.resume();
|
|
return reject(new Error(`HTTP ${code}`));
|
|
}
|
|
resolve(res);
|
|
});
|
|
req.on("error", reject);
|
|
req.setTimeout(120_000, () => { try { req.destroy(new Error("timeout")); } catch {} });
|
|
});
|
|
}
|
|
|
|
ipcMain.handle("mk:download-and-install", async (event, url) => {
|
|
if (typeof url !== "string" || !/^https?:\/\//.test(url)) {
|
|
return { ok: false, error: "invalid url" };
|
|
}
|
|
// Path: %TEMP%\mk-trades-update-<timestamp>.exe (timestamp avoids
|
|
// file-locked rename-on-rerun scenarios).
|
|
const fname = `mk-trades-update-${Date.now()}.exe`;
|
|
const dest = path.join(os.tmpdir(), fname);
|
|
try {
|
|
const res = await _httpGet(url);
|
|
const total = parseInt(res.headers["content-length"] || "0", 10) || 0;
|
|
let received = 0;
|
|
let lastEmit = 0;
|
|
const out = fs.createWriteStream(dest);
|
|
await new Promise((resolve, reject) => {
|
|
res.on("data", (chunk) => {
|
|
received += chunk.length;
|
|
const now = Date.now();
|
|
// Throttle progress events to ~6/s so renderer doesn't get hammered.
|
|
if (now - lastEmit > 160) {
|
|
lastEmit = now;
|
|
try {
|
|
event.sender.send("mk:installer-progress", {
|
|
received, total,
|
|
pct: total > 0 ? received / total : null,
|
|
});
|
|
} catch {}
|
|
}
|
|
});
|
|
res.on("error", reject);
|
|
out.on("error", reject);
|
|
out.on("finish", resolve);
|
|
res.pipe(out);
|
|
});
|
|
// Final progress emit so the UI can flip to "launching".
|
|
try {
|
|
event.sender.send("mk:installer-progress", {
|
|
received, total: received, pct: 1, done: true,
|
|
});
|
|
} catch {}
|
|
|
|
// Spawn the installer detached so it survives the terminal quitting.
|
|
// NSIS handles its own "stop running app" but we quit explicitly so
|
|
// the install dir is unlocked the moment NSIS reaches the file copy.
|
|
//
|
|
// v7: NO /S flag. With electron-builder oneClick:true the installer
|
|
// is already near-silent (just a small "Installing…" progress
|
|
// window) AND it auto-launches the new exe via .onInstSuccess.
|
|
// /S would suppress BOTH the progress window AND the auto-launch,
|
|
// leaving the user with no running app after update.
|
|
// windowsHide:true is kept to avoid any console flash, but the
|
|
// NSIS progress window (a GUI window, not a console) still shows.
|
|
try {
|
|
const child = spawn(dest, [], {
|
|
detached: true,
|
|
stdio: "ignore",
|
|
windowsHide: true,
|
|
});
|
|
child.unref();
|
|
} catch (e) {
|
|
return { ok: false, error: `spawn failed: ${String(e)}` };
|
|
}
|
|
|
|
// Tiny delay so the installer process is alive before we quit — avoids
|
|
// the Windows "starting up" race where a freshly-spawned detached child
|
|
// can be killed if its parent exits within ~50ms on some configs.
|
|
setTimeout(() => {
|
|
try { app.quit(); } catch {}
|
|
}, 800);
|
|
return { ok: true, file: dest };
|
|
} catch (e) {
|
|
try { fs.unlinkSync(dest); } catch {}
|
|
return { ok: false, error: String(e?.message || e) };
|
|
}
|
|
});
|
|
|
|
// ── IPC: open a detached child window for a given page ────────────────
|
|
// Renderer (TabContextMenu → preload.openInNewWindow) calls this with a
|
|
// pageId. We validate against the allowlist, spawn a BrowserWindow with
|
|
// parent: mainWindow (so it closes when main closes), and load the same
|
|
// app URL with ?detached=<pageId> so the renderer boots into DetachedShell.
|
|
ipcMain.handle("mk:open-in-new-window", async (_event, payload) => {
|
|
try {
|
|
const pageId = payload && payload.pageId;
|
|
if (!VALID_DETACHED_PAGES.has(pageId)) {
|
|
return { ok: false, error: `invalid pageId: ${String(pageId)}` };
|
|
}
|
|
if (!mainWindow) {
|
|
return { ok: false, error: "main window not available" };
|
|
}
|
|
|
|
const childIcon = path.join(app.isPackaged ? process.resourcesPath : __dirname, "..", "icon.ico");
|
|
|
|
const win = new BrowserWindow({
|
|
width: 1200,
|
|
height: 800,
|
|
minWidth: 640,
|
|
minHeight: 480,
|
|
icon: childIcon,
|
|
backgroundColor: "#000000",
|
|
parent: mainWindow,
|
|
autoHideMenuBar: true,
|
|
show: false,
|
|
webPreferences: {
|
|
contextIsolation: true,
|
|
nodeIntegration: false,
|
|
preload: path.join(__dirname, "preload.js"),
|
|
},
|
|
});
|
|
|
|
const url = `${BACKEND_URL}/?detached=${encodeURIComponent(pageId)}`;
|
|
win.loadURL(url);
|
|
win.once("ready-to-show", () => win.show());
|
|
win.on("closed", () => detachedWindows.delete(win));
|
|
|
|
// v8: external-URL routing (matches main window).
|
|
win.webContents.setWindowOpenHandler(({ url: u }) => {
|
|
if (/^https?:\/\//.test(u)) {
|
|
shell.openExternal(u).catch(err =>
|
|
console.error("[electron] shell.openExternal failed:", err));
|
|
}
|
|
return { action: "deny" };
|
|
});
|
|
|
|
// v8: keyboard shortcuts. Ctrl+= / Ctrl+- / Ctrl+0 zoom (always on);
|
|
// F12 devtools (dev mode only). Mirrors main window.
|
|
win.webContents.on("before-input-event", (event, input) => {
|
|
if (input.type !== "keyDown") return;
|
|
if (input.key === "F12" &&
|
|
(process.env.ELECTRON_DEV === "1" || !app.isPackaged)) {
|
|
win.webContents.toggleDevTools();
|
|
return;
|
|
}
|
|
if (input.control || input.meta) {
|
|
const wc = win.webContents;
|
|
if (input.key === "=" || input.key === "+") {
|
|
wc.setZoomLevel(Math.min(wc.getZoomLevel() + 0.5, 9));
|
|
event.preventDefault();
|
|
} else if (input.key === "-") {
|
|
wc.setZoomLevel(Math.max(wc.getZoomLevel() - 0.5, -8));
|
|
event.preventDefault();
|
|
} else if (input.key === "0") {
|
|
wc.setZoomLevel(0);
|
|
event.preventDefault();
|
|
}
|
|
}
|
|
});
|
|
|
|
detachedWindows.add(win);
|
|
console.log(`[electron] spawned detached window: pageId=${pageId} url=${url}`);
|
|
return { ok: true };
|
|
} catch (e) {
|
|
console.error("[electron] open-in-new-window failed:", e);
|
|
return { ok: false, error: String(e) };
|
|
}
|
|
});
|
|
|
|
// Single-instance lock — clicking the icon while running just focuses the window
|
|
const gotLock = app.requestSingleInstanceLock();
|
|
if (!gotLock) {
|
|
app.quit();
|
|
} else {
|
|
app.on("second-instance", () => {
|
|
if (mainWindow) {
|
|
if (mainWindow.isMinimized()) mainWindow.restore();
|
|
mainWindow.focus();
|
|
}
|
|
});
|
|
}
|