Some checks failed
build-packages / resolve bundled mosh-client (push) Has been cancelled
build-packages / resolve bundled et-client (push) Has been cancelled
build-packages / build-macos (push) Has been cancelled
build-packages / build-windows (push) Has been cancelled
build-packages / build-linux-x64 (push) Has been cancelled
build-packages / build-linux-arm64 (push) Has been cancelled
build-packages / release (push) Has been cancelled
build-packages / update Nix release metadata (push) Has been cancelled
build-packages / bump homebrew tap (push) Has been cancelled
test / lint-and-test (push) Has been cancelled
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
build-et-binaries / build-linux-x64 (push) Has been cancelled
build-et-binaries / build-linux-arm64 (push) Has been cancelled
build-et-binaries / build-macos-universal (push) Has been cancelled
build-et-binaries / build-windows-x64 (push) Has been cancelled
build-et-binaries / release (push) Has been cancelled
857 lines
30 KiB
JavaScript
857 lines
30 KiB
JavaScript
/**
|
|
* Session Logs Bridge - Handles session log export and auto-save operations
|
|
* Provides functionality to export terminal logs to files and manage auto-save settings
|
|
*/
|
|
|
|
const crypto = require("node:crypto");
|
|
const fs = require("node:fs");
|
|
const path = require("node:path");
|
|
const { dialog } = require("electron");
|
|
const {
|
|
terminalDataToHtmlContent,
|
|
terminalDataToPlainText,
|
|
} = require("./terminalLogSanitizer.cjs");
|
|
|
|
const FILE_NAME_UNSAFE_CHARS = new Set(["<", ">", ":", "\"", "/", "\\", "|", "?", "*"]);
|
|
const WINDOWS_RESERVED_DEVICE_NAME = /^(con|prn|aux|nul|com[1-9¹²³]|lpt[1-9¹²³])(?:\..*)?$/i;
|
|
const manualSessionLogTokens = new Map();
|
|
/** @type {Map<string, { sessionId: string, filePath: string, format: string, senderId: number | null }>} */
|
|
const pendingManualSessionLogChoices = new Map();
|
|
const SESSION_LOG_FORMATS = new Set(["txt", "raw", "html"]);
|
|
|
|
function isControlCharacter(char) {
|
|
const code = char.codePointAt(0);
|
|
return code !== undefined && ((code >= 0 && code <= 0x1f) || (code >= 0x7f && code <= 0x9f));
|
|
}
|
|
|
|
/**
|
|
* Get current Date to a local ISO-like string (YYYY-MM-DDTHH-MM-SS)
|
|
*/
|
|
function toLocalISOString(date = new Date()) {
|
|
const pad = (n) => String(n).padStart(2, '0');
|
|
|
|
const year = date.getFullYear();
|
|
const month = pad(date.getMonth() + 1);
|
|
const day = pad(date.getDate());
|
|
const hours = pad(date.getHours());
|
|
const minutes = pad(date.getMinutes());
|
|
const seconds = pad(date.getSeconds());
|
|
|
|
return `${year}-${month}-${day}T${hours}-${minutes}-${seconds}`;
|
|
}
|
|
|
|
function safePathSegment(value, fallback = "unknown") {
|
|
const raw = String(value || "");
|
|
let safe = Array.from(raw, (char) => {
|
|
return FILE_NAME_UNSAFE_CHARS.has(char) || isControlCharacter(char) ? "_" : char;
|
|
}).join("").trim();
|
|
|
|
if (!safe || safe === "." || safe === "..") {
|
|
return fallback;
|
|
}
|
|
|
|
safe = safe.replace(/\.+$/g, (match) => "_".repeat(match.length));
|
|
|
|
if (WINDOWS_RESERVED_DEVICE_NAME.test(safe)) {
|
|
safe = `${safe}_`;
|
|
}
|
|
|
|
return safe;
|
|
}
|
|
|
|
/**
|
|
* Escape HTML special characters to prevent XSS
|
|
* Must be applied before converting ANSI codes to HTML spans
|
|
*/
|
|
function escapeHtml(str) {
|
|
return str
|
|
.replace(/&/g, "&")
|
|
.replace(/</g, "<")
|
|
.replace(/>/g, ">")
|
|
.replace(/"/g, """)
|
|
.replace(/'/g, "'");
|
|
}
|
|
|
|
function terminalPlainTextToHtml(plainText, hostLabel, timestamp) {
|
|
const htmlContent = escapeHtml(plainText || "");
|
|
return wrapTerminalHtmlContent(htmlContent, hostLabel, timestamp);
|
|
}
|
|
|
|
function buildExtendedForegroundPalette(htmlContent) {
|
|
const colors = new Set(Array.from(
|
|
htmlContent.matchAll(/var\(--term-custom-([a-f0-9]{6}), #[a-f0-9]{6}\)/g),
|
|
(match) => match[1],
|
|
));
|
|
const light = [];
|
|
const dark = [];
|
|
for (const hex of colors) {
|
|
const channels = hex.match(/../g).map((channel) => parseInt(channel, 16));
|
|
const linearChannels = channels.map((channel) => {
|
|
const value = channel / 255;
|
|
return value <= 0.04045 ? value / 12.92 : ((value + 0.055) / 1.055) ** 2.4;
|
|
});
|
|
const luminance = linearChannels[0] * 0.2126 + linearChannels[1] * 0.7152 + linearChannels[2] * 0.0722;
|
|
const maxLuminance = 1.05 / 4.5 - 0.05;
|
|
let adjusted = channels;
|
|
// A single linear-light scaling reaches the contrast target without an
|
|
// iterative search for every color on each streaming snapshot.
|
|
if (luminance > maxLuminance) {
|
|
const scale = maxLuminance / luminance;
|
|
adjusted = linearChannels.map((channel) => {
|
|
const value = channel * scale;
|
|
const srgb = value <= 0.0031308 ? value * 12.92 : 1.055 * value ** (1 / 2.4) - 0.055;
|
|
return Math.floor(srgb * 255);
|
|
});
|
|
}
|
|
const lightHex = adjusted.map((channel) => channel.toString(16).padStart(2, "0")).join("");
|
|
light.push(`--term-custom-${hex}: #${lightHex};`);
|
|
dark.push(`--term-custom-${hex}: #${hex};`);
|
|
}
|
|
return { light: light.join("\n "), dark: dark.join("\n ") };
|
|
}
|
|
|
|
function wrapTerminalHtmlContent(htmlContent, hostLabel, timestamp) {
|
|
const extendedPalette = buildExtendedForegroundPalette(htmlContent);
|
|
const dateStr = new Date(timestamp).toLocaleString();
|
|
const safeHostLabel = escapeHtml(hostLabel || "Unknown");
|
|
const safeDateStr = escapeHtml(dateStr);
|
|
|
|
return `<!DOCTYPE html>
|
|
<html>
|
|
<head>
|
|
<meta charset="utf-8">
|
|
<meta name="color-scheme" content="light dark">
|
|
<title>Session Log - ${safeHostLabel}</title>
|
|
<style>
|
|
:root {
|
|
color-scheme: light dark;
|
|
/* Day palette (default): readable on light browser/OS themes. */
|
|
--term-default-bg: #ffffff;
|
|
--term-default-fg: #24292f;
|
|
--term-header-fg: #6a737d;
|
|
--term-border: #d0d7de;
|
|
/* ANSI palette (basic 0-7, bright 8-15) tuned for the light background. */
|
|
--ansi-0: #000000;
|
|
--ansi-1: #cd3131;
|
|
--ansi-2: #008000;
|
|
--ansi-3: #747700;
|
|
--ansi-4: #0451a5;
|
|
--ansi-5: #bc05bc;
|
|
--ansi-6: #007c9b;
|
|
--ansi-7: #555555;
|
|
--ansi-8: #666666;
|
|
--ansi-9: #cd3131;
|
|
--ansi-10: #008500;
|
|
--ansi-11: #797600;
|
|
--ansi-12: #0451a5;
|
|
--ansi-13: #bc05bc;
|
|
--ansi-14: #007c9b;
|
|
--ansi-15: #767676;
|
|
${extendedPalette.light}
|
|
}
|
|
@media (prefers-color-scheme: dark) {
|
|
:root {
|
|
--term-default-bg: #1e1e1e;
|
|
--term-default-fg: #d4d4d4;
|
|
--term-header-fg: #888888;
|
|
--term-border: #444444;
|
|
--ansi-0: #000000;
|
|
--ansi-1: #cd3131;
|
|
--ansi-2: #0dbc79;
|
|
--ansi-3: #e5e510;
|
|
--ansi-4: #2472c8;
|
|
--ansi-5: #bc3fbc;
|
|
--ansi-6: #11a8cd;
|
|
--ansi-7: #e5e5e5;
|
|
--ansi-8: #666666;
|
|
--ansi-9: #f14c4c;
|
|
--ansi-10: #23d18b;
|
|
--ansi-11: #f5f543;
|
|
--ansi-12: #3b8eea;
|
|
--ansi-13: #d670d6;
|
|
--ansi-14: #29b8db;
|
|
--ansi-15: #ffffff;
|
|
${extendedPalette.dark}
|
|
}
|
|
}
|
|
body {
|
|
background: var(--term-default-bg);
|
|
color: var(--term-default-fg);
|
|
font-family: ui-monospace, 'Cascadia Mono', 'JetBrains Mono', 'SF Mono',
|
|
Consolas, Monaco, Menlo, monospace;
|
|
font-size: 13px;
|
|
line-height: 1.5;
|
|
padding: 20px;
|
|
white-space: pre-wrap;
|
|
word-wrap: break-word;
|
|
}
|
|
.header {
|
|
border-bottom: 1px solid var(--term-border);
|
|
padding-bottom: 10px;
|
|
margin-bottom: 20px;
|
|
color: var(--term-header-fg);
|
|
}
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<div class="header">
|
|
Host: ${safeHostLabel}<br>
|
|
Date: ${safeDateStr}
|
|
</div>
|
|
<div class="content">${htmlContent || ""}</div>
|
|
</body>
|
|
</html>`;
|
|
}
|
|
|
|
/**
|
|
* Convert terminal data to HTML after applying terminal text controls while
|
|
* preserving SGR styles such as color, bold, italic, and underline.
|
|
*/
|
|
function terminalDataToHtml(terminalData, hostLabel, timestamp) {
|
|
return wrapTerminalHtmlContent(terminalDataToHtmlContent(terminalData), hostLabel, timestamp);
|
|
}
|
|
|
|
/**
|
|
* Export a session log to a file (manual export via save dialog)
|
|
*/
|
|
async function exportSessionLog(event, payload) {
|
|
const { terminalData, hostLabel, hostname, startTime, format } = payload;
|
|
|
|
if (!terminalData) {
|
|
throw new Error("No terminal data to export");
|
|
}
|
|
|
|
// Generate default filename
|
|
const date = new Date(startTime);
|
|
const dateStr = toLocalISOString(date);
|
|
const safeHostLabel = safePathSegment(hostLabel || hostname, "session");
|
|
const ext = format === "html" ? "html" : format === "raw" ? "log" : "txt";
|
|
const defaultPath = `${safeHostLabel}_${dateStr}.${ext}`;
|
|
|
|
// Show save dialog
|
|
const result = await dialog.showSaveDialog({
|
|
defaultPath,
|
|
filters: [
|
|
{ name: "Text Files", extensions: ["txt"] },
|
|
{ name: "Log Files", extensions: ["log"] },
|
|
{ name: "HTML Files", extensions: ["html"] },
|
|
{ name: "All Files", extensions: ["*"] },
|
|
],
|
|
});
|
|
|
|
if (result.canceled || !result.filePath) {
|
|
return { success: false, canceled: true };
|
|
}
|
|
|
|
// Prepare content based on format
|
|
let content;
|
|
const actualFormat = path.extname(result.filePath).slice(1) || format;
|
|
|
|
if (actualFormat === "html") {
|
|
content = terminalDataToHtml(terminalData, hostLabel, startTime);
|
|
} else if (actualFormat === "log" || actualFormat === "raw") {
|
|
// Raw format preserves ANSI codes
|
|
content = terminalData;
|
|
} else {
|
|
// Plain text - apply terminal text controls and remove escape sequences
|
|
content = terminalDataToPlainText(terminalData);
|
|
}
|
|
|
|
await fs.promises.writeFile(result.filePath, content, "utf8");
|
|
|
|
return { success: true, filePath: result.filePath };
|
|
}
|
|
|
|
/**
|
|
* Select a directory for session logs storage
|
|
*/
|
|
async function selectSessionLogsDir(event) {
|
|
const result = await dialog.showOpenDialog({
|
|
properties: ["openDirectory", "createDirectory"],
|
|
title: "Select Session Logs Directory",
|
|
});
|
|
|
|
if (result.canceled || !result.filePaths || result.filePaths.length === 0) {
|
|
return { success: false, canceled: true };
|
|
}
|
|
|
|
return { success: true, directory: result.filePaths[0] };
|
|
}
|
|
|
|
/**
|
|
* Auto-save a session log to the configured directory
|
|
* Called when a terminal session ends
|
|
*/
|
|
async function autoSaveSessionLog(event, payload) {
|
|
const { terminalData, hostLabel, hostname, hostId, startTime, format, directory } = payload;
|
|
|
|
if (!terminalData || !directory) {
|
|
return { success: false, error: "Missing terminal data or directory" };
|
|
}
|
|
|
|
try {
|
|
// Create host subdirectory
|
|
const safeHostLabel = safePathSegment(hostLabel || hostname || hostId, "unknown");
|
|
const hostDir = path.join(directory, safeHostLabel);
|
|
|
|
await fs.promises.mkdir(hostDir, { recursive: true });
|
|
|
|
// Generate filename with timestamp
|
|
const date = new Date(startTime);
|
|
const dateStr = toLocalISOString(date);
|
|
const ext = format === "html" ? "html" : format === "raw" ? "log" : "txt";
|
|
const fileName = `${dateStr}.${ext}`;
|
|
const filePath = path.join(hostDir, fileName);
|
|
|
|
// Prepare content based on format
|
|
let content;
|
|
if (format === "html") {
|
|
content = terminalDataToHtml(terminalData, hostLabel, startTime);
|
|
} else if (format === "raw") {
|
|
content = terminalData;
|
|
} else {
|
|
content = terminalDataToPlainText(terminalData);
|
|
}
|
|
|
|
await fs.promises.writeFile(filePath, content, "utf8");
|
|
|
|
return { success: true, filePath };
|
|
} catch (err) {
|
|
console.error("Failed to auto-save session log:", err);
|
|
return { success: false, error: err.message };
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Open the session logs directory in the system file explorer
|
|
*/
|
|
async function openSessionLogsDir(event, payload) {
|
|
const { shell } = require("electron");
|
|
const { directory } = payload;
|
|
|
|
if (!directory) {
|
|
return { success: false, error: "No directory specified" };
|
|
}
|
|
|
|
try {
|
|
// Check if directory exists
|
|
await fs.promises.access(directory);
|
|
await shell.openPath(directory);
|
|
return { success: true };
|
|
} catch (err) {
|
|
return { success: false, error: err.message };
|
|
}
|
|
}
|
|
|
|
// Auto-save writes `{hostDir}/{YYYY-MM-DDTHH-MM-SS}.{txt|log|html}`.
|
|
// Manual export / continuous logs commonly write `{label}_{YYYY-MM-DDTHH-MM-SS}.{ext}`.
|
|
const SESSION_LOG_TIMESTAMP_FILE = /^\d{4}-\d{2}-\d{2}T\d{2}-\d{2}-\d{2}\.(txt|log|html)$/i;
|
|
const SESSION_LOG_LABELED_FILE = /^.+_\d{4}-\d{2}-\d{2}T\d{2}-\d{2}-\d{2}\.(txt|log|html)$/i;
|
|
|
|
/**
|
|
* True when a basename matches a Netcatty session-log artifact filename.
|
|
* Used so "clear all" never wipes unrelated files in a shared save directory.
|
|
*/
|
|
function isSessionLogArtifactName(name) {
|
|
const base = path.basename(String(name || ""));
|
|
return SESSION_LOG_TIMESTAMP_FILE.test(base) || SESSION_LOG_LABELED_FILE.test(base);
|
|
}
|
|
|
|
/**
|
|
* Live write targets from this process's sessionLogStreamManager.
|
|
* Main and terminal worker each own a separate module instance.
|
|
* @returns {string[]}
|
|
*/
|
|
function getLocalActiveLogPaths() {
|
|
const sessionLogStreamManager = require("./sessionLogStreamManager.cjs");
|
|
if (typeof sessionLogStreamManager.getActiveLogPaths !== "function") return [];
|
|
return sessionLogStreamManager.getActiveLogPaths()
|
|
.filter((p) => typeof p === "string" && p.length > 0)
|
|
.map((p) => path.resolve(p));
|
|
}
|
|
|
|
/**
|
|
* True when the terminal worker process is already running (or the manager
|
|
* does not expose a probe — unit-test mocks that only provide request()).
|
|
* Avoids cold-starting the utilityProcess solely for clear-all.
|
|
* @param {object|null} terminalWorkerManager
|
|
* @returns {boolean}
|
|
*/
|
|
function isTerminalWorkerRunning(terminalWorkerManager) {
|
|
if (!terminalWorkerManager) return false;
|
|
if (typeof terminalWorkerManager.isRunning === "function") {
|
|
return Boolean(terminalWorkerManager.isRunning());
|
|
}
|
|
// Mocks that only supply request() — treat as queryable.
|
|
return typeof terminalWorkerManager.request === "function";
|
|
}
|
|
|
|
/**
|
|
* Union of active log paths from the main process and the terminal worker.
|
|
* Worker-owned auto-save streams are invisible to the main-process manager.
|
|
* @param {object|null} terminalWorkerManager
|
|
* @returns {Promise<Set<string>>}
|
|
*/
|
|
async function collectActiveLogPaths(terminalWorkerManager = null) {
|
|
const activeLogPaths = new Set(getLocalActiveLogPaths());
|
|
|
|
if (!isTerminalWorkerRunning(terminalWorkerManager)) {
|
|
return activeLogPaths;
|
|
}
|
|
|
|
try {
|
|
const result = await terminalWorkerManager.request(
|
|
"netcatty:sessionLogs:getActivePaths",
|
|
{},
|
|
{},
|
|
);
|
|
const workerPaths = Array.isArray(result)
|
|
? result
|
|
: (Array.isArray(result?.paths) ? result.paths : []);
|
|
for (const p of workerPaths) {
|
|
if (typeof p === "string" && p.length > 0) {
|
|
activeLogPaths.add(path.resolve(p));
|
|
}
|
|
}
|
|
} catch {
|
|
// Worker unavailable or channel missing — main-process paths only.
|
|
}
|
|
|
|
return activeLogPaths;
|
|
}
|
|
|
|
/**
|
|
* Delete known session-log artifacts inside the configured save directory
|
|
* (used by the "clear all logs" action in Settings).
|
|
*
|
|
* Only removes:
|
|
* - Top-level files matching session-log filename patterns
|
|
* - Host subdirectories that exclusively contain session-log files
|
|
* - Session-log files inside mixed host subdirectories (other entries kept)
|
|
*
|
|
* Never deletes unrelated top-level files/folders (e.g. Documents/Downloads
|
|
* when the user pointed the save directory at a shared folder).
|
|
*
|
|
* Active auto-save / continuous-log write targets are skipped so clear-all
|
|
* never unlinks a live stream (orphan inode / silent stop). Paths include
|
|
* both main-process streams and worker-owned streams when a terminal worker
|
|
* is running.
|
|
*/
|
|
async function clearSessionLogsDir(event, payload = {}, terminalWorkerManager = null) {
|
|
const { directory } = payload;
|
|
|
|
if (!directory) {
|
|
return { success: false, deletedCount: 0, failedCount: 0, error: "No directory specified" };
|
|
}
|
|
|
|
let deletedCount = 0;
|
|
let failedCount = 0;
|
|
|
|
// Live write targets (main + worker) — skip these paths.
|
|
const activeLogPaths = await collectActiveLogPaths(terminalWorkerManager);
|
|
const isActiveLogPath = (filePath) => activeLogPaths.has(path.resolve(filePath));
|
|
|
|
try {
|
|
const entries = await fs.promises.readdir(directory, { withFileTypes: true });
|
|
for (const entry of entries) {
|
|
const entryPath = path.join(directory, entry.name);
|
|
|
|
// Skip anything that is not a plain file or directory (symlinks, sockets, …).
|
|
// Use dirent type when available; fall back to lstat for exotic FS entries.
|
|
let isFile = entry.isFile();
|
|
let isDirectory = entry.isDirectory();
|
|
if ((!isFile && !isDirectory) || entry.isSymbolicLink()) {
|
|
// Dirent may report the target type for some platforms; re-check with lstat
|
|
// so we never follow or delete symlink targets outside the save directory.
|
|
try {
|
|
const st = await fs.promises.lstat(entryPath);
|
|
if (st.isSymbolicLink()) continue;
|
|
isFile = st.isFile();
|
|
isDirectory = st.isDirectory();
|
|
} catch {
|
|
continue;
|
|
}
|
|
}
|
|
if (!isFile && !isDirectory) continue;
|
|
|
|
try {
|
|
if (isFile) {
|
|
if (!isSessionLogArtifactName(entry.name)) continue;
|
|
if (isActiveLogPath(entryPath)) continue;
|
|
await fs.promises.rm(entryPath, { force: true });
|
|
deletedCount++;
|
|
continue;
|
|
}
|
|
|
|
// Host subdirectory created by auto-save: only touch known log files.
|
|
const nested = await fs.promises.readdir(entryPath, { withFileTypes: true });
|
|
const logFiles = [];
|
|
let hasNonLogEntry = false;
|
|
let hasActiveLog = false;
|
|
const resolvedEntryPath = path.resolve(entryPath);
|
|
|
|
// txt/html streams may not create a file until the first snapshot flush, so
|
|
// the active path may be absent from readdir. Any registered active path
|
|
// whose parent is this host dir still counts as live work.
|
|
for (const activePath of activeLogPaths) {
|
|
if (path.dirname(activePath) === resolvedEntryPath) {
|
|
hasActiveLog = true;
|
|
break;
|
|
}
|
|
}
|
|
|
|
for (const nestedEntry of nested) {
|
|
const nestedPath = path.join(entryPath, nestedEntry.name);
|
|
let nestedIsFile = nestedEntry.isFile();
|
|
if (nestedEntry.isSymbolicLink() || (!nestedIsFile && !nestedEntry.isDirectory())) {
|
|
try {
|
|
const st = await fs.promises.lstat(nestedPath);
|
|
if (st.isSymbolicLink() || !st.isFile()) {
|
|
hasNonLogEntry = true;
|
|
continue;
|
|
}
|
|
nestedIsFile = true;
|
|
} catch {
|
|
hasNonLogEntry = true;
|
|
continue;
|
|
}
|
|
}
|
|
if (nestedIsFile && isSessionLogArtifactName(nestedEntry.name)) {
|
|
if (isActiveLogPath(nestedPath)) {
|
|
hasActiveLog = true;
|
|
} else {
|
|
logFiles.push(nestedPath);
|
|
}
|
|
} else {
|
|
hasNonLogEntry = true;
|
|
}
|
|
}
|
|
|
|
if (logFiles.length === 0 && !hasActiveLog) {
|
|
// Not an app host-log folder (or empty / only non-log content) — leave it alone.
|
|
continue;
|
|
}
|
|
|
|
if (!hasNonLogEntry && !hasActiveLog) {
|
|
// Pure session-log host folder with no live streams: remove as one artifact.
|
|
await fs.promises.rm(entryPath, { recursive: true, force: true });
|
|
deletedCount++;
|
|
} else {
|
|
// Mixed directory and/or active streams: only delete inactive known log files.
|
|
for (const logPath of logFiles) {
|
|
try {
|
|
await fs.promises.rm(logPath, { force: true });
|
|
deletedCount++;
|
|
} catch (err) {
|
|
failedCount++;
|
|
console.error(`[SessionLogs] Could not delete ${path.basename(logPath)}:`, err.message);
|
|
}
|
|
}
|
|
}
|
|
} catch (err) {
|
|
failedCount++;
|
|
console.error(`[SessionLogs] Could not delete ${entry.name}:`, err.message);
|
|
}
|
|
}
|
|
return { success: true, deletedCount, failedCount };
|
|
} catch (err) {
|
|
if (err?.code === "ENOENT") {
|
|
return { success: true, deletedCount: 0, failedCount: 0 };
|
|
}
|
|
console.error("[SessionLogs] Failed to clear session logs directory:", err);
|
|
return { success: false, deletedCount, failedCount, error: err.message };
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Resolve a manual session-log destination via the save dialog (and optional
|
|
* overwrite confirm). Separated from stream start so the renderer can re-sample
|
|
* alternate-screen / initial-line state after the dialog closes.
|
|
*
|
|
* The selected path is stored only in the main process. Callers receive an
|
|
* opaque selectionToken for startManualSessionLog — renderer-supplied paths are
|
|
* never trusted for open/truncate.
|
|
*/
|
|
async function chooseManualSessionLogPath(event, payload = {}) {
|
|
const { sessionId, sessionName, preferredDirectory } = payload;
|
|
if (!sessionId) {
|
|
return { success: false, canceled: false, error: "Missing sessionId" };
|
|
}
|
|
|
|
const targetDirectory = typeof preferredDirectory === "string" && preferredDirectory.trim()
|
|
? preferredDirectory.trim()
|
|
: require("node:os").homedir();
|
|
const format = SESSION_LOG_FORMATS.has(payload.format) ? payload.format : "raw";
|
|
const extension = format === "raw" ? "log" : format;
|
|
const displaySessionName = sessionName || sessionId;
|
|
const safeSessionName = safePathSegment(displaySessionName, "session");
|
|
const defaultPath = path.join(targetDirectory, `${safeSessionName}_${toLocalISOString(new Date())}.${extension}`);
|
|
|
|
try {
|
|
const result = await dialog.showSaveDialog({
|
|
defaultPath,
|
|
filters: [
|
|
{ name: format === "txt" ? "Text Files" : format === "html" ? "HTML Files" : "Log Files", extensions: [extension] },
|
|
{ name: "All Files", extensions: ["*"] },
|
|
],
|
|
});
|
|
|
|
if (result.canceled || !result.filePath) {
|
|
return { success: true, canceled: true };
|
|
}
|
|
|
|
const filePath = normalizeManualSessionLogFilePath(result.filePath, extension);
|
|
if (filePath !== result.filePath && !(await confirmManualSessionLogOverwrite(filePath))) {
|
|
return { success: true, canceled: true };
|
|
}
|
|
|
|
// Drop any prior unused selection for this session so only the latest dialog
|
|
// result can be redeemed.
|
|
for (const [token, pending] of pendingManualSessionLogChoices) {
|
|
if (pending.sessionId === sessionId) pendingManualSessionLogChoices.delete(token);
|
|
}
|
|
|
|
const selectionToken = crypto.randomBytes(16).toString("hex");
|
|
pendingManualSessionLogChoices.set(selectionToken, {
|
|
sessionId,
|
|
filePath,
|
|
format,
|
|
senderId: event?.sender?.id ?? null,
|
|
});
|
|
|
|
return {
|
|
success: true,
|
|
canceled: false,
|
|
selectionToken,
|
|
// Informational for UI/tests; start must redeem selectionToken, not this path.
|
|
filePath,
|
|
format,
|
|
};
|
|
} catch (err) {
|
|
return { success: false, canceled: false, error: err?.message || String(err) };
|
|
}
|
|
}
|
|
|
|
function redeemManualSessionLogSelection(event, sessionId, selectionToken) {
|
|
if (typeof selectionToken !== "string" || !selectionToken) return null;
|
|
const pending = pendingManualSessionLogChoices.get(selectionToken);
|
|
if (!pending || pending.sessionId !== sessionId) return null;
|
|
const senderId = event?.sender?.id;
|
|
if (pending.senderId != null && senderId != null && pending.senderId !== senderId) {
|
|
return null;
|
|
}
|
|
pendingManualSessionLogChoices.delete(selectionToken);
|
|
return pending;
|
|
}
|
|
|
|
async function startManualSessionLog(event, payload = {}) {
|
|
const sessionLogStreamManager = require("./sessionLogStreamManager.cjs");
|
|
const { sessionId, sessionName, preferredDirectory, initialLine } = payload;
|
|
if (!sessionId) {
|
|
return { success: false, started: false, error: "Missing sessionId" };
|
|
}
|
|
|
|
if (sessionLogStreamManager.hasStream(sessionId)) {
|
|
return { success: false, started: false, error: "Session log is already active" };
|
|
}
|
|
|
|
const displaySessionName = sessionName || sessionId;
|
|
let filePath = "";
|
|
let format = SESSION_LOG_FORMATS.has(payload.format) ? payload.format : "raw";
|
|
|
|
try {
|
|
if (typeof payload.selectionToken === "string" && payload.selectionToken) {
|
|
const pending = redeemManualSessionLogSelection(event, sessionId, payload.selectionToken);
|
|
if (!pending) {
|
|
return {
|
|
success: false,
|
|
started: false,
|
|
error: "Invalid or expired session log selection",
|
|
};
|
|
}
|
|
filePath = pending.filePath;
|
|
format = pending.format;
|
|
} else if (typeof payload.filePath === "string" && payload.filePath.trim()) {
|
|
// Do not trust renderer-supplied paths: they bypass the save dialog and
|
|
// overwrite confirmation and could truncate arbitrary writable files.
|
|
return {
|
|
success: false,
|
|
started: false,
|
|
error: "Session log path must be chosen via the save dialog",
|
|
};
|
|
} else {
|
|
// One-shot callers / tests: show the dialog and start immediately.
|
|
const chosen = await chooseManualSessionLogPath(event, {
|
|
sessionId,
|
|
sessionName,
|
|
preferredDirectory,
|
|
format,
|
|
});
|
|
if (!chosen.success) {
|
|
return { success: false, started: false, error: chosen.error || "Failed to choose session log path" };
|
|
}
|
|
if (chosen.canceled || !chosen.selectionToken) {
|
|
return { success: true, started: false, canceled: true };
|
|
}
|
|
const pending = redeemManualSessionLogSelection(event, sessionId, chosen.selectionToken);
|
|
if (!pending) {
|
|
return { success: false, started: false, error: "Invalid or expired session log selection" };
|
|
}
|
|
filePath = pending.filePath;
|
|
format = pending.format;
|
|
}
|
|
|
|
const startResult = sessionLogStreamManager.startStreamToFile(sessionId, {
|
|
filePath,
|
|
format,
|
|
hostLabel: displaySessionName,
|
|
startTime: Date.now(),
|
|
timestampsEnabled: Boolean(payload.timestampsEnabled),
|
|
initialLine: typeof initialLine === "string" ? initialLine : "",
|
|
separateInitialLineBeforeLeadingCarriageReturn: true,
|
|
stopRequiresToken: true,
|
|
// Caller should sample this after the save dialog resolves so enter/leave
|
|
// while the dialog is open does not seed a stale alternate-screen mode.
|
|
alternateScreenActive: payload.alternateScreenActive === true,
|
|
});
|
|
|
|
if (!startResult.ok) {
|
|
return { success: false, started: false, error: startResult.error || "Failed to start session log" };
|
|
}
|
|
|
|
manualSessionLogTokens.set(sessionId, startResult.token);
|
|
return { success: true, started: true, filePath };
|
|
} catch (err) {
|
|
return { success: false, started: false, error: err?.message || String(err) };
|
|
}
|
|
}
|
|
|
|
function normalizeManualSessionLogFilePath(filePath, extension = "log") {
|
|
return path.extname(filePath).toLowerCase() === `.${extension}` ? filePath : `${filePath}.${extension}`;
|
|
}
|
|
|
|
async function confirmManualSessionLogOverwrite(filePath) {
|
|
try {
|
|
await fs.promises.access(filePath, fs.constants.F_OK);
|
|
} catch (err) {
|
|
if (err?.code === "ENOENT") return true;
|
|
throw err;
|
|
}
|
|
|
|
const result = await dialog.showMessageBox({
|
|
type: "warning",
|
|
buttons: ["Overwrite", "Cancel"],
|
|
defaultId: 1,
|
|
cancelId: 1,
|
|
noLink: true,
|
|
title: "Overwrite session log?",
|
|
message: `"${path.basename(filePath)}" already exists.`,
|
|
detail: "Choose Overwrite to replace it, or Cancel to keep the existing file.",
|
|
});
|
|
|
|
return result.response === 0;
|
|
}
|
|
|
|
async function stopManualSessionLog(event, payload = {}) {
|
|
const sessionLogStreamManager = require("./sessionLogStreamManager.cjs");
|
|
const { sessionId } = payload;
|
|
if (!sessionId) {
|
|
return { success: false, stopped: false, error: "Missing sessionId" };
|
|
}
|
|
|
|
try {
|
|
const token = manualSessionLogTokens.get(sessionId);
|
|
if (!token) {
|
|
return { success: true, stopped: false };
|
|
}
|
|
|
|
const filePath = await sessionLogStreamManager.stopStream(sessionId, token);
|
|
if (!filePath) {
|
|
if (!sessionLogStreamManager.hasStream(sessionId)) {
|
|
manualSessionLogTokens.delete(sessionId);
|
|
return { success: false, stopped: true, error: "Failed to finalize session log" };
|
|
}
|
|
return { success: true, stopped: false };
|
|
}
|
|
manualSessionLogTokens.delete(sessionId);
|
|
return { success: true, stopped: true, filePath };
|
|
} catch (err) {
|
|
return { success: false, stopped: false, error: err?.message || String(err) };
|
|
}
|
|
}
|
|
|
|
async function getManualSessionLogStatus(event, payload = {}) {
|
|
const sessionLogStreamManager = require("./sessionLogStreamManager.cjs");
|
|
const { sessionId } = payload;
|
|
if (!sessionId) {
|
|
return { success: false, isLogging: false, error: "Missing sessionId" };
|
|
}
|
|
return { success: true, isLogging: sessionLogStreamManager.hasStream(sessionId) };
|
|
}
|
|
|
|
/**
|
|
* Worker-side handlers only. The terminal utilityProcess has its own
|
|
* sessionLogStreamManager instance; main queries these paths before clear-all.
|
|
*/
|
|
function registerWorkerHandlers(ipcMain) {
|
|
ipcMain.handle("netcatty:sessionLogs:getActivePaths", async () => getLocalActiveLogPaths());
|
|
}
|
|
|
|
/**
|
|
* Register IPC handlers for session logs operations
|
|
*/
|
|
function registerHandlers(ipcMain, options = {}) {
|
|
const terminalWorkerManager = options.terminalWorkerManager || null;
|
|
|
|
ipcMain.handle("netcatty:sessionLogs:export", exportSessionLog);
|
|
ipcMain.handle("netcatty:sessionLogs:selectDir", selectSessionLogsDir);
|
|
ipcMain.handle("netcatty:sessionLogs:autoSave", autoSaveSessionLog);
|
|
ipcMain.handle("netcatty:sessionLogs:openDir", openSessionLogsDir);
|
|
ipcMain.handle(
|
|
"netcatty:sessionLogs:clear",
|
|
(event, payload) => clearSessionLogsDir(event, payload, terminalWorkerManager),
|
|
);
|
|
// Main can also answer this (manual / script streams) for symmetry / tests.
|
|
ipcMain.handle("netcatty:sessionLogs:getActivePaths", async () => getLocalActiveLogPaths());
|
|
ipcMain.handle("netcatty:sessionLog:manualChoosePath", chooseManualSessionLogPath);
|
|
ipcMain.handle("netcatty:sessionLog:manualStart", startManualSessionLog);
|
|
ipcMain.handle("netcatty:sessionLog:manualStop", stopManualSessionLog);
|
|
ipcMain.handle("netcatty:sessionLog:manualStatus", getManualSessionLogStatus);
|
|
|
|
// In the default terminal-worker runtime, sessions run in a utilityProcess
|
|
// and call appendData() on the worker's own sessionLogStreamManager module
|
|
// instance. Manual session logs (and script session logs) are started in
|
|
// the *main* process, so without this tap their streams never receive any
|
|
// terminal output — the saved file would only contain the initial prompt
|
|
// line captured from the renderer buffer (issue #1938). The worker already
|
|
// mirrors every output chunk to the main process for script output buffers;
|
|
// feed that same stream into the main-process log streams. appendData() is
|
|
// a no-op for sessions without an active main-process stream.
|
|
terminalWorkerManager?.addOutputTap?.((sessionId, data) => {
|
|
if (typeof data !== "string" || data.length === 0) return;
|
|
require("./sessionLogStreamManager.cjs").appendData(sessionId, data);
|
|
});
|
|
}
|
|
|
|
module.exports = {
|
|
registerHandlers,
|
|
registerWorkerHandlers,
|
|
exportSessionLog,
|
|
selectSessionLogsDir,
|
|
autoSaveSessionLog,
|
|
openSessionLogsDir,
|
|
clearSessionLogsDir,
|
|
collectActiveLogPaths,
|
|
getLocalActiveLogPaths,
|
|
isSessionLogArtifactName,
|
|
chooseManualSessionLogPath,
|
|
startManualSessionLog,
|
|
stopManualSessionLog,
|
|
getManualSessionLogStatus,
|
|
toLocalISOString,
|
|
terminalDataToHtml,
|
|
terminalPlainTextToHtml,
|
|
wrapTerminalHtmlContent,
|
|
safePathSegment,
|
|
};
|