Some checks failed
AI automation / Route event (push) Has been cancelled
AI automation / Hand reopened issue to maintainers (push) Has been cancelled
AI automation / Clean source issue state (push) Has been cancelled
AI automation / Reconcile handoffs (push) Has been cancelled
AI automation / Classify issue (push) Has been cancelled
AI automation / Claude Code smoke (push) Has been cancelled
AI automation / Review issue follow-up (push) Has been cancelled
AI automation / Publish issue follow-up (push) Has been cancelled
AI automation / Implement with Claude Code (push) Has been cancelled
AI automation / Publish implement PR (push) Has been cancelled
AI automation / Continue queued issue comments (push) Has been cancelled
AI automation / Codex review loop (push) Has been cancelled
AI automation / Publish Codex fix (push) Has been cancelled
AI automation / Clear Codex dispatch marker (push) Has been cancelled
AI automation / Own PR re-request Codex (push) Has been cancelled
AI automation / External PR re-request Codex (push) Has been cancelled
AI automation / Poll Codex reaction / retry (push) Has been cancelled
SSH 连接修复: - main.cjs 顶部在所有 ssh2 require 之前安装 boringSslDhCompat 补丁 - 5 条 SSH 连接路径统一 legacyAlgorithms 默认值为 true portable 版主机数据修复: - main.cjs portable 模式设置 NETCATTY_PORTABLE=1 - preload.cjs 暴露 window.netcatty.isPortable - useVaultState portable 模式跳过 safeStorage DPAPI 加密的 hosts_v1 - inspection saveVaultHosts handler 补 legacyAlgorithms/tags/os 字段映射 打包体积修复: - 恢复 electron-builder.config.cjs 原始 40+ 条排除规则 - package.json 加 pend 运行时依赖
1745 lines
62 KiB
JavaScript
1745 lines
62 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;
|
||
}
|
||
|
||
// BoringSSL DH compatibility shim — MUST be installed before ANY ssh2 require.
|
||
// Multiple bridges (sshBridge, sftpBridge, hostKeyVerifier, privateKeyNormalizer,
|
||
// sshAuthHelper, portForwardingBridge, inspectionExec, systemSshAgent...) each require
|
||
// ssh2 at module load. ssh2 destructes crypto.createDiffieHellmanGroup at load time,
|
||
// so the monkey-patch has to run before even the first ssh2 module load.
|
||
// Installing here (top of main, before any bridge requires) guarantees ordering.
|
||
require("./bridges/boringSslDhCompat.cjs").installBoringSslDhCompat();
|
||
|
||
// 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, ipcMain } = 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 { execFile } = require("node:child_process");
|
||
const { getCliDiscoveryFilePath } = require("./cli/discoveryPath.cjs");
|
||
const {
|
||
SSH_DEEP_LINK_CHANNEL,
|
||
TELNET_DEEP_LINK_CHANNEL,
|
||
JMS_DEEP_LINK_CHANNEL,
|
||
applyInitialJmsDeepLinkPreference,
|
||
applyInitialSshDeepLinkPreference,
|
||
applyJmsProtocolClientPreference,
|
||
applySshProtocolClientPreference,
|
||
collectJmsDeepLinkUrls,
|
||
collectSshDeepLinkQueueItems,
|
||
getSshDeepLinkRendererReadyTimeoutMs,
|
||
redactPuttyCommandLinePasswords,
|
||
isJmsDeepLinkUrl,
|
||
isSshDeepLinkUrl,
|
||
isTelnetDeepLinkUrl,
|
||
readJmsDeepLinkEnabledPreference,
|
||
readSshDeepLinkEnabledPreference,
|
||
shouldDeliverJmsDeepLink,
|
||
shouldDeliverSshDeepLink,
|
||
shouldRequeueFailedSshDeepLinkDelivery,
|
||
shouldDeliverTelnetDeepLink,
|
||
updateJmsDeepLinkEnabledPreference,
|
||
updateSshDeepLinkEnabledPreference,
|
||
writeJmsDeepLinkEnabledPreference,
|
||
writeSshDeepLinkEnabledPreference,
|
||
} = require("./deepLink.cjs");
|
||
const { getReusableMainWindow } = require("./mainWindowReuse.cjs");
|
||
const { createAppContentWindowClosedHandler } = require("./appWindowLifecycle.cjs");
|
||
const { PLUGIN_PROTOCOL_SCHEME } = require("./plugins/constants.cjs");
|
||
const { runPluginShutdown } = require("./plugins/shutdownCoordinator.cjs");
|
||
const {
|
||
OPEN_TERMINAL_PATH_CHANNEL,
|
||
collectOpenTerminalPathArgs,
|
||
resolveOpenTerminalPath,
|
||
resolveOpenTerminalPathsFromArgs,
|
||
} = require("./openTerminalPath.cjs");
|
||
const {
|
||
applyExplorerContextMenuPreference,
|
||
applyInitialExplorerContextMenuPreference,
|
||
resolveExplorerContextMenuEnabled,
|
||
resolveExplorerContextMenuLaunchSpec,
|
||
updateExplorerContextMenuEnabledPreference,
|
||
writeExplorerContextMenuEnabledPreference,
|
||
} = require("./explorerContextMenu.cjs");
|
||
const {
|
||
registerHandlers: registerAutoLaunchHandlers,
|
||
wasLaunchedHidden,
|
||
} = require("./autoLaunch.cjs");
|
||
|
||
try {
|
||
protocol?.registerSchemesAsPrivileged?.([
|
||
{
|
||
scheme: "app",
|
||
privileges: {
|
||
standard: true,
|
||
secure: true,
|
||
supportFetchAPI: true,
|
||
corsEnabled: true,
|
||
stream: true,
|
||
},
|
||
},
|
||
{
|
||
scheme: PLUGIN_PROTOCOL_SCHEME,
|
||
privileges: {
|
||
standard: true,
|
||
secure: true,
|
||
supportFetchAPI: true,
|
||
corsEnabled: false,
|
||
codeCache: true,
|
||
},
|
||
},
|
||
]);
|
||
} catch (err) {
|
||
console.warn("[Main] Failed to register custom 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 getHttpNetworkProxyBridge = createLazyModule("./bridges/httpNetworkProxyBridge.cjs");
|
||
const getWindowManager = createLazyModule("./bridges/windowManager.cjs");
|
||
const getVaultBackupBridge = createLazyModule("./bridges/vaultBackupBridge.cjs");
|
||
const {
|
||
DEFAULT_APP_LOCK_SETTINGS,
|
||
canLockFromSettings,
|
||
createAppLockSettingsStore,
|
||
} = require("./bridges/appLockSettingsStore.cjs");
|
||
const {
|
||
createAppLockController,
|
||
createAppLockRuntimeBridge,
|
||
} = require("./bridges/appLockRuntimeBridge.cjs");
|
||
const {
|
||
createAppLockSystemAuthBridge,
|
||
resolveDefaultHelperPath,
|
||
} = require("./bridges/appLockSystemAuthBridge.cjs");
|
||
const {
|
||
emitAppLockReopen,
|
||
ensureAppLockForFreshSession,
|
||
handleAppHide,
|
||
handleActivateWithMainWindow,
|
||
handleBeforeQuit,
|
||
hasNoUsableAppContentWindows,
|
||
shouldCommitQuitWithoutDirtyCheck,
|
||
} = require("./main/appLockLifecycle.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");
|
||
}
|
||
// Avoid Chromium spare renderers that inflate baseline memory for little gain.
|
||
app.commandLine.appendSwitch("disable-features", "SpareRendererForSitePerProcess");
|
||
// Aggressive GPU enablement can break some environments; opt out with NETCATTY_COMPAT_GPU=1.
|
||
if (process.env.NETCATTY_COMPAT_GPU !== "1") {
|
||
// 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("NetMesh Dev");
|
||
app.setPath("userData", path.join(app.getPath("userData"), "dev"));
|
||
}
|
||
const { applyPortableDataDirectory } = require("./portableData.cjs");
|
||
const portableData = applyPortableDataDirectory({ app });
|
||
if (portableData) {
|
||
console.info(`[Main] Portable data directory: ${portableData.dataDirectory}`);
|
||
// portable 版 safeStorage DPAPI 绑定 userData 路径,解不开 dev 版 LS 加密 blob。
|
||
// 设置环境变量让 useVaultState 跳过 LS hosts_v1,直接从 inspection-data.json 读明文。
|
||
process.env.NETCATTY_PORTABLE = "1";
|
||
}
|
||
const appPath = path.join(__dirname, "..");
|
||
const preload = path.join(__dirname, "preload.cjs");
|
||
const isMac = process.platform === "darwin";
|
||
const appIconManager = require("./bridges/appIconManager.cjs");
|
||
appIconManager.initializeAppIconManager(appPath, {
|
||
preferPublic: !app.isPackaged,
|
||
isMac,
|
||
});
|
||
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",
|
||
};
|
||
|
||
const APP_PROTOCOL_LONG_CACHE_EXTENSIONS = new Set([
|
||
".js",
|
||
".mjs",
|
||
".css",
|
||
".json",
|
||
".png",
|
||
".jpg",
|
||
".jpeg",
|
||
".gif",
|
||
".svg",
|
||
".ico",
|
||
".woff",
|
||
".woff2",
|
||
".ttf",
|
||
".eot",
|
||
".wav",
|
||
".mp3",
|
||
".mp4",
|
||
".webm",
|
||
".wasm",
|
||
]);
|
||
|
||
function resolveContentType(filePath) {
|
||
const ext = path.extname(filePath).toLowerCase();
|
||
return DIST_MIME_TYPES[ext] || "application/octet-stream";
|
||
}
|
||
|
||
function resolveAppProtocolCacheControl(filePath, distPath) {
|
||
const relativePath = path.relative(distPath, filePath).replace(/\\/g, "/");
|
||
if (relativePath === "index.html") return "no-store";
|
||
const ext = path.extname(filePath).toLowerCase();
|
||
if (relativePath.startsWith("assets/") && APP_PROTOCOL_LONG_CACHE_EXTENSIONS.has(ext)) {
|
||
return "public, max-age=31536000, immutable";
|
||
}
|
||
return "no-cache";
|
||
}
|
||
|
||
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,
|
||
"Cache-Control": resolveAppProtocolCacheControl(fullPath, distPath),
|
||
"Content-Type": resolveContentType(fullPath),
|
||
},
|
||
});
|
||
} catch (err) {
|
||
return notFound();
|
||
}
|
||
});
|
||
} catch (err) {
|
||
console.error("[Main] Failed to register app:// protocol handler:", err);
|
||
}
|
||
}
|
||
|
||
function focusMainWindow() {
|
||
try {
|
||
const win = getReusableMainWindow({ getWindowManager });
|
||
if (!win) return false;
|
||
|
||
// 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 {}
|
||
|
||
handleActivateWithMainWindow({
|
||
app,
|
||
mainWindow: win,
|
||
globalShortcutBridge: getGlobalShortcutBridge(),
|
||
windowManager: getWindowManager(),
|
||
reopenWindows: getAppLockReopenWindows(),
|
||
});
|
||
try {
|
||
app.focus({ steal: true });
|
||
} catch {}
|
||
|
||
return true;
|
||
} catch {
|
||
return false;
|
||
}
|
||
}
|
||
|
||
function notifyAllAppLockReopenWindows() {
|
||
emitAppLockReopen(getAppLockReopenWindows());
|
||
}
|
||
|
||
function getAppLockReopenWindows() {
|
||
const windowManager = getWindowManager();
|
||
const seen = new Set();
|
||
const out = [];
|
||
const add = (win) => {
|
||
if (!win || seen.has(win)) return;
|
||
seen.add(win);
|
||
out.push(win);
|
||
};
|
||
for (const win of windowManager.getMainWindows?.() ?? []) add(win);
|
||
add(windowManager.getSettingsWindow?.() ?? null);
|
||
add(getGlobalShortcutBridge().getTrayPanelWindow?.() ?? null);
|
||
for (const win of windowManager.getTerminalPopupWindows?.() ?? []) add(win);
|
||
// Detached #/session-window renderers are app-content windows; they must get
|
||
// reopenSignal for Touch ID/Hello auto-prompt after background re-lock.
|
||
for (const win of windowManager.getAppContentWindows?.() ?? []) add(win);
|
||
return out;
|
||
}
|
||
|
||
// Shared state
|
||
const sessions = new Map();
|
||
const sftpClients = new Map();
|
||
const keyRoot = path.join(os.homedir(), ".netcatty", "keys");
|
||
const APP_LOCK_SETTINGS_FILE = "app-lock-settings.json";
|
||
let cloudSyncSessionPassword = null;
|
||
const CLOUD_SYNC_PASSWORD_FILE = "netcatty_cloud_sync_master_password_v1";
|
||
let appLockSettingsStore = null;
|
||
const appLockRuntimeBridge = createAppLockRuntimeBridge();
|
||
let appLockController = null;
|
||
|
||
function getLiveAppLockWindows() {
|
||
const windowManager = getWindowManager();
|
||
return [
|
||
BrowserWindow.getFocusedWindow?.(),
|
||
...(windowManager.getMainWindows?.() ?? []),
|
||
windowManager.getSettingsWindow?.() ?? null,
|
||
getGlobalShortcutBridge().getTrayPanelWindow?.() ?? null,
|
||
...(windowManager.getTerminalPopupWindows?.() ?? []),
|
||
].filter((win) => (
|
||
win &&
|
||
typeof win.isDestroyed === "function" &&
|
||
!win.isDestroyed() &&
|
||
typeof win.getNativeWindowHandle === "function"
|
||
));
|
||
}
|
||
|
||
function getAppLockNativeWindowHandle() {
|
||
const win = getLiveAppLockWindows()[0] || null;
|
||
return win ? win.getNativeWindowHandle() : null;
|
||
}
|
||
|
||
// 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,
|
||
getAppIconPath: () => appIconManager.getAppIconPath(appPath),
|
||
isMac,
|
||
electronDir,
|
||
appPath,
|
||
appIconManager,
|
||
sessions,
|
||
sftpClients,
|
||
CLOUD_SYNC_PASSWORD_FILE,
|
||
getCliDiscoveryFilePath,
|
||
sshBridge,
|
||
sftpBridge,
|
||
localFsBridge,
|
||
transferBridge,
|
||
portForwardingBridge,
|
||
terminalBridge,
|
||
crashLogBridge,
|
||
ptyProcessTree,
|
||
ensureMainWindow: createAndShowMainWindow,
|
||
getOauthBridge,
|
||
getGithubAuthBridge,
|
||
getGoogleAuthBridge,
|
||
getOnedriveAuthBridge,
|
||
getCloudSyncBridge,
|
||
getFileWatcherBridge,
|
||
getTempDirBridge,
|
||
getSessionLogsBridge,
|
||
getCompressUploadBridge,
|
||
getGlobalShortcutBridge,
|
||
getCredentialBridge,
|
||
getAutoUpdateBridge,
|
||
getAiBridge,
|
||
getHttpNetworkProxyBridge,
|
||
getWindowManager,
|
||
getVaultBackupBridge,
|
||
getAppLockController: () => appLockController,
|
||
isPathInside,
|
||
});
|
||
/**
|
||
* Create the main application window
|
||
*/
|
||
async function createWindow({ startHidden = false } = {}) {
|
||
const windowManager = getWindowManager();
|
||
windowManager.setAppContentWindowClosedHandler(createAppContentWindowClosedHandler({
|
||
app,
|
||
windowManager,
|
||
}));
|
||
const win = await windowManager.createWindow(electronModule, {
|
||
preload,
|
||
devServerUrl: effectiveDevServerUrl,
|
||
isDev,
|
||
appIcon: appIconManager.getAppIconPath(appPath),
|
||
isMac,
|
||
electronDir,
|
||
onRegisterBridge: registerBridges,
|
||
startHidden,
|
||
});
|
||
|
||
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;
|
||
|
||
// macOS Dock/tray reopen after every app-content window was closed leaves the
|
||
// process alive with an already-initialized (and possibly unlocked) app-lock
|
||
// runtime. Re-lock before the new renderer mounts so unlock does not stick.
|
||
// Count settings/tray/popup windows too — an open Settings or session popup
|
||
// means this is not a fresh session (Codex P2 on 100394dc).
|
||
try {
|
||
if (hasNoUsableAppContentWindows(getAppLockReopenWindows())) {
|
||
ensureAppLockForFreshSession(appLockController, "startup");
|
||
}
|
||
} catch {
|
||
// ignore — window creation should still proceed
|
||
}
|
||
|
||
const existingWin = getReusableMainWindow({ getWindowManager });
|
||
if (existingWin) {
|
||
focusMainWindow();
|
||
return existingWin;
|
||
}
|
||
|
||
const startHidden = consumeColdStartHiddenLaunch();
|
||
|
||
mainWindowStartupPromise = (async () => {
|
||
processErrorController.beginMainWindowStartup();
|
||
try {
|
||
const win = await createWindow({ startHidden });
|
||
// A hidden cold start never fires "show" — waiting for it would hang
|
||
// startup forever, so only wait when the window is meant to appear.
|
||
if (!startHidden) 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);
|
||
});
|
||
// windowShown latches process-error-guard protection on for the rest
|
||
// of the app's life (see processErrorGuards.cjs), so a hidden cold
|
||
// start must still report true here: createWindow() above already
|
||
// succeeded (window created, page loaded) — a deliberately hidden
|
||
// window is a completed startup, not a failure. Passing false would
|
||
// leave the guard permanently "strict", classifying any later
|
||
// non-network error as fatal and killing an otherwise healthy
|
||
// tray-only session that never happened to show a window.
|
||
processErrorController.completeMainWindowStartup({ windowShown: true });
|
||
return win;
|
||
} catch (err) {
|
||
processErrorController.completeMainWindowStartup({ windowShown: false });
|
||
throw err;
|
||
} finally {
|
||
mainWindowStartupPromise = null;
|
||
}
|
||
})();
|
||
|
||
return mainWindowStartupPromise;
|
||
}
|
||
|
||
let sshDeepLinkEnabled = readSshDeepLinkEnabledPreference({ app });
|
||
// PuTTY-style CLI args (-ssh user@host -P 22 -pw pass) are an explicit launch
|
||
// method for bastion/PAM callers (#3044). They must connect even when Netcatty
|
||
// is not the registered ssh:// protocol client (or that registration failed),
|
||
// including when a second launch hands its argv to an already-running
|
||
// instance. Only scheme URLs (ssh:// … / telnet:// …) follow the preference.
|
||
const initialDeepLinkQueueItems = collectSshDeepLinkQueueItems(process.argv, {
|
||
includeSchemeUrls: sshDeepLinkEnabled,
|
||
});
|
||
const pendingSshDeepLinkUrls = [...initialDeepLinkQueueItems.ssh];
|
||
const pendingTelnetDeepLinkUrls = [...initialDeepLinkQueueItems.telnet];
|
||
// Snapshot the pristine argv before scrubbing it in place below: the
|
||
// single-instance handoff forwards this list to the running instance and a
|
||
// redacted copy would make warm PuTTY-style launches authenticate with the
|
||
// masked password.
|
||
const rawLaunchArgvForHandoff = [...process.argv];
|
||
redactPuttyCommandLinePasswords(process.argv);
|
||
const pendingOpenTerminalPaths = resolveOpenTerminalPathsFromArgs(process.argv);
|
||
let flushingSshDeepLinks = false;
|
||
let flushingTelnetDeepLinks = false;
|
||
let flushingOpenTerminalPaths = false;
|
||
// Only scheme-originated requests are invalidated by the protocol preference.
|
||
let sshSchemeDeliveryGeneration = 0;
|
||
|
||
let jmsDeepLinkEnabled = readJmsDeepLinkEnabledPreference({ app });
|
||
const pendingJmsDeepLinkUrls = jmsDeepLinkEnabled ? collectJmsDeepLinkUrls(process.argv) : [];
|
||
let flushingJmsDeepLinks = false;
|
||
let jmsDeepLinkDeliveryGeneration = 0;
|
||
|
||
let explorerContextMenuEnabled = resolveExplorerContextMenuEnabled({ app }).enabled === true;
|
||
|
||
function queueSshDeepLink(rawUrl, { viaCommandLine = false } = {}) {
|
||
if (!viaCommandLine && !sshDeepLinkEnabled) return;
|
||
if (!isSshDeepLinkUrl(rawUrl)) return;
|
||
pendingSshDeepLinkUrls.push({ rawUrl, viaCommandLine });
|
||
if (app.isReady?.()) {
|
||
void flushPendingSshDeepLinks();
|
||
}
|
||
}
|
||
|
||
function queueTelnetDeepLink(rawUrl, { viaCommandLine = false } = {}) {
|
||
if (!viaCommandLine && !sshDeepLinkEnabled) return;
|
||
if (!isTelnetDeepLinkUrl(rawUrl)) return;
|
||
pendingTelnetDeepLinkUrls.push({ rawUrl, viaCommandLine });
|
||
if (app.isReady?.()) {
|
||
void flushPendingTelnetDeepLinks();
|
||
}
|
||
}
|
||
|
||
function queueOpenTerminalPath(rawPath, options = {}) {
|
||
const resolvedPath = resolveOpenTerminalPath(rawPath, options);
|
||
if (!resolvedPath) return;
|
||
pendingOpenTerminalPaths.push(resolvedPath);
|
||
if (app.isReady?.()) {
|
||
void flushPendingOpenTerminalPaths();
|
||
}
|
||
}
|
||
|
||
function queueResolvedOpenTerminalPaths(paths) {
|
||
if (!Array.isArray(paths) || paths.length === 0) return;
|
||
pendingOpenTerminalPaths.push(...paths);
|
||
if (app.isReady?.()) {
|
||
void flushPendingOpenTerminalPaths();
|
||
}
|
||
}
|
||
|
||
// Drops preference-gated pending links while keeping command-line launches
|
||
// queued — CLI args may still be delivered after the ssh:// preference flips.
|
||
function dropSchemePendingDeepLinks() {
|
||
// Permanently cancel requests already waiting for a window or renderer,
|
||
// even if protocol handling is enabled again before that wait completes.
|
||
sshSchemeDeliveryGeneration += 1;
|
||
for (let index = pendingSshDeepLinkUrls.length - 1; index >= 0; index -= 1) {
|
||
if (pendingSshDeepLinkUrls[index]?.viaCommandLine === true) continue;
|
||
pendingSshDeepLinkUrls.splice(index, 1);
|
||
}
|
||
for (let index = pendingTelnetDeepLinkUrls.length - 1; index >= 0; index -= 1) {
|
||
if (pendingTelnetDeepLinkUrls[index]?.viaCommandLine === true) continue;
|
||
pendingTelnetDeepLinkUrls.splice(index, 1);
|
||
}
|
||
}
|
||
|
||
ipcMain?.handle?.("netcatty:deepLink:ssh:setEnabled", async (_event, payload) => {
|
||
const enabled = payload?.enabled !== false;
|
||
const result = updateSshDeepLinkEnabledPreference({
|
||
currentEnabled: sshDeepLinkEnabled,
|
||
enabled,
|
||
applyPreference: (nextEnabled) => applySshProtocolClientPreference({ app, enabled: nextEnabled, isDev }),
|
||
writePreference: (nextEnabled) => writeSshDeepLinkEnabledPreference({ app, enabled: nextEnabled }),
|
||
clearPending: dropSchemePendingDeepLinks,
|
||
});
|
||
sshDeepLinkEnabled = result.enabled;
|
||
return result;
|
||
});
|
||
|
||
ipcMain?.handle?.("netcatty:deepLink:ssh:getEnabled", async () => sshDeepLinkEnabled);
|
||
|
||
function queueJmsDeepLink(rawUrl) {
|
||
if (!jmsDeepLinkEnabled) return;
|
||
if (!isJmsDeepLinkUrl(rawUrl)) return;
|
||
pendingJmsDeepLinkUrls.push(rawUrl);
|
||
if (app.isReady?.()) {
|
||
void flushPendingJmsDeepLinks();
|
||
}
|
||
}
|
||
|
||
ipcMain?.handle?.("netcatty:deepLink:jms:setEnabled", async (_event, payload) => {
|
||
const enabled = payload?.enabled !== false;
|
||
const result = updateJmsDeepLinkEnabledPreference({
|
||
currentEnabled: jmsDeepLinkEnabled,
|
||
enabled,
|
||
applyPreference: (nextEnabled) => applyJmsProtocolClientPreference({ app, enabled: nextEnabled, isDev }),
|
||
writePreference: (nextEnabled) => writeJmsDeepLinkEnabledPreference({ app, enabled: nextEnabled }),
|
||
clearPending: () => {
|
||
pendingJmsDeepLinkUrls.length = 0;
|
||
jmsDeepLinkDeliveryGeneration += 1;
|
||
},
|
||
});
|
||
jmsDeepLinkEnabled = result.enabled;
|
||
return result;
|
||
});
|
||
|
||
ipcMain?.handle?.("netcatty:deepLink:jms:getEnabled", async () => jmsDeepLinkEnabled);
|
||
|
||
ipcMain?.handle?.("netcatty:explorerContextMenu:setEnabled", async (_event, payload) => {
|
||
const enabled = payload?.enabled !== false;
|
||
const launchSpec = resolveExplorerContextMenuLaunchSpec();
|
||
const result = updateExplorerContextMenuEnabledPreference({
|
||
currentEnabled: explorerContextMenuEnabled,
|
||
enabled,
|
||
applyPreference: (nextEnabled) => applyExplorerContextMenuPreference({
|
||
enabled: nextEnabled,
|
||
executablePath: launchSpec.executablePath,
|
||
appArgs: launchSpec.appArgs,
|
||
}),
|
||
writePreference: (nextEnabled) => writeExplorerContextMenuEnabledPreference({
|
||
app,
|
||
enabled: nextEnabled,
|
||
executablePath: launchSpec.executablePath,
|
||
appArgs: launchSpec.appArgs,
|
||
}),
|
||
});
|
||
explorerContextMenuEnabled = result.enabled === true;
|
||
return result;
|
||
});
|
||
|
||
ipcMain?.handle?.("netcatty:explorerContextMenu:getEnabled", async () => ({
|
||
enabled: explorerContextMenuEnabled,
|
||
supported: process.platform === "win32",
|
||
}));
|
||
|
||
if (ipcMain) registerAutoLaunchHandlers(ipcMain, { app });
|
||
|
||
// Cold-start only: true when the OS login item launched us hidden (--hidden
|
||
// on Windows, wasOpenedAsHidden on macOS). Consumed exactly once by whichever
|
||
// createAndShowMainWindow() call reaches it first — this is NOT guaranteed to
|
||
// be the default bootstrap call near the bottom of this file: a genuine
|
||
// second instance (or a Dock reopen) can arrive after app.whenReady() but
|
||
// before the bootstrap's own await chain gets there, racing it. Any caller
|
||
// that represents an explicit foreground request (second-instance, activate,
|
||
// deep links, ...) must re-focus after creation regardless of which flag it
|
||
// happened to consume, or a user-triggered relaunch can silently create a
|
||
// hidden window with nothing to show it.
|
||
let consumeColdStartHiddenLaunch = (() => {
|
||
let pending = wasLaunchedHidden({ argv: process.argv, app });
|
||
return () => {
|
||
const value = pending;
|
||
pending = false;
|
||
return value;
|
||
};
|
||
})();
|
||
|
||
async function deliverJmsDeepLink(rawUrl, expectedGeneration = jmsDeepLinkDeliveryGeneration) {
|
||
if (!shouldDeliverJmsDeepLink({
|
||
enabled: jmsDeepLinkEnabled,
|
||
deliveryGeneration: jmsDeepLinkDeliveryGeneration,
|
||
expectedGeneration,
|
||
})) return;
|
||
const win = await createAndShowMainWindow();
|
||
if (!shouldDeliverJmsDeepLink({
|
||
enabled: jmsDeepLinkEnabled,
|
||
deliveryGeneration: jmsDeepLinkDeliveryGeneration,
|
||
expectedGeneration,
|
||
})) return;
|
||
focusMainWindow();
|
||
const windowManager = getWindowManager();
|
||
const result = await windowManager.sendWhenRendererReady?.(
|
||
win,
|
||
JMS_DEEP_LINK_CHANNEL,
|
||
{ url: rawUrl },
|
||
{
|
||
timeoutMs: 0,
|
||
shouldSend: () => shouldDeliverJmsDeepLink({
|
||
enabled: jmsDeepLinkEnabled,
|
||
deliveryGeneration: jmsDeepLinkDeliveryGeneration,
|
||
expectedGeneration,
|
||
}),
|
||
cancelReason: "jms-deep-link-disabled",
|
||
},
|
||
);
|
||
if (result && result.success === false && result.reason !== "jms-deep-link-disabled") {
|
||
console.warn("[Main] Failed to deliver jms:// deep link:", result.error || result.reason);
|
||
}
|
||
return result || { success: true };
|
||
}
|
||
|
||
async function flushPendingJmsDeepLinks() {
|
||
if (flushingJmsDeepLinks) return;
|
||
flushingJmsDeepLinks = true;
|
||
let requeueDelayMs = 0;
|
||
try {
|
||
while (jmsDeepLinkEnabled && pendingJmsDeepLinkUrls.length > 0) {
|
||
const rawUrl = pendingJmsDeepLinkUrls.shift();
|
||
if (!rawUrl) continue;
|
||
const result = await deliverJmsDeepLink(rawUrl, jmsDeepLinkDeliveryGeneration);
|
||
if (shouldRequeueFailedSshDeepLinkDelivery({
|
||
enabled: jmsDeepLinkEnabled,
|
||
deliveryGeneration: jmsDeepLinkDeliveryGeneration,
|
||
expectedGeneration: jmsDeepLinkDeliveryGeneration,
|
||
result,
|
||
cancelReason: "jms-deep-link-disabled",
|
||
})) {
|
||
pendingJmsDeepLinkUrls.unshift(rawUrl);
|
||
requeueDelayMs = 1000;
|
||
break;
|
||
}
|
||
}
|
||
} catch (err) {
|
||
console.warn("[Main] Failed to process jms:// deep link:", err);
|
||
} finally {
|
||
flushingJmsDeepLinks = false;
|
||
if (jmsDeepLinkEnabled && pendingJmsDeepLinkUrls.length > 0) {
|
||
if (requeueDelayMs > 0) {
|
||
setTimeout(() => {
|
||
void flushPendingJmsDeepLinks();
|
||
}, requeueDelayMs);
|
||
} else {
|
||
void flushPendingJmsDeepLinks();
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
async function deliverSshDeepLink(rawUrl, expectedGeneration = sshSchemeDeliveryGeneration, { viaCommandLine = false } = {}) {
|
||
// The ssh:// preference can flip while a delivery is waiting for the
|
||
// renderer, so re-check it at every gate instead of reusing the queue-time
|
||
// snapshot. Command-line (PuTTY-style) launches bypass the preference.
|
||
const shouldDeliver = () => viaCommandLine === true || shouldDeliverSshDeepLink({
|
||
enabled: sshDeepLinkEnabled,
|
||
deliveryGeneration: sshSchemeDeliveryGeneration,
|
||
expectedGeneration,
|
||
});
|
||
if (!shouldDeliver()) {
|
||
return { success: false, reason: "ssh-deep-link-disabled" };
|
||
}
|
||
const win = await createAndShowMainWindow();
|
||
if (!shouldDeliver()) {
|
||
return { success: false, reason: "ssh-deep-link-disabled" };
|
||
}
|
||
focusMainWindow();
|
||
const windowManager = getWindowManager();
|
||
// timeoutMs: 0 waits until AppLockGate marks the renderer ready after unlock,
|
||
// so a slow password entry does not drop startup ssh:// links.
|
||
const result = await windowManager.sendWhenRendererReady?.(
|
||
win,
|
||
SSH_DEEP_LINK_CHANNEL,
|
||
{ url: rawUrl },
|
||
{
|
||
timeoutMs: getSshDeepLinkRendererReadyTimeoutMs({ isDev }),
|
||
shouldSend: shouldDeliver,
|
||
cancelReason: "ssh-deep-link-disabled",
|
||
},
|
||
);
|
||
if (result && result.success === false && result.reason !== "ssh-deep-link-disabled") {
|
||
console.warn("[Main] Failed to deliver ssh:// deep link:", result.error || result.reason);
|
||
}
|
||
return result || { success: true };
|
||
}
|
||
|
||
async function deliverTelnetDeepLink(rawUrl, expectedGeneration = sshSchemeDeliveryGeneration, { viaCommandLine = false } = {}) {
|
||
// Mirror deliverSshDeepLink: gate on the live preference so disabling
|
||
// protocol handling cancels in-flight scheme deliveries, while
|
||
// command-line launches stay deliverable.
|
||
const shouldDeliver = () => viaCommandLine === true || shouldDeliverTelnetDeepLink({
|
||
enabled: sshDeepLinkEnabled,
|
||
deliveryGeneration: sshSchemeDeliveryGeneration,
|
||
expectedGeneration,
|
||
});
|
||
if (!shouldDeliver()) return;
|
||
const win = await createAndShowMainWindow();
|
||
if (!shouldDeliver()) return;
|
||
focusMainWindow();
|
||
const windowManager = getWindowManager();
|
||
const result = await windowManager.sendWhenRendererReady?.(
|
||
win,
|
||
TELNET_DEEP_LINK_CHANNEL,
|
||
{ url: rawUrl },
|
||
{
|
||
timeoutMs: 0,
|
||
shouldSend: shouldDeliver,
|
||
cancelReason: "telnet-deep-link-disabled",
|
||
},
|
||
);
|
||
if (result && result.success === false && result.reason !== "telnet-deep-link-disabled") {
|
||
console.warn("[Main] Failed to deliver telnet:// deep link:", result.error || result.reason);
|
||
}
|
||
return result || { success: true };
|
||
}
|
||
|
||
async function flushPendingSshDeepLinks() {
|
||
if (flushingSshDeepLinks) return;
|
||
flushingSshDeepLinks = true;
|
||
let requeueDelayMs = 0;
|
||
try {
|
||
while (pendingSshDeepLinkUrls.length > 0) {
|
||
const item = pendingSshDeepLinkUrls.shift();
|
||
if (!item?.rawUrl) continue;
|
||
// Command-line launches bypass the ssh:// protocol-client preference;
|
||
// scheme-originated deliveries gate on it and are cancelled once it is
|
||
// disabled (deliverSshDeepLink re-checks the live preference).
|
||
const expectedGeneration = sshSchemeDeliveryGeneration;
|
||
const result = await deliverSshDeepLink(item.rawUrl, expectedGeneration, {
|
||
viaCommandLine: item.viaCommandLine === true,
|
||
});
|
||
if (shouldRequeueFailedSshDeepLinkDelivery({
|
||
enabled: item.viaCommandLine === true || sshDeepLinkEnabled,
|
||
deliveryGeneration: item.viaCommandLine === true ? expectedGeneration : sshSchemeDeliveryGeneration,
|
||
expectedGeneration,
|
||
result,
|
||
cancelReason: "ssh-deep-link-disabled",
|
||
})) {
|
||
// Window died or delivery failed while the link is still valid — keep it
|
||
// queued for the next successful window/renderer ready cycle.
|
||
pendingSshDeepLinkUrls.unshift(item);
|
||
requeueDelayMs = 1000;
|
||
break;
|
||
}
|
||
}
|
||
} catch (err) {
|
||
console.warn("[Main] Failed to process ssh:// deep link:", err);
|
||
} finally {
|
||
flushingSshDeepLinks = false;
|
||
if (pendingSshDeepLinkUrls.length > 0) {
|
||
if (requeueDelayMs > 0) {
|
||
setTimeout(() => {
|
||
void flushPendingSshDeepLinks();
|
||
}, requeueDelayMs);
|
||
} else {
|
||
void flushPendingSshDeepLinks();
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
async function flushPendingTelnetDeepLinks() {
|
||
if (flushingTelnetDeepLinks) return;
|
||
flushingTelnetDeepLinks = true;
|
||
let requeueDelayMs = 0;
|
||
try {
|
||
while (pendingTelnetDeepLinkUrls.length > 0) {
|
||
const item = pendingTelnetDeepLinkUrls.shift();
|
||
if (!item?.rawUrl) continue;
|
||
// Command-line launches bypass the ssh:// protocol-client preference;
|
||
// scheme-originated deliveries gate on it and are cancelled once it is
|
||
// disabled (deliverTelnetDeepLink re-checks the live preference).
|
||
const expectedGeneration = sshSchemeDeliveryGeneration;
|
||
const result = await deliverTelnetDeepLink(item.rawUrl, expectedGeneration, {
|
||
viaCommandLine: item.viaCommandLine === true,
|
||
});
|
||
if (shouldRequeueFailedSshDeepLinkDelivery({
|
||
enabled: item.viaCommandLine === true || sshDeepLinkEnabled,
|
||
deliveryGeneration: item.viaCommandLine === true ? expectedGeneration : sshSchemeDeliveryGeneration,
|
||
expectedGeneration,
|
||
result,
|
||
cancelReason: "telnet-deep-link-disabled",
|
||
})) {
|
||
pendingTelnetDeepLinkUrls.unshift(item);
|
||
requeueDelayMs = 1000;
|
||
break;
|
||
}
|
||
}
|
||
} catch (err) {
|
||
console.warn("[Main] Failed to process telnet:// deep link:", err);
|
||
} finally {
|
||
flushingTelnetDeepLinks = false;
|
||
if (pendingTelnetDeepLinkUrls.length > 0) {
|
||
if (requeueDelayMs > 0) {
|
||
setTimeout(() => {
|
||
void flushPendingTelnetDeepLinks();
|
||
}, requeueDelayMs);
|
||
} else {
|
||
void flushPendingTelnetDeepLinks();
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
async function deliverOpenTerminalPath(targetPath) {
|
||
const win = await createAndShowMainWindow();
|
||
focusMainWindow();
|
||
const windowManager = getWindowManager();
|
||
const result = await windowManager.sendWhenRendererReady?.(
|
||
win,
|
||
OPEN_TERMINAL_PATH_CHANNEL,
|
||
{ path: targetPath },
|
||
{ timeoutMs: 0 },
|
||
);
|
||
if (result && result.success === false) {
|
||
console.warn("[Main] Failed to deliver open terminal path:", result.error || result.reason);
|
||
}
|
||
return result || { success: true };
|
||
}
|
||
|
||
async function flushPendingOpenTerminalPaths() {
|
||
if (flushingOpenTerminalPaths) return;
|
||
flushingOpenTerminalPaths = true;
|
||
let requeueDelayMs = 0;
|
||
try {
|
||
while (pendingOpenTerminalPaths.length > 0) {
|
||
const targetPath = pendingOpenTerminalPaths.shift();
|
||
if (!targetPath) continue;
|
||
const result = await deliverOpenTerminalPath(targetPath);
|
||
if (result && result.success === false) {
|
||
pendingOpenTerminalPaths.unshift(targetPath);
|
||
requeueDelayMs = 1000;
|
||
break;
|
||
}
|
||
}
|
||
} catch (err) {
|
||
console.warn("[Main] Failed to process open terminal path:", err);
|
||
} finally {
|
||
flushingOpenTerminalPaths = false;
|
||
if (pendingOpenTerminalPaths.length > 0) {
|
||
if (requeueDelayMs > 0) {
|
||
setTimeout(() => {
|
||
void flushPendingOpenTerminalPaths();
|
||
}, requeueDelayMs);
|
||
} else {
|
||
void flushPendingOpenTerminalPaths();
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
function hasPendingColdStartLaunchIntents() {
|
||
return (
|
||
flushingSshDeepLinks
|
||
|| flushingTelnetDeepLinks
|
||
|| flushingJmsDeepLinks
|
||
|| flushingOpenTerminalPaths
|
||
|| pendingSshDeepLinkUrls.length > 0
|
||
|| pendingTelnetDeepLinkUrls.length > 0
|
||
|| pendingJmsDeepLinkUrls.length > 0
|
||
|| pendingOpenTerminalPaths.length > 0
|
||
);
|
||
}
|
||
|
||
async function drainColdStartLaunchIntents() {
|
||
// Re-entrant void flushes from finally blocks must finish before we notify
|
||
// the renderer; otherwise landing can race a still-queued startup intent.
|
||
for (let attempt = 0; attempt < 50; attempt += 1) {
|
||
await Promise.all([
|
||
flushPendingSshDeepLinks(),
|
||
flushPendingTelnetDeepLinks(),
|
||
flushPendingJmsDeepLinks(),
|
||
flushPendingOpenTerminalPaths(),
|
||
]);
|
||
if (!hasPendingColdStartLaunchIntents()) return;
|
||
await new Promise((resolve) => setImmediate(resolve));
|
||
}
|
||
}
|
||
|
||
function notifyColdStartIntentsSettled(win) {
|
||
try {
|
||
if (!win || win.isDestroyed?.()) return;
|
||
win.webContents?.send?.("netcatty:startup:coldStartIntentsSettled");
|
||
} catch (err) {
|
||
console.warn("[Main] Failed to notify cold-start intents settled:", err);
|
||
}
|
||
}
|
||
|
||
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 = "NetMesh";
|
||
const code = err && typeof err === "object" ? err.code : null;
|
||
const message =
|
||
code === "ENOENT"
|
||
? "Renderer files are missing. Please reinstall or rebuild NetMesh."
|
||
: "Failed to load the UI. Please relaunch NetMesh.";
|
||
|
||
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).
|
||
// The raw argument list rides along as additionalData: the second-instance
|
||
// event delivers Chromium-regrouped argv (all dash switches before positional
|
||
// args), which separates PuTTY-style flags from their values and breaks the
|
||
// CLI connection parser. The second process's own process.argv preserves the
|
||
// original order.
|
||
const gotLock = app.requestSingleInstanceLock({ rawLaunchArgv: rawLaunchArgvForHandoff.slice(1) });
|
||
// Electron has synchronously copied the handoff data. Release our pristine
|
||
// snapshot so a primary instance does not retain the launch password forever.
|
||
rawLaunchArgvForHandoff.length = 0;
|
||
if (!gotLock) {
|
||
app.quit();
|
||
} else {
|
||
app.on("open-url", (event, rawUrl) => {
|
||
event.preventDefault();
|
||
if (isJmsDeepLinkUrl(rawUrl)) {
|
||
queueJmsDeepLink(rawUrl);
|
||
return;
|
||
}
|
||
if (isTelnetDeepLinkUrl(rawUrl)) {
|
||
queueTelnetDeepLink(rawUrl);
|
||
return;
|
||
}
|
||
queueSshDeepLink(rawUrl);
|
||
});
|
||
|
||
app.on("open-file", (event, filePath) => {
|
||
event.preventDefault();
|
||
queueOpenTerminalPath(filePath);
|
||
});
|
||
|
||
app.on("second-instance", (_event, argv, workingDirectory, additionalData) => {
|
||
// Prefer the raw argument list forwarded by the second process (see the
|
||
// requestSingleInstanceLock comment) — the regrouped event argv separates
|
||
// PuTTY-style flags from their values, so fall back only when the second
|
||
// instance predates the raw-argv handoff.
|
||
const rawLaunchArgv = Array.isArray(additionalData?.rawLaunchArgv)
|
||
? additionalData.rawLaunchArgv
|
||
: null;
|
||
const secondInstanceArgv = rawLaunchArgv ? [argv[0], ...rawLaunchArgv] : argv;
|
||
const jmsDeepLinkUrls = collectJmsDeepLinkUrls(secondInstanceArgv);
|
||
// Scheme URLs follow the ssh:// protocol-client preference; PuTTY-style
|
||
// CLI args from a bastion/PAM launch always queue (viaCommandLine).
|
||
const deepLinkQueueItems = collectSshDeepLinkQueueItems(secondInstanceArgv, {
|
||
includeSchemeUrls: sshDeepLinkEnabled,
|
||
});
|
||
redactPuttyCommandLinePasswords(secondInstanceArgv);
|
||
if (rawLaunchArgv) {
|
||
// Parsing and subsequent routing use the independent ordered copy.
|
||
// Release both consumed transport buffers: Chromium may have moved the
|
||
// password away from -pw, so adjacency-based redaction is unsafe here.
|
||
rawLaunchArgv.length = 0;
|
||
argv.length = 0;
|
||
}
|
||
if (jmsDeepLinkUrls.length > 0) {
|
||
if (jmsDeepLinkEnabled) {
|
||
jmsDeepLinkUrls.forEach(queueJmsDeepLink);
|
||
}
|
||
return;
|
||
}
|
||
if (deepLinkQueueItems.telnet.length > 0) {
|
||
deepLinkQueueItems.telnet.forEach((item) => {
|
||
queueTelnetDeepLink(item.rawUrl, { viaCommandLine: item.viaCommandLine });
|
||
});
|
||
return;
|
||
}
|
||
if (deepLinkQueueItems.ssh.length > 0) {
|
||
deepLinkQueueItems.ssh.forEach((item) => {
|
||
queueSshDeepLink(item.rawUrl, { viaCommandLine: item.viaCommandLine });
|
||
});
|
||
return;
|
||
}
|
||
if (collectOpenTerminalPathArgs(secondInstanceArgv).length > 0) {
|
||
const baseDirectory = typeof workingDirectory === "string" ? workingDirectory : undefined;
|
||
const openTerminalPaths = resolveOpenTerminalPathsFromArgs(secondInstanceArgv, { baseDirectory });
|
||
if (openTerminalPaths.length > 0) {
|
||
queueResolvedOpenTerminalPaths(openTerminalPaths);
|
||
} else {
|
||
// Still bring the app forward when Explorer launched us but the path
|
||
// failed validation — silent no-op feels like a broken menu item.
|
||
console.warn("[Main] Open-terminal-path args present but no valid path resolved:", secondInstanceArgv);
|
||
if (!focusMainWindow()) {
|
||
// Explicit foreground request (a second instance launch): if a
|
||
// still-pending hidden auto-launch cold start races ahead of the
|
||
// normal bootstrap and consumes the --hidden flag here, the window
|
||
// would otherwise get created hidden with nothing to show it —
|
||
// focus again to guarantee visibility either way.
|
||
void createAndShowMainWindow().then(() => {
|
||
focusMainWindow();
|
||
}).catch((err) => {
|
||
console.error("[Main] Failed to recreate window on open-terminal-path:", err);
|
||
});
|
||
}
|
||
}
|
||
return;
|
||
}
|
||
if (!focusMainWindow()) {
|
||
// Window is missing or crashed — try to recreate it. Same
|
||
// hidden-launch race guard as above: this is an explicit foreground
|
||
// request, so re-focus after creation regardless of which flag this
|
||
// particular call happened to consume.
|
||
void createAndShowMainWindow().then(() => {
|
||
focusMainWindow();
|
||
}).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(async () => {
|
||
registerAppProtocol();
|
||
|
||
const initialSshDeepLinkPreference = applyInitialSshDeepLinkPreference({
|
||
enabled: sshDeepLinkEnabled,
|
||
applyPreference: (enabled) => applySshProtocolClientPreference({ app, enabled, isDev }),
|
||
// Failed protocol registration must not drop command-line launch intents
|
||
// (they do not depend on being the ssh:// protocol client).
|
||
clearPending: () => {
|
||
dropSchemePendingDeepLinks();
|
||
},
|
||
});
|
||
sshDeepLinkEnabled = initialSshDeepLinkPreference.enabled;
|
||
|
||
appLockSettingsStore = createAppLockSettingsStore({
|
||
filePath: path.join(app.getPath("userData"), APP_LOCK_SETTINGS_FILE),
|
||
readFile: (filePath, encoding) => fs.promises.readFile(filePath, encoding),
|
||
writeFile: (filePath, content, options) => fs.promises.writeFile(filePath, content, options),
|
||
rename: (from, to) => fs.promises.rename(from, to),
|
||
});
|
||
|
||
let persistedAppLockSettings = DEFAULT_APP_LOCK_SETTINGS;
|
||
try {
|
||
persistedAppLockSettings = await appLockSettingsStore.load();
|
||
} catch (err) {
|
||
console.warn("[Main] Failed to load app lock settings, defaulting to disabled:", err);
|
||
persistedAppLockSettings = appLockSettingsStore.getSnapshot();
|
||
}
|
||
|
||
const lockOnStartup = canLockFromSettings(persistedAppLockSettings);
|
||
appLockRuntimeBridge.initialize({
|
||
locked: lockOnStartup,
|
||
reason: lockOnStartup ? "startup" : null,
|
||
lastActivityAt: Date.now(),
|
||
});
|
||
const appLockSystemAuthBridge = createAppLockSystemAuthBridge({
|
||
platform: process.platform,
|
||
systemPreferences: electronModule.systemPreferences,
|
||
execFile,
|
||
helperPath: resolveDefaultHelperPath({ isPackaged: app.isPackaged }),
|
||
getNativeWindowHandle: getAppLockNativeWindowHandle,
|
||
});
|
||
appLockController = createAppLockController({
|
||
settingsStore: appLockSettingsStore,
|
||
runtimeBridge: appLockRuntimeBridge,
|
||
systemAuthBridge: appLockSystemAuthBridge,
|
||
getMainWindows: () => getWindowManager().getMainWindows?.() ?? [],
|
||
// Includes detached session windows (registerAsMainWindow:false).
|
||
getAppContentWindows: () => getWindowManager().getAppContentWindows?.() ?? [],
|
||
getSettingsWindow: () => getWindowManager().getSettingsWindow?.() ?? null,
|
||
getTrayPanelWindow: () => getGlobalShortcutBridge().getTrayPanelWindow?.() ?? null,
|
||
getTerminalPopupWindows: () => getWindowManager().getTerminalPopupWindows?.() ?? [],
|
||
});
|
||
appLockController.syncIdleTimer?.();
|
||
|
||
const initialJmsDeepLinkPreference = applyInitialJmsDeepLinkPreference({
|
||
enabled: jmsDeepLinkEnabled,
|
||
applyPreference: (enabled) => applyJmsProtocolClientPreference({ app, enabled, isDev }),
|
||
clearPending: () => {
|
||
pendingJmsDeepLinkUrls.length = 0;
|
||
jmsDeepLinkDeliveryGeneration += 1;
|
||
},
|
||
});
|
||
jmsDeepLinkEnabled = initialJmsDeepLinkPreference.enabled;
|
||
|
||
const explorerLaunchSpec = resolveExplorerContextMenuLaunchSpec();
|
||
const initialExplorerContextMenuPreference = applyInitialExplorerContextMenuPreference({
|
||
app,
|
||
executablePath: explorerLaunchSpec.executablePath,
|
||
appArgs: explorerLaunchSpec.appArgs,
|
||
});
|
||
explorerContextMenuEnabled = initialExplorerContextMenuPreference.enabled === true;
|
||
|
||
// Spellcheck dictionaries/workers are unused in Netcatty and cost memory.
|
||
try {
|
||
session?.defaultSession?.setSpellCheckerEnabled?.(false);
|
||
} catch {
|
||
// ignore
|
||
}
|
||
|
||
// 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, undefined, {
|
||
isAppLocked: () => Boolean(appLockRuntimeBridge?.getState?.()?.locked),
|
||
setAppLockWindowTitle: (win, title) => appLockController?.setWindowTitle?.(win, title),
|
||
});
|
||
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 {
|
||
appLockController?.protectWindow?.(win);
|
||
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);
|
||
const iconPath = appIconManager.getAppIconPath(appPath);
|
||
if (iconPath && win.setIcon) win.setIcon(iconPath);
|
||
}
|
||
} catch {
|
||
// ignore
|
||
}
|
||
});
|
||
|
||
// Create the main window
|
||
void createAndShowMainWindow().then(async (win) => {
|
||
// Empty cold-start queues would otherwise notify immediately (before the
|
||
// renderer subscribes). Wait for ready, then drain, then settle.
|
||
try {
|
||
await getWindowManager().waitForRendererReady(win, {
|
||
timeoutMs: 0,
|
||
});
|
||
} catch (err) {
|
||
console.warn(
|
||
"[Main] Renderer ready signal was late or missing before cold-start settle:",
|
||
err?.message || err,
|
||
);
|
||
}
|
||
await drainColdStartLaunchIntents();
|
||
notifyColdStartIntentsSettled(win);
|
||
|
||
// Trigger auto-update check 5 s after window creation.
|
||
// startAutoCheck() is a no-op on unsupported platforms (for example Linux
|
||
// Snap or an unmarked development build).
|
||
getAutoUpdateBridge().startAutoCheck(5000);
|
||
|
||
// Settings prewarm is opt-in: a hidden BrowserWindow holds a full renderer.
|
||
// Enable with NETCATTY_PREWARM_SETTINGS=1 (delayed so first paint is undisturbed).
|
||
if (process.env.NETCATTY_PREWARM_SETTINGS === "1") {
|
||
setTimeout(() => {
|
||
getWindowManager().prewarmSettingsWindow(electronModule, {
|
||
preload,
|
||
devServerUrl: effectiveDevServerUrl,
|
||
isDev,
|
||
appIcon: appIconManager.getAppIconPath(appPath),
|
||
isMac,
|
||
electronDir,
|
||
});
|
||
}, 15000);
|
||
}
|
||
}).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 (handleActivateWithMainWindow({
|
||
app,
|
||
mainWindow: mainWin,
|
||
globalShortcutBridge: getGlobalShortcutBridge(),
|
||
windowManager: getWindowManager(),
|
||
reopenWindows: getAppLockReopenWindows(),
|
||
})) {
|
||
return;
|
||
}
|
||
} catch {}
|
||
|
||
if (focusMainWindow()) return;
|
||
// Main window doesn't exist — create it even if other windows (e.g.
|
||
// settings) are open. Explicit foreground request (Dock reopen/click):
|
||
// guard against the same hidden-launch race as the second-instance
|
||
// handler above by re-focusing after creation.
|
||
void createAndShowMainWindow().then(() => {
|
||
focusMainWindow();
|
||
}).catch((err) => {
|
||
console.error("[Main] Failed to create window on activate:", err);
|
||
showStartupError(err);
|
||
if (!hasUsableWindow()) {
|
||
try { app.quit(); } catch {}
|
||
}
|
||
});
|
||
});
|
||
|
||
app.on("hide", () => {
|
||
handleAppHide(appLockController);
|
||
});
|
||
});
|
||
|
||
// Cleanup on all windows closed. On macOS the process stays alive for Dock
|
||
// reactivation — re-apply App Lock so a later reopen does not inherit an
|
||
// unlocked runtime from the previous session in this process.
|
||
app.on("window-all-closed", () => {
|
||
if (process.platform !== "darwin") {
|
||
app.quit();
|
||
return;
|
||
}
|
||
ensureAppLockForFreshSession(appLockController, "startup");
|
||
});
|
||
|
||
// 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 = () => {
|
||
try {
|
||
appLockController?.setLocked?.("background");
|
||
} catch {
|
||
// ignore
|
||
}
|
||
getWindowManager().setIsQuitting(true);
|
||
quitGuardChannelBusy = true;
|
||
void runPluginShutdown()
|
||
.then(({ timedOut }) => {
|
||
if (timedOut) console.warn("[Plugins] Shutdown deadline elapsed; continuing app quit");
|
||
})
|
||
.catch((error) => {
|
||
console.warn("[Plugins] Shutdown failed; continuing app quit:", error);
|
||
})
|
||
.finally(() => {
|
||
quitGuardChannelBusy = false;
|
||
quitConfirmed = true;
|
||
app.quit();
|
||
});
|
||
};
|
||
|
||
app.on("before-quit", (event) => {
|
||
const { ipcMain: _ipcMain } = electronModule;
|
||
// Target app-content 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 dirtyEditorWindows = typeof getWindowManager().getDirtyEditorWindows === "function"
|
||
? getWindowManager().getDirtyEditorWindows()
|
||
: null;
|
||
const mainWindows = Array.isArray(dirtyEditorWindows)
|
||
? dirtyEditorWindows
|
||
: typeof getWindowManager().getMainWindows === "function"
|
||
? getWindowManager().getMainWindows()
|
||
: [getWindowManager().getMainWindow()].filter(Boolean);
|
||
|
||
void handleBeforeQuit({
|
||
event,
|
||
mainWindows,
|
||
queryDirtyEditors,
|
||
appLockController,
|
||
windowManager: getWindowManager(),
|
||
app,
|
||
ipcMain: _ipcMain,
|
||
quitConfirmed,
|
||
quitGuardChannelBusy,
|
||
timeoutMs: QUIT_GUARD_TIMEOUT_MS,
|
||
setQuitGuardChannelBusy(value) {
|
||
quitGuardChannelBusy = value;
|
||
},
|
||
setQuitConfirmed(value) {
|
||
quitConfirmed = value;
|
||
},
|
||
// Plugin shutdown is asynchronous, so commit paths must cancel the
|
||
// original quit and re-enter app.quit() through commitQuit.
|
||
commitQuit,
|
||
// Cancel a pending update install when the user aborts quit to save
|
||
// dirty editors (#1215 review) — the install bridge owns its in-flight
|
||
// state, so clear it alongside the window-manager flag.
|
||
cancelPendingUpdateInstall: () => getAutoUpdateBridge().cancelPendingInstall?.(),
|
||
}).catch((err) => {
|
||
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 {
|
||
// End parked SSH transports that outlived their last tab/tunnel lease.
|
||
const { discardAllTransports } = require("./bridges/sshConnectionPool.cjs");
|
||
discardAllTransports("app-quit");
|
||
} catch (err) {
|
||
console.warn("Error during SSH transport 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,
|
||
};
|