Files
SenMeshNet/electron/main/registerBridges.cjs
zhaolei 2e71e03d71
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
feat: 巡检增强 / 拓扑大屏 / 远程 HTTP / portable 绿色打包
巡检模块:巡检调度器改为轮询驱动,新增每日报告存储、告警规则独立
Store、设备 Store 解耦,_executeSnapshot 补齐 DEBUG 声明,巡检中心
与告警中心页增强。

拓扑大屏:MonitoringTopologyView 扩展分组/布局控制,
useMonitoringData 重构数据流。

远程 HTTP:新增 electron/remoteHttpServer.cjs,preload/api.cjs 暴露
IPC,registerBridges 注册 netcatty:http:open 桥。

portable 绿色打包:
- electron-builder.config.cjs 恢复原始白名单(61MB app.asar),
  收集器自动追踪运行时依赖;
- portableData.cjs 重写为 .portable 标记检测;
- scripts/migratePortableLocalStorage.cjs 离线脚本把 Local Storage
  的 http://localhost:5173 origin 批量迁移为 app://netcatty;
- scripts/scanWorkspaceDeps.cjs 扫描 workspace 运行时依赖。
2026-10-19 21:11:57 +08:00

1226 lines
48 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use strict";
let bridgesRegistered = false;
let cloudSyncSessionPassword = null;
const { readClipboardFiles, readClipboardImage, hasClipboardImage } = require("../bridges/clipboardFiles.cjs");
const { registerSystemNotificationHandlers } = require("../bridges/systemNotification.cjs");
const excludedFigSpecPrefixes = ["aws", "gcloud", "az"];
function isExcludedFigSpec(commandName) {
return excludedFigSpecPrefixes.some((prefix) => commandName === prefix || commandName.startsWith(`${prefix}/`));
}
function filterExcludedFigSpecs(specNames) {
return specNames.filter((name) => !isExcludedFigSpec(name));
}
function waitForApplicationSpawn(child, requireCleanLauncherExit = false) {
return new Promise((resolve, reject) => {
const onSpawn = () => {
if (requireCleanLauncherExit) return;
cleanup();
resolve();
};
const onClose = (code) => {
if (!requireCleanLauncherExit) return;
cleanup();
if (code === 0) resolve();
else reject(new Error(`Application launcher exited with code ${code}`));
};
const onError = (error) => {
cleanup();
reject(error);
};
const cleanup = () => {
child.removeListener("spawn", onSpawn);
child.removeListener("error", onError);
child.removeListener("close", onClose);
};
child.once("spawn", onSpawn);
child.once("error", onError);
if (requireCleanLauncherExit) child.once("close", onClose);
});
}
function createCloudSyncSessionPasswordReader({
getAppLockController,
getCachedPassword,
setCachedPassword,
readPersistedPassword,
}) {
return async function readCloudSyncSessionPassword() {
const controller = getAppLockController?.();
if (controller?.getRuntimeState?.()?.locked === true) {
return null;
}
const cached = getCachedPassword();
if (cached) return cached;
const persisted = readPersistedPassword();
setCachedPassword(persisted);
return persisted;
};
}
function createBridgeRegistrar(context) {
const {
electronModule,
app,
BrowserWindow,
shell,
clipboard,
path,
fs,
os,
preload,
effectiveDevServerUrl,
isDev,
getAppIconPath,
isMac,
electronDir,
sessions,
sftpClients,
CLOUD_SYNC_PASSWORD_FILE,
getCliDiscoveryFilePath,
sshBridge,
sftpBridge,
localFsBridge,
transferBridge,
portForwardingBridge,
terminalBridge,
crashLogBridge,
ptyProcessTree,
ensureMainWindow,
getOauthBridge,
getGithubAuthBridge,
getGoogleAuthBridge,
getOnedriveAuthBridge,
getCloudSyncBridge,
getFileWatcherBridge,
getTempDirBridge,
getSessionLogsBridge,
getCompressUploadBridge,
getGlobalShortcutBridge,
getCredentialBridge,
getAutoUpdateBridge,
getAiBridge,
getHttpNetworkProxyBridge,
getWindowManager,
getVaultBackupBridge,
getAppLockController,
isPathInside,
} = context;
let bridgesRegistered = false;
/**
* Register all IPC bridges with Electron
*/
const registerBridges = (win) => {
if (bridgesRegistered) return;
bridgesRegistered = true;
const { ipcMain } = electronModule;
const { safeStorage } = electronModule;
const oauthBridge = getOauthBridge();
const githubAuthBridge = getGithubAuthBridge();
const googleAuthBridge = getGoogleAuthBridge();
const onedriveAuthBridge = getOnedriveAuthBridge();
const cloudSyncBridge = getCloudSyncBridge();
const fileWatcherBridge = getFileWatcherBridge();
const tempDirBridge = getTempDirBridge();
const sessionLogsBridge = getSessionLogsBridge();
const compressUploadBridge = getCompressUploadBridge();
const globalShortcutBridge = getGlobalShortcutBridge();
const credentialBridge = getCredentialBridge();
const autoUpdateBridge = getAutoUpdateBridge();
const aiBridge = getAiBridge();
const httpNetworkProxyBridge = getHttpNetworkProxyBridge();
const vaultBackupBridge = getVaultBackupBridge();
const appLockController = getAppLockController?.();
const {
createTrustedPluginBridgeSender,
registerPluginBridge,
} = require("../plugins/pluginBridge.cjs");
let terminalWorkerManager = null;
const { toElectronAccelerator } = require("../plugins/keybindings.cjs");
const { startDevelopmentPluginHost } = require("../plugins/hostBootstrap.cjs");
const pluginHostService = startDevelopmentPluginHost({
env: process.env,
createService: () => {
const { createPluginHostService } = require("../plugins/hostService.cjs");
const {
createNativePermissionDecisionProvider,
} = require("../plugins/nativePermissionDecision.cjs");
const {
terminalInterceptorChoiceLabel,
terminalInterceptorIdentifier,
terminalInterceptorMessages,
} = require("../plugins/terminalInterceptorMessages.cjs");
return createPluginHostService({
app,
electron: electronModule,
safeStorage,
requestPermissionDecision: createNativePermissionDecisionProvider({
dialog: electronModule.dialog,
window: win,
}),
getLocale: () => getWindowManager().getCurrentLanguage?.() ?? "en",
requestTerminalInterceptorSelection: async ({ direction, providers }) => {
const messages = terminalInterceptorMessages(getWindowManager().getCurrentLanguage?.() ?? "en");
const buttons = [
...providers.map(terminalInterceptorChoiceLabel),
messages.noInterceptor,
];
const result = await electronModule.dialog.showMessageBox(win, {
type: "question",
title: messages.selectTitle(direction),
message: messages.selectMessage(direction),
buttons,
cancelId: buttons.length - 1,
defaultId: buttons.length - 1,
noLink: true,
});
return result.response >= 0 && result.response < providers.length
? providers[result.response].provider.id
: null;
},
showTerminalInterceptorWarning: (warning) => {
const messages = terminalInterceptorMessages(getWindowManager().getCurrentLanguage?.() ?? "en");
void electronModule.dialog.showMessageBox(win, {
type: "warning",
title: messages.warningTitle,
message: messages.warningMessage,
detail: warning?.providerId
? `${messages.warningDetail}\n${terminalInterceptorIdentifier(warning.providerId)}`
: messages.warningDetail,
}).catch(() => {});
},
});
},
registerShutdown: (handler) => {
const { registerPluginShutdown } = require("../plugins/shutdownCoordinator.cjs");
return registerPluginShutdown(handler);
},
logger: console,
});
registerPluginBridge(ipcMain, {
manager: pluginHostService?.manager,
contributionService: pluginHostService?.contributionService,
terminalProviderService: pluginHostService?.terminalProviderService,
terminalDataPipelineService: pluginHostService?.terminalDataPipelineService,
extensionProviderService: pluginHostService?.extensionProviderService,
syncSidecarService: pluginHostService?.syncSidecarService,
credentialResolver: pluginHostService?.credentialResolver,
secretStore: pluginHostService?.secretStore,
getTerminalWorkerManager: () => terminalWorkerManager,
selectImporterFile: async (event) => {
const owner = electronModule.BrowserWindow.fromWebContents(event.sender);
const result = await electronModule.dialog.showOpenDialog(owner || undefined, {
title: "Select file to import",
properties: ["openFile", "showHiddenFiles"],
filters: [{ name: "All Files", extensions: ["*"] }],
});
return result.canceled ? null : result.filePaths[0] ?? null;
},
resolveContributionIcon: (payload) => pluginHostService?.contributionIconService?.resolve(payload),
viewHost: pluginHostService?.viewHost,
env: process.env,
isTrustedSender: createTrustedPluginBridgeSender({ devServerUrl: effectiveDevServerUrl }),
broadcast: (channel, payload) => {
for (const window of electronModule.BrowserWindow.getAllWindows()) {
if (!window.isDestroyed()) window.webContents.send(channel, payload);
}
},
});
if (pluginHostService?.contributionService) {
const windowManager = getWindowManager();
const {
createThemeContributionNativeImage,
resolveApplicationMenuIconReference,
} = require("../plugins/themeContributionIcon.cjs");
const applicationMenuPackageIcons = new Map();
const pendingApplicationMenuPackageIcons = new Map();
let applicationMenuIconGeneration = 0;
const applicationMenuProvider = () => {
const snapshot = pluginHostService.contributionService.snapshot({
locale: windowManager.getCurrentLanguage?.() ?? "en",
context: { "netcatty.surface": "application" },
});
return snapshot.plugins.flatMap((plugin) => {
const commandById = new Map(plugin.commands.map((command) => [command.id, command]));
return plugin.menus
.filter((menu) => menu.location === "application" && menu.visible)
.sort((left, right) => (left.order ?? 0) - (right.order ?? 0))
.map((menu) => {
const iconReference = resolveApplicationMenuIconReference(menu, commandById);
let icon;
if (iconReference?.kind === "theme") {
icon = createThemeContributionNativeImage(
electronModule.nativeImage,
iconReference,
electronModule.nativeTheme?.shouldUseDarkColors === true,
);
} else if (iconReference?.kind === "package" && pluginHostService.contributionIconService) {
const iconKey = JSON.stringify([plugin.id, iconReference.light, iconReference.dark ?? null]);
const resolved = applicationMenuPackageIcons.get(iconKey);
if (resolved) {
const dataUrl = electronModule.nativeTheme?.shouldUseDarkColors && resolved.dark
? resolved.dark
: resolved.light;
const candidate = electronModule.nativeImage?.createFromDataURL?.(dataUrl);
if (candidate && !candidate.isEmpty?.()) icon = candidate;
} else if (!pendingApplicationMenuPackageIcons.has(iconKey)) {
const generation = applicationMenuIconGeneration;
const pending = pluginHostService.contributionIconService.resolve({
pluginId: plugin.id,
icon: iconReference,
}).then((next) => {
if (generation !== applicationMenuIconGeneration) return;
applicationMenuPackageIcons.set(iconKey, next);
windowManager.setPluginApplicationMenuProvider?.(applicationMenuProvider);
}).catch(() => {}).finally(() => {
if (pendingApplicationMenuPackageIcons.get(iconKey) === pending) {
pendingApplicationMenuPackageIcons.delete(iconKey);
}
});
pendingApplicationMenuPackageIcons.set(iconKey, pending);
}
}
return {
id: menu.id,
label: menu.title,
icon,
enabled: menu.enabled,
checked: menu.checked,
group: menu.group ?? "",
order: menu.order ?? 0,
accelerator: toElectronAccelerator(menu.shortcut),
click: (_menuItem, _window, event) => {
const command = event?.altKey && menu.alt ? menu.alt : menu.command;
void pluginHostService.contributionService.executeCommand(command, undefined, {
source: "application-menu",
context: { "netcatty.surface": "application" },
}).catch((error) => console.warn("[Plugins] Application command failed:", error?.message ?? error));
},
};
});
});
};
const refreshPluginApplicationMenu = ({ invalidateIcons = false } = {}) => {
if (invalidateIcons) {
applicationMenuIconGeneration += 1;
applicationMenuPackageIcons.clear();
pendingApplicationMenuPackageIcons.clear();
}
windowManager.setPluginApplicationMenuProvider?.(applicationMenuProvider);
};
refreshPluginApplicationMenu();
pluginHostService.contributionService.onDidChange(() => refreshPluginApplicationMenu({ invalidateIcons: true }));
electronModule.nativeTheme?.on?.("updated", () => {
windowManager.setPluginApplicationMenuProvider?.(applicationMenuProvider);
});
}
const getCloudSyncPasswordPath = () => {
try {
return path.join(app.getPath("userData"), CLOUD_SYNC_PASSWORD_FILE);
} catch {
return null;
}
};
const readPersistedCloudSyncPassword = () => {
try {
if (!safeStorage?.isEncryptionAvailable?.()) return null;
const filePath = getCloudSyncPasswordPath();
if (!filePath || !fs.existsSync(filePath)) return null;
const base64 = fs.readFileSync(filePath, "utf8");
if (!base64) return null;
const buf = Buffer.from(base64, "base64");
const decrypted = safeStorage.decryptString(buf);
return typeof decrypted === "string" && decrypted.length ? decrypted : null;
} catch (err) {
console.warn("[CloudSync] Failed to read persisted password:", err?.message || err);
return null;
}
};
const persistCloudSyncPassword = (password) => {
try {
if (!safeStorage?.isEncryptionAvailable?.()) return false;
const filePath = getCloudSyncPasswordPath();
if (!filePath) return false;
const encrypted = safeStorage.encryptString(password);
fs.writeFileSync(filePath, encrypted.toString("base64"), { mode: 0o600 });
return true;
} catch (err) {
console.warn("[CloudSync] Failed to persist password:", err?.message || err);
return false;
}
};
const clearPersistedCloudSyncPassword = () => {
try {
const filePath = getCloudSyncPasswordPath();
if (filePath && fs.existsSync(filePath)) {
fs.unlinkSync(filePath);
}
} catch (err) {
console.warn("[CloudSync] Failed to clear persisted password:", err?.message || err);
}
};
// Initialize bridges with shared dependencies
const userDataDir = app.getPath("userData");
const cliDiscoveryFilePath = getCliDiscoveryFilePath({ userDataDir });
const { createTerminalOutputChannel } = require("../bridges/terminalOutputChannel.cjs");
const {
createTerminalWorkerManager,
isTerminalWorkerEnabled,
registerTransportIdleTtlSettingsSync,
} = require("../bridges/terminalWorkerManager.cjs");
const terminalOutputChannel = createTerminalOutputChannel({
MessageChannelMain: electronModule.MessageChannelMain,
});
terminalWorkerManager = isTerminalWorkerEnabled({ env: process.env }) && electronModule.utilityProcess
? createTerminalWorkerManager({
utilityProcess: electronModule.utilityProcess,
electronModule,
terminalOutputChannel,
MessageChannelMain: electronModule.MessageChannelMain,
})
: null;
registerTransportIdleTtlSettingsSync(ipcMain, terminalWorkerManager);
if (terminalWorkerManager) {
const { registerPluginShutdown } = require("../plugins/shutdownCoordinator.cjs");
registerPluginShutdown(async () => {
try {
await terminalWorkerManager.request("netcatty:portforward:stopAll", {}, {});
} catch (error) {
console.warn("[PortForward] Worker shutdown cleanup failed:", error?.message || error);
}
});
}
const terminalPipelineSessionManager = terminalWorkerManager ?? {
getSessionOwnerWebContentsId(sessionId) {
const owner = sessions.get(sessionId)?.webContentsId;
return Number.isSafeInteger(owner) ? owner : null;
},
ownsSession(sessionId, webContentsId) {
return Number.isSafeInteger(webContentsId)
&& sessions.get(sessionId)?.webContentsId === webContentsId;
},
};
pluginHostService?.terminalDataPipelineService?.bindTerminalWorkerManager(
terminalPipelineSessionManager,
);
const reportOpenedSessionActivity = (event) => aiBridge.reportOpenedSessionActivity?.(event);
terminalWorkerManager?.addOutputTap?.((sessionId) => {
reportOpenedSessionActivity({ sessionId, phase: "touch" });
});
const deps = {
sessions,
sftpClients,
electronModule,
ensureMainWindow,
getMainWindow: () => getWindowManager().getMainWindow?.(),
sendWhenRendererReady: (...args) => getWindowManager().sendWhenRendererReady?.(...args),
cliDiscoveryFilePath,
userDataDir,
terminalOutputChannel,
terminalWorkerManager,
reportOpenedSessionActivity,
transferBridge,
};
sshBridge.init(deps);
sftpBridge.init(deps);
transferBridge.init(deps);
terminalBridge.init(deps);
fileWatcherBridge.init(deps);
globalShortcutBridge.init({
...deps,
getAppLockController,
});
aiBridge.init(deps);
crashLogBridge.init(deps);
// Initialize compress upload bridge with transferBridge dependency
compressUploadBridge.init({
...deps,
transferBridge,
});
// Initialize temp directory (synchronously)
tempDirBridge.ensureTempDir();
// Register all IPC handlers
sshBridge.registerHandlers(ipcMain, { terminalWorkerManager });
sftpBridge.registerHandlers(ipcMain, { terminalWorkerManager });
localFsBridge.registerHandlers(ipcMain);
transferBridge.registerHandlers(ipcMain, { terminalWorkerManager });
portForwardingBridge.registerHandlers(ipcMain, { terminalWorkerManager });
terminalBridge.registerHandlers(ipcMain, { terminalWorkerManager });
const scriptBridge = require("../bridges/scriptBridge.cjs");
scriptBridge.init({
sessions,
electronModule,
terminalBridge,
terminalWorkerManager,
getMainWindow: () => win,
});
scriptBridge.registerHandlers(ipcMain);
const { createSystemManagerBridge } = require("../bridges/systemManagerBridge.cjs");
const systemManagerBridge = createSystemManagerBridge({
getSessions: () => sessions,
execOnEtSession: (...args) => terminalBridge.execOnEtSession(...args),
ensureMoshStatsConnection: (...args) => sshBridge.ensureMoshStatsConnection(...args),
process,
});
systemManagerBridge.registerHandlers(ipcMain, { terminalWorkerManager });
oauthBridge.setupOAuthBridge(ipcMain);
githubAuthBridge.registerHandlers(ipcMain, electronModule);
googleAuthBridge.registerHandlers(ipcMain, electronModule);
onedriveAuthBridge.registerHandlers(ipcMain, electronModule);
cloudSyncBridge.registerHandlers(ipcMain, electronModule);
fileWatcherBridge.registerHandlers(ipcMain, { terminalWorkerManager });
tempDirBridge.registerHandlers(ipcMain, shell, electronModule);
sessionLogsBridge.registerHandlers(ipcMain, { terminalWorkerManager });
compressUploadBridge.registerHandlers(ipcMain, { terminalWorkerManager });
globalShortcutBridge.registerHandlers(ipcMain);
credentialBridge.registerHandlers(ipcMain, electronModule);
autoUpdateBridge.init(deps);
autoUpdateBridge.registerHandlers(ipcMain);
aiBridge.registerHandlers(ipcMain);
httpNetworkProxyBridge.registerHandlers(ipcMain, electronModule);
crashLogBridge.registerHandlers(ipcMain);
vaultBackupBridge.registerHandlers(ipcMain, electronModule);
appLockController?.registerHandlers?.(ipcMain);
// ----- Inspection (设备巡检) -----
const { registerInspectionHandlers } = require("../bridges/inspection/index.cjs");
const inspectionStorage = require("../bridges/inspection/inspectionStorage.cjs");
inspectionStorage.init(userDataDir);
registerInspectionHandlers(ipcMain, {
readStorage: (key) => inspectionStorage.readStorage(key),
writeStorage: (key, val) => inspectionStorage.writeStorage(key, val),
// 传入 sessions 用于复用终端 SSH 连接
sessions,
});
// 给 MCP capability dispatch 里的 inspection handlers 也注入 sessions(RCA agent 用)
try {
const inspectionAgentHandlers = require("../bridges/mcpServerBridge/inspectionAgentHandlers.cjs");
inspectionAgentHandlers.configure({ sessions });
} catch (_) {}
// ZMODEM cancel handler
ipcMain.on("netcatty:zmodem:cancel", (event, payload) => {
if (terminalWorkerManager) {
terminalWorkerManager.send("netcatty:zmodem:cancel", payload, {
webContentsId: event?.sender?.id,
});
return;
}
const session = sessions.get(payload.sessionId);
if (session?.zmodemSentry) {
session.zmodemSentry.cancel(payload.options);
}
});
ipcMain.handle("netcatty:zmodem:drag-drop-upload", async (event, payload) => {
if (terminalWorkerManager) {
return terminalWorkerManager.request("netcatty:zmodem:drag-drop-upload", payload, {
webContentsId: event?.sender?.id,
});
}
const { sessionId, files, uploadCommand } = payload || {};
const session = sessions.get(sessionId);
if (!session?.zmodemSentry?.queueDragDropUpload) {
return { success: false, error: "ZMODEM upload is not available for this session" };
}
if (session.zmodemSentry.isActive?.()) {
return { success: false, error: "ZMODEM transfer already in progress" };
}
const filePaths = [];
const remoteNames = [];
const tempPaths = [];
for (const file of files || []) {
if (!file?.name) continue;
let localPath = file.path;
if (!localPath && file.data) {
localPath = tempDirBridge.getTempFilePath(file.name);
await fs.promises.writeFile(localPath, Buffer.from(file.data));
tempPaths.push(localPath);
}
if (!localPath) continue;
try {
await fs.promises.access(localPath);
} catch {
continue;
}
filePaths.push(localPath);
remoteNames.push(file.remoteName || path.basename(localPath));
}
if (!filePaths.length) {
for (const tempPath of tempPaths) {
try { await fs.promises.unlink(tempPath); } catch { /* ignore */ }
}
return { success: false, error: "No readable files to upload" };
}
try {
session.zmodemSentry.queueDragDropUpload({
filePaths,
remoteNames,
uploadCommand: uploadCommand || "rz -y\r",
tempPaths,
});
return { success: true };
} catch (err) {
for (const tempPath of tempPaths) {
try { await fs.promises.unlink(tempPath); } catch { /* ignore */ }
}
return { success: false, error: err?.message || String(err) };
}
});
// Fig autocomplete spec loader — uses dynamic import() since @withfig/autocomplete is ESM
ipcMain.handle("netcatty:figspec:list", async () => {
try {
const fs = require("fs");
const mod = await import("@withfig/autocomplete");
const figSpecs = mod.default || [];
// Merge local specs (covers commands missing from @withfig/autocomplete)
const localSpecDir = path.join(electronDir, "specs");
let localNames = [];
try {
localNames = fs.readdirSync(localSpecDir)
.filter(f => f.endsWith(".js"))
.map(f => f.slice(0, -3));
} catch { /* no local specs dir */ }
const merged = filterExcludedFigSpecs([...new Set([...figSpecs, ...localNames])]);
return merged;
} catch (err) {
console.warn("[Main] Failed to load fig spec list:", err?.message || err);
return [];
}
});
ipcMain.handle("netcatty:figspec:load", async (_event, commandName) => {
try {
// Sanitize: reject absolute paths, path traversal, and non-spec characters
if (!commandName || commandName.startsWith("/") || commandName.startsWith("\\") ||
commandName.includes("..") || !/^[@a-zA-Z0-9._/+-]+$/.test(commandName)) return null;
if (isExcludedFigSpec(commandName)) return null;
const { pathToFileURL } = require("url");
const fs = require("fs");
// Try local specs first (covers commands missing from @withfig/autocomplete)
const localSpec = path.join(electronDir, "specs", `${commandName}.js`);
if (fs.existsSync(localSpec)) {
const mod = await import(pathToFileURL(localSpec).href);
const spec = mod.default?.default ?? mod.default ?? null;
return spec ? JSON.parse(JSON.stringify(spec)) : null;
}
// Fall back to @withfig/autocomplete
// Can't use `import("@withfig/autocomplete/build/...")` because the package's
// "exports" field restricts allowed import paths. Use file URL to bypass.
const specFile = path.join(electronDir, "..", "node_modules", "@withfig", "autocomplete", "build", `${commandName}.js`);
const mod = await import(pathToFileURL(specFile).href);
const spec = mod.default?.default ?? mod.default ?? null;
// IPC requires serializable data — JSON round-trip strips functions/symbols
return spec ? JSON.parse(JSON.stringify(spec)) : null;
} catch (err) {
console.warn("[Main] Failed to load fig spec:", commandName, err?.message);
return null;
}
});
// Local directory listing for autocomplete (local terminal sessions)
ipcMain.handle("netcatty:local:listdir", async (_event, payload) => {
try {
const {
path: dirPath,
foldersOnly,
filterPrefix = "",
limit = 100,
} = payload || {};
if (typeof dirPath !== "string" || dirPath.length === 0) {
return { success: false, entries: [], error: "Invalid directory path" };
}
const resolvedPath = dirPath.startsWith("~")
? dirPath.replace(/^~/, require("os").homedir())
: dirPath;
const normalizedPrefix = typeof filterPrefix === "string" ? filterPrefix.toLowerCase() : "";
const maxEntries = Number.isFinite(limit) ? Math.min(Math.max(1, Math.floor(limit)), 200) : 100;
const entries = await fs.promises.readdir(resolvedPath, { withFileTypes: true });
const result = [];
for (const entry of entries) {
if (result.length >= maxEntries) break;
if (entry.name === "." || entry.name === "..") continue;
if (normalizedPrefix && !entry.name.toLowerCase().startsWith(normalizedPrefix)) continue;
let type = entry.isDirectory() ? "directory" : entry.isSymbolicLink() ? "symlink" : "file";
if (foldersOnly) {
if (type === "directory") {
// keep
} else if (type === "symlink") {
try {
const stat = await fs.promises.stat(path.join(resolvedPath, entry.name));
if (!stat.isDirectory()) continue;
} catch {
continue;
}
} else {
continue;
}
}
result.push({ name: entry.name, type });
}
return { success: true, entries: result };
} catch {
return { success: false, entries: [] };
}
});
// Settings window handler
ipcMain.handle("netcatty:settings:open", async (event) => {
try {
await getWindowManager().openSettingsWindow(electronModule, {
preload,
devServerUrl: effectiveDevServerUrl,
isDev,
appIcon: getAppIconPath(),
isMac,
electronDir,
sourceWindow: BrowserWindow.fromWebContents(event.sender),
});
return true;
} catch (err) {
console.error("[Main] Failed to open settings window:", err);
return false;
}
});
ipcMain.handle("netcatty:window:openSession", async (_event, payload) => {
try {
if (!payload || typeof payload !== "object" || !payload.sourceSession) {
return { success: false, error: "Invalid session payload" };
}
const title = typeof payload.title === "string" && payload.title.trim()
? payload.title.trim()
: "Netcatty";
const win = await getWindowManager().createWindow(electronModule, {
preload,
devServerUrl: effectiveDevServerUrl,
isDev,
appIcon: getAppIconPath(),
isMac,
electronDir,
route: "session-window",
registerAsMainWindow: false,
onRegisterBridge: registerBridges,
});
const appLockController = getAppLockController?.();
if (typeof appLockController?.setWindowTitle === "function") {
appLockController.setWindowTitle(win, title);
} else {
try {
win.setTitle(title);
} catch {
// ignore
}
}
const delivery = await getWindowManager().sendWhenRendererReady(
win,
"netcatty:window:openSession",
{
title,
sourceSession: payload.sourceSession,
localShellType: payload.localShellType,
},
{ timeoutMs: 8000 },
);
if (!delivery.success) {
console.warn(
"[Main] New session window could not receive the duplicated tab:",
delivery.reason || delivery.error,
);
return { success: false, error: delivery.error || "Failed to open new window" };
}
return { success: true };
} catch (err) {
console.error("[Main] Failed to open session in new window:", err);
return { success: false, error: err?.message || "Failed to open new window" };
}
});
ipcMain.handle("netcatty:window:openTerminalPopup", async (event, payload) => {
try {
if (!payload || typeof payload !== "object") {
return { success: false, error: "Invalid popup payload" };
}
crashLogBridge.captureDiagnostic("terminal-popup", "openTerminalPopup IPC received", {
title: payload.title,
parentSessionId: payload.parentSessionId,
startupCommand: payload.startupCommand,
sourceSessionId: payload.sourceSession?.id,
sourceProtocol: payload.sourceSession?.protocol,
sourceHostLabel: payload.sourceSession?.hostLabel,
});
const sourceWindow = BrowserWindow.fromWebContents(event.sender);
const result = await getWindowManager().openTerminalPopupWindow(electronModule, {
preload,
devServerUrl: effectiveDevServerUrl,
isDev,
appIcon: getAppIconPath(),
isMac,
electronDir,
sourceWindow,
}, payload);
crashLogBridge.captureDiagnostic("terminal-popup", "openTerminalPopup IPC result", {
title: payload.title,
success: result?.success,
error: result?.error,
popupId: result?.popupId,
});
return result;
} catch (err) {
crashLogBridge.captureError("terminal-popup", err, {
title: payload?.title,
parentSessionId: payload?.parentSessionId,
});
console.error("[Main] Failed to open terminal popup:", err);
return { success: false, error: err?.message || "Failed to open terminal popup" };
}
});
// Cloud sync master password (stored in-memory + persisted via safeStorage)
ipcMain.handle("netcatty:cloudSync:session:setPassword", async (_event, password) => {
cloudSyncSessionPassword = typeof password === "string" && password.length ? password : null;
if (cloudSyncSessionPassword) {
persistCloudSyncPassword(cloudSyncSessionPassword);
// Peer renderers may have observed MASTER_KEY_CONFIG before the
// settings renderer finished unlocking and shared this password.
for (const win of BrowserWindow.getAllWindows()) {
if (!win.isDestroyed() && !win.webContents.isDestroyed()) {
win.webContents.send("netcatty:cloudSync:session:passwordAvailable");
}
}
} else {
clearPersistedCloudSyncPassword();
}
return true;
});
ipcMain.handle("netcatty:cloudSync:session:getPassword", createCloudSyncSessionPasswordReader({
getAppLockController,
getCachedPassword: () => cloudSyncSessionPassword,
setCachedPassword: (password) => {
cloudSyncSessionPassword = password;
},
readPersistedPassword: readPersistedCloudSyncPassword,
}));
ipcMain.handle("netcatty:cloudSync:session:clearPassword", async () => {
cloudSyncSessionPassword = null;
clearPersistedCloudSyncPassword();
return true;
});
// Open external URL in default browser. Falls back to an in-app
// BrowserWindow when the OS has no handler for the URL (e.g. Windows with
// no default browser configured — error 0x483). Rejects only in the rare
// case where both the system browser AND the fallback window fail, so
// existing callers that rely on rejection semantics still abort cleanly.
ipcMain.handle("netcatty:openExternal", async (_event, url) => {
const { shell } = electronModule;
await getWindowManager().tryOpenExternalWithFallback(shell, url);
});
ipcMain.handle("netcatty:openPath", async (_event, targetPath) => {
if (typeof targetPath !== "string" || targetPath.trim() === "") {
return { success: false, error: "Invalid path" };
}
try {
const { shell } = electronModule;
const error = await shell.openPath(targetPath);
return error ? { success: false, error } : { success: true };
} catch (error) {
return {
success: false,
error: error instanceof Error ? error.message : String(error),
};
}
});
// App information for About/Application screens
ipcMain.handle("netcatty:app:getInfo", async () => {
return {
name: app.getName(),
version: app.getVersion(),
platform: process.platform,
};
});
// PTY child process list for busy-check before close
ipcMain.handle("netcatty:pty:childProcesses", async (_event, sessionId) => {
if (typeof sessionId !== "string") return [];
return ptyProcessTree.getChildProcesses(sessionId);
});
// Native confirmation dialog when closing a session with a running process
// Returns true only if the user explicitly clicks "Close". ESC/dialog-dismiss
// resolves as cancelId (0) → false, which is the safe default (do not close).
ipcMain.handle(
"netcatty:dialog:confirmCloseBusy",
async (event, payload) => {
const command = typeof payload?.command === "string" ? payload.command : "unknown";
const title = typeof payload?.title === "string" ? payload.title : "Confirm close";
const message = typeof payload?.message === "string"
? payload.message
: `Process "${command}" is still running and will be terminated.`;
const cancelLabel = typeof payload?.cancelLabel === "string" ? payload.cancelLabel : "Cancel";
const closeLabel = typeof payload?.closeLabel === "string" ? payload.closeLabel : "Close";
const { dialog } = electronModule;
const win = BrowserWindow.fromWebContents(event.sender);
const { response } = await dialog.showMessageBox(win || undefined, {
type: "warning",
title,
message,
buttons: [cancelLabel, closeLabel],
defaultId: 0,
cancelId: 0,
noLink: true,
});
return response === 1; // true = user picked Close
},
);
// OSC 9/777/99 desktop notifications (Codex, iTerm2, kitty, rxvt)
registerSystemNotificationHandlers(ipcMain, { electronModule, BrowserWindow });
// Clipboard helpers for renderer fallback paths (e.g. Monaco paste in Electron)
ipcMain.handle("netcatty:clipboard:readText", async () => {
try {
return clipboard?.readText?.() || "";
} catch {
return "";
}
});
ipcMain.handle("netcatty:clipboard:writeText", async (_event, text) => {
try {
if (typeof clipboard?.writeText !== "function") return false;
clipboard.writeText(typeof text === "string" ? text : "");
return true;
} catch {
return false;
}
});
ipcMain.handle("netcatty:clipboard:readFiles", async () => {
return readClipboardFiles({ clipboard, fsImpl: fs, pathImpl: path });
});
ipcMain.handle("netcatty:clipboard:readImage", async () => {
return readClipboardImage({ clipboard, fsImpl: fs, tempDirBridge });
});
ipcMain.handle("netcatty:clipboard:hasImage", async () => {
return hasClipboardImage({ clipboard });
});
// Select an application from system file picker
ipcMain.handle("netcatty:selectApplication", async () => {
const { dialog } = electronModule;
let filters = [];
let defaultPath;
if (process.platform === "darwin") {
filters = [{ name: "Applications", extensions: ["app"] }];
defaultPath = "/Applications";
} else if (process.platform === "win32") {
filters = [{ name: "Executables", extensions: ["exe", "com", "bat", "cmd"] }];
defaultPath = "C:\\Program Files";
} else {
// Linux - no specific filter, user can pick any executable
filters = [{ name: "All Files", extensions: ["*"] }];
defaultPath = "/usr/bin";
}
const result = await dialog.showOpenDialog({
title: "Select Application",
defaultPath,
filters,
properties: ["openFile"],
});
if (result.canceled || !result.filePaths.length) {
return null;
}
const appPath = result.filePaths[0];
const appName = path.basename(appPath).replace(/\.[^.]+$/, "");
return { path: appPath, name: appName };
});
// Open a file with a specific application
ipcMain.handle("netcatty:openWithApplication", async (_event, { filePath, appPath }) => {
const { spawn: cpSpawn } = require("node:child_process");
console.log(`[Main] Opening file with application:`);
console.log(`[Main] File: ${filePath}`);
console.log(`[Main] App: ${appPath}`);
console.log(`[Main] Platform: ${process.platform}`);
try {
let child;
if (process.platform === "darwin") {
// On macOS, use 'open' command with -a flag for specific app
const args = ["-a", appPath, filePath];
console.log(`[Main] Command: open ${args.join(' ')}`);
child = cpSpawn("open", args, { detached: true, stdio: "pipe" });
} else if (process.platform === "win32") {
// Spawn the selected executable directly. This preserves paths with
// spaces without a shell and reports an invalid app path as ENOENT.
console.log(`[Main] Command: ${appPath} ${filePath}`);
child = cpSpawn(appPath, [filePath], { detached: true, stdio: "pipe" });
} else {
// On Linux, spawn the app with the file
console.log(`[Main] Command: ${appPath} ${filePath}`);
child = cpSpawn(appPath, [filePath], { detached: true, stdio: "pipe" });
}
// Log any errors from the child process
child.on("error", (err) => {
console.error(`[Main] Failed to start application:`, err.message);
});
child.stderr?.on("data", (data) => {
// On Windows, stderr may be encoded in GBK/CP936, try to decode
if (process.platform === "win32") {
try {
// Try decoding as GBK (code page 936) for Chinese Windows
const { TextDecoder } = require("node:util");
const decoder = new TextDecoder("gbk");
const decoded = decoder.decode(data);
console.log(`[Main] Application stderr: ${decoded}`);
} catch {
// Fallback to hex dump if decoding fails
console.log(`[Main] Application stderr (hex): ${data.toString("hex")}`);
}
} else {
console.error(`[Main] Application stderr:`, data.toString());
}
});
child.on("exit", (code, signal) => {
// On Windows, many apps (like Notepad++) pass the file to an existing instance
// and immediately exit with code 1, this is normal behavior
if (code !== 0 && code !== null) {
if (process.platform === "win32") {
console.log(`[Main] Application exited with code: ${code}, signal: ${signal} (this may be normal for single-instance apps)`);
} else {
console.warn(`[Main] Application exited with code: ${code}, signal: ${signal}`);
}
} else {
console.log(`[Main] Application started successfully`);
}
});
// spawn() reports ENOENT and similar launch failures asynchronously.
// Do not acknowledge success until the child has actually started, so
// the renderer can unregister and delete its downloaded temp file.
await waitForApplicationSpawn(child, process.platform === "darwin");
child.unref();
return true;
} catch (err) {
console.error(`[Main] Error opening file with application:`, err);
throw err;
}
});
// Open a file with the system default application
ipcMain.handle("netcatty:openWithSystemDefault", async (_event, { filePath }) => {
const { shell } = require("electron");
try {
const error = await shell.openPath(filePath);
if (error) {
return { success: false, error };
}
return { success: true };
} catch (err) {
return { success: false, error: err instanceof Error ? err.message : String(err) };
}
});
// Show save file dialog and return selected path
ipcMain.handle("netcatty:showSaveDialog", async (_event, { defaultPath, filters }) => {
const { dialog } = electronModule;
const result = await dialog.showSaveDialog({
defaultPath,
filters: filters || [{ name: "All Files", extensions: ["*"] }],
});
if (result.canceled || !result.filePath) {
return null;
}
return result.filePath;
});
// Select a file and return the selected path
ipcMain.handle("netcatty:selectFile", async (_event, { title, defaultPath, filters }) => {
const { dialog } = electronModule;
const result = await dialog.showOpenDialog({
title: title || "Select File",
defaultPath: defaultPath || os.homedir(),
filters: filters || [{ name: "All Files", extensions: ["*"] }],
properties: ["openFile", "showHiddenFiles"],
});
if (result.canceled || !result.filePaths.length) {
return null;
}
return result.filePaths[0];
});
// Select a directory and return the selected path
ipcMain.handle("netcatty:selectDirectory", async (_event, { title, defaultPath }) => {
const { dialog } = electronModule;
const result = await dialog.showOpenDialog({
title: title || "Select Directory",
defaultPath,
properties: ["openDirectory", "createDirectory"],
});
if (result.canceled || !result.filePaths.length) {
return null;
}
return result.filePaths[0];
});
// Download SFTP file to temp with progress reporting via transfer events.
// Progress/complete/cancelled events are delivered via the netcatty:transfer:*
// channels (handled by transferBridge.startTransfer), so the IPC return value
// only carries the resolved temp path. Cancellation is NOT an error here —
// the UI already transitions the task to "cancelled" via the dedicated event.
ipcMain.handle("netcatty:sftp:downloadToTempWithProgress", async (event, { sftpId, remotePath, fileName, encoding, transferId }) => {
const localPath = await getTempDirBridge().getTempFilePath(fileName);
const cleanupPartialDownload = async () => {
try {
await fs.promises.rm(localPath, { force: true });
} catch (err) {
console.warn(`[Main] Failed to clean temp download after interruption: ${localPath}`, err);
}
};
try {
// Omit totalBytes so transferBridge STATs the remote size. Explicit 0
// means a planned empty snapshot and skips the remote body (#2787).
const payload = {
transferId,
sourcePath: remotePath,
targetPath: localPath,
sourceType: "sftp",
targetType: "local",
sourceSftpId: sftpId,
sourceEncoding: encoding,
};
const result = terminalWorkerManager
? await (transferBridge.runAdmittedTransfer ? transferBridge.runAdmittedTransfer(
event,
payload,
undefined,
() => terminalWorkerManager.request("netcatty:transfer:start", {
...payload,
skipAdmission: true,
}, {
webContentsId: event?.sender?.id,
}),
) : terminalWorkerManager.request("netcatty:transfer:start", payload, {
webContentsId: event?.sender?.id,
}))
: await transferBridge.startTransfer(event, payload);
if (result.error) {
await cleanupPartialDownload();
if (result.error === "Transfer cancelled") {
return { localPath, cancelled: true };
}
throw new Error(result.error);
}
return { localPath, cancelled: false };
} catch (err) {
await cleanupPartialDownload();
throw err;
}
});
// Delete a temp file (for cleanup when editors close)
ipcMain.handle("netcatty:deleteTempFile", async (_event, { filePath }) => {
try {
// Only allow deleting files in Netcatty temp directory for security
const netcattyTempDir = path.resolve(getTempDirBridge().getTempDir());
const resolvedPath = path.resolve(String(filePath || ""));
if (!isPathInside(netcattyTempDir, resolvedPath)) {
console.warn(`[Main] Refused to delete file outside Netcatty temp dir: ${filePath}`);
return { success: false };
}
await fs.promises.unlink(resolvedPath);
console.log(`[Main] Temp file deleted: ${filePath}`);
return { success: true };
} catch (err) {
// Silently handle failures (file may be in use or already deleted)
console.log(`[Main] Could not delete temp file: ${filePath} (${err.message})`);
return { success: false };
}
});
console.log('[Main] All bridges registered successfully');
// ─── 远程 HTTP Server(单机版浏览器访问) ───
try {
const remoteHttp = require("../remoteHttpServer.cjs");
// 复用已初始化的 inspectionStorage,共享同一个模块级缓存
// 这样 Electron 本地改配置后,远程 HTTP API 立即读到最新值
ipcMain.handle("remoteHttp:getStatus", () => remoteHttp.getStatus());
ipcMain.handle("remoteHttp:configure", (_e, enabled, port) => remoteHttp.configure(enabled, port));
// 自动启动(打包版才有意义,开发版也启方便调试)
remoteHttp.start({ storage: inspectionStorage }).catch((e) => {
console.warn("[RemoteHTTP] auto-start failed:", e.message);
});
} catch (e) {
console.warn("[RemoteHTTP] 远程 HTTP Server 初始化失败:", e.message);
}
};
return registerBridges;
}
module.exports = {
createCloudSyncSessionPasswordReader,
createBridgeRegistrar,
filterExcludedFigSpecs,
isExcludedFigSpec,
_waitForApplicationSpawnForTests: waitForApplicationSpawn,
};