Files
Netcatty/electron/main.cjs
2025-12-07 03:25:07 +08:00

360 lines
11 KiB
JavaScript

// Make sure the helper processes do not get forced into Node-only mode.
// Presence of ELECTRON_RUN_AS_NODE (even "0") makes helpers parse Chromium
// switches as Node flags, leading to "bad option: --type=renderer".
if (process.env.ELECTRON_RUN_AS_NODE) {
delete process.env.ELECTRON_RUN_AS_NODE;
}
let electronModule;
try {
electronModule = require("node:electron");
} catch {
electronModule = require("electron");
}
console.log("electron module raw:", electronModule);
console.log("process.versions:", process.versions);
console.log("env ELECTRON_RUN_AS_NODE:", process.env.ELECTRON_RUN_AS_NODE);
const { app, BrowserWindow } = 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 pty = require("node-pty");
const SftpClient = require("ssh2-sftp-client");
const { Client: SSHClient } = require("ssh2");
// GPU: keep hardware acceleration enabled for smoother rendering
// (If you hit GPU issues, you can restore these switches.)
// app.commandLine.appendSwitch("disable-gpu");
// app.commandLine.appendSwitch("disable-software-rasterizer");
app.commandLine.appendSwitch("no-sandbox");
const devServerUrl = process.env.VITE_DEV_SERVER_URL;
const isDev = !!devServerUrl;
const preload = path.join(__dirname, "preload.cjs");
const isMac = process.platform === "darwin";
const appIcon = path.join(__dirname, "../public/icon.png");
const sessions = new Map();
const sftpClients = new Map();
const keyRoot = path.join(os.homedir(), ".nebula-ssh", "keys");
const ensureKeyDir = () => {
try {
fs.mkdirSync(keyRoot, { recursive: true, mode: 0o700 });
} catch (err) {
console.warn("Unable to ensure key cache dir", err);
}
};
const writeKeyToDisk = (keyId, privateKey) => {
if (!privateKey) return null;
ensureKeyDir();
const filename = `${keyId || "temp"}.pem`;
const target = path.join(keyRoot, filename);
const normalized = privateKey.endsWith("\n") ? privateKey : `${privateKey}\n`;
try {
fs.writeFileSync(target, normalized, { mode: 0o600 });
return target;
} catch (err) {
console.error("Failed to persist private key", err);
return null;
}
};
const registerSSHBridge = (win) => {
if (registerSSHBridge._registered) return;
registerSSHBridge._registered = true;
const start = (event, options) => {
const sessionId =
options.sessionId ||
`${Date.now()}-${Math.random().toString(16).slice(2)}`;
const sshArgs = [];
if (options.port) sshArgs.push("-p", String(options.port));
sshArgs.push("-o", "StrictHostKeyChecking=accept-new");
sshArgs.push(
"-o",
`UserKnownHostsFile=${path.join(os.homedir(), ".ssh", "known_hosts")}`
);
if (options.agentForwarding) sshArgs.push("-A");
const keyPath = options.privateKey
? writeKeyToDisk(options.keyId || sessionId, options.privateKey)
: null;
if (keyPath) {
sshArgs.push("-i", keyPath);
}
if (Array.isArray(options.extraArgs)) {
sshArgs.push(...options.extraArgs);
}
sshArgs.push(`${options.username}@${options.hostname}`);
const env = {
...process.env,
LANG: options.charset || process.env.LANG || "en_US.UTF-8",
TERM: "xterm-256color",
};
const proc = pty.spawn("ssh", sshArgs, {
cols: options.cols || 80,
rows: options.rows || 24,
env,
});
const session = {
proc,
webContentsId: event.sender.id,
password: options.password,
sentPassword: false,
};
sessions.set(sessionId, session);
proc.onData((data) => {
if (session.password && !session.sentPassword && /password:/i.test(data)) {
proc.write(`${session.password}\r`);
session.sentPassword = true;
}
const contents = BrowserWindow.fromWebContents(event.sender)?.webContents;
contents?.send("nebula:data", { sessionId, data });
});
proc.onExit((evt) => {
const contents = BrowserWindow.fromWebContents(event.sender)?.webContents;
contents?.send("nebula:exit", { sessionId, ...evt });
sessions.delete(sessionId);
});
return { sessionId };
};
const write = (_event, payload) => {
const session = sessions.get(payload.sessionId);
session?.proc.write(payload.data);
};
const resize = (_event, payload) => {
const session = sessions.get(payload.sessionId);
if (!session) return;
try {
session.proc.resize(payload.cols, payload.rows);
} catch (err) {
console.warn("Resize failed", err);
}
};
const close = (_event, payload) => {
const session = sessions.get(payload.sessionId);
if (!session) return;
try {
session.proc.kill();
} catch (err) {
console.warn("Kill failed", err);
}
sessions.delete(payload.sessionId);
};
electronModule.ipcMain.handle("nebula:start", start);
electronModule.ipcMain.on("nebula:write", write);
electronModule.ipcMain.on("nebula:resize", resize);
electronModule.ipcMain.on("nebula:close", close);
// One-off hidden exec (for probes like distro detection)
const execOnce = async (_event, payload) => {
return new Promise((resolve, reject) => {
const conn = new SSHClient();
let stdout = "";
let stderr = "";
let settled = false;
const timeoutMs = payload.timeout || 10000;
const timer = setTimeout(() => {
if (settled) return;
settled = true;
conn.end();
reject(new Error("SSH exec timeout"));
}, timeoutMs);
conn
.on("ready", () => {
conn.exec(payload.command, (err, stream) => {
if (err) {
clearTimeout(timer);
settled = true;
conn.end();
return reject(err);
}
stream
.on("data", (data) => {
stdout += data.toString();
})
.stderr.on("data", (data) => {
stderr += data.toString();
})
.on("close", (code) => {
if (settled) return;
clearTimeout(timer);
settled = true;
conn.end();
resolve({ stdout, stderr, code });
});
});
})
.on("error", (err) => {
if (settled) return;
clearTimeout(timer);
settled = true;
reject(err);
})
.on("end", () => {
if (settled) return;
clearTimeout(timer);
settled = true;
resolve({ stdout, stderr, code: null });
});
conn.connect({
host: payload.hostname,
port: payload.port || 22,
username: payload.username,
password: payload.password,
privateKey: payload.privateKey,
readyTimeout: timeoutMs,
keepaliveInterval: 0,
});
});
};
electronModule.ipcMain.handle("nebula:ssh:exec", execOnce);
// SFTP handlers
const openSftp = async (_event, options) => {
const client = new SftpClient();
const connId = options.sessionId || `${Date.now()}-sftp-${Math.random().toString(16).slice(2)}`;
const connectOpts = {
host: options.hostname,
port: options.port || 22,
username: options.username || "root",
};
if (options.privateKey) {
connectOpts.privateKey = options.privateKey;
} else if (options.password) {
connectOpts.password = options.password;
}
await client.connect(connectOpts);
sftpClients.set(connId, client);
return { sftpId: connId };
};
const listSftp = async (_event, payload) => {
const client = sftpClients.get(payload.sftpId);
if (!client) throw new Error("SFTP session not found");
const list = await client.list(payload.path || ".");
return list.map((item) => ({
name: item.name,
type: item.type === "d" ? "directory" : "file",
size: `${item.size} bytes`,
lastModified: new Date(item.modifyTime || Date.now()).toISOString(),
}));
};
const readSftp = async (_event, payload) => {
const client = sftpClients.get(payload.sftpId);
if (!client) throw new Error("SFTP session not found");
const buffer = await client.get(payload.path);
return buffer.toString();
};
const writeSftp = async (_event, payload) => {
const client = sftpClients.get(payload.sftpId);
if (!client) throw new Error("SFTP session not found");
await client.put(Buffer.from(payload.content, "utf-8"), payload.path);
return true;
};
const closeSftp = async (_event, payload) => {
const client = sftpClients.get(payload.sftpId);
if (!client) return;
try {
await client.end();
} catch (err) {
console.warn("SFTP close failed", err);
}
sftpClients.delete(payload.sftpId);
};
const mkdirSftp = async (_event, payload) => {
const client = sftpClients.get(payload.sftpId);
if (!client) throw new Error("SFTP session not found");
await client.mkdir(payload.path, true);
return true;
};
electronModule.ipcMain.handle("nebula:sftp:open", openSftp);
electronModule.ipcMain.handle("nebula:sftp:list", listSftp);
electronModule.ipcMain.handle("nebula:sftp:read", readSftp);
electronModule.ipcMain.handle("nebula:sftp:write", writeSftp);
electronModule.ipcMain.handle("nebula:sftp:close", closeSftp);
electronModule.ipcMain.handle("nebula:sftp:mkdir", mkdirSftp);
};
async function createWindow() {
const win = new BrowserWindow({
width: 1400,
height: 900,
backgroundColor: "#0b1220",
icon: appIcon,
titleBarStyle: isMac ? "hiddenInset" : "hidden",
titleBarOverlay: {
color: isMac ? "#0b1220" : "#0b1220",
symbolColor: "#ffffff",
height: 44,
},
trafficLightPosition: isMac ? { x: 12, y: 12 } : undefined,
webPreferences: {
preload,
contextIsolation: true,
nodeIntegration: false,
sandbox: false,
},
});
if (isDev) {
try {
await win.loadURL(devServerUrl);
win.webContents.openDevTools({ mode: "detach" });
registerSSHBridge(win);
return;
} catch (e) {
console.warn("Dev server not reachable, falling back to bundled dist.", e);
}
}
const indexPath = path.join(__dirname, "../dist/index.html");
await win.loadFile(indexPath);
registerSSHBridge(win);
}
app.whenReady().then(() => {
if (isMac && appIcon && app.dock?.setIcon) {
try {
app.dock.setIcon(appIcon);
} catch (err) {
console.warn("Failed to set dock icon", err);
}
}
createWindow();
app.on("activate", () => {
if (BrowserWindow.getAllWindows().length === 0) createWindow();
});
});
app.on("window-all-closed", () => {
if (process.platform !== "darwin") app.quit();
});