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
巡检模块:巡检调度器改为轮询驱动,新增每日报告存储、告警规则独立 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 运行时依赖。
1226 lines
48 KiB
JavaScript
1226 lines
48 KiB
JavaScript
"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,
|
||
};
|