Compare commits

...

2 Commits

Author SHA1 Message Date
libalpm64
071c95ab5c chore(deps): bump fast-xml-parser and @aws-sdk/xml-builder
Some checks failed
build-packages / build-linux-x64 (push) Has been cancelled
build-packages / build-macos (push) Has been cancelled
build-packages / build-windows (push) Has been cancelled
build-packages / build-linux-arm64 (push) Has been cancelled
build-packages / release (push) Has been cancelled
Closes #770
2026-04-19 16:38:44 +08:00
陈大猫
ec99875dec [codex] avoid main-process runtime crashes (#772)
* avoid main-process runtime crashes

* fix main-process startup error boundary

* tighten main-process startup readiness

* fix startup fallback window health checks

* exclude hidden windows from recovery checks
2026-04-19 16:31:00 +08:00
6 changed files with 895 additions and 120 deletions

View File

@@ -0,0 +1,253 @@
const test = require("node:test");
const assert = require("node:assert/strict");
const { EventEmitter } = require("node:events");
const {
classifyProcessError,
createProcessErrorController,
installProcessErrorHandlers,
isNonFatalNetworkError,
} = require("./processErrorGuards.cjs");
test("treats Chromium ERR_NETWORK_CHANGED as non-fatal", () => {
assert.equal(
isNonFatalNetworkError(new Error("net::ERR_NETWORK_CHANGED")),
true,
);
});
test("treats other Chromium net::ERR_* failures as non-fatal network errors", () => {
assert.equal(
isNonFatalNetworkError(new Error("net::ERR_INTERNET_DISCONNECTED")),
true,
);
assert.equal(
isNonFatalNetworkError(new Error("net::ERR_NAME_NOT_RESOLVED")),
true,
);
});
test("treats Node socket error codes as non-fatal network errors", () => {
const err = new Error("socket reset");
err.code = "ECONNRESET";
assert.equal(isNonFatalNetworkError(err), true);
const dnsErr = new Error("dns failed");
dnsErr.code = "ENOTFOUND";
assert.equal(isNonFatalNetworkError(dnsErr), true);
});
test("keeps non-network errors fatal", () => {
assert.equal(
isNonFatalNetworkError(new Error("Something else broke")),
false,
);
});
test("generic startup exceptions stay fatal before the app is up", () => {
const result = classifyProcessError(new Error("boom"), {
runtimeStarted: false,
});
assert.equal(result.action, "fatal");
});
test("generic runtime exceptions are suppressed after startup", () => {
const result = classifyProcessError(new Error("boom"), {
runtimeStarted: true,
});
assert.equal(result.action, "suppress");
assert.match(result.reason, /runtime/i);
});
test("generic runtime promise rejections are also suppressed after startup", () => {
const result = classifyProcessError(new Error("promise boom"), {
runtimeStarted: true,
origin: "unhandledRejection",
});
assert.equal(result.action, "suppress");
assert.match(result.reason, /runtime/i);
});
test("controller keeps startup strict until the main window is actually shown", () => {
const controller = createProcessErrorController();
controller.beginMainWindowStartup();
assert.equal(controller.isRuntimeProtectionActive(), false);
controller.completeMainWindowStartup({ windowShown: true });
assert.equal(controller.isRuntimeProtectionActive(), true);
});
test("controller becomes strict again while recreating a missing main window", () => {
const controller = createProcessErrorController();
controller.beginMainWindowStartup();
controller.completeMainWindowStartup({ windowShown: true });
assert.equal(controller.isRuntimeProtectionActive(), true);
controller.beginMainWindowStartup();
assert.equal(controller.isRuntimeProtectionActive(), false);
controller.completeMainWindowStartup({ windowShown: false });
assert.equal(controller.isRuntimeProtectionActive(), true);
});
test("startup-period errors stay fatal while recreating the main window", () => {
const fakeProcess = new EventEmitter();
const fatals = [];
const controller = createProcessErrorController({
captureError() {},
onFatalError(err) {
fatals.push(err.message);
throw err;
},
logError() {},
logWarn() {},
});
installProcessErrorHandlers(fakeProcess, controller);
controller.completeMainWindowStartup({ windowShown: true });
controller.beginMainWindowStartup();
assert.throws(() => {
fakeProcess.emit("uncaughtException", new Error("recreate boom"));
}, /recreate boom/);
assert.deepEqual(fatals, ["recreate boom"]);
});
test("fatal startup failures uninstall listeners and keep throwing", () => {
const fakeProcess = new EventEmitter();
const captured = [];
const fatals = [];
let uninstall = null;
const controller = createProcessErrorController({
captureError(source, err) {
captured.push([source, err.message]);
},
onFatalError(err) {
fatals.push(err.message);
uninstall?.();
throw err;
},
logError() {},
logWarn() {},
});
uninstall = installProcessErrorHandlers(fakeProcess, controller);
assert.throws(() => {
fakeProcess.emit("uncaughtException", new Error("startup boom"));
}, /startup boom/);
assert.deepEqual(fatals, ["startup boom"]);
assert.deepEqual(captured, [["uncaughtException", "startup boom"]]);
assert.equal(fakeProcess.listenerCount("uncaughtException"), 0);
assert.equal(fakeProcess.listenerCount("unhandledRejection"), 0);
});
test("installed handlers suppress runtime failures after startup", () => {
const fakeProcess = new EventEmitter();
const captured = [];
const errors = [];
const warnings = [];
const controller = createProcessErrorController({
captureError(source, err) {
captured.push([source, err.message]);
},
onFatalError(err) {
throw err;
},
logError(...args) {
errors.push(args.map(String).join(" "));
},
logWarn(...args) {
warnings.push(args.map(String).join(" "));
},
});
installProcessErrorHandlers(fakeProcess, controller);
controller.beginMainWindowStartup();
controller.completeMainWindowStartup({ windowShown: true });
fakeProcess.emit("uncaughtException", new Error("runtime boom"));
fakeProcess.emit("unhandledRejection", new Error("runtime rejection"));
assert.deepEqual(captured, [
["uncaughtException", "runtime boom"],
["unhandledRejection", "runtime rejection"],
]);
assert.equal(errors.some((line) => line.includes("runtime error after startup")), true);
assert.equal(warnings.length, 0);
});
test("unhandled rejection marks the forwarded error so uncaught follow-up is not double-captured", () => {
const captured = [];
const fatals = [];
const controller = createProcessErrorController({
captureError(source, err) {
captured.push([source, err.message]);
},
onFatalError(err) {
fatals.push(err);
},
logError() {},
logWarn() {},
});
controller.handleUnhandledRejection(new Error("startup rejection"));
assert.equal(fatals.length, 1);
assert.equal(fatals[0].__fromUnhandledRejection, true);
assert.deepEqual(captured, [["unhandledRejection", "startup rejection"]]);
controller.handleUncaughtException(fatals[0]);
assert.deepEqual(captured, [["unhandledRejection", "startup rejection"]]);
});
test("benign stream teardown errors are ignored by the installed handlers", () => {
const fakeProcess = new EventEmitter();
let captureCount = 0;
let fatalCount = 0;
const controller = createProcessErrorController({
captureError() {
captureCount += 1;
},
onFatalError() {
fatalCount += 1;
},
logError() {},
logWarn() {},
});
installProcessErrorHandlers(fakeProcess, controller);
const err = new Error("broken pipe");
err.code = "EPIPE";
fakeProcess.emit("uncaughtException", err);
assert.equal(captureCount, 0);
assert.equal(fatalCount, 0);
});
test("controller suppresses wrapped network errors from err.cause", () => {
const err = new Error("request failed");
err.cause = new Error("net::ERR_NETWORK_CHANGED");
const result = classifyProcessError(err, {
runtimeStarted: false,
});
assert.equal(isNonFatalNetworkError(err), true);
assert.equal(result.action, "suppress");
});
test("controller suppresses ssh-style errors with a level property", () => {
const err = new Error("connection lost before handshake");
err.level = "client-socket";
const result = classifyProcessError(err, {
runtimeStarted: false,
});
assert.equal(isNonFatalNetworkError(err), true);
assert.equal(result.action, "suppress");
});

View File

@@ -0,0 +1,193 @@
function isNonFatalNetworkError(err) {
if (!err) return false;
// Any error with an ssh2 `level` property is a connection/auth-level error,
// never a reason to kill the entire multi-session app.
if (err.level) return true;
const candidates = [err, err.cause].filter(Boolean);
for (const candidate of candidates) {
const code = candidate.code;
// Common TCP/DNS/routing errors that can surface from Node.js sockets
// without an ssh2 `level` (e.g. proxy sockets, raw net.connect calls).
switch (code) {
case "ECONNRESET":
case "ECONNREFUSED":
case "ECONNABORTED":
case "ETIMEDOUT":
case "ENOTFOUND":
case "EHOSTUNREACH":
case "EHOSTDOWN":
case "ENETUNREACH":
case "ENETDOWN":
case "EADDRNOTAVAIL":
case "EPROTO":
case "EPERM":
return true;
default:
break;
}
// Chromium/Electron networking often rejects with a message like
// "net::ERR_NETWORK_CHANGED" but without a useful `code` property.
// These are transport failures for background fetch/update/sync work,
// not reasons to kill the whole app.
const message = String(candidate.message || "");
if (/net::ERR_(?:NETWORK_[A-Z_]+|INTERNET_DISCONNECTED|NAME_NOT_RESOLVED|CONNECTION_[A-Z_]+|ADDRESS_[A-Z_]+|SSL_[A-Z_]+|CERT_[A-Z_]+|PROXY_[A-Z_]+|TUNNEL_[A-Z_]+|SOCKS_[A-Z_]+)/.test(message)) {
return true;
}
}
return false;
}
function isBenignStreamError(err) {
const code = err?.code;
return code === "EPIPE" || code === "ERR_STREAM_DESTROYED";
}
function classifyProcessError(err, options = {}) {
const runtimeStarted = options.runtimeStarted === true;
if (isBenignStreamError(err)) {
return {
action: "ignore",
reason: "benign stream teardown",
};
}
if (isNonFatalNetworkError(err)) {
return {
action: "suppress",
reason: "non-fatal network error",
};
}
if (runtimeStarted) {
return {
action: "suppress",
reason: "runtime error after startup",
};
}
return {
action: "fatal",
reason: "startup error before app became usable",
};
}
function createProcessErrorController(options = {}) {
const captureError = typeof options.captureError === "function" ? options.captureError : () => {};
const onFatalError = typeof options.onFatalError === "function"
? options.onFatalError
: (err) => { throw err; };
const logError = typeof options.logError === "function" ? options.logError : (...args) => console.error(...args);
const logWarn = typeof options.logWarn === "function" ? options.logWarn : (...args) => console.warn(...args);
let hasShownMainWindow = false;
let pendingMainWindowStartupCount = 0;
const isRuntimeProtectionActive = () => (
hasShownMainWindow && pendingMainWindowStartupCount === 0
);
const beginMainWindowStartup = () => {
pendingMainWindowStartupCount += 1;
};
const completeMainWindowStartup = ({ windowShown = false } = {}) => {
if (pendingMainWindowStartupCount > 0) {
pendingMainWindowStartupCount -= 1;
}
if (windowShown) {
hasShownMainWindow = true;
}
};
const handleUncaughtException = (err) => {
const decision = classifyProcessError(err, {
runtimeStarted: isRuntimeProtectionActive(),
origin: "uncaughtException",
});
if (decision.action === "ignore") {
logWarn("Ignored process error:", decision.reason, err?.code || err?.message || err);
return;
}
if (decision.action === "suppress") {
if (!err?.__fromUnhandledRejection) {
captureError("uncaughtException", err);
}
logError(`Suppressed uncaught exception (${decision.reason}):`, err);
return;
}
if (!err?.__fromUnhandledRejection) {
captureError("uncaughtException", err);
}
onFatalError(err, {
origin: "uncaughtException",
decision,
reason: err,
});
};
const handleUnhandledRejection = (reason) => {
const decision = classifyProcessError(reason, {
runtimeStarted: isRuntimeProtectionActive(),
origin: "unhandledRejection",
});
if (decision.action === "ignore") {
return;
}
if (decision.action === "suppress") {
captureError("unhandledRejection", reason);
logError(`Suppressed unhandled rejection (${decision.reason}):`, reason);
return;
}
captureError("unhandledRejection", reason);
const err = reason instanceof Error ? reason : new Error(String(reason));
err.__fromUnhandledRejection = true;
onFatalError(err, {
origin: "unhandledRejection",
decision,
reason,
});
};
return {
beginMainWindowStartup,
completeMainWindowStartup,
handleUncaughtException,
handleUnhandledRejection,
isRuntimeProtectionActive,
};
}
function installProcessErrorHandlers(processObject, controller) {
if (!processObject?.on || !processObject?.removeListener) {
throw new Error("A process-like EventEmitter is required");
}
if (!controller?.handleUncaughtException || !controller?.handleUnhandledRejection) {
throw new Error("A process error controller is required");
}
processObject.on("uncaughtException", controller.handleUncaughtException);
processObject.on("unhandledRejection", controller.handleUnhandledRejection);
return () => {
processObject.removeListener("uncaughtException", controller.handleUncaughtException);
processObject.removeListener("unhandledRejection", controller.handleUnhandledRejection);
};
}
module.exports = {
classifyProcessError,
createProcessErrorController,
installProcessErrorHandlers,
isBenignStreamError,
isNonFatalNetworkError,
};

View File

@@ -36,6 +36,9 @@ let menuDeps = null;
let electronApp = null; // Reference to Electron app for userData path
let isQuitting = false;
const rendererReadyCallbacksByWebContentsId = new Map();
const rendererReadySeenByWebContentsId = new Set();
const rendererReadyWaitersByWebContentsId = new Map();
const unhealthyWebContentsIds = new Set();
const DEBUG_WINDOWS = process.env.NETCATTY_DEBUG_WINDOWS === "1";
const OAUTH_DEFAULT_WIDTH = 600;
const OAUTH_DEFAULT_HEIGHT = 700;
@@ -791,6 +794,128 @@ function setupDeferredShow(win, { timeoutMs = 3000, waitForRendererReady = true
return { showOnce, markRendererReady };
}
function resolveRendererReady(wcId) {
if (!wcId) return;
unhealthyWebContentsIds.delete(wcId);
rendererReadySeenByWebContentsId.add(wcId);
const cb = rendererReadyCallbacksByWebContentsId.get(wcId);
if (cb) cb();
const waiters = rendererReadyWaitersByWebContentsId.get(wcId);
if (!waiters || waiters.size === 0) return;
rendererReadyWaitersByWebContentsId.delete(wcId);
for (const resolve of waiters) {
try {
resolve();
} catch {
// ignore waiter errors
}
}
}
function isWindowUsable(win, options = {}) {
const requireVisible = options.requireVisible === true;
if (!win || typeof win.isDestroyed !== "function" || win.isDestroyed()) {
return false;
}
if (requireVisible) {
if (typeof win.isVisible !== "function") return false;
try {
if (!win.isVisible()) return false;
} catch {
return false;
}
}
const contents = win.webContents;
if (!contents || typeof contents.isDestroyed !== "function" || contents.isDestroyed()) {
return false;
}
const wcId = (() => {
try {
return contents.id;
} catch {
return null;
}
})();
if (wcId && unhealthyWebContentsIds.has(wcId)) {
return false;
}
if (typeof contents.isCrashed === "function") {
try {
if (contents.isCrashed()) return false;
} catch {
return false;
}
}
return true;
}
function waitForRendererReady(win, { timeoutMs = 15000 } = {}) {
return new Promise((resolve, reject) => {
const wcId = (() => {
try {
return win?.webContents?.id;
} catch {
return null;
}
})();
if (!win || win.isDestroyed?.() || !wcId) {
reject(new Error("Main window is unavailable before renderer ready."));
return;
}
if (rendererReadySeenByWebContentsId.has(wcId)) {
resolve();
return;
}
let timer = null;
const cleanup = () => {
if (timer) clearTimeout(timer);
timer = null;
try { win.removeListener("closed", handleClosed); } catch {}
try { win.webContents?.removeListener?.("render-process-gone", handleGone); } catch {}
const waiters = rendererReadyWaitersByWebContentsId.get(wcId);
if (waiters) {
waiters.delete(handleReady);
if (waiters.size === 0) {
rendererReadyWaitersByWebContentsId.delete(wcId);
}
}
};
const handleReady = () => {
cleanup();
resolve();
};
const handleClosed = () => {
cleanup();
reject(new Error("Main window closed before renderer became ready."));
};
const handleGone = (_event, details) => {
cleanup();
reject(new Error(`Renderer process exited before ready: ${details?.reason || "unknown"}`));
};
let waiters = rendererReadyWaitersByWebContentsId.get(wcId);
if (!waiters) {
waiters = new Set();
rendererReadyWaitersByWebContentsId.set(wcId, waiters);
}
waiters.add(handleReady);
win.once("closed", handleClosed);
win.webContents?.once?.("render-process-gone", handleGone);
if (Number(timeoutMs) > 0) {
timer = setTimeout(() => {
cleanup();
reject(new Error("Renderer did not report ready before timeout."));
}, timeoutMs);
}
});
}
/**
* Create the main application window
*/
@@ -869,12 +994,27 @@ async function createWindow(electronModule, options) {
// Clear reference when the main window is destroyed
win.on('closed', () => {
try {
if (win?.webContents?.id) {
unhealthyWebContentsIds.delete(win.webContents.id);
rendererReadySeenByWebContentsId.delete(win.webContents.id);
}
} catch {
// ignore
}
if (mainWindow === win) mainWindow = null;
});
// Log renderer crashes for diagnostics (skip normal clean exits)
win.webContents.on("render-process-gone", (_event, details) => {
if (details?.reason === "clean-exit") return;
try {
if (win.webContents?.id) {
unhealthyWebContentsIds.add(win.webContents.id);
}
} catch {
// ignore
}
try {
const crashLogBridge = require("./crashLogBridge.cjs");
crashLogBridge.captureError("render-process-gone", new Error(
@@ -1515,8 +1655,7 @@ function registerWindowHandlers(ipcMain, nativeTheme) {
ipcMain.on("netcatty:renderer:ready", (event) => {
const wcId = event?.sender?.id;
if (!wcId) return;
const cb = rendererReadyCallbacksByWebContentsId.get(wcId);
if (cb) cb();
resolveRendererReady(wcId);
});
}
@@ -1606,6 +1745,8 @@ module.exports = {
buildAppMenu,
getMainWindow,
getSettingsWindow,
isWindowUsable,
waitForRendererReady,
setIsQuitting,
openFallbackBrowser,
tryOpenExternalWithFallback,

View File

@@ -0,0 +1,67 @@
const test = require("node:test");
const assert = require("node:assert/strict");
const { isWindowUsable } = require("./windowManager.cjs");
function createWindowStub({ destroyed = false, webContents } = {}) {
return {
isDestroyed() {
return destroyed;
},
isVisible() {
return true;
},
webContents,
};
}
test("isWindowUsable returns false when webContents is crashed", () => {
const win = createWindowStub({
webContents: {
isDestroyed() {
return false;
},
isCrashed() {
return true;
},
},
});
assert.equal(isWindowUsable(win), false);
});
test("isWindowUsable returns true for a healthy live window", () => {
const win = createWindowStub({
webContents: {
isDestroyed() {
return false;
},
isCrashed() {
return false;
},
},
});
assert.equal(isWindowUsable(win), true);
});
test("isWindowUsable can require a visible window", () => {
const hiddenWin = {
...createWindowStub({
webContents: {
isDestroyed() {
return false;
},
isCrashed() {
return false;
},
},
}),
isVisible() {
return false;
},
};
assert.equal(isWindowUsable(hiddenWin, { requireVisible: true }), false);
assert.equal(isWindowUsable(hiddenWin, { requireVisible: false }), true);
});

View File

@@ -20,79 +20,31 @@ if (process.env.ELECTRON_RUN_AS_NODE) {
// Load crash log bridge early so process-level error handlers can use it
const crashLogBridge = require("./bridges/crashLogBridge.cjs");
// SSH / network errors that must never crash the process.
// ssh2 can emit multiple 'error' events per connection (e.g. ECONNRESET followed
// by "Connection lost before handshake"). If a listener is consumed after the first
// event, the second becomes an uncaught exception. These are non-fatal for the app.
function isNonFatalNetworkError(err) {
if (!err) return false;
// Any error with an ssh2 `level` property is a connection/auth-level error,
// never a reason to kill the entire multi-session app.
if (err.level) return true;
const code = err.code;
// Common TCP/DNS/routing errors that can surface from Node.js sockets
// without an ssh2 `level` (e.g. proxy sockets, raw net.connect calls).
switch (code) {
case 'ECONNRESET':
case 'ECONNREFUSED':
case 'ECONNABORTED':
case 'ETIMEDOUT':
case 'ENOTFOUND':
case 'EHOSTUNREACH':
case 'EHOSTDOWN':
case 'ENETUNREACH':
case 'ENETDOWN':
case 'EADDRNOTAVAIL':
case 'EPROTO':
case 'EPERM':
return true;
default:
return false;
}
}
// Handle uncaught exceptions — log all, only re-throw truly fatal ones
process.on('uncaughtException', (err) => {
// Skip benign stream teardown errors — don't pollute crash logs with false positives
if (err.code === 'EPIPE' || err.code === 'ERR_STREAM_DESTROYED') {
console.warn('Ignored stream error:', err.code);
return;
}
// Non-fatal SSH/network errors: log but do NOT crash the process
if (isNonFatalNetworkError(err)) {
if (!err.__fromUnhandledRejection) {
try { crashLogBridge.captureError('uncaughtException', err); } catch {}
const {
createProcessErrorController,
installProcessErrorHandlers,
} = require("./bridges/processErrorGuards.cjs");
const processErrorController = createProcessErrorController({
captureError(source, err) {
try { crashLogBridge.captureError(source, err); } catch {}
},
onFatalError(err, context) {
uninstallProcessErrorHandlers();
if (context?.origin === 'unhandledRejection') {
console.error('Unhandled rejection:', context.reason);
} else {
console.error('Uncaught exception:', err);
}
console.warn('Non-fatal uncaught exception (suppressed):', err.message);
return;
}
// Skip logging if already captured by unhandledRejection handler
if (!err.__fromUnhandledRejection) {
try { crashLogBridge.captureError('uncaughtException', err); } catch {}
}
console.error('Uncaught exception:', err);
throw err;
});
process.on('unhandledRejection', (reason) => {
// Skip benign stream teardown errors
const code = reason?.code;
if (code === 'EPIPE' || code === 'ERR_STREAM_DESTROYED') return;
// Non-fatal SSH/network errors: log but do NOT re-throw
if (isNonFatalNetworkError(reason)) {
try { crashLogBridge.captureError('unhandledRejection', reason); } catch {}
console.warn('Non-fatal unhandled rejection (suppressed):', reason?.message || reason);
return;
}
try { crashLogBridge.captureError('unhandledRejection', reason); } catch {}
console.error('Unhandled rejection:', reason);
// Re-throw to preserve fatal semantics. Mark so uncaughtException handler
// can skip duplicate logging.
const err = reason instanceof Error ? reason : new Error(String(reason));
err.__fromUnhandledRejection = true;
throw err;
throw err;
},
logError(...args) {
console.error(...args);
},
logWarn(...args) {
console.warn(...args);
},
});
let uninstallProcessErrorHandlers = installProcessErrorHandlers(process, processErrorController);
// Load Electron
let electronModule;
@@ -1013,6 +965,80 @@ async function createWindow() {
return win;
}
function waitForWindowToShow(win) {
return new Promise((resolve, reject) => {
if (!win || win.isDestroyed?.()) {
reject(new Error("Main window was destroyed before first show."));
return;
}
if (win.isVisible?.()) {
resolve();
return;
}
const cleanup = () => {
try { win.removeListener("show", handleShow); } catch {}
try { win.removeListener("closed", handleClosed); } catch {}
try { win.webContents?.removeListener?.("render-process-gone", handleGone); } catch {}
};
const handleShow = () => {
cleanup();
resolve();
};
const handleClosed = () => {
cleanup();
reject(new Error("Main window closed before first show."));
};
const handleGone = (_event, details) => {
cleanup();
reject(new Error(`Renderer process exited before first show: ${details?.reason || "unknown"}`));
};
win.once("show", handleShow);
win.once("closed", handleClosed);
win.webContents?.once?.("render-process-gone", handleGone);
});
}
let mainWindowStartupPromise = null;
async function createAndShowMainWindow() {
if (mainWindowStartupPromise) return mainWindowStartupPromise;
mainWindowStartupPromise = (async () => {
processErrorController.beginMainWindowStartup();
try {
const win = await createWindow();
await waitForWindowToShow(win);
void getWindowManager().waitForRendererReady(win, {
timeoutMs: isDev ? 30000 : 15000,
}).catch((err) => {
console.warn("[Main] Renderer ready signal was late or missing after first show:", err?.message || err);
});
processErrorController.completeMainWindowStartup({ windowShown: true });
return win;
} catch (err) {
processErrorController.completeMainWindowStartup({ windowShown: false });
throw err;
} finally {
mainWindowStartupPromise = null;
}
})();
return mainWindowStartupPromise;
}
function hasUsableWindow() {
try {
const windowManager = getWindowManager();
return [windowManager.getMainWindow?.(), windowManager.getSettingsWindow?.()]
.some((win) => windowManager.isWindowUsable?.(win, { requireVisible: true }));
} catch {
return false;
}
}
function showStartupError(err) {
const title = "Netcatty";
const code = err && typeof err === "object" ? err.code : null;
@@ -1038,9 +1064,12 @@ if (!gotLock) {
app.on("second-instance", () => {
if (!focusMainWindow()) {
// Window is missing or crashed — try to recreate it
void createWindow().catch((err) => {
void createAndShowMainWindow().catch((err) => {
console.error("[Main] Failed to recreate window on second-instance:", err);
showStartupError(err);
if (!hasUsableWindow()) {
try { app.quit(); } catch {}
}
});
}
});
@@ -1058,9 +1087,17 @@ if (!gotLock) {
}
}
// Build and set application menu
const menu = getWindowManager().buildAppMenu(Menu, app, isMac);
Menu.setApplicationMenu(menu);
// Build and set application menu. A broken menu should not take down
// the entire app — fall back to no custom menu and continue startup.
try {
const menu = getWindowManager().buildAppMenu(Menu, app, isMac);
Menu.setApplicationMenu(menu);
} catch (err) {
console.error("[Main] Failed to build application menu:", err);
try {
Menu.setApplicationMenu(null);
} catch {}
}
app.on("browser-window-created", (_event, win) => {
try {
@@ -1080,7 +1117,7 @@ if (!gotLock) {
});
// Create the main window
void createWindow().then(() => {
void createAndShowMainWindow().then(() => {
// Trigger auto-update check 5 s after window creation.
// startAutoCheck() is a no-op on unsupported platforms (Linux deb/rpm/snap).
getAutoUpdateBridge().startAutoCheck(5000);
@@ -1130,9 +1167,12 @@ if (!gotLock) {
if (focusMainWindow()) return;
// Main window doesn't exist — create it even if other windows (e.g. settings) are open
void createWindow().catch((err) => {
void createAndShowMainWindow().catch((err) => {
console.error("[Main] Failed to create window on activate:", err);
showStartupError(err);
if (!hasUsableWindow()) {
try { app.quit(); } catch {}
}
});
});
});

163
package-lock.json generated
View File

@@ -1105,13 +1105,13 @@
}
},
"node_modules/@aws-sdk/xml-builder": {
"version": "3.972.4",
"resolved": "https://registry.npmjs.org/@aws-sdk/xml-builder/-/xml-builder-3.972.4.tgz",
"integrity": "sha512-0zJ05ANfYqI6+rGqj8samZBFod0dPPousBjLEqg8WdxSgbMAkRgLyn81lP215Do0rFJ/17LIXwr7q0yK24mP6Q==",
"version": "3.972.18",
"resolved": "https://registry.npmjs.org/@aws-sdk/xml-builder/-/xml-builder-3.972.18.tgz",
"integrity": "sha512-BMDNVG1ETXRhl1tnisQiYBef3RShJ1kfZA7x7afivTFMLirfHNTb6U71K569HNXhSXbQZsweHvSDZ6euBw8hPA==",
"license": "Apache-2.0",
"dependencies": {
"@smithy/types": "^4.12.0",
"fast-xml-parser": "5.3.4",
"@smithy/types": "^4.14.1",
"fast-xml-parser": "5.5.8",
"tslib": "^2.6.2"
},
"engines": {
@@ -1158,7 +1158,6 @@
"integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/code-frame": "^7.29.0",
"@babel/generator": "^7.29.0",
@@ -1804,6 +1803,7 @@
"dev": true,
"license": "BSD-2-Clause",
"optional": true,
"peer": true,
"dependencies": {
"cross-dirname": "^0.1.0",
"debug": "^4.3.4",
@@ -1825,6 +1825,7 @@
"dev": true,
"license": "MIT",
"optional": true,
"peer": true,
"dependencies": {
"graceful-fs": "^4.2.0",
"jsonfile": "^6.0.1",
@@ -1841,6 +1842,7 @@
"dev": true,
"license": "MIT",
"optional": true,
"peer": true,
"dependencies": {
"universalify": "^2.0.0"
},
@@ -1855,6 +1857,7 @@
"dev": true,
"license": "MIT",
"optional": true,
"peer": true,
"engines": {
"node": ">= 10.0.0"
}
@@ -3310,7 +3313,6 @@
"resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.27.1.tgz",
"integrity": "sha512-sr6GbP+4edBwFndLbM60gf07z0FQ79gaExpnsjMGePXqFcSSb7t6iscpjk9DhFhwd+mTEQrzNafGP8/iGGFYaA==",
"license": "MIT",
"peer": true,
"dependencies": {
"@hono/node-server": "^1.19.9",
"ajv": "^8.17.1",
@@ -5594,9 +5596,9 @@
}
},
"node_modules/@smithy/types": {
"version": "4.12.0",
"resolved": "https://registry.npmjs.org/@smithy/types/-/types-4.12.0.tgz",
"integrity": "sha512-9YcuJVTOBDjg9LWo23Qp0lTQ3D7fQsQtwle0jVfpbUHy9qBwCEgKuVH4FqFB3VYu0nwdHKiEMA+oXz7oV8X1kw==",
"version": "4.14.1",
"resolved": "https://registry.npmjs.org/@smithy/types/-/types-4.14.1.tgz",
"integrity": "sha512-59b5HtSVrVR/eYNei3BUj3DCPKD/G7EtDDe7OEJE7i7FtQFugYo6MxbotS8mVJkLNVf8gYaAlEBwwtJ9HzhWSg==",
"license": "Apache-2.0",
"dependencies": {
"tslib": "^2.6.2"
@@ -6106,6 +6108,66 @@
"node": ">=14.0.0"
}
},
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/core": {
"version": "1.7.1",
"dev": true,
"inBundle": true,
"license": "MIT",
"optional": true,
"dependencies": {
"@emnapi/wasi-threads": "1.1.0",
"tslib": "^2.4.0"
}
},
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/runtime": {
"version": "1.7.1",
"dev": true,
"inBundle": true,
"license": "MIT",
"optional": true,
"dependencies": {
"tslib": "^2.4.0"
}
},
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/wasi-threads": {
"version": "1.1.0",
"dev": true,
"inBundle": true,
"license": "MIT",
"optional": true,
"dependencies": {
"tslib": "^2.4.0"
}
},
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@napi-rs/wasm-runtime": {
"version": "1.1.0",
"dev": true,
"inBundle": true,
"license": "MIT",
"optional": true,
"dependencies": {
"@emnapi/core": "^1.7.1",
"@emnapi/runtime": "^1.7.1",
"@tybys/wasm-util": "^0.10.1"
}
},
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@tybys/wasm-util": {
"version": "0.10.1",
"dev": true,
"inBundle": true,
"license": "MIT",
"optional": true,
"dependencies": {
"tslib": "^2.4.0"
}
},
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/tslib": {
"version": "2.8.1",
"dev": true,
"inBundle": true,
"license": "0BSD",
"optional": true
},
"node_modules/@tailwindcss/oxide-win32-arm64-msvc": {
"version": "4.1.18",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.1.18.tgz",
@@ -6299,7 +6361,6 @@
"resolved": "https://registry.npmjs.org/@types/mdast/-/mdast-4.0.4.tgz",
"integrity": "sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA==",
"license": "MIT",
"peer": true,
"dependencies": {
"@types/unist": "*"
}
@@ -6380,7 +6441,6 @@
"integrity": "sha512-hAAP5io/7csFStuOmR782YmTthKBJ9ND3WVL60hcOjvtGFb+HJxH4O5huAcmcZ9v9G8P+JETiZ/G1B8MALnWZQ==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@eslint-community/regexpp": "^4.12.2",
"@typescript-eslint/scope-manager": "8.54.0",
@@ -6410,7 +6470,6 @@
"integrity": "sha512-BtE0k6cjwjLZoZixN0t5AKP0kSzlGu7FctRXYuPAm//aaiZhmfq1JwdYpYr1brzEspYyFeF+8XF5j2VK6oalrA==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@typescript-eslint/scope-manager": "8.54.0",
"@typescript-eslint/types": "8.54.0",
@@ -6961,7 +7020,6 @@
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
"dev": true,
"license": "MIT",
"peer": true,
"bin": {
"acorn": "bin/acorn"
},
@@ -7012,7 +7070,6 @@
"integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"fast-deep-equal": "^3.1.1",
"fast-json-stable-stringify": "^2.0.0",
@@ -7573,7 +7630,6 @@
}
],
"license": "MIT",
"peer": true,
"dependencies": {
"baseline-browser-mapping": "^2.9.0",
"caniuse-lite": "^1.0.30001759",
@@ -8316,7 +8372,8 @@
"integrity": "sha512-+R08/oI0nl3vfPcqftZRpytksBXDzOUveBq/NBVx0sUp1axwzPQrKinNx5yd5sxPu8j1wIy8AfnVQ+5eFdha6Q==",
"dev": true,
"license": "MIT",
"optional": true
"optional": true,
"peer": true
},
"node_modules/cross-env": {
"version": "10.1.0",
@@ -8600,7 +8657,6 @@
"integrity": "sha512-uOOBA3f+kW3o4KpSoMQ6SNpdXU7WtxlJRb9vCZgOvqhTz4b3GjcoWKstdisizNZLsylhTMv8TLHFPFW0Uxsj/g==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"app-builder-lib": "26.7.0",
"builder-util": "26.4.1",
@@ -8982,6 +9038,7 @@
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@electron/asar": "^3.2.1",
"debug": "^4.1.1",
@@ -9002,6 +9059,7 @@
"integrity": "sha512-YJDaCJZEnBmcbw13fvdAM9AwNOJwOzrE4pqMqBq5nFiEqXUqHwlK4B+3pUw6JNvfSPtX05xFHtYy/1ni01eGCw==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"graceful-fs": "^4.1.2",
"jsonfile": "^4.0.0",
@@ -9231,7 +9289,6 @@
"integrity": "sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@eslint-community/eslint-utils": "^4.8.0",
"@eslint-community/regexpp": "^4.12.1",
@@ -9683,10 +9740,10 @@
],
"license": "BSD-3-Clause"
},
"node_modules/fast-xml-parser": {
"version": "5.3.4",
"resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-5.3.4.tgz",
"integrity": "sha512-EFd6afGmXlCx8H8WTZHhAoDaWaGyuIBoZJ2mknrNxug+aZKjkp0a0dlars9Izl+jF+7Gu1/5f/2h68cQpe0IiA==",
"node_modules/fast-xml-builder": {
"version": "1.1.5",
"resolved": "https://registry.npmjs.org/fast-xml-builder/-/fast-xml-builder-1.1.5.tgz",
"integrity": "sha512-4TJn/8FKLeslLAH3dnohXqE3QSoxkhvaMzepOIZytwJXZO69Bfz0HBdDHzOTOon6G59Zrk6VQ2bEiv1t61rfkA==",
"funding": [
{
"type": "github",
@@ -9695,7 +9752,24 @@
],
"license": "MIT",
"dependencies": {
"strnum": "^2.1.0"
"path-expression-matcher": "^1.1.3"
}
},
"node_modules/fast-xml-parser": {
"version": "5.5.8",
"resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-5.5.8.tgz",
"integrity": "sha512-Z7Fh2nVQSb2d+poDViM063ix2ZGt9jmY1nWhPfHBOK2Hgnb/OW3P4Et3P/81SEej0J7QbWtJqxO05h8QYfK7LQ==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/NaturalIntelligence"
}
],
"license": "MIT",
"dependencies": {
"fast-xml-builder": "^1.1.4",
"path-expression-matcher": "^1.2.0",
"strnum": "^2.2.0"
},
"bin": {
"fxparser": "src/cli/cli.js"
@@ -10593,7 +10667,6 @@
"resolved": "https://registry.npmjs.org/hono/-/hono-4.12.7.tgz",
"integrity": "sha512-jq9l1DM0zVIvsm3lv9Nw9nlJnMNPOcAtsbsgiUhWcFzPE99Gvo6yRTlszSLLYacMeQ6quHD6hMfId8crVHvexw==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=16.9.0"
}
@@ -12083,7 +12156,6 @@
}
],
"license": "MIT",
"peer": true,
"dependencies": {
"@types/debug": "^4.0.0",
"debug": "^4.0.0",
@@ -12701,8 +12773,7 @@
"url": "https://opencollective.com/unified"
}
],
"license": "MIT",
"peer": true
"license": "MIT"
},
"node_modules/micromatch": {
"version": "4.0.8",
@@ -12957,6 +13028,7 @@
"integrity": "sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"minimist": "^1.2.6"
},
@@ -12969,7 +13041,6 @@
"resolved": "https://registry.npmjs.org/monaco-editor/-/monaco-editor-0.55.1.tgz",
"integrity": "sha512-jz4x+TJNFHwHtwuV9vA9rMujcZRb0CEilTEwG2rRSpe/A7Jdkuj8xPKttCgOh+v/lkHy7HsZ64oj+q3xoAFl9A==",
"license": "MIT",
"peer": true,
"dependencies": {
"dompurify": "3.2.7",
"marked": "14.0.0"
@@ -13551,6 +13622,21 @@
"node": ">=8"
}
},
"node_modules/path-expression-matcher": {
"version": "1.5.0",
"resolved": "https://registry.npmjs.org/path-expression-matcher/-/path-expression-matcher-1.5.0.tgz",
"integrity": "sha512-cbrerZV+6rvdQrrD+iGMcZFEiiSrbv9Tfdkvnusy6y0x0GKBXREFg/Y65GhIfm0tnLntThhzCnfKwp1WRjeCyQ==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/NaturalIntelligence"
}
],
"license": "MIT",
"engines": {
"node": ">=14.0.0"
}
},
"node_modules/path-is-absolute": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz",
@@ -13729,6 +13815,7 @@
"dev": true,
"license": "MIT",
"optional": true,
"peer": true,
"dependencies": {
"commander": "^9.4.0"
},
@@ -13746,6 +13833,7 @@
"dev": true,
"license": "MIT",
"optional": true,
"peer": true,
"engines": {
"node": "^12.20.0 || >=14"
}
@@ -13936,7 +14024,6 @@
"resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz",
"integrity": "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=0.10.0"
}
@@ -13946,7 +14033,6 @@
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.4.tgz",
"integrity": "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"scheduler": "^0.27.0"
},
@@ -15155,9 +15241,9 @@
}
},
"node_modules/strnum": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/strnum/-/strnum-2.1.2.tgz",
"integrity": "sha512-l63NF9y/cLROq/yqKXSLtcMeeyOfnSQlfMSlzFt/K73oIaD8DGaQWd7Z34X9GPiKqP5rbSh84Hl4bOlLcjiSrQ==",
"version": "2.2.3",
"resolved": "https://registry.npmjs.org/strnum/-/strnum-2.2.3.tgz",
"integrity": "sha512-oKx6RUCuHfT3oyVjtnrmn19H1SiCqgJSg+54XqURKp5aCMbrXrhLjRN9TjuwMjiYstZ0MzDrHqkGZ5dFTKd+zg==",
"funding": [
{
"type": "github",
@@ -15277,6 +15363,7 @@
"integrity": "sha512-yYrrsWnrXMcdsnu/7YMYAofM1ktpL5By7vZhf15CrXijWWrEYZks5AXBudalfSWJLlnen/QUJUB5aoB0kqZUGA==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"mkdirp": "^0.5.1",
"rimraf": "~2.6.2"
@@ -15341,6 +15428,7 @@
"deprecated": "Rimraf versions prior to v4 are no longer supported",
"dev": true,
"license": "ISC",
"peer": true,
"dependencies": {
"glob": "^7.1.3"
},
@@ -15415,7 +15503,6 @@
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"dev": true,
"license": "MIT",
"peer": true,
"engines": {
"node": ">=12"
},
@@ -15530,7 +15617,6 @@
"integrity": "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"esbuild": "~0.27.0",
"get-tsconfig": "^4.7.5"
@@ -15629,7 +15715,6 @@
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"dev": true,
"license": "Apache-2.0",
"peer": true,
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
@@ -15650,7 +15735,6 @@
"resolved": "https://registry.npmjs.org/unified/-/unified-11.0.5.tgz",
"integrity": "sha512-xKvGhPWw3k84Qjh8bI3ZeJjqnyadK+GEFtazSfZv/rKeTkTjOJho6mFqh2SM96iIcZokxiOpg78GazTSg8+KHA==",
"license": "MIT",
"peer": true,
"dependencies": {
"@types/unist": "^3.0.0",
"bail": "^2.0.0",
@@ -15989,7 +16073,6 @@
"integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"esbuild": "^0.27.0",
"fdir": "^6.5.0",
@@ -16083,7 +16166,6 @@
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"dev": true,
"license": "MIT",
"peer": true,
"engines": {
"node": ">=12"
},
@@ -16362,7 +16444,6 @@
"resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz",
"integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==",
"license": "MIT",
"peer": true,
"funding": {
"url": "https://github.com/sponsors/colinhacks"
}