Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
187 changes: 187 additions & 0 deletions electron/ipc/handlers.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { ipcMain, dialog, BrowserWindow, shell, app } from "electron";
import fs from "fs/promises";
import path from "path";
import AdmZip from "adm-zip";
import AutoLaunch from "auto-launch";
import {
getProjects,
Expand All @@ -24,6 +25,7 @@ import {
restartProject,
getRunningProjects,
getProjectLogs,
getAllProjectLogs,
writeToProcess,
getProjectStats,
getProjectStartTime,
Expand All @@ -34,6 +36,7 @@ import {
startTunnel,
stopTunnel,
getTunnelLogs,
getAllTunnelLogs,
clearTunnelLogs,
getTunnelStatus,
} from "../services/tunnelManager.js";
Expand Down Expand Up @@ -65,6 +68,19 @@ const appLauncher = new AutoLaunch({
path: process.execPath,
});

/**
* Create a filesystem-safe filename segment for Windows/macOS/Linux.
* Removes characters that are invalid on Windows and trims length.
*/
const sanitizeFileName = (value) => {
const name = typeof value === "string" ? value : "";
const cleaned = name
.replace(/[<>:"/\\|?*\u0000-\u001F]/g, "_")
.replace(/\s+/g, " ")
.trim();
return cleaned.length > 0 ? cleaned.slice(0, 120) : "project";
};

export const registerHandlers = () => {
// Helper to log all IPC calls
const originalHandle = ipcMain.handle.bind(ipcMain);
Expand Down Expand Up @@ -289,11 +305,89 @@ export const registerHandlers = () => {
ipcMain.handle("logs:get", async (_, id) => {
return getProjectLogs(id);
});
ipcMain.handle("logs:getAll", async () => {
return getAllProjectLogs();
});
ipcMain.handle("logs:clear", async (_, id) => {
clearProjectLogs(id);
return true;
});

ipcMain.handle("logs:exportProject", async (_, projectId) => {
const numericId = Number(projectId);
if (!Number.isFinite(numericId)) {
throw new Error("Invalid projectId for logs export");
}

const window = BrowserWindow.getFocusedWindow() || global.mainWindow || null;
const projects = await getProjects();
const project = projects.find((p) => Number(p.id) === numericId) || null;
const projectName = project?.name || `Project ${numericId}`;

const fileName = `${sanitizeFileName(projectName)}-${numericId}.log`;
const { canceled, filePath } = await dialog.showSaveDialog(window, {
title: `Export console logs - ${projectName}`,
defaultPath: fileName,
filters: [
{ name: "Log Files", extensions: ["log"] },
{ name: "All Files", extensions: ["*"] },
],
});

if (canceled || !filePath) {
return { canceled: true };
}

const logs = getProjectLogs(numericId);
const header = `===== Console Logs: ${projectName} (ID: ${numericId}) =====\n`;
const body = Array.isArray(logs) ? logs.map((l) => l?.data ?? "").join("") : "";
const text = `${header}${body}`;

await fs.writeFile(filePath, text, "utf8");
return { success: true, path: filePath };
});

ipcMain.handle("logs:exportAll", async () => {
const window = BrowserWindow.getFocusedWindow() || global.mainWindow || null;

const projects = await getProjects();
const timestamp = new Date().toISOString().replace(/[:.]/g, "-");
const defaultName = `console-logs-all-${timestamp}.zip`;

const { canceled, filePath } = await dialog.showSaveDialog(window, {
title: "Export console logs (.zip)",
defaultPath: defaultName,
filters: [
{ name: "Zip Files", extensions: ["zip"] },
{ name: "All Files", extensions: ["*"] },
],
});

if (canceled || !filePath) {
return { canceled: true };
}

const zip = new AdmZip();

for (const project of projects) {
const numericId = Number(project?.id);
if (!Number.isFinite(numericId)) continue;

const projectName = project?.name || `Project ${numericId}`;
const logs = getProjectLogs(numericId);
const header = `===== Console Logs: ${projectName} (ID: ${numericId}) =====\n`;
const body = Array.isArray(logs) ? logs.map((l) => l?.data ?? "").join("") : "";
const text = `${header}${body}`;

const fileName = `${sanitizeFileName(projectName)}-${numericId}.log`;
zip.addFile(fileName, Buffer.from(text, "utf8"));
}

const out = zip.toBuffer();
await fs.writeFile(filePath, out);
Comment on lines +370 to +387
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

These ZIP exports can freeze the Electron main process on large histories.

Each handler builds every project's log text in memory, keeps those buffers in the archive, then materializes the full ZIP buffer before fs.writeFile. On noisy projects that can stall the whole app or OOM during export. Please move archive creation off the main thread or switch to a streaming/temp-file approach.

Also applies to: 492-509

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@electron/ipc/handlers.js` around lines 370 - 387, The ZIP creation currently
builds all project log strings and calls AdmZip/zip.toBuffer() on the main
thread (see AdmZip, zip.addFile, zip.toBuffer, getProjectLogs, sanitizeFileName,
fs.writeFile) which can OOM/freeze; instead either move the archive work into a
Node worker thread that runs the same loop and writes the file, or switch to a
streaming archive API (e.g., archiver or yazl) that pipes entries directly to an
fs.createWriteStream (iterate projects, call getProjectLogs(numericId) per
entry, sanitizeFileName for entry names, and append each entry as a stream) so
you never materialize the full archive buffer in memory before writing to disk.
Ensure the handler returns progress/success once the worker/stream finishes and
remove the zip.toBuffer()/await fs.writeFile pattern from the main thread.

return { success: true, path: filePath };
});

ipcMain.handle("project:getStats", async (_, id) => {
return getProjectStats(id);
});
Expand All @@ -314,6 +408,9 @@ export const registerHandlers = () => {
ipcMain.handle("tunnel:getLogs", async (_, id) => {
return getTunnelLogs(id);
});
ipcMain.handle("tunnel:getAllLogs", async () => {
return getAllTunnelLogs();
});

ipcMain.handle("tunnel:clearLogs", async (_, id) => {
return clearTunnelLogs(id);
Expand All @@ -323,6 +420,96 @@ export const registerHandlers = () => {
return getTunnelStatus(id);
});

ipcMain.handle("tunnel:exportProject", async (_, projectId) => {
const numericId = Number(projectId);
if (!Number.isFinite(numericId)) {
throw new Error("Invalid projectId for tunnel logs export");
}

const window = BrowserWindow.getFocusedWindow() || global.mainWindow || null;
const projects = await getProjects();
const project = projects.find((p) => Number(p.id) === numericId) || null;
const projectName = project?.name || `Project ${numericId}`;

const fileName = `${sanitizeFileName(projectName)}-${numericId}.log`;
const { canceled, filePath } = await dialog.showSaveDialog(window, {
title: `Export tunnel logs - ${projectName}`,
defaultPath: fileName,
filters: [
{ name: "Log Files", extensions: ["log"] },
{ name: "All Files", extensions: ["*"] },
],
});

if (canceled || !filePath) {
return { canceled: true };
}

const logs = getTunnelLogs(numericId);
const header = `===== Tunnel Logs: ${projectName} (ID: ${numericId}) =====\n`;

const formatLine = (entry) => {
const timestamp = entry?.timestamp ? new Date(entry.timestamp) : null;
const time = timestamp ? timestamp.toLocaleTimeString("en-US", { hour12: false }) : "unknown";
const message = entry?.message ?? "";
return `[${time}] ${message}\n`;
Comment on lines +451 to +455
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Preserve the full tunnel timestamp in exported lines.

electron/services/tunnelManager.js, Lines 41-47 already stores a full ISO timestamp, but these formatters collapse it to local time-of-day only. That makes multi-day exports ambiguous and drops the original timezone when the file is shared.

🛠️ Suggested fix
     const formatLine = (entry) => {
       const timestamp = entry?.timestamp ? new Date(entry.timestamp) : null;
-      const time = timestamp ? timestamp.toLocaleTimeString("en-US", { hour12: false }) : "unknown";
+      const time =
+        timestamp && !Number.isNaN(timestamp.getTime())
+          ? timestamp.toISOString()
+          : "unknown";
       const message = entry?.message ?? "";
       return `[${time}] ${message}\n`;
     };

Apply the same formatter change in both handlers.

Also applies to: 485-489

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@electron/ipc/handlers.js` around lines 451 - 455, The current formatLine
function reduces entry.timestamp to a local time-of-day string, losing date and
timezone; update formatLine (and the other handler's equivalent) to preserve the
full ISO timestamp from entry.timestamp (e.g., use entry.timestamp or new
Date(entry.timestamp).toISOString()) in the exported line while still optionally
including the local time if desired, ensuring you reference entry.timestamp and
the formatLine function so the output contains the full unambiguous timestamp
rather than just the time-of-day.

};

const body = Array.isArray(logs) ? logs.map(formatLine).join("") : "";
const text = `${header}${body}`;

await fs.writeFile(filePath, text, "utf8");
return { success: true, path: filePath };
});

ipcMain.handle("tunnel:exportAll", async () => {
const window = BrowserWindow.getFocusedWindow() || global.mainWindow || null;

const projects = await getProjects();
const timestamp = new Date().toISOString().replace(/[:.]/g, "-");
const defaultName = `tunnel-logs-all-${timestamp}.zip`;

const { canceled, filePath } = await dialog.showSaveDialog(window, {
title: "Export tunnel logs (.zip)",
defaultPath: defaultName,
filters: [
{ name: "Zip Files", extensions: ["zip"] },
{ name: "All Files", extensions: ["*"] },
],
});

if (canceled || !filePath) {
return { canceled: true };
}

const formatLine = (entry) => {
const timestamp = entry?.timestamp ? new Date(entry.timestamp) : null;
const time = timestamp ? timestamp.toLocaleTimeString("en-US", { hour12: false }) : "unknown";
const message = entry?.message ?? "";
return `[${time}] ${message}\n`;
};

const zip = new AdmZip();

for (const project of projects) {
const numericId = Number(project?.id);
if (!Number.isFinite(numericId)) continue;

const projectName = project?.name || `Project ${numericId}`;
const logs = getTunnelLogs(numericId);
const header = `===== Tunnel Logs: ${projectName} (ID: ${numericId}) =====\n`;
const body = Array.isArray(logs) ? logs.map(formatLine).join("") : "";
const text = `${header}${body}`;

const fileName = `${sanitizeFileName(projectName)}-${numericId}.log`;
zip.addFile(fileName, Buffer.from(text, "utf8"));
}

const out = zip.toBuffer();
await fs.writeFile(filePath, out);
return { success: true, path: filePath };
});

// Dialogs
ipcMain.handle("dialog:openDirectory", async () => {
const { canceled, filePaths } = await dialog.showOpenDialog({
Expand Down
6 changes: 6 additions & 0 deletions electron/preload.js
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,10 @@ contextBridge.exposeInMainWorld("api", {
stopWatchingFolder: (folderPath) => ipcRenderer.invoke("watcher:stop", folderPath),

getLogs: (id) => ipcRenderer.invoke("logs:get", id),
getAllLogs: () => ipcRenderer.invoke("logs:getAll"),
clearLogs: (id) => ipcRenderer.invoke("logs:clear", id),
exportConsoleLogsProject: (id) => ipcRenderer.invoke("logs:exportProject", id),
exportConsoleLogsAll: () => ipcRenderer.invoke("logs:exportAll"),

isAutoLaunchEnabled: () => ipcRenderer.invoke("app:isAutoLaunchEnabled"),
enableAutoLaunch: () => ipcRenderer.invoke("app:enableAutoLaunch"),
Expand Down Expand Up @@ -181,6 +184,9 @@ contextBridge.exposeInMainWorld("api", {
clearTunnelLogs: (id) => ipcRenderer.invoke("tunnel:clearLogs", id),
getTunnelStatus: (id) => ipcRenderer.invoke("tunnel:getStatus", id),
getTunnelLogs: (id) => ipcRenderer.invoke("tunnel:getLogs", id),
getAllTunnelLogs: () => ipcRenderer.invoke("tunnel:getAllLogs"),
exportTunnelLogsProject: (id) => ipcRenderer.invoke("tunnel:exportProject", id),
exportTunnelLogsAll: () => ipcRenderer.invoke("tunnel:exportAll"),
getSettings: () => ipcRenderer.invoke("settings:get"),
updateSettings: (settings) => ipcRenderer.invoke("settings:update", settings),
getUserDataPath: () => ipcRenderer.invoke("database:getUserDataPath"),
Expand Down
5 changes: 5 additions & 0 deletions electron/services/projectsManager.js
Original file line number Diff line number Diff line change
Expand Up @@ -202,6 +202,11 @@ async function isProcessGroupAlive(rootPid, platform) {

export const getRunningProjects = () => Object.keys(runningRuntimes);
export const getProjectLogs = (id) => logHistory[id] || [];
/**
* Get all in-memory project console logs.
* Returned object shape: { [projectId: string]: Array<{data,type,timestamp,projectId}> }
*/
export const getAllProjectLogs = () => ({ ...logHistory });
export const getProjectStartTime = (id) =>
runningRuntimes[id] ? runningRuntimes[id].startTime : null;

Expand Down
6 changes: 6 additions & 0 deletions electron/services/tunnelManager.js
Original file line number Diff line number Diff line change
Expand Up @@ -336,6 +336,12 @@ export const getTunnelLogs = (projectId) => {
return tunnelLogs[projectId] || [];
};

/**
* Get all in-memory tunnel logs per project.
* Returned object shape: { [projectId: string]: Array<{message,type,timestamp}> }
*/
export const getAllTunnelLogs = () => ({ ...tunnelLogs });

/**
* Clear tunnel logs for a project
* @param {number} projectId - Project ID
Expand Down
9 changes: 5 additions & 4 deletions package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "selfhost-helper",
"version": "0.35.1",
"version": "0.38.0",
"description": "Node.js Project Manager",
"main": "electron/main.js",
"type": "module",
Expand All @@ -11,7 +11,7 @@
"build": "vite build && electron-builder",
"build:dev": "vite build && cross-env NODE_ENV=development electron-builder --config.extraMetadata.appId=com.selfhosthelper.dev --config.directories.output=release-dev --config.productName=\"SelfHost Helper Dev\"",
"build:prod": "vite build && cross-env NODE_ENV=production electron-builder",
"publish:prod": "dotenv -e .env -- cross-env NODE_ENV=production electron-builder --publish always",
"publish:prod": "vite build && dotenv -e .env -- cross-env NODE_ENV=production electron-builder --publish always",
"build:web": "vite build",
"postinstall": "electron-builder install-app-deps",
"lint": "eslint .",
Expand Down Expand Up @@ -117,7 +117,6 @@
"dependencies": {
"@hello-pangea/dnd": "^18.0.1",
"@monaco-editor/react": "latest",
"monaco-editor": "latest",
"@radix-ui/react-context-menu": "^2.2.16",
"@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-label": "^2.1.8",
Expand All @@ -130,16 +129,18 @@
"@xterm/addon-fit": "^0.10.0",
"@xterm/addon-web-links": "^0.11.0",
"@xterm/xterm": "^5.5.0",
"adm-zip": "^0.5.16",
"auto-launch": "latest",
"electron-updater": "^4.0.0",
"chalk": "^5.6.2",
"chokidar": "latest",
"class-variance-authority": "latest",
"cloudflared": "latest",
"clsx": "latest",
"electron-updater": "^4.0.0",
"framer-motion": "latest",
"jotai": "^2.16.1",
"lucide-react": "latest",
"monaco-editor": "latest",
"node-addon-api": "^8.5.0",
"pg-hstore": "^2.3.4",
"pidusage": "^4.0.1",
Expand Down
Loading
Loading