874 lines
29 KiB
JavaScript
874 lines
29 KiB
JavaScript
/**
|
|
* Netcatty Electron Main Process
|
|
*
|
|
* This is the main entry point for the Electron application.
|
|
* All major functionality has been extracted into separate bridge modules:
|
|
*
|
|
* - sshBridge.cjs: SSH connections and session management
|
|
* - sftpBridge.cjs: SFTP file operations
|
|
* - localFsBridge.cjs: Local filesystem operations
|
|
* - transferBridge.cjs: File transfers with progress
|
|
* - portForwardingBridge.cjs: SSH port forwarding tunnels
|
|
* - terminalBridge.cjs: Local shell, telnet, and mosh sessions
|
|
* - windowManager.cjs: Electron window management
|
|
*/
|
|
|
|
// Handle environment setup
|
|
if (process.env.ELECTRON_RUN_AS_NODE) {
|
|
delete 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");
|
|
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);
|
|
}
|
|
throw err;
|
|
},
|
|
logError(...args) {
|
|
console.error(...args);
|
|
},
|
|
logWarn(...args) {
|
|
console.warn(...args);
|
|
},
|
|
});
|
|
let uninstallProcessErrorHandlers = installProcessErrorHandlers(process, processErrorController);
|
|
|
|
// Load Electron
|
|
let electronModule;
|
|
try {
|
|
electronModule = require("node:electron");
|
|
} catch {
|
|
electronModule = require("electron");
|
|
}
|
|
|
|
const { app, BrowserWindow, Menu, protocol, shell, clipboard, session } = electronModule || {};
|
|
if (!app || !BrowserWindow) {
|
|
throw new Error("Failed to load Electron runtime. Ensure the app is launched with the Electron binary.");
|
|
}
|
|
|
|
const path = require("node:path");
|
|
const os = require("node:os");
|
|
const fs = require("node:fs");
|
|
const { getCliDiscoveryFilePath } = require("./cli/discoveryPath.cjs");
|
|
|
|
try {
|
|
protocol?.registerSchemesAsPrivileged?.([
|
|
{
|
|
scheme: "app",
|
|
privileges: {
|
|
standard: true,
|
|
secure: true,
|
|
supportFetchAPI: true,
|
|
corsEnabled: true,
|
|
stream: true,
|
|
},
|
|
},
|
|
]);
|
|
} catch (err) {
|
|
console.warn("[Main] Failed to register app:// scheme privileges:", err);
|
|
}
|
|
|
|
// Apply ssh2 protocol patch needed for OpenSSH sk-* signature layouts.
|
|
|
|
function createLazyModule(modulePath) {
|
|
let cachedModule = null;
|
|
return () => {
|
|
if (!cachedModule) {
|
|
cachedModule = require(modulePath);
|
|
}
|
|
return cachedModule;
|
|
};
|
|
}
|
|
|
|
// Restore standard DH groups that Electron's BoringSSL dropped from the named
|
|
// createDiffieHellmanGroup() API (e.g. modp2 / diffie-hellman-group1-sha1), so
|
|
// legacy network devices stay reachable (#1035). MUST run before any module that
|
|
// requires ssh2 — ssh2 destructures createDiffieHellmanGroup at load time.
|
|
require("./bridges/boringSslDhCompat.cjs").installBoringSslDhCompat();
|
|
|
|
// Import bridge modules
|
|
const sshBridge = require("./bridges/sshBridge.cjs");
|
|
const sftpBridge = require("./bridges/sftpBridge.cjs");
|
|
const localFsBridge = require("./bridges/localFsBridge.cjs");
|
|
const transferBridge = require("./bridges/transferBridge.cjs");
|
|
const portForwardingBridge = require("./bridges/portForwardingBridge.cjs");
|
|
const terminalBridge = require("./bridges/terminalBridge.cjs");
|
|
const sessionLogStreamManager = require("./bridges/sessionLogStreamManager.cjs");
|
|
// crashLogBridge is required at the top of the file (before error handlers)
|
|
const getOauthBridge = createLazyModule("./bridges/oauthBridge.cjs");
|
|
const getGithubAuthBridge = createLazyModule("./bridges/githubAuthBridge.cjs");
|
|
const getGoogleAuthBridge = createLazyModule("./bridges/googleAuthBridge.cjs");
|
|
const getOnedriveAuthBridge = createLazyModule("./bridges/onedriveAuthBridge.cjs");
|
|
const getCloudSyncBridge = createLazyModule("./bridges/cloudSyncBridge.cjs");
|
|
const getFileWatcherBridge = createLazyModule("./bridges/fileWatcherBridge.cjs");
|
|
const getTempDirBridge = createLazyModule("./bridges/tempDirBridge.cjs");
|
|
const getSessionLogsBridge = createLazyModule("./bridges/sessionLogsBridge.cjs");
|
|
const getCompressUploadBridge = createLazyModule("./bridges/compressUploadBridge.cjs");
|
|
const getGlobalShortcutBridge = createLazyModule("./bridges/globalShortcutBridge.cjs");
|
|
const getCredentialBridge = createLazyModule("./bridges/credentialBridge.cjs");
|
|
const getAutoUpdateBridge = createLazyModule("./bridges/autoUpdateBridge.cjs");
|
|
const getAiBridge = createLazyModule("./bridges/aiBridge.cjs");
|
|
const getWindowManager = createLazyModule("./bridges/windowManager.cjs");
|
|
const getVaultBackupBridge = createLazyModule("./bridges/vaultBackupBridge.cjs");
|
|
const ptyProcessTree = require("./bridges/ptyProcessTree.cjs");
|
|
const { queryDirtyEditors } = require("./bridges/dirtyEditorGuard.cjs");
|
|
|
|
// GPU settings
|
|
// NOTE: Do not disable Chromium sandbox by default.
|
|
// If you need to debug with sandbox disabled, set NETCATTY_NO_SANDBOX=1.
|
|
if (process.env.NETCATTY_NO_SANDBOX === "1") {
|
|
app.commandLine.appendSwitch("no-sandbox");
|
|
}
|
|
// Force hardware acceleration even on blocklisted GPUs (macs sometimes fall back to software)
|
|
app.commandLine.appendSwitch("ignore-gpu-blocklist");
|
|
app.commandLine.appendSwitch("ignore-gpu-blacklist"); // Some Chromium builds use this alias; keep both for safety
|
|
app.commandLine.appendSwitch("enable-gpu-rasterization");
|
|
app.commandLine.appendSwitch("enable-zero-copy");
|
|
|
|
// Silence noisy DevTools Autofill CDP errors (Electron's backend doesn't expose this domain)
|
|
app.on("web-contents-created", (_event, contents) => {
|
|
if (contents.getType() !== "devtools") return;
|
|
// Drop console output from Autofill requests in DevTools frontend
|
|
contents.on("did-finish-load", () => {
|
|
contents
|
|
.executeJavaScript(`
|
|
(() => {
|
|
const block = (methodName) => {
|
|
const original = console[methodName];
|
|
if (!original) return;
|
|
console[methodName] = (...args) => {
|
|
if (args.some(arg => typeof arg === "string" && arg.includes("Autofill."))) return;
|
|
original(...args);
|
|
};
|
|
};
|
|
block("error");
|
|
block("warn");
|
|
})();
|
|
`)
|
|
.catch(() => {});
|
|
});
|
|
contents.on("console-message", (event, _level, message, _line, sourceId) => {
|
|
if (sourceId?.startsWith("devtools://") && message.includes("Autofill.")) {
|
|
event.preventDefault();
|
|
}
|
|
});
|
|
});
|
|
|
|
// Application configuration
|
|
const devServerUrl = process.env.VITE_DEV_SERVER_URL;
|
|
// Never treat a packaged app as "dev" even if the user has VITE_DEV_SERVER_URL set globally.
|
|
const isDev = !app.isPackaged && !!devServerUrl;
|
|
const effectiveDevServerUrl = isDev ? devServerUrl : undefined;
|
|
if (isDev) {
|
|
app.setName("Netcatty Dev");
|
|
app.setPath("userData", path.join(app.getPath("userData"), "dev"));
|
|
}
|
|
const preload = path.join(__dirname, "preload.cjs");
|
|
const isMac = process.platform === "darwin";
|
|
function resolveAppIconPath() {
|
|
const candidates = [
|
|
path.join(__dirname, "../dist/icon.png"),
|
|
path.join(__dirname, "../public/icon.png"),
|
|
];
|
|
return candidates.find((candidate) => fs.existsSync(candidate)) || candidates[0];
|
|
}
|
|
const appIcon = resolveAppIconPath();
|
|
const electronDir = __dirname;
|
|
|
|
const APP_PROTOCOL_HEADERS = {
|
|
// Required for crossOriginIsolated / SharedArrayBuffer.
|
|
// Mirrors the dev-server headers in `vite.config.ts`.
|
|
"Cross-Origin-Opener-Policy": "same-origin",
|
|
"Cross-Origin-Embedder-Policy": "credentialless",
|
|
};
|
|
|
|
const DIST_MIME_TYPES = {
|
|
".html": "text/html",
|
|
".js": "text/javascript",
|
|
".mjs": "text/javascript",
|
|
".css": "text/css",
|
|
".json": "application/json",
|
|
".png": "image/png",
|
|
".jpg": "image/jpeg",
|
|
".jpeg": "image/jpeg",
|
|
".gif": "image/gif",
|
|
".svg": "image/svg+xml",
|
|
".ico": "image/x-icon",
|
|
".woff": "font/woff",
|
|
".woff2": "font/woff2",
|
|
".ttf": "font/ttf",
|
|
".eot": "application/vnd.ms-fontobject",
|
|
".wav": "audio/wav",
|
|
".mp3": "audio/mpeg",
|
|
".mp4": "video/mp4",
|
|
".webm": "video/webm",
|
|
".wasm": "application/wasm",
|
|
};
|
|
|
|
function resolveContentType(filePath) {
|
|
const ext = path.extname(filePath).toLowerCase();
|
|
return DIST_MIME_TYPES[ext] || "application/octet-stream";
|
|
}
|
|
|
|
function isPathInside(parentPath, childPath) {
|
|
const parent = path.resolve(parentPath);
|
|
const child = path.resolve(childPath);
|
|
if (child === parent) return true;
|
|
return child.startsWith(`${parent}${path.sep}`);
|
|
}
|
|
|
|
function resolveDistPath() {
|
|
return path.join(electronDir, "../dist");
|
|
}
|
|
|
|
function registerAppProtocol() {
|
|
if (!protocol?.handle) return;
|
|
|
|
try {
|
|
protocol.handle("app", async (request) => {
|
|
const notFound = () =>
|
|
new Response("Not Found", {
|
|
status: 404,
|
|
headers: { ...APP_PROTOCOL_HEADERS, "Content-Type": "text/plain" },
|
|
});
|
|
|
|
try {
|
|
const url = new URL(request.url);
|
|
let pathname = url.pathname || "/";
|
|
try {
|
|
pathname = decodeURIComponent(pathname);
|
|
} catch {
|
|
// keep undecoded
|
|
}
|
|
|
|
if (!pathname || pathname === "/") pathname = "/index.html";
|
|
|
|
const distPath = path.resolve(resolveDistPath());
|
|
const relative = pathname.replace(/^\/+/, "");
|
|
let fullPath = path.resolve(distPath, relative);
|
|
|
|
if (!isPathInside(distPath, fullPath)) {
|
|
return new Response("Forbidden", {
|
|
status: 403,
|
|
headers: { ...APP_PROTOCOL_HEADERS, "Content-Type": "text/plain" },
|
|
});
|
|
}
|
|
|
|
// SPA fallback: for extension-less paths, serve index.html.
|
|
if (!path.extname(fullPath)) {
|
|
fullPath = path.resolve(distPath, "index.html");
|
|
}
|
|
|
|
const file = await fs.promises.readFile(fullPath);
|
|
return new Response(file, {
|
|
status: 200,
|
|
headers: {
|
|
...APP_PROTOCOL_HEADERS,
|
|
"Content-Type": resolveContentType(fullPath),
|
|
},
|
|
});
|
|
} catch (err) {
|
|
return notFound();
|
|
}
|
|
});
|
|
} catch (err) {
|
|
console.error("[Main] Failed to register app:// protocol handler:", err);
|
|
}
|
|
}
|
|
|
|
function focusMainWindow() {
|
|
try {
|
|
const mainWin = getWindowManager().getMainWindow?.();
|
|
const win = mainWin && !mainWin.isDestroyed?.() ? mainWin : null;
|
|
if (!win) return false;
|
|
|
|
// Check if the webContents has crashed or been destroyed
|
|
try {
|
|
if (win.webContents?.isCrashed?.()) {
|
|
console.warn('[Main] Main window webContents has crashed, destroying window');
|
|
win.destroy();
|
|
return false;
|
|
}
|
|
} catch {}
|
|
|
|
// Cancel any in-flight close-to-tray hide so second-instance / dock-click
|
|
// re-entry beats a pending leave-full-screen → hide sequence.
|
|
try {
|
|
getGlobalShortcutBridge().clearPendingFullscreenHide?.(win);
|
|
} catch {}
|
|
|
|
try {
|
|
if (win.isMinimized && win.isMinimized()) win.restore();
|
|
} catch {}
|
|
try {
|
|
win.show();
|
|
} catch {}
|
|
try {
|
|
win.focus();
|
|
} catch {}
|
|
try {
|
|
app.focus({ steal: true });
|
|
} catch {}
|
|
|
|
return true;
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
// Shared state
|
|
const sessions = new Map();
|
|
const sftpClients = new Map();
|
|
const keyRoot = path.join(os.homedir(), ".netcatty", "keys");
|
|
let cloudSyncSessionPassword = null;
|
|
const CLOUD_SYNC_PASSWORD_FILE = "netcatty_cloud_sync_master_password_v1";
|
|
|
|
// Key management helpers
|
|
const ensureKeyDir = async () => {
|
|
try {
|
|
await fs.promises.mkdir(keyRoot, { recursive: true, mode: 0o700 });
|
|
} catch (err) {
|
|
console.warn("Unable to ensure key cache dir", err);
|
|
}
|
|
};
|
|
|
|
const writeKeyToDisk = async (keyId, privateKey) => {
|
|
if (!privateKey) return null;
|
|
await ensureKeyDir();
|
|
const safeId = String(keyId || "temp").replace(/[^a-zA-Z0-9_-]/g, "_").slice(0, 120);
|
|
const filename = `${safeId}.pem`;
|
|
const target = path.join(keyRoot, filename);
|
|
const normalized = privateKey.endsWith("\n") ? privateKey : `${privateKey}\n`;
|
|
try {
|
|
await fs.promises.writeFile(target, normalized, { mode: 0o600 });
|
|
return target;
|
|
} catch (err) {
|
|
console.error("Failed to persist private key", err);
|
|
return null;
|
|
}
|
|
};
|
|
|
|
const { createBridgeRegistrar } = require("./main/registerBridges.cjs");
|
|
|
|
const registerBridges = createBridgeRegistrar({
|
|
electronModule,
|
|
app,
|
|
BrowserWindow,
|
|
shell,
|
|
clipboard,
|
|
path,
|
|
fs,
|
|
os,
|
|
preload,
|
|
effectiveDevServerUrl,
|
|
isDev,
|
|
appIcon,
|
|
isMac,
|
|
electronDir,
|
|
sessions,
|
|
sftpClients,
|
|
CLOUD_SYNC_PASSWORD_FILE,
|
|
getCliDiscoveryFilePath,
|
|
sshBridge,
|
|
sftpBridge,
|
|
localFsBridge,
|
|
transferBridge,
|
|
portForwardingBridge,
|
|
terminalBridge,
|
|
crashLogBridge,
|
|
ptyProcessTree,
|
|
getOauthBridge,
|
|
getGithubAuthBridge,
|
|
getGoogleAuthBridge,
|
|
getOnedriveAuthBridge,
|
|
getCloudSyncBridge,
|
|
getFileWatcherBridge,
|
|
getTempDirBridge,
|
|
getSessionLogsBridge,
|
|
getCompressUploadBridge,
|
|
getGlobalShortcutBridge,
|
|
getCredentialBridge,
|
|
getAutoUpdateBridge,
|
|
getAiBridge,
|
|
getWindowManager,
|
|
getVaultBackupBridge,
|
|
isPathInside,
|
|
});
|
|
/**
|
|
* Create the main application window
|
|
*/
|
|
async function createWindow() {
|
|
const win = await getWindowManager().createWindow(electronModule, {
|
|
preload,
|
|
devServerUrl: effectiveDevServerUrl,
|
|
isDev,
|
|
appIcon,
|
|
isMac,
|
|
electronDir,
|
|
onRegisterBridge: registerBridges,
|
|
});
|
|
|
|
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;
|
|
const message =
|
|
code === "ENOENT"
|
|
? "Renderer files are missing. Please reinstall or rebuild Netcatty."
|
|
: "Failed to load the UI. Please relaunch Netcatty.";
|
|
|
|
try {
|
|
electronModule.dialog?.showErrorBox?.(title, message);
|
|
} catch {
|
|
// ignore
|
|
}
|
|
}
|
|
|
|
// Ensure single-instance behavior — must run before app.whenReady() so
|
|
// the second instance never attempts to register the app:// protocol or
|
|
// create a BrowserWindow (which would fail with ERR_FAILED).
|
|
const gotLock = app.requestSingleInstanceLock();
|
|
if (!gotLock) {
|
|
app.quit();
|
|
} else {
|
|
app.on("second-instance", () => {
|
|
if (!focusMainWindow()) {
|
|
// Window is missing or crashed — try to recreate it
|
|
void createAndShowMainWindow().catch((err) => {
|
|
console.error("[Main] Failed to recreate window on second-instance:", err);
|
|
showStartupError(err);
|
|
if (!hasUsableWindow()) {
|
|
try { app.quit(); } catch {}
|
|
}
|
|
});
|
|
}
|
|
});
|
|
|
|
// Application lifecycle
|
|
app.whenReady().then(() => {
|
|
registerAppProtocol();
|
|
|
|
// Grant only the Chromium permissions the app actually uses, and only
|
|
// to the app's own origin. The default session is shared with in-app
|
|
// OAuth pop-ups (accounts.google.com, login.microsoftonline.com, ...),
|
|
// so non-app origins are denied outright; for the app itself we keep
|
|
// an explicit allow-list rather than blanket-approving everything.
|
|
try {
|
|
const defaultSession = session?.defaultSession;
|
|
if (defaultSession) {
|
|
// app:// is registered as a standard scheme in Chromium
|
|
// (registerSchemesAsPrivileged above) but Node's WHATWG URL parser
|
|
// doesn't include it in its special-scheme list, so
|
|
// `new URL('app://netcatty/...').origin` returns the string "null"
|
|
// — matching against an `app://netcatty` origin string would
|
|
// therefore fail in packaged builds. Match by protocol + host
|
|
// instead, and only fall back to .origin for HTTP-family URLs
|
|
// (the dev server).
|
|
const allowedHttpOrigins = new Set();
|
|
if (effectiveDevServerUrl) {
|
|
try {
|
|
allowedHttpOrigins.add(new URL(effectiveDevServerUrl).origin);
|
|
} catch {
|
|
// ignore malformed dev server URL
|
|
}
|
|
}
|
|
const isAppOrigin = (rawUrl) => {
|
|
if (!rawUrl) return false;
|
|
try {
|
|
const parsed = new URL(String(rawUrl));
|
|
if (parsed.protocol === "app:") {
|
|
return parsed.host === "netcatty";
|
|
}
|
|
return allowedHttpOrigins.has(parsed.origin);
|
|
} catch {
|
|
return false;
|
|
}
|
|
};
|
|
|
|
// Permissions the renderer is known to need:
|
|
// - local-fonts: terminal font picker enumeration (this PR)
|
|
// - clipboard-read / clipboard-sanitized-write: terminal & SFTP
|
|
// copy-paste flows (navigator.clipboard.{read,write}Text)
|
|
const APP_ALLOWED_PERMISSIONS = new Set([
|
|
"local-fonts",
|
|
"clipboard-read",
|
|
"clipboard-sanitized-write",
|
|
]);
|
|
|
|
defaultSession.setPermissionRequestHandler((wc, permission, callback, details) => {
|
|
const requestingUrl =
|
|
details?.requestingUrl ||
|
|
(typeof wc?.getURL === "function" ? wc.getURL() : "");
|
|
if (!isAppOrigin(requestingUrl)) {
|
|
callback(false);
|
|
return;
|
|
}
|
|
callback(APP_ALLOWED_PERMISSIONS.has(permission));
|
|
});
|
|
|
|
defaultSession.setPermissionCheckHandler((wc, permission, requestingOrigin, details) => {
|
|
const url =
|
|
requestingOrigin ||
|
|
details?.requestingUrl ||
|
|
(typeof wc?.getURL === "function" ? wc.getURL() : "");
|
|
if (!isAppOrigin(url)) return false;
|
|
return APP_ALLOWED_PERMISSIONS.has(permission);
|
|
});
|
|
}
|
|
} catch (err) {
|
|
console.warn("[Main] Failed to install permission handlers:", err);
|
|
}
|
|
|
|
// 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 {
|
|
const windowManager = getWindowManager();
|
|
const mainWin = windowManager.getMainWindow();
|
|
const settingsWin = windowManager.getSettingsWindow();
|
|
const isPrimary = win === mainWin || win === settingsWin;
|
|
if (!isPrimary) {
|
|
win.setMenuBarVisibility(false);
|
|
win.autoHideMenuBar = true;
|
|
win.setMenu(null);
|
|
if (appIcon && win.setIcon) win.setIcon(appIcon);
|
|
}
|
|
} catch {
|
|
// ignore
|
|
}
|
|
});
|
|
|
|
// Create the main window
|
|
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);
|
|
|
|
// Pre-warm the settings window in the background so it opens instantly.
|
|
// Delay slightly to avoid competing with main window first-paint resources.
|
|
setTimeout(() => {
|
|
getWindowManager().prewarmSettingsWindow(electronModule, {
|
|
preload,
|
|
devServerUrl: effectiveDevServerUrl,
|
|
isDev,
|
|
appIcon,
|
|
isMac,
|
|
electronDir,
|
|
});
|
|
}, 3000);
|
|
}).catch((err) => {
|
|
console.error("[Main] Failed to create main window:", err);
|
|
showStartupError(err);
|
|
try {
|
|
app.quit();
|
|
} catch {}
|
|
});
|
|
|
|
// Re-create or focus window on macOS dock click
|
|
app.on("activate", () => {
|
|
// If the main window was hidden (e.g. "close to tray"), clicking the Dock icon
|
|
// should bring it back. Fallback to creating a new window if none exists.
|
|
try {
|
|
const mainWin = getWindowManager().getMainWindow?.();
|
|
if (mainWin && !mainWin.isDestroyed?.()) {
|
|
// If a close-to-tray hide is still pending (fullscreen exit animation
|
|
// not finished yet), cancel it — user intent to bring the window
|
|
// back overrides the pending hide.
|
|
try {
|
|
getGlobalShortcutBridge().clearPendingFullscreenHide?.(mainWin);
|
|
} catch {}
|
|
if (mainWin.isMinimized?.()) mainWin.restore();
|
|
mainWin.show?.();
|
|
mainWin.focus?.();
|
|
try {
|
|
app.focus({ steal: true });
|
|
} catch {}
|
|
return;
|
|
}
|
|
} catch {}
|
|
|
|
if (focusMainWindow()) return;
|
|
// Main window doesn't exist — create it even if other windows (e.g. settings) are open
|
|
void createAndShowMainWindow().catch((err) => {
|
|
console.error("[Main] Failed to create window on activate:", err);
|
|
showStartupError(err);
|
|
if (!hasUsableWindow()) {
|
|
try { app.quit(); } catch {}
|
|
}
|
|
});
|
|
});
|
|
});
|
|
|
|
// Cleanup on all windows closed
|
|
app.on("window-all-closed", () => {
|
|
if (process.platform !== "darwin") {
|
|
app.quit();
|
|
}
|
|
});
|
|
|
|
// Quit guard state:
|
|
// - quitConfirmed: once true, before-quit falls through without re-checking.
|
|
// Set right before we call app.quit() after a successful dirty-editor check,
|
|
// so the re-entered before-quit doesn't loop back into another check.
|
|
// - quitGuardChannelBusy: prevents a second check from being started while the
|
|
// first round-trip is still in flight.
|
|
// Note: both are intentionally NOT reset on the dirty=true path — if the user
|
|
// cancels quit to save, a subsequent Cmd+Q re-enters with quitConfirmed=false
|
|
// and quitGuardChannelBusy=false (reset in the once/timeout handlers), which
|
|
// kicks off a fresh check as expected.
|
|
let quitGuardChannelBusy = false;
|
|
let quitConfirmed = false;
|
|
|
|
// 5s timeout: long enough for the renderer to show a toast before reporting
|
|
// back, short enough that a hung renderer doesn't strand the app forever.
|
|
const QUIT_GUARD_TIMEOUT_MS = 5000;
|
|
|
|
// Commit the window manager to "we're quitting" state. Must only run once
|
|
// we've decided to actually proceed — if we set it unconditionally on every
|
|
// before-quit, a dirty-cancelled quit leaves isQuitting=true and changes
|
|
// later window-close behavior (e.g. close-to-tray hooks that gate on
|
|
// !isQuitting would stop firing).
|
|
const commitQuit = () => {
|
|
getWindowManager().setIsQuitting(true);
|
|
quitConfirmed = true;
|
|
app.quit();
|
|
};
|
|
|
|
app.on("before-quit", (event) => {
|
|
// Fast path: we've already confirmed the quit once (commitQuit ran) and
|
|
// app.quit() re-fired before-quit. Let it through.
|
|
if (quitConfirmed) return;
|
|
|
|
// NOTE: an update install (quitAndInstall) intentionally still runs the
|
|
// dirty-editor check below. setQuittingForUpdate(true) only bypasses
|
|
// close-to-tray (so the window actually closes and Squirrel.Mac's ShipIt
|
|
// can swap the bundle); it must NOT skip the unsaved-work guard, or
|
|
// clicking "Restart Now" with a dirty SFTP editor would silently lose
|
|
// edits (#1215 review). If the user cancels to save, the quit is aborted
|
|
// and autoUpdateBridge's watchdog clears the quitting-for-update flags.
|
|
|
|
// A check is already in flight — swallow this event; the in-flight handler
|
|
// will issue commitQuit() when it completes if appropriate.
|
|
if (quitGuardChannelBusy) {
|
|
event.preventDefault();
|
|
return;
|
|
}
|
|
|
|
const { ipcMain: _ipcMain } = electronModule;
|
|
// Target all visible/recoverable main windows explicitly. Falling back to
|
|
// BrowserWindow.getAllWindows() could pick tray/settings windows whose
|
|
// renderers don't listen for app:query-dirty-editors and would force the
|
|
// timeout fallback on every quit.
|
|
const mainWindows = typeof getWindowManager().getMainWindows === "function"
|
|
? getWindowManager().getMainWindows()
|
|
: [getWindowManager().getMainWindow()].filter(Boolean);
|
|
|
|
// No reachable main window (tray-panel "Quit" path) — there's no visible
|
|
// UI to surface a "save first" toast on, so skip the round-trip and quit
|
|
// directly. A minimized window is still reachable via taskbar/Dock.
|
|
const reachableMainWindows = mainWindows.filter((candidate) => (
|
|
candidate && !candidate.isDestroyed?.() &&
|
|
(candidate.isVisible?.() || candidate.isMinimized?.())
|
|
));
|
|
if (reachableMainWindows.length === 0) {
|
|
commitQuit();
|
|
return;
|
|
}
|
|
|
|
// The renderer needs to be alive for the IPC roundtrip to make sense.
|
|
// Crashed/dead renderers are skipped; there is no usable UI to warn from.
|
|
const queryableWebContents = reachableMainWindows
|
|
.map((candidate) => candidate.webContents)
|
|
.filter((wc) => wc && !wc.isDestroyed?.() && !wc.isCrashed?.());
|
|
if (queryableWebContents.length === 0) {
|
|
commitQuit();
|
|
return;
|
|
}
|
|
|
|
quitGuardChannelBusy = true;
|
|
event.preventDefault();
|
|
|
|
// Ask the renderer whether any editor tab has unsaved changes. The same
|
|
// round-trip is used by the auto-update install handler (#1215); both go
|
|
// through queryDirtyEditors so the request/reply/timeout handling stays in
|
|
// one place. It fails open (resolves false) on timeout / dead renderer, so
|
|
// a hung renderer can never strand the quit.
|
|
Promise.all(
|
|
queryableWebContents.map((wc) => queryDirtyEditors(wc, QUIT_GUARD_TIMEOUT_MS, { ipcMain: _ipcMain })),
|
|
)
|
|
.then((dirtyResults) => {
|
|
quitGuardChannelBusy = false;
|
|
const hasDirty = dirtyResults.some(Boolean);
|
|
if (!hasDirty) {
|
|
commitQuit();
|
|
return;
|
|
}
|
|
// hasDirty: the renderer showed a toast for dirty editors and the user
|
|
// is saving instead of quitting.
|
|
//
|
|
// A normal quit never sets isQuitting before commitQuit, so there is
|
|
// nothing to undo. But an update install (quitAndInstall) calls
|
|
// setQuittingForUpdate(true) — which also flips isQuitting=true to
|
|
// bypass close-to-tray — BEFORE this dirty check runs. If the user
|
|
// cancels to save, clear it NOW instead of waiting up to 10s for
|
|
// autoUpdateBridge's watchdog; otherwise close-to-tray and other
|
|
// !isQuitting-gated behavior stay bypassed while the app keeps running
|
|
// (#1215 review).
|
|
const wm = getWindowManager();
|
|
if (wm.isQuittingForUpdate?.()) wm.setQuittingForUpdate(false);
|
|
})
|
|
.catch((err) => {
|
|
// queryDirtyEditors is written to never reject, but guard anyway: a
|
|
// throw here would leave quitGuardChannelBusy=true and wedge the app
|
|
// un-quittable. Fail open and let the quit through.
|
|
console.warn("[Main] dirty-editor quit guard failed:", err);
|
|
quitGuardChannelBusy = false;
|
|
commitQuit();
|
|
});
|
|
});
|
|
|
|
// Cleanup all PTY sessions and port forwarding tunnels before quitting
|
|
app.on("will-quit", () => {
|
|
try {
|
|
sessionLogStreamManager.cleanupAll();
|
|
} catch (err) {
|
|
console.warn("Error during session log stream cleanup:", err);
|
|
}
|
|
try {
|
|
terminalBridge.cleanupAllSessions();
|
|
} catch (err) {
|
|
console.warn("Error during terminal cleanup:", err);
|
|
}
|
|
try {
|
|
portForwardingBridge.stopAllPortForwards();
|
|
} catch (err) {
|
|
console.warn("Error during port forwarding cleanup:", err);
|
|
}
|
|
try {
|
|
getGlobalShortcutBridge().cleanup();
|
|
} catch (err) {
|
|
console.warn("Error during global shortcut cleanup:", err);
|
|
}
|
|
try {
|
|
getAiBridge().cleanup();
|
|
} catch (err) {
|
|
console.warn("Error during AI bridge cleanup:", err);
|
|
}
|
|
});
|
|
}
|
|
|
|
// Graceful shutdown on SIGTERM/SIGINT to prevent zombie processes
|
|
for (const sig of ['SIGTERM', 'SIGINT']) {
|
|
process.on(sig, () => {
|
|
console.log(`[Main] Received ${sig}, quitting…`);
|
|
app.quit();
|
|
});
|
|
}
|
|
|
|
// Export for testing
|
|
module.exports = {
|
|
sessions,
|
|
sftpClients,
|
|
ensureKeyDir,
|
|
writeKeyToDisk,
|
|
};
|