Initial commit: Quantum Terminal — Free & Open Source Trading Platform
This commit is contained in:
@@ -0,0 +1,698 @@
|
||||
// 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();
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
// version: v5
|
||||
// Electron preload — context bridge for privileged native APIs.
|
||||
//
|
||||
// Anything renderer-side that needs to talk to the OS (file save dialog,
|
||||
// open new window, etc.) goes through here.
|
||||
//
|
||||
// v5 — F-24 (Security wave 2): exposes secureSavePassword /
|
||||
// secureLoadPassword / secureClearPassword that route to main's
|
||||
// safeStorage via IPC. Replaces consumer-side plaintext password
|
||||
// in localStorage (audit F-24 vulnerability). LoginScreen.jsx v50
|
||||
// consumes these; one-time migration in v50 reads any legacy
|
||||
// localStorage.mk_saved_pass, encrypts it, then deletes the
|
||||
// plaintext. See main.js v9 for IPC handler details + storage path.
|
||||
// v4 — exposes downloadAndInstall(url, onProgress) for the in-app updater.
|
||||
// The main process streams the installer to %TEMP%, fires
|
||||
// "mk:installer-progress" events for the UI, then spawns the .exe
|
||||
// detached and quits the terminal. Renderer-side callers wire the
|
||||
// onProgress callback to a progress bar and await the return value.
|
||||
|
||||
const { contextBridge, ipcRenderer } = require("electron");
|
||||
|
||||
contextBridge.exposeInMainWorld("mkElectron", {
|
||||
isElectron: true,
|
||||
|
||||
// saveScreenshot(suggestedName, bytes)
|
||||
// suggestedName: string filename ("US500_M15_2026-04-27.png")
|
||||
// bytes: Uint8Array | ArrayBuffer of raw PNG bytes
|
||||
// returns: Promise<{ saved: boolean, path?: string }>
|
||||
//
|
||||
// Bypasses Chromium's download manager — instead the main process pops
|
||||
// a native dialog.showSaveDialog and fs.writeFile-s the buffer directly.
|
||||
// Much faster than `link.click()` on a data URL.
|
||||
saveScreenshot: async (suggestedName, bytes) => {
|
||||
// Bytes can be Uint8Array (good) or ArrayBuffer (we wrap). IPC handles
|
||||
// typed arrays as Buffer on the main side.
|
||||
const buf = bytes instanceof ArrayBuffer ? new Uint8Array(bytes) : bytes;
|
||||
return ipcRenderer.invoke("mk:save-screenshot", { suggestedName, bytes: buf });
|
||||
},
|
||||
|
||||
// openInNewWindow(pageId)
|
||||
// pageId: one of "quant" | "fundamental" | "flow" | "options" | "stress" | "learn"
|
||||
// returns: Promise<{ ok: boolean, error?: string }>
|
||||
//
|
||||
// Asks the main process to spawn a child BrowserWindow that loads the
|
||||
// same React app with `?detached=<pageId>` so the renderer boots into
|
||||
// DetachedShell mode. Each child window has parent: mainWindow so it
|
||||
// closes automatically when the user closes the main window.
|
||||
openInNewWindow: (pageId) => ipcRenderer.invoke("mk:open-in-new-window", { pageId }),
|
||||
|
||||
// fetchTrumpNews({ force? })
|
||||
// returns Promise<{ ok, items?: [{title, link, pubDate, source}], fetchedAt?, cached?, error? }>
|
||||
//
|
||||
// Main process pulls the Google News RSS feed (search=Trump), parses items,
|
||||
// caches in-memory for 5 min. Pass { force: true } to bypass cache.
|
||||
fetchTrumpNews: (opts) => ipcRenderer.invoke("mk:fetch-trump-news", opts || {}),
|
||||
|
||||
// openExternal(url)
|
||||
// Opens the given URL in the user's default browser via shell.openExternal.
|
||||
// Only http(s) URLs are allowed.
|
||||
openExternal: (url) => ipcRenderer.invoke("mk:open-external", url),
|
||||
|
||||
// v5: secureSavePassword(email, password)
|
||||
// F-24 — Replaces localStorage.mk_saved_pass with OS-keychain
|
||||
// encrypted storage via main process safeStorage. Email kept plain
|
||||
// (not sensitive); password encrypted at rest. Returns
|
||||
// Promise<{ ok, error? }>. error="encryption_unavailable" on
|
||||
// platforms where safeStorage can't access keychain (rare Linux).
|
||||
secureSavePassword: (email, password) =>
|
||||
ipcRenderer.invoke("mk:secure-save-password", { email, password }),
|
||||
|
||||
// v5: secureLoadPassword()
|
||||
// Returns Promise<{ ok, email?, password?, error? }>. When no creds
|
||||
// saved, returns { ok: true, email: "", password: "" }. Decryption
|
||||
// failures (encrypted-but-keychain-rotated) return { ok: false,
|
||||
// error: ... }. Callers should treat any error path as "no saved
|
||||
// password" and prompt the user.
|
||||
secureLoadPassword: () => ipcRenderer.invoke("mk:secure-load-password"),
|
||||
|
||||
// v5: secureClearPassword()
|
||||
// Deletes the encrypted creds file. Returns Promise<{ ok, error? }>.
|
||||
// Idempotent — succeeds even if no file exists.
|
||||
secureClearPassword: () => ipcRenderer.invoke("mk:secure-clear-password"),
|
||||
|
||||
// v4: downloadAndInstall(url, onProgress?)
|
||||
// Streams installer to %TEMP%, spawns it detached, then quits the
|
||||
// running terminal. onProgress({ received, total, pct, done? }) is
|
||||
// called periodically while downloading.
|
||||
// Returns Promise<{ ok, file?, error? }> — terminal will quit ~800ms
|
||||
// after { ok: true } so the renderer typically only sees the resolve
|
||||
// moment briefly before the window closes.
|
||||
downloadAndInstall: (url, onProgress) => {
|
||||
const handler = (_e, data) => {
|
||||
try { if (typeof onProgress === "function") onProgress(data || {}); } catch {}
|
||||
};
|
||||
ipcRenderer.on("mk:installer-progress", handler);
|
||||
const cleanup = () => { try { ipcRenderer.removeListener("mk:installer-progress", handler); } catch {} };
|
||||
return ipcRenderer.invoke("mk:download-and-install", url)
|
||||
.then((res) => { cleanup(); return res; })
|
||||
.catch((e) => { cleanup(); throw e; });
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user