mirror of
https://github.com/binaricat/Netcatty.git
synced 2026-11-13 01:50:47 +00:00
Compare commits
10
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7b58f554e8 | ||
|
|
ae56dafac0 | ||
|
|
e654025024 | ||
|
|
1e4b0d5e6a | ||
|
|
59ecf671f9 | ||
|
|
8505d0c640 | ||
|
|
ac50272ce0 | ||
|
|
e50647de66 | ||
|
|
3446a34399 | ||
|
|
8e5212f0a7 |
No files matched your search
@@ -1,6 +1,15 @@
|
||||
import type { Messages } from '../types';
|
||||
|
||||
export const enTerminalMessages: Messages = {
|
||||
'terminal.toolbar.openSessionLog': 'Open session log',
|
||||
'terminal.toolbar.sessionLogActions': 'Session log actions',
|
||||
'terminal.toolbar.sessionLog.menu': 'Session log',
|
||||
'terminal.toolbar.sessionLog.open': 'Open',
|
||||
'terminal.toolbar.sessionLog.reveal': 'Show in folder',
|
||||
'terminal.toolbar.sessionLog.chooseApplication': 'Open with…',
|
||||
'terminal.toolbar.sessionLog.systemDefault': 'Open with system default',
|
||||
'terminal.toolbar.openSessionLogFailed': 'Failed to open session log.',
|
||||
|
||||
'terminal.menu.saveScreen': 'Save current screen',
|
||||
'terminal.saveScreen.failed': 'Failed to save terminal screen.',
|
||||
// Multi-line paste confirmation dialog (#3398)
|
||||
|
||||
@@ -1,6 +1,15 @@
|
||||
import type { Messages } from '../types';
|
||||
|
||||
export const esTerminalMessages: Messages = {
|
||||
'terminal.toolbar.openSessionLog': 'Abrir registro de sesión',
|
||||
'terminal.toolbar.sessionLogActions': 'Acciones del registro',
|
||||
'terminal.toolbar.sessionLog.menu': 'Registro de sesión',
|
||||
'terminal.toolbar.sessionLog.open': 'Abrir',
|
||||
'terminal.toolbar.sessionLog.reveal': 'Mostrar en carpeta',
|
||||
'terminal.toolbar.sessionLog.chooseApplication': 'Abrir con…',
|
||||
'terminal.toolbar.sessionLog.systemDefault': 'Abrir con la aplicación predeterminada',
|
||||
'terminal.toolbar.openSessionLogFailed': 'No se pudo abrir el registro.',
|
||||
|
||||
'terminal.menu.saveScreen': 'Guardar pantalla actual',
|
||||
'terminal.saveScreen.failed': 'No se pudo guardar la pantalla del terminal.',
|
||||
// Diálogo de confirmación de pegado multilínea (#3398)
|
||||
|
||||
@@ -1,6 +1,15 @@
|
||||
import type { Messages } from '../types';
|
||||
|
||||
export const ruTerminalMessages: Messages = {
|
||||
'terminal.toolbar.openSessionLog': 'Открыть журнал сессии',
|
||||
'terminal.toolbar.sessionLogActions': 'Действия с журналом',
|
||||
'terminal.toolbar.sessionLog.menu': 'Журнал сессии',
|
||||
'terminal.toolbar.sessionLog.open': 'Открыть',
|
||||
'terminal.toolbar.sessionLog.reveal': 'Показать в папке',
|
||||
'terminal.toolbar.sessionLog.chooseApplication': 'Открыть с помощью…',
|
||||
'terminal.toolbar.sessionLog.systemDefault': 'Открыть по умолчанию',
|
||||
'terminal.toolbar.openSessionLogFailed': 'Не удалось открыть журнал.',
|
||||
|
||||
'terminal.menu.saveScreen': 'Сохранить текущий экран',
|
||||
'terminal.saveScreen.failed': 'Не удалось сохранить экран терминала.',
|
||||
// Диалог подтверждения многострочной вставки (#3398)
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import en from './en';
|
||||
import es from './es';
|
||||
import ru from './ru';
|
||||
import zhCN from './zh-CN';
|
||||
import zhTW from './zh-TW';
|
||||
|
||||
const keys = [
|
||||
'terminal.toolbar.openSessionLog',
|
||||
'terminal.toolbar.sessionLogActions',
|
||||
'terminal.toolbar.sessionLog.menu',
|
||||
'terminal.toolbar.sessionLog.open',
|
||||
'terminal.toolbar.sessionLog.reveal',
|
||||
'terminal.toolbar.sessionLog.chooseApplication',
|
||||
'terminal.toolbar.sessionLog.systemDefault',
|
||||
'terminal.toolbar.openSessionLogFailed',
|
||||
] as const;
|
||||
|
||||
test('saved session log actions are translated in every shipped locale', () => {
|
||||
for (const [locale, messages] of Object.entries({ en, es, ru, 'zh-CN': zhCN, 'zh-TW': zhTW })) {
|
||||
for (const key of keys) assert.ok(messages[key], `${locale} is missing ${key}`);
|
||||
assert.equal(messages['terminal.toolbar.sessionLog.rememberApplication'], undefined);
|
||||
}
|
||||
assert.equal(en['terminal.toolbar.sessionLog.menu'], 'Session log');
|
||||
assert.equal(en['terminal.toolbar.sessionLog.open'], 'Open');
|
||||
assert.equal(en['terminal.toolbar.sessionLog.reveal'], 'Show in folder');
|
||||
assert.equal(en['terminal.toolbar.sessionLog.chooseApplication'], 'Open with…');
|
||||
assert.equal(en['terminal.toolbar.sessionLog.systemDefault'], 'Open with system default');
|
||||
assert.equal(zhCN['terminal.toolbar.sessionLog.menu'], '会话日志');
|
||||
assert.equal(zhTW['terminal.toolbar.sessionLog.menu'], '工作階段日誌');
|
||||
});
|
||||
@@ -1,6 +1,15 @@
|
||||
import type { Messages } from '../types';
|
||||
|
||||
export const zhCNTerminalMessages: Messages = {
|
||||
'terminal.toolbar.openSessionLog': '打开会话日志',
|
||||
'terminal.toolbar.sessionLogActions': '会话日志操作',
|
||||
'terminal.toolbar.sessionLog.menu': '会话日志',
|
||||
'terminal.toolbar.sessionLog.open': '打开',
|
||||
'terminal.toolbar.sessionLog.reveal': '在文件夹中显示',
|
||||
'terminal.toolbar.sessionLog.chooseApplication': '打开方式…',
|
||||
'terminal.toolbar.sessionLog.systemDefault': '用系统默认应用打开',
|
||||
'terminal.toolbar.openSessionLogFailed': '无法打开会话日志。',
|
||||
|
||||
'terminal.menu.saveScreen': '保存当前屏幕',
|
||||
'terminal.saveScreen.failed': '保存终端屏幕失败。',
|
||||
// 多行粘贴确认对话框(#3398)
|
||||
|
||||
@@ -1,6 +1,15 @@
|
||||
import type { Messages } from '../types';
|
||||
|
||||
export const zhTWTerminalMessages: Messages = {
|
||||
'terminal.toolbar.openSessionLog': '開啟工作階段日誌',
|
||||
'terminal.toolbar.sessionLogActions': '工作階段日誌操作',
|
||||
'terminal.toolbar.sessionLog.menu': '工作階段日誌',
|
||||
'terminal.toolbar.sessionLog.open': '開啟',
|
||||
'terminal.toolbar.sessionLog.reveal': '在資料夾中顯示',
|
||||
'terminal.toolbar.sessionLog.chooseApplication': '開啟方式…',
|
||||
'terminal.toolbar.sessionLog.systemDefault': '用系統預設應用程式開啟',
|
||||
'terminal.toolbar.openSessionLogFailed': '無法開啟工作階段日誌。',
|
||||
|
||||
'terminal.menu.saveScreen': '儲存目前畫面',
|
||||
'terminal.saveScreen.failed': '儲存終端畫面失敗。',
|
||||
// 多行貼上確認對話框(#3398)
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { JSDOM } from 'jsdom';
|
||||
import { performSessionLogAction } from './sessionLogActions';
|
||||
import { STORAGE_KEY_SESSION_LOG_APPLICATION } from '../../infrastructure/config/storageKeys';
|
||||
|
||||
test('session log actions resolve and flush the selected session, remember apps and reset to default', async () => {
|
||||
const dom = new JSDOM('', { url: 'http://localhost' });
|
||||
const previousWindow = Object.getOwnPropertyDescriptor(globalThis, 'window');
|
||||
const previousStorage = Object.getOwnPropertyDescriptor(globalThis, 'localStorage');
|
||||
Object.defineProperty(globalThis, 'window', { configurable: true, value: dom.window });
|
||||
Object.defineProperty(globalThis, 'localStorage', { configurable: true, value: dom.window.localStorage });
|
||||
const calls: unknown[][] = [];
|
||||
let picked: { path: string; name: string } | null = { path: '/Applications/My Editor.app', name: 'My Editor' };
|
||||
let available = true;
|
||||
let failOpen = false;
|
||||
const bridge = {
|
||||
prepareSessionLog: async (payload: { sessionId: string }) => {
|
||||
calls.push(['prepare', payload]);
|
||||
return available ? { success: true, filePath: `/logs/${payload.sessionId}.html` }
|
||||
: { success: false, error: 'No saved log for this session' };
|
||||
},
|
||||
revealSessionLog: async (payload: { sessionId: string }) => {
|
||||
calls.push(['reveal', payload]);
|
||||
return { success: true };
|
||||
},
|
||||
selectApplication: async () => { calls.push(['choose']); return picked; },
|
||||
openWithApplication: async (file: string, app: string) => {
|
||||
calls.push(['application', file, app]);
|
||||
if (failOpen) throw new Error('Launch failed');
|
||||
return true;
|
||||
},
|
||||
openWithSystemDefault: async (file: string) => { calls.push(['default', file]); return { success: true }; },
|
||||
};
|
||||
Object.assign(dom.window, { netcatty: bridge });
|
||||
try {
|
||||
await performSessionLogAction('first', 'open');
|
||||
assert.deepEqual(calls.splice(0), [['prepare', { sessionId: 'first' }], ['default', '/logs/first.html']]);
|
||||
await performSessionLogAction('second', 'chooseApplication');
|
||||
assert.deepEqual(calls.splice(0), [
|
||||
['prepare', { sessionId: 'second' }], ['choose'], ['prepare', { sessionId: 'second' }],
|
||||
['application', '/logs/second.html', picked!.path],
|
||||
]);
|
||||
assert.equal(JSON.parse(dom.window.localStorage.getItem(STORAGE_KEY_SESSION_LOG_APPLICATION)!).name, 'My Editor');
|
||||
await performSessionLogAction('first', 'open');
|
||||
assert.deepEqual(calls.splice(0), [['prepare', { sessionId: 'first' }], ['application', '/logs/first.html', picked!.path]]);
|
||||
await performSessionLogAction('first', 'reveal');
|
||||
assert.deepEqual(calls.splice(0), [['reveal', { sessionId: 'first' }]]);
|
||||
await performSessionLogAction('first', 'systemDefault');
|
||||
assert.equal(dom.window.localStorage.getItem(STORAGE_KEY_SESSION_LOG_APPLICATION), null);
|
||||
calls.length = 0;
|
||||
picked = null;
|
||||
await performSessionLogAction('first', 'chooseApplication');
|
||||
assert.deepEqual(calls.splice(0), [['prepare', { sessionId: 'first' }], ['choose']]);
|
||||
available = false;
|
||||
await assert.rejects(performSessionLogAction('missing', 'chooseApplication'), /No saved log/);
|
||||
assert.deepEqual(calls.splice(0), [['prepare', { sessionId: 'missing' }]]);
|
||||
available = true;
|
||||
picked = { path: '/missing-editor', name: 'Missing' };
|
||||
failOpen = true;
|
||||
await assert.rejects(performSessionLogAction('first', 'chooseApplication'), /Launch failed/);
|
||||
assert.equal(dom.window.localStorage.getItem(STORAGE_KEY_SESSION_LOG_APPLICATION), null);
|
||||
} finally {
|
||||
if (previousWindow) Object.defineProperty(globalThis, 'window', previousWindow);
|
||||
else Reflect.deleteProperty(globalThis, 'window');
|
||||
if (previousStorage) Object.defineProperty(globalThis, 'localStorage', previousStorage);
|
||||
else Reflect.deleteProperty(globalThis, 'localStorage');
|
||||
dom.window.close();
|
||||
}
|
||||
});
|
||||
|
||||
// The integration boundary must translate backend errors, including ordinary
|
||||
// Error instances. Raw filesystem/app-launch details belong only in diagnostics.
|
||||
test('terminal log action errors use the translated toast instead of raw backend text', () => {
|
||||
const source = readFileSync(new URL('../../components/Terminal.tsx', import.meta.url), 'utf8');
|
||||
const handler = source.slice(source.indexOf(' const handleSessionLogAction ='),
|
||||
source.indexOf(' const handleToggleSessionLog ='));
|
||||
assert.match(handler, /logger\.error\("\[Terminal\] Failed to open or reveal session log:", err\)/);
|
||||
assert.match(handler, /toast\.error\(t\('terminal\.toolbar\.openSessionLogFailed'\)\)/);
|
||||
assert.doesNotMatch(handler, /err\.message/);
|
||||
});
|
||||
@@ -0,0 +1,48 @@
|
||||
import { STORAGE_KEY_SESSION_LOG_APPLICATION } from '../../infrastructure/config/storageKeys';
|
||||
import { localStorageAdapter } from '../../infrastructure/persistence/localStorageAdapter';
|
||||
import { netcattyBridge } from '../../infrastructure/services/netcattyBridge';
|
||||
|
||||
export type SessionLogAction = 'open' | 'reveal' | 'chooseApplication' | 'systemDefault';
|
||||
type LogApplication = { path: string; name: string };
|
||||
|
||||
/** Resolve the session's actual log in its owning process before opening it. */
|
||||
export async function performSessionLogAction(sessionId: string, action: SessionLogAction): Promise<void> {
|
||||
const bridge = netcattyBridge.get();
|
||||
if (!bridge?.prepareSessionLog) throw new Error('Session log bridge unavailable');
|
||||
if (action === 'reveal') {
|
||||
const result = await bridge.revealSessionLog?.({ sessionId });
|
||||
if (!result?.success) throw new Error(result?.error || 'Failed to reveal session log');
|
||||
return;
|
||||
}
|
||||
|
||||
// Check availability before presenting an application picker. Resolve again
|
||||
// afterwards so any output received while the picker was open is flushed.
|
||||
let result = await bridge.prepareSessionLog({ sessionId });
|
||||
if (!result.success || !result.filePath) throw new Error(result.error || 'No saved log for this session');
|
||||
let application = localStorageAdapter.read<LogApplication>(STORAGE_KEY_SESSION_LOG_APPLICATION);
|
||||
if (action === 'chooseApplication') {
|
||||
if (!bridge.selectApplication) throw new Error('Application picker unavailable');
|
||||
application = await bridge.selectApplication();
|
||||
if (!application) return;
|
||||
result = await bridge.prepareSessionLog({ sessionId });
|
||||
if (!result.success || !result.filePath) throw new Error(result.error || 'No saved log for this session');
|
||||
} else if (action === 'systemDefault') {
|
||||
application = null;
|
||||
}
|
||||
|
||||
if (application?.path) {
|
||||
if (!bridge.openWithApplication) throw new Error('Application opening unavailable');
|
||||
const opened = await bridge.openWithApplication(result.filePath, application.path);
|
||||
if (!opened) throw new Error('Failed to open session log');
|
||||
} else {
|
||||
const opened = await bridge.openWithSystemDefault?.(result.filePath);
|
||||
if (!opened?.success) throw new Error(opened?.error || 'Failed to open session log');
|
||||
}
|
||||
// Only remember a choice after a successful launch; failed launches leave
|
||||
// the previous preference intact and can be retried or reset from the menu.
|
||||
if (action === 'chooseApplication') {
|
||||
localStorageAdapter.write(STORAGE_KEY_SESSION_LOG_APPLICATION, application);
|
||||
} else if (action === 'systemDefault') {
|
||||
localStorageAdapter.remove(STORAGE_KEY_SESSION_LOG_APPLICATION);
|
||||
}
|
||||
}
|
||||
@@ -50,7 +50,7 @@ export const useSessionLogBackend = () => {
|
||||
|
||||
const getManualSessionLogStatus = useCallback(async (payload: ManualStatusPayload) => {
|
||||
const bridge = netcattyBridge.get();
|
||||
return bridge?.getManualSessionLogStatus?.(payload) ?? { success: false, isLogging: false, error: "Session log bridge unavailable" };
|
||||
return bridge?.getManualSessionLogStatus?.(payload) ?? { success: false, isLogging: false, hasSessionLog: false, error: "Session log bridge unavailable" };
|
||||
}, []);
|
||||
|
||||
return {
|
||||
|
||||
+25
-2
@@ -73,6 +73,7 @@ import {
|
||||
} from "../application/state/terminalAutoReconnect";
|
||||
import { useStoredBoolean } from "../application/state/useStoredBoolean";
|
||||
import { readOptionalStoredStringValue, useStoredString } from "../application/state/useStoredString";
|
||||
import { performSessionLogAction, type SessionLogAction } from "../application/state/sessionLogActions";
|
||||
import { useSessionLogBackend } from "../application/state/useSessionLogBackend";
|
||||
import { useTerminalLayoutSuppressActive } from "../application/state/terminalLayoutSuppressStore";
|
||||
import { useAppearanceChromeStore } from "../application/state/appearanceChromeStore";
|
||||
@@ -906,6 +907,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
const [isCancelling, setIsCancelling] = useState(false);
|
||||
const [showSFTP, setShowSFTP] = useState(false);
|
||||
const [isSessionLogging, setIsSessionLogging] = useState(false);
|
||||
const [canOpenSessionLog, setCanOpenSessionLog] = useState(false);
|
||||
const [progressValue, setProgressValue] = useState(CONNECTION_PROGRESS_START);
|
||||
const [isDisconnectedDialogDismissed, setIsDisconnectedDialogDismissed] = useState(false);
|
||||
const [connectionReuseFellBack, setConnectionReuseFellBack] = useState(false);
|
||||
@@ -4127,6 +4129,15 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
onClipboardImageUploadResult: handleClipboardImageUploadResult,
|
||||
});
|
||||
|
||||
const handleSessionLogAction = useCallback(async (action: SessionLogAction) => {
|
||||
try {
|
||||
await performSessionLogAction(sessionRef.current ?? sessionId, action);
|
||||
} catch (err) {
|
||||
logger.error("[Terminal] Failed to open or reveal session log:", err);
|
||||
toast.error(t('terminal.toolbar.openSessionLogFailed'));
|
||||
}
|
||||
}, [sessionId, t]);
|
||||
|
||||
const handleToggleSessionLog = useCallback(async () => {
|
||||
const currentSessionId = sessionRef.current ?? sessionId;
|
||||
if (!currentSessionId) {
|
||||
@@ -4176,6 +4187,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
if (startResult?.success) {
|
||||
if (!startResult?.started && startResult?.canceled) return;
|
||||
setIsSessionLogging(!!startResult?.started);
|
||||
if (startResult?.started) setCanOpenSessionLog(true);
|
||||
} else {
|
||||
toast.error(startResult?.error || "Failed to start session log");
|
||||
}
|
||||
@@ -4200,14 +4212,21 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
const currentSessionId = sessionRef.current ?? sessionId;
|
||||
if (!currentSessionId) {
|
||||
setIsSessionLogging(false);
|
||||
setCanOpenSessionLog(false);
|
||||
return;
|
||||
}
|
||||
|
||||
let cancelled = false;
|
||||
void getManualSessionLogStatus({ sessionId: currentSessionId }).then((result) => {
|
||||
if (!cancelled) setIsSessionLogging(!!result?.isLogging);
|
||||
if (!cancelled) {
|
||||
setIsSessionLogging(!!result?.isLogging);
|
||||
setCanOpenSessionLog(!!result?.hasSessionLog);
|
||||
}
|
||||
}).catch(() => {
|
||||
if (!cancelled) setIsSessionLogging(false);
|
||||
if (!cancelled) {
|
||||
setIsSessionLogging(false);
|
||||
setCanOpenSessionLog(false);
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
@@ -4283,6 +4302,8 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
isSearchOpen={isSearchOpen}
|
||||
onToggleSearch={handleToggleSearch}
|
||||
showLogButton
|
||||
onSessionLogAction={handleSessionLogAction}
|
||||
canOpenSessionLog={canOpenSessionLog}
|
||||
onToggleSessionLog={handleToggleSessionLog}
|
||||
isSessionLogging={isSessionLogging}
|
||||
isSessionLogDisabled={status !== "connected" && !isSessionLogging}
|
||||
@@ -4315,6 +4336,8 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
handleSendYmodem,
|
||||
handleSetTerminalEncoding,
|
||||
handleToggleSessionLog,
|
||||
handleSessionLogAction,
|
||||
canOpenSessionLog,
|
||||
handleToggleSearch,
|
||||
handleToolbarRecordingToggle,
|
||||
host,
|
||||
|
||||
@@ -6,7 +6,7 @@ import { renderToStaticMarkup } from "react-dom/server";
|
||||
|
||||
import { I18nProvider } from "../../application/i18n/I18nProvider.tsx";
|
||||
import type { Host } from "../../types.ts";
|
||||
import { TerminalToolbar } from "./TerminalToolbar.tsx";
|
||||
import { TERMINAL_TOOLBAR_LAYOUT_DEFAULTS, TerminalToolbar } from "./TerminalToolbar.tsx";
|
||||
|
||||
const toolbarSource = readFileSync(new URL("./TerminalToolbar.tsx", import.meta.url), "utf8");
|
||||
|
||||
@@ -213,3 +213,26 @@ for (const host of [serialHost, pluginHost]) {
|
||||
assert.equal(renderToolbar(host).includes('aria-label="Open SFTP"'), false);
|
||||
});
|
||||
}
|
||||
|
||||
test('open session log stays in the overflow menu until logging is active', () => {
|
||||
assert.equal(TERMINAL_TOOLBAR_LAYOUT_DEFAULTS.placement.openSessionLog, 'collapse');
|
||||
const hidden = renderToolbar(sshHost, 'disconnected', { onSessionLogAction: () => {} });
|
||||
assert.doesNotMatch(hidden, /aria-label="Open session log"/);
|
||||
assert.doesNotMatch(hidden, /Open session log/);
|
||||
|
||||
for (const status of ['connected', 'disconnected'] as const) {
|
||||
const markup = renderToolbar(sshHost, status, {
|
||||
onSessionLogAction: () => {},
|
||||
canOpenSessionLog: true,
|
||||
});
|
||||
assert.doesNotMatch(markup, /aria-label="Open session log"/);
|
||||
assert.doesNotMatch(markup, /aria-label="Session log actions"/);
|
||||
assert.match(markup, /aria-label="More actions"/);
|
||||
}
|
||||
assert.match(toolbarSource, /case 'openSessionLog':/);
|
||||
assert.match(toolbarSource, /data-session-log-submenu="true"/);
|
||||
assert.match(toolbarSource, /terminal\.toolbar\.sessionLog\.menu/);
|
||||
assert.match(toolbarSource, /terminal\.toolbar\.sessionLog\.\$\{action\}/);
|
||||
assert.equal(toolbarSource.includes('rememberApplication'), false);
|
||||
assert.equal(toolbarSource.includes('Open session log'), false);
|
||||
});
|
||||
@@ -6,6 +6,8 @@
|
||||
import {
|
||||
Check,
|
||||
ChevronRight,
|
||||
ChevronDown,
|
||||
FileSearch,
|
||||
Circle,
|
||||
Download,
|
||||
FileText,
|
||||
@@ -25,6 +27,7 @@ import {
|
||||
} from 'lucide-react';
|
||||
import React, { useMemo, useState } from 'react';
|
||||
import { useI18n } from '../../application/i18n/I18nProvider';
|
||||
import type { SessionLogAction } from '../../application/state/sessionLogActions';
|
||||
import { useToolbarItemLayout } from '../../application/state/useToolbarItemLayout';
|
||||
import type { ToolbarItemLayoutDefaults } from '../../domain/toolbarItemLayout';
|
||||
import { STORAGE_KEY_TERMINAL_TOOLBAR_LAYOUT } from '../../infrastructure/config/storageKeys';
|
||||
@@ -51,6 +54,7 @@ export const TERMINAL_TOOLBAR_ITEM_IDS = [
|
||||
'compose',
|
||||
'search',
|
||||
'sessionLog',
|
||||
'openSessionLog',
|
||||
'scripts',
|
||||
'history',
|
||||
'configureOsc7',
|
||||
@@ -73,6 +77,7 @@ export const TERMINAL_TOOLBAR_LAYOUT_DEFAULTS: ToolbarItemLayoutDefaults = {
|
||||
compose: 'show',
|
||||
search: 'show',
|
||||
sessionLog: 'show',
|
||||
openSessionLog: 'collapse',
|
||||
scripts: 'show',
|
||||
history: 'collapse',
|
||||
configureOsc7: 'collapse',
|
||||
@@ -114,6 +119,9 @@ export interface TerminalToolbarProps {
|
||||
// Manual session log
|
||||
showLogButton?: boolean;
|
||||
onToggleSessionLog?: () => void;
|
||||
onSessionLogAction?: (action: SessionLogAction) => void;
|
||||
/** Automatic logging is enabled, or this session has started a manual log. */
|
||||
canOpenSessionLog?: boolean;
|
||||
isSessionLogging?: boolean;
|
||||
isSessionLogDisabled?: boolean;
|
||||
// Compose bar
|
||||
@@ -152,6 +160,8 @@ export const TerminalToolbar: React.FC<TerminalToolbarProps> = ({
|
||||
onToggleSearch,
|
||||
showLogButton = false,
|
||||
onToggleSessionLog,
|
||||
onSessionLogAction,
|
||||
canOpenSessionLog = false,
|
||||
isSessionLogging = false,
|
||||
isSessionLogDisabled = false,
|
||||
isComposeBarOpen,
|
||||
@@ -196,12 +206,13 @@ export const TerminalToolbar: React.FC<TerminalToolbarProps> = ({
|
||||
const pendingScriptDeleteCount = (pendingScriptDeleteIds ?? []).filter((id) =>
|
||||
snippets.some((snippet) => snippet.id === id),
|
||||
).length;
|
||||
// Overflow popover + encoding submenu are both controlled so that
|
||||
// picking an encoding closes the whole chain, and so the parent popover
|
||||
// can ignore clicks that land in the submenu portal (otherwise the
|
||||
// submenu click would read as "outside" and dismiss the parent).
|
||||
// Overflow popover and its submenus are controlled so that picking an
|
||||
// item closes the whole chain, and so the parent popover can ignore
|
||||
// clicks that land in a submenu portal (otherwise the submenu click
|
||||
// would read as "outside" and dismiss the parent).
|
||||
const [overflowOpen, setOverflowOpen] = useState(false);
|
||||
const [encodingSubmenuOpen, setEncodingSubmenuOpen] = useState(false);
|
||||
const [sessionLogSubmenuOpen, setSessionLogSubmenuOpen] = useState(false);
|
||||
const buttonBase =
|
||||
'h-6 w-6 p-0 shadow-none border-none text-[color:var(--terminal-toolbar-fg)] bg-transparent hover:bg-transparent';
|
||||
|
||||
@@ -248,6 +259,7 @@ export const TerminalToolbar: React.FC<TerminalToolbarProps> = ({
|
||||
ids.push('ymodemSend', 'ymodemReceive');
|
||||
}
|
||||
if (showLogButton) ids.push('sessionLog');
|
||||
if (onSessionLogAction && canOpenSessionLog) ids.push('openSessionLog');
|
||||
if (historySupported) ids.push('history');
|
||||
if (onConfigureOsc7 && !hidesSftp) ids.push('configureOsc7');
|
||||
if (onStartRecording) ids.push('recording');
|
||||
@@ -264,6 +276,8 @@ export const TerminalToolbar: React.FC<TerminalToolbarProps> = ({
|
||||
onSetTerminalEncoding,
|
||||
onStartRecording,
|
||||
showLogButton,
|
||||
onSessionLogAction,
|
||||
canOpenSessionLog,
|
||||
]);
|
||||
|
||||
const itemLabels = useMemo(
|
||||
@@ -278,6 +292,7 @@ export const TerminalToolbar: React.FC<TerminalToolbarProps> = ({
|
||||
sessionLog: isSessionLogging
|
||||
? t('terminal.toolbar.stopSessionLog')
|
||||
: t('terminal.toolbar.startSessionLog'),
|
||||
openSessionLog: t('terminal.toolbar.sessionLog.menu'),
|
||||
scripts: t('terminal.toolbar.scripts'),
|
||||
history: t('terminal.toolbar.history'),
|
||||
configureOsc7: t('terminal.toolbar.configureOsc7'),
|
||||
@@ -298,6 +313,7 @@ export const TerminalToolbar: React.FC<TerminalToolbarProps> = ({
|
||||
compose: <TextCursorInput size={14} />,
|
||||
search: <Search size={14} />,
|
||||
sessionLog: <FileText size={14} />,
|
||||
openSessionLog: <FileSearch size={14} />,
|
||||
scripts: <Zap size={14} />,
|
||||
history: <History size={14} />,
|
||||
configureOsc7: <FolderSync size={14} />,
|
||||
@@ -437,6 +453,52 @@ export const TerminalToolbar: React.FC<TerminalToolbarProps> = ({
|
||||
);
|
||||
}
|
||||
|
||||
const renderSessionLogSubmenu = () => {
|
||||
if (!onSessionLogAction) return null;
|
||||
return (
|
||||
<Popover open={sessionLogSubmenuOpen} onOpenChange={setSessionLogSubmenuOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className={menuItemClass}
|
||||
aria-haspopup="menu"
|
||||
aria-expanded={sessionLogSubmenuOpen}
|
||||
>
|
||||
<FileSearch size={12} className="shrink-0" />
|
||||
<span className="flex-1 text-left truncate">{t('terminal.toolbar.sessionLog.menu')}</span>
|
||||
<ChevronRight size={12} className="shrink-0 text-muted-foreground" />
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
data-session-log-submenu="true"
|
||||
data-section="terminal-toolbar-menu"
|
||||
className="w-56 p-1"
|
||||
side="right"
|
||||
align="start"
|
||||
sideOffset={6}
|
||||
>
|
||||
{(['open', 'reveal', 'chooseApplication', 'systemDefault'] as const).map((action) => (
|
||||
<button
|
||||
key={action}
|
||||
type="button"
|
||||
className={menuItemClass}
|
||||
onClick={() => {
|
||||
onSessionLogAction(action);
|
||||
setSessionLogSubmenuOpen(false);
|
||||
setOverflowOpen(false);
|
||||
}}
|
||||
>
|
||||
<FileSearch size={12} className="shrink-0" />
|
||||
<span className="flex-1 text-left truncate">
|
||||
{t(`terminal.toolbar.sessionLog.${action}`)}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
};
|
||||
|
||||
const renderEncodingSubmenu = () => {
|
||||
if (!encodingSwitchSupported || !onSetTerminalEncoding) return null;
|
||||
return (
|
||||
@@ -631,6 +693,38 @@ export const TerminalToolbar: React.FC<TerminalToolbarProps> = ({
|
||||
<TooltipContent side="bottom">{t('terminal.toolbar.searchTerminal')}</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
case 'openSessionLog':
|
||||
return (
|
||||
<div key={id} className="flex items-center gap-0.5">
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button type="button" variant="secondary" size="icon" className={buttonBase}
|
||||
aria-label={t('terminal.toolbar.openSessionLog')}
|
||||
onClick={() => onSessionLogAction?.('open')}>
|
||||
<FileSearch size={12} />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom">{t('terminal.toolbar.openSessionLog')}</TooltipContent>
|
||||
</Tooltip>
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<Button type="button" variant="secondary" size="icon" className={cn(buttonBase, 'w-4')}
|
||||
aria-label={t('terminal.toolbar.sessionLogActions')}>
|
||||
<ChevronDown size={10} />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent align="end" className="w-56 p-1">
|
||||
{(['reveal', 'chooseApplication', 'systemDefault'] as const).map(action => (
|
||||
<PopoverClose asChild key={action}>
|
||||
<button type="button" className={menuItemClass} onClick={() => onSessionLogAction?.(action)}>
|
||||
{t(`terminal.toolbar.sessionLog.${action}`)}
|
||||
</button>
|
||||
</PopoverClose>
|
||||
))}
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
);
|
||||
case 'sessionLog':
|
||||
return (
|
||||
<Tooltip key={id}>
|
||||
@@ -903,6 +997,8 @@ export const TerminalToolbar: React.FC<TerminalToolbarProps> = ({
|
||||
</button>
|
||||
</PopoverClose>
|
||||
);
|
||||
case 'openSessionLog':
|
||||
return <React.Fragment key={id}>{renderSessionLogSubmenu()}</React.Fragment>;
|
||||
case 'sessionLog':
|
||||
return (
|
||||
<PopoverClose asChild key={id}>
|
||||
@@ -1020,7 +1116,10 @@ export const TerminalToolbar: React.FC<TerminalToolbarProps> = ({
|
||||
open={overflowOpen}
|
||||
onOpenChange={(open) => {
|
||||
setOverflowOpen(open);
|
||||
if (!open) setEncodingSubmenuOpen(false);
|
||||
if (!open) {
|
||||
setEncodingSubmenuOpen(false);
|
||||
setSessionLogSubmenuOpen(false);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Tooltip>
|
||||
@@ -1048,7 +1147,7 @@ export const TerminalToolbar: React.FC<TerminalToolbarProps> = ({
|
||||
// "outside" this popover; without this guard a click
|
||||
// in the submenu would dismiss the parent.
|
||||
const target = e.target as Element | null;
|
||||
if (target?.closest('[data-encoding-submenu="true"]')) {
|
||||
if (target?.closest('[data-encoding-submenu="true"], [data-session-log-submenu="true"]')) {
|
||||
e.preventDefault();
|
||||
}
|
||||
}}
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
# Saved session logs
|
||||
|
||||
Recording and opening a session log stay separate.
|
||||
|
||||
- **Start / Stop session log** controls manual recording.
|
||||
- **Session log** lives in the terminal toolbar's existing **More actions**
|
||||
menu, as one submenu with **Open**, **Show in folder**, **Open with…**,
|
||||
and **Open with system default**. It is not a default toolbar button.
|
||||
Toolbar customization can show the shortcut on the bar or hide it. When it
|
||||
is shown, the arrow beside it offers the same reveal and open-with actions.
|
||||
|
||||
The submenu appears when this session has a saved log, and it stays available
|
||||
after recording stops. A successful reconnect drops a stopped log from the
|
||||
old connection, and the submenu goes with it. A failed reconnect keeps it.
|
||||
Pending terminal output is flushed before opening; recording continues
|
||||
normally. Automatic logs and manually recorded logs both open this way.
|
||||
|
||||
A successfully selected application is remembered locally for subsequent log
|
||||
opens, independently of SFTP file associations. Choosing the system default
|
||||
clears this preference. Text, raw, and HTML logs retain their original format.
|
||||
|
||||
The last log remains accessible after recording stops or a session disconnects.
|
||||
After a successful reconnect, a stopped log from the old connection is not reused.
|
||||
If reconnecting fails, the previous saved log remains available.
|
||||
Manual recording that is still active remains the current log. If the current session has no saved log,
|
||||
or the file was moved or deleted, the action reports an error rather than
|
||||
opening an unrelated file. The action does not enable logging automatically.
|
||||
@@ -41,6 +41,8 @@ function createBridgeRegistrarForTest({
|
||||
tempDir,
|
||||
sftpClients = new Map(),
|
||||
transferBridge = createNoopBridge(),
|
||||
sshBridge = null,
|
||||
terminalBridge = null,
|
||||
electronModuleOverrides = {},
|
||||
}) {
|
||||
const noopBridge = createNoopBridge();
|
||||
@@ -81,12 +83,12 @@ function createBridgeRegistrarForTest({
|
||||
sftpClients,
|
||||
CLOUD_SYNC_PASSWORD_FILE: "cloud-sync-password",
|
||||
getCliDiscoveryFilePath: () => path.join(tempDir, "cli-discovery.json"),
|
||||
sshBridge: { ...noopBridge, ensureMoshStatsConnection() {} },
|
||||
sshBridge: sshBridge || { ...noopBridge, ensureMoshStatsConnection() {} },
|
||||
sftpBridge,
|
||||
localFsBridge: noopBridge,
|
||||
transferBridge,
|
||||
portForwardingBridge: noopBridge,
|
||||
terminalBridge: { ...noopBridge, execOnEtSession() {} },
|
||||
terminalBridge: terminalBridge || { ...noopBridge, execOnEtSession() {} },
|
||||
crashLogBridge: noopBridge,
|
||||
ptyProcessTree: { getChildProcesses: () => [] },
|
||||
getOauthBridge: () => ({ setupOAuthBridge() {} }),
|
||||
@@ -250,3 +252,33 @@ test("downloadToTempWithProgress proxies transfer work to the terminal worker in
|
||||
cancelled: false,
|
||||
});
|
||||
});
|
||||
|
||||
for (const mode of ["disabled", "unavailable", "worker"]) {
|
||||
test(`registrar enables saved-log restart guards only for main fallback (${mode})`, async (t) => {
|
||||
const tempDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "netcatty-log-fallback-test-"));
|
||||
const previousWorkerSetting = process.env.NETCATTY_TERMINAL_WORKER;
|
||||
process.env.NETCATTY_TERMINAL_WORKER = mode === "disabled" ? "0" : "1";
|
||||
t.after(async () => {
|
||||
if (previousWorkerSetting === undefined) delete process.env.NETCATTY_TERMINAL_WORKER;
|
||||
else process.env.NETCATTY_TERMINAL_WORKER = previousWorkerSetting;
|
||||
await fs.promises.rm(tempDir, { recursive: true, force: true });
|
||||
});
|
||||
const observed = {};
|
||||
const bridgeSpy = (name) => ({
|
||||
...createNoopBridge(),
|
||||
registerHandlers(_ipcMain, options) { observed[name] = options; },
|
||||
});
|
||||
const registerBridges = createBridgeRegistrarForTest({
|
||||
ipcMain: createIpcMainStub(), tempDir,
|
||||
sshBridge: bridgeSpy("ssh"), terminalBridge: bridgeSpy("terminal"),
|
||||
electronModuleOverrides: mode === "unavailable" ? {} : {
|
||||
utilityProcess: { fork() { throw new Error("registration must not cold-start the worker"); } },
|
||||
},
|
||||
});
|
||||
registerBridges({});
|
||||
for (const name of ["ssh", "terminal"]) {
|
||||
assert.equal(observed[name].manageSessionLogRestart, mode !== "worker");
|
||||
assert.equal(Boolean(observed[name].terminalWorkerManager), mode === "worker");
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -16,9 +16,16 @@ const { createProgrammaticCommandLogRewriter } = require("./programmaticCommandL
|
||||
|
||||
// Active log streams keyed by sessionId
|
||||
const activeStreams = new Map();
|
||||
// Keep only the latest server-owned target for each session, including while
|
||||
// finalization is in flight. Finished records retain no terminal/parser data.
|
||||
const sessionLogs = new Map();
|
||||
|
||||
// Buffer flush interval (ms)
|
||||
const FLUSH_INTERVAL = 500;
|
||||
// A failed snapshot retries without waiting for more output, but not in a
|
||||
// tight loop. The delay grows while the same failure persists.
|
||||
const SNAPSHOT_RETRY_MIN_MS = 250;
|
||||
const SNAPSHOT_RETRY_MAX_MS = 5000;
|
||||
// Max buffer size before immediate flush (bytes)
|
||||
const MAX_BUFFER_SIZE = 64 * 1024;
|
||||
const SUDO_AUTOFILL_MARKER_PATTERN = /__NETCATTY_SUDO_[a-z0-9_]+__/gi;
|
||||
@@ -120,6 +127,7 @@ function startStream(sessionId, opts) {
|
||||
console.warn(`[SessionLogStream] Stream already active for ${sessionId}, stopping old one`);
|
||||
stopStream(sessionId, activeStreams.get(sessionId)?.startToken);
|
||||
}
|
||||
forgetSessionLog(sessionId);
|
||||
|
||||
const { hostLabel, hostname, directory, format, startTime } = opts;
|
||||
if (!directory) {
|
||||
@@ -166,16 +174,6 @@ function createStreamEntry(sessionId, opts) {
|
||||
? fs.createWriteStream(filePath, { flags: "w", encoding: "utf8" })
|
||||
: null;
|
||||
|
||||
if (writeStream) {
|
||||
writeStream.on("error", (err) => {
|
||||
console.error(`[SessionLogStream] Write error for ${sessionId}:`, err.message);
|
||||
const entry = activeStreams.get(sessionId);
|
||||
if (entry) {
|
||||
entry.disabled = true;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
const startToken = Symbol("session-log-stream");
|
||||
const entry = {
|
||||
writeStream,
|
||||
@@ -202,6 +200,13 @@ function createStreamEntry(sessionId, opts) {
|
||||
snapshotPromise: null,
|
||||
snapshotRequested: false,
|
||||
snapshotDirty: false,
|
||||
snapshotError: null,
|
||||
snapshotRetryTimer: null,
|
||||
snapshotRetryDelay: 0,
|
||||
// Set only while prepareSessionLog is waiting on a snapshot, so that
|
||||
// caller can observe the failure before the delayed retry starts.
|
||||
deferSnapshotRetry: false,
|
||||
error: null,
|
||||
closing: false,
|
||||
disabled: false,
|
||||
startToken,
|
||||
@@ -210,11 +215,21 @@ function createStreamEntry(sessionId, opts) {
|
||||
pendingInitialLineLeadingCarriageReturn: false,
|
||||
};
|
||||
|
||||
if (writeStream) {
|
||||
writeStream.on("error", (err) => {
|
||||
console.error(`[SessionLogStream] Write error for ${sessionId}:`, err.message);
|
||||
// A delayed error from an old connection must not disable a new stream.
|
||||
entry.disabled = true;
|
||||
entry.error = err;
|
||||
});
|
||||
}
|
||||
|
||||
entry.flushTimer = setInterval(() => {
|
||||
flushBuffer(entry);
|
||||
}, FLUSH_INTERVAL);
|
||||
|
||||
activeStreams.set(sessionId, entry);
|
||||
sessionLogs.set(sessionId, { entry, filePath, startToken, stopPromise: null, error: null });
|
||||
console.log(`[SessionLogStream] Started stream for ${sessionId} -> ${filePath}`);
|
||||
return startToken;
|
||||
}
|
||||
@@ -283,6 +298,7 @@ function flushBuffer(entry) {
|
||||
} catch (err) {
|
||||
console.error("[SessionLogStream] Flush error:", err.message);
|
||||
entry.disabled = true;
|
||||
entry.error = err;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -318,9 +334,27 @@ function renderSnapshotContent(entry, { finalize = false } = {}) {
|
||||
: content;
|
||||
}
|
||||
|
||||
function clearSnapshotRetry(entry) {
|
||||
if (!entry?.snapshotRetryTimer) return;
|
||||
clearTimeout(entry.snapshotRetryTimer);
|
||||
entry.snapshotRetryTimer = null;
|
||||
}
|
||||
|
||||
function scheduleSnapshotRetry(entry) {
|
||||
if (!entry || entry.closing || entry.disabled || entry.isRaw || entry.snapshotRetryTimer) return;
|
||||
const delay = entry.snapshotRetryDelay || SNAPSHOT_RETRY_MIN_MS;
|
||||
entry.snapshotRetryTimer = setTimeout(() => {
|
||||
entry.snapshotRetryTimer = null;
|
||||
if (entry.closing || entry.disabled) return;
|
||||
scheduleSnapshot(entry);
|
||||
}, delay);
|
||||
entry.snapshotRetryDelay = Math.min(delay * 2, SNAPSHOT_RETRY_MAX_MS);
|
||||
}
|
||||
|
||||
function scheduleSnapshot(entry) {
|
||||
if (!entry || entry.disabled || entry.isRaw || entry.closing) return;
|
||||
if (!entry.snapshotDirty) return;
|
||||
clearSnapshotRetry(entry);
|
||||
|
||||
if (entry.snapshotPromise) {
|
||||
entry.snapshotRequested = true;
|
||||
@@ -328,18 +362,26 @@ function scheduleSnapshot(entry) {
|
||||
}
|
||||
|
||||
entry.snapshotDirty = false;
|
||||
entry.snapshotError = null;
|
||||
entry.snapshotPromise = fs.promises
|
||||
.writeFile(entry.filePath, renderSnapshotContent(entry), "utf8")
|
||||
.then(() => {
|
||||
entry.snapshotRetryDelay = 0;
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error("[SessionLogStream] Snapshot write failed:", err.message);
|
||||
entry.snapshotDirty = true;
|
||||
entry.snapshotError = err;
|
||||
})
|
||||
.finally(() => {
|
||||
entry.snapshotPromise = null;
|
||||
if ((entry.snapshotRequested || entry.snapshotDirty) && !entry.closing) {
|
||||
entry.snapshotRequested = false;
|
||||
scheduleSnapshot(entry);
|
||||
}
|
||||
// prepareSessionLog must see this failure. Other callers retry on a
|
||||
// delay, or sooner when new output is flushed.
|
||||
if (entry.snapshotError && entry.deferSnapshotRetry) return;
|
||||
if (!(entry.snapshotRequested || entry.snapshotDirty) || entry.closing) return;
|
||||
entry.snapshotRequested = false;
|
||||
if (entry.snapshotError) scheduleSnapshotRetry(entry);
|
||||
else scheduleSnapshot(entry);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -432,6 +474,21 @@ async function stopStream(sessionId, expectedToken) {
|
||||
activeStreams.delete(sessionId);
|
||||
entry.closing = true;
|
||||
|
||||
const record = sessionLogs.get(sessionId);
|
||||
const stopPromise = finalizeStream(sessionId, entry);
|
||||
if (record?.entry === entry) record.stopPromise = stopPromise;
|
||||
const finalPath = await stopPromise;
|
||||
if (record?.entry === entry) {
|
||||
record.entry = null;
|
||||
record.stopPromise = null;
|
||||
record.error = finalPath ? null : (entry.error || new Error("Failed to finalize session log"));
|
||||
}
|
||||
return finalPath;
|
||||
}
|
||||
|
||||
async function finalizeStream(sessionId, entry) {
|
||||
clearSnapshotRetry(entry);
|
||||
|
||||
// Stop periodic flush
|
||||
if (entry.flushTimer) {
|
||||
clearInterval(entry.flushTimer);
|
||||
@@ -451,11 +508,21 @@ async function stopStream(sessionId, expectedToken) {
|
||||
flushBuffer(entry);
|
||||
await waitForSnapshotIdle(entry);
|
||||
|
||||
// Close the raw write stream and wait for it to finish.
|
||||
// Close the raw write stream and wait for it to finish. A flush that
|
||||
// already disabled the entry must still end the stream, or the fd leaks.
|
||||
if (entry.writeStream) {
|
||||
await new Promise((resolve) => {
|
||||
entry.writeStream.end(resolve);
|
||||
});
|
||||
let closeError = null;
|
||||
try {
|
||||
await new Promise((resolve, reject) => {
|
||||
entry.writeStream.end((err) => err ? reject(err) : resolve());
|
||||
});
|
||||
} catch (err) {
|
||||
closeError = err;
|
||||
}
|
||||
if (entry.disabled || closeError) {
|
||||
entry.disabled = true;
|
||||
entry.error = entry.error || closeError || new Error("Session log stream failed");
|
||||
}
|
||||
} else if (!entry.disabled) {
|
||||
try {
|
||||
await fs.promises.writeFile(entry.filePath, renderSnapshotContent(entry, { finalize: true }), "utf8");
|
||||
@@ -463,6 +530,7 @@ async function stopStream(sessionId, expectedToken) {
|
||||
} catch (err) {
|
||||
console.error(`[SessionLogStream] Final snapshot write failed for ${sessionId}:`, err.message);
|
||||
entry.disabled = true;
|
||||
entry.error = err;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -472,6 +540,101 @@ async function stopStream(sessionId, expectedToken) {
|
||||
return entry.disabled ? null : finalPath;
|
||||
}
|
||||
|
||||
/** Flush the session's current log without ending recording, or return its
|
||||
* last finalized log. Paths are never inferred from labels or renderer input.
|
||||
*/
|
||||
async function prepareSessionLog(sessionId) {
|
||||
const record = sessionLogs.get(sessionId);
|
||||
if (!record) return null;
|
||||
|
||||
if (record.stopPromise) {
|
||||
await record.stopPromise;
|
||||
} else if (record.entry) {
|
||||
const entry = record.entry;
|
||||
if (entry.disabled) throw entry.error || new Error("Session log stream failed");
|
||||
flushBuffer(entry);
|
||||
if (entry.disabled) throw entry.error || new Error("Failed to flush session log");
|
||||
if (entry.isRaw) {
|
||||
// An empty write is a barrier for all earlier writes, not just the
|
||||
// manager's buffer. Calling write() alone does not make the file ready.
|
||||
await new Promise((resolve, reject) => {
|
||||
entry.writeStream.write("", (err) => err ? reject(err) : resolve());
|
||||
});
|
||||
} else {
|
||||
// Also materialize an empty txt/html log before its first output arrives.
|
||||
// Hold the retry until this wait returns, then resume on the backoff timer.
|
||||
entry.snapshotDirty = true;
|
||||
entry.deferSnapshotRetry = true;
|
||||
try {
|
||||
scheduleSnapshot(entry);
|
||||
await waitForSnapshotIdle(entry);
|
||||
if (entry.snapshotError) throw entry.snapshotError;
|
||||
} finally {
|
||||
entry.deferSnapshotRetry = false;
|
||||
if (!entry.snapshotPromise && !entry.closing
|
||||
&& (entry.snapshotRequested || entry.snapshotDirty)) {
|
||||
if (entry.snapshotError) scheduleSnapshotRetry(entry);
|
||||
else scheduleSnapshot(entry);
|
||||
}
|
||||
}
|
||||
}
|
||||
// Stop may have begun while the flush was pending; wait for its final file.
|
||||
if (record.stopPromise) await record.stopPromise;
|
||||
}
|
||||
|
||||
if (sessionLogs.get(sessionId) !== record) {
|
||||
throw new Error("Session log changed; try again");
|
||||
}
|
||||
if (record.error || record.entry?.disabled) {
|
||||
throw record.error || record.entry.error || new Error("Session log stream failed");
|
||||
}
|
||||
const stat = await fs.promises.stat(record.filePath);
|
||||
if (!stat.isFile()) throw new Error("Session log is not a file");
|
||||
if (sessionLogs.get(sessionId) !== record) {
|
||||
throw new Error("Session log changed; try again");
|
||||
}
|
||||
return record.filePath;
|
||||
}
|
||||
|
||||
/** Invalidate a previous connection's target without disturbing its writer.
|
||||
* Late finalization cannot restore it because records are identity-scoped.
|
||||
*/
|
||||
function forgetSessionLog(sessionId) {
|
||||
sessionLogs.delete(sessionId);
|
||||
}
|
||||
|
||||
function getSessionLogToken(sessionId) {
|
||||
return sessionLogs.get(sessionId)?.startToken ?? null;
|
||||
}
|
||||
|
||||
/** Drop only the inactive target observed before an asynchronous reconnect.
|
||||
* A continuing manual stream or a newly-created/finalized log must survive.
|
||||
*/
|
||||
function forgetInactiveSessionLog(sessionId, expectedToken) {
|
||||
if (!expectedToken || activeStreams.has(sessionId)
|
||||
|| getSessionLogToken(sessionId) !== expectedToken) return false;
|
||||
return sessionLogs.delete(sessionId);
|
||||
}
|
||||
|
||||
/** Main-process fallback starts do not pass through the worker's lifecycle
|
||||
* guard. Retire their prior inactive log only after a successful same-id start.
|
||||
*/
|
||||
function withSessionLogRestart(handler) {
|
||||
return async (event, payload) => {
|
||||
const sessionId = payload?.sessionId;
|
||||
const previousToken = getSessionLogToken(sessionId);
|
||||
const result = await handler(event, payload);
|
||||
if (sessionId && result?.sessionId === sessionId && result.success !== false && !result.error) {
|
||||
forgetInactiveSessionLog(sessionId, previousToken);
|
||||
}
|
||||
return result;
|
||||
};
|
||||
}
|
||||
|
||||
function hasSessionLog(sessionId) {
|
||||
return sessionLogs.has(sessionId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a session has an active log stream.
|
||||
* @param {string} sessionId
|
||||
@@ -502,6 +665,7 @@ async function cleanupAll() {
|
||||
console.log(`[SessionLogStream] Cleaning up ${activeStreams.size} active streams`);
|
||||
const entries = [...activeStreams.entries()];
|
||||
await Promise.allSettled(entries.map(([id, entry]) => stopStream(id, entry.startToken)));
|
||||
sessionLogs.clear();
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
@@ -511,6 +675,12 @@ module.exports = {
|
||||
registerSudoAutofillInput,
|
||||
registerProgrammaticCommandLogRewrite,
|
||||
stopStream,
|
||||
prepareSessionLog,
|
||||
forgetSessionLog,
|
||||
getSessionLogToken,
|
||||
forgetInactiveSessionLog,
|
||||
withSessionLogRestart,
|
||||
hasSessionLog,
|
||||
hasStream,
|
||||
getActiveLogPaths,
|
||||
cleanupAll,
|
||||
|
||||
@@ -5,6 +5,12 @@ const path = require("node:path");
|
||||
const {
|
||||
appendData,
|
||||
hasStream,
|
||||
hasSessionLog,
|
||||
prepareSessionLog,
|
||||
forgetSessionLog,
|
||||
getSessionLogToken,
|
||||
forgetInactiveSessionLog,
|
||||
withSessionLogRestart,
|
||||
registerProgrammaticCommandLogRewrite,
|
||||
registerSudoAutofillInput,
|
||||
startStream,
|
||||
@@ -929,3 +935,293 @@ for (const format of ["txt", "html"]) {
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
for (const format of ["txt", "html", "raw"]) {
|
||||
test(`prepare ${format} log flushes current output and leaves recording active`, async (t) => {
|
||||
const directory = path.join(TEMP_ROOT, `prepare-${format}-${Date.now()}`);
|
||||
const filePath = path.join(directory, `session.${format}`);
|
||||
const sessionId = `prepare-${format}`;
|
||||
t.after(async () => {
|
||||
await stopStream(sessionId);
|
||||
forgetSessionLog(sessionId);
|
||||
fs.rmSync(directory, { recursive: true, force: true });
|
||||
});
|
||||
startStreamToFile(sessionId, { filePath, format });
|
||||
assert.equal(await prepareSessionLog(sessionId), filePath, "empty logs must exist before opening");
|
||||
assert.equal(fs.statSync(filePath).isFile(), true);
|
||||
appendData(sessionId, "first\r\n");
|
||||
assert.equal(await prepareSessionLog(sessionId), filePath);
|
||||
assert.match(fs.readFileSync(filePath, "utf8"), /first/);
|
||||
assert.equal(hasStream(sessionId), true);
|
||||
appendData(sessionId, "second\r\n");
|
||||
await prepareSessionLog(sessionId);
|
||||
assert.match(fs.readFileSync(filePath, "utf8"), /first[\s\S]*second/);
|
||||
const stopping = stopStream(sessionId);
|
||||
assert.equal(await prepareSessionLog(sessionId), filePath, "prepare waits for finalization");
|
||||
assert.equal(await stopping, filePath);
|
||||
assert.equal(hasStream(sessionId), false);
|
||||
assert.equal(await prepareSessionLog(sessionId), filePath, "stopped logs remain accessible");
|
||||
assert.equal(await prepareSessionLog(`${sessionId}-wrong`), null);
|
||||
fs.unlinkSync(filePath);
|
||||
await assert.rejects(prepareSessionLog(sessionId), /ENOENT/);
|
||||
});
|
||||
}
|
||||
|
||||
test("prepare raw log waits for the write callback, including already-queued writes", async (t) => {
|
||||
const { Writable } = require("node:stream");
|
||||
const directory = path.join(TEMP_ROOT, `prepare-raw-barrier-${Date.now()}`);
|
||||
const filePath = path.join(directory, "session.log");
|
||||
const sessionId = "prepare-raw-barrier";
|
||||
let completedWrites = 0;
|
||||
t.mock.method(fs, "createWriteStream", () => new Writable({
|
||||
write(chunk, _encoding, callback) {
|
||||
setTimeout(() => {
|
||||
fs.appendFileSync(filePath, chunk);
|
||||
completedWrites++;
|
||||
callback();
|
||||
}, 15);
|
||||
},
|
||||
}));
|
||||
t.after(async () => {
|
||||
await stopStream(sessionId);
|
||||
forgetSessionLog(sessionId);
|
||||
fs.rmSync(directory, { recursive: true, force: true });
|
||||
});
|
||||
startStreamToFile(sessionId, { filePath, format: "raw" });
|
||||
appendData(sessionId, "x".repeat(64 * 1024));
|
||||
const preparing = prepareSessionLog(sessionId);
|
||||
assert.equal(completedWrites, 0);
|
||||
assert.equal(await preparing, filePath);
|
||||
assert.equal(completedWrites, 2, "data write and barrier callbacks must both complete");
|
||||
assert.equal(fs.readFileSync(filePath, "utf8").length, 64 * 1024);
|
||||
});
|
||||
|
||||
test("prepare reports snapshot failures promptly and background writes keep retrying", async (t) => {
|
||||
const directory = path.join(TEMP_ROOT, `prepare-error-${Date.now()}`);
|
||||
const filePath = path.join(directory, "session.txt");
|
||||
const sessionId = "prepare-snapshot-error";
|
||||
const writeFile = fs.promises.writeFile.bind(fs.promises);
|
||||
let fail = true;
|
||||
let attempts = 0;
|
||||
t.mock.method(fs.promises, "writeFile", async (...args) => {
|
||||
if (args[0] === filePath) {
|
||||
attempts++;
|
||||
if (fail) throw new Error("disk full");
|
||||
}
|
||||
return writeFile(...args);
|
||||
});
|
||||
t.after(async () => {
|
||||
fail = false;
|
||||
await stopStream(sessionId);
|
||||
forgetSessionLog(sessionId);
|
||||
fs.rmSync(directory, { recursive: true, force: true });
|
||||
});
|
||||
startStreamToFile(sessionId, { filePath, format: "txt" });
|
||||
appendData(sessionId, "not lost\n");
|
||||
await assert.rejects(prepareSessionLog(sessionId), /disk full/);
|
||||
const attemptsAtReport = attempts;
|
||||
assert.ok(attemptsAtReport >= 1);
|
||||
fail = false;
|
||||
const started = Date.now();
|
||||
while (!fs.existsSync(filePath) || !fs.readFileSync(filePath, "utf8").includes("not lost")) {
|
||||
if (Date.now() - started > 2000) {
|
||||
assert.fail(`snapshot retry did not persist the log (attempts=${attempts})`);
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, 10));
|
||||
}
|
||||
assert.ok(attempts > attemptsAtReport, "a failed snapshot write retries without another prepare");
|
||||
assert.equal(await prepareSessionLog(sessionId), filePath);
|
||||
});
|
||||
|
||||
test("persistent snapshot failures back off instead of retrying immediately", async (t) => {
|
||||
const directory = path.join(TEMP_ROOT, `snapshot-backoff-${Date.now()}`);
|
||||
const filePath = path.join(directory, "session.txt");
|
||||
const sessionId = "snapshot-backoff";
|
||||
const writeFile = fs.promises.writeFile.bind(fs.promises);
|
||||
let attempts = 0;
|
||||
t.mock.method(fs.promises, "writeFile", async (...args) => {
|
||||
if (args[0] === filePath) {
|
||||
attempts++;
|
||||
throw new Error("disk full");
|
||||
}
|
||||
return writeFile(...args);
|
||||
});
|
||||
t.after(async () => {
|
||||
await stopStream(sessionId);
|
||||
forgetSessionLog(sessionId);
|
||||
fs.rmSync(directory, { recursive: true, force: true });
|
||||
});
|
||||
startStreamToFile(sessionId, { filePath, format: "txt" });
|
||||
appendData(sessionId, "x".repeat(64 * 1024));
|
||||
await new Promise((resolve) => setTimeout(resolve, 40));
|
||||
assert.equal(attempts, 1, "a failed snapshot must not retry in a tight loop");
|
||||
await new Promise((resolve) => setTimeout(resolve, 400));
|
||||
assert.equal(attempts, 2, "the first retry waits for the backoff delay");
|
||||
});
|
||||
|
||||
test("finalize ends a raw write stream that a synchronous flush already disabled", async (t) => {
|
||||
const directory = path.join(TEMP_ROOT, `finalize-disabled-${Date.now()}`);
|
||||
const filePath = path.join(directory, "session.log");
|
||||
const sessionId = "finalize-disabled-raw";
|
||||
const stream = {
|
||||
ended: false,
|
||||
on() { return this; },
|
||||
write() { throw new Error("flush failed"); },
|
||||
end(callback) {
|
||||
this.ended = true;
|
||||
if (typeof callback === "function") callback();
|
||||
},
|
||||
};
|
||||
t.mock.method(fs, "createWriteStream", () => stream);
|
||||
t.after(async () => {
|
||||
await stopStream(sessionId);
|
||||
forgetSessionLog(sessionId);
|
||||
fs.rmSync(directory, { recursive: true, force: true });
|
||||
});
|
||||
const started = startStreamToFile(sessionId, { filePath, format: "raw" });
|
||||
assert.equal(started.ok, true);
|
||||
appendData(sessionId, "pending");
|
||||
assert.equal(await stopStream(sessionId), null);
|
||||
assert.equal(stream.ended, true);
|
||||
});
|
||||
|
||||
test("prepare reports raw stream errors rather than opening an empty or stale file", async (t) => {
|
||||
const directory = path.join(TEMP_ROOT, `prepare-raw-error-${Date.now()}`);
|
||||
const filePath = path.join(directory, "directory.log");
|
||||
const sessionId = "prepare-raw-error";
|
||||
fs.mkdirSync(filePath, { recursive: true });
|
||||
t.after(async () => {
|
||||
await stopStream(sessionId);
|
||||
forgetSessionLog(sessionId);
|
||||
fs.rmSync(directory, { recursive: true, force: true });
|
||||
});
|
||||
startStreamToFile(sessionId, { filePath, format: "raw" });
|
||||
appendData(sessionId, "data");
|
||||
await assert.rejects(prepareSessionLog(sessionId), /EISDIR/);
|
||||
assert.equal(await stopStream(sessionId), null);
|
||||
await assert.rejects(prepareSessionLog(sessionId), /EISDIR/);
|
||||
});
|
||||
|
||||
test("late finalization and stale tokens cannot replace a reconnected session's target", async (t) => {
|
||||
const directory = path.join(TEMP_ROOT, `prepare-reconnect-${Date.now()}`);
|
||||
const oldPath = path.join(directory, "old.txt");
|
||||
const newPath = path.join(directory, "new.txt");
|
||||
const sessionId = "prepare-reconnect";
|
||||
const writeFile = fs.promises.writeFile.bind(fs.promises);
|
||||
let releaseOldWrite;
|
||||
const oldWriteGate = new Promise((resolve) => { releaseOldWrite = resolve; });
|
||||
t.mock.method(fs.promises, "writeFile", async (...args) => {
|
||||
if (args[0] === oldPath) await oldWriteGate;
|
||||
return writeFile(...args);
|
||||
});
|
||||
t.after(async () => {
|
||||
releaseOldWrite();
|
||||
await stopStream(sessionId);
|
||||
forgetSessionLog(sessionId);
|
||||
fs.rmSync(directory, { recursive: true, force: true });
|
||||
});
|
||||
const first = startStreamToFile(sessionId, { filePath: oldPath, format: "txt" });
|
||||
appendData(sessionId, "old\n");
|
||||
const stopping = stopStream(sessionId, first.token);
|
||||
const stalePrepare = prepareSessionLog(sessionId);
|
||||
const second = startStreamToFile(sessionId, { filePath: newPath, format: "txt" });
|
||||
assert.equal(second.ok, true);
|
||||
appendData(sessionId, "new\n");
|
||||
releaseOldWrite();
|
||||
await assert.rejects(stalePrepare, /changed/);
|
||||
assert.equal(await stopping, oldPath);
|
||||
assert.equal(await stopStream(sessionId, first.token), null);
|
||||
assert.equal(await prepareSessionLog(sessionId), newPath);
|
||||
await stopStream(sessionId, second.token);
|
||||
assert.equal(await prepareSessionLog(sessionId), newPath);
|
||||
});
|
||||
|
||||
test("forgetting a session prevents late finalization from restoring its old path", async (t) => {
|
||||
const directory = path.join(TEMP_ROOT, `prepare-forget-${Date.now()}`);
|
||||
const filePath = path.join(directory, "old.txt");
|
||||
const sessionId = "prepare-forget";
|
||||
t.after(() => fs.rmSync(directory, { recursive: true, force: true }));
|
||||
startStreamToFile(sessionId, { filePath, format: "txt" });
|
||||
appendData(sessionId, "old\n");
|
||||
const stopping = stopStream(sessionId);
|
||||
forgetSessionLog(sessionId);
|
||||
await stopping;
|
||||
assert.equal(hasSessionLog(sessionId), false);
|
||||
assert.equal(await prepareSessionLog(sessionId), null);
|
||||
});
|
||||
|
||||
test("conditional log invalidation clears only the observed inactive stream token", async (t) => {
|
||||
const directory = path.join(TEMP_ROOT, `conditional-forget-${Date.now()}`);
|
||||
const sessionId = "conditional-forget";
|
||||
const oldPath = path.join(directory, "old.txt");
|
||||
const newPath = path.join(directory, "new.txt");
|
||||
t.after(async () => {
|
||||
await stopStream(sessionId);
|
||||
forgetSessionLog(sessionId);
|
||||
fs.rmSync(directory, { recursive: true, force: true });
|
||||
});
|
||||
assert.equal(getSessionLogToken(sessionId), null);
|
||||
assert.equal(forgetInactiveSessionLog(sessionId, null), false);
|
||||
const first = startStreamToFile(sessionId, { filePath: oldPath, format: "txt" });
|
||||
assert.equal(getSessionLogToken(sessionId), first.token);
|
||||
assert.equal(forgetInactiveSessionLog(sessionId, first.token), false, "active recording survives");
|
||||
await stopStream(sessionId);
|
||||
assert.equal(getSessionLogToken(sessionId), first.token, "finalization preserves record identity");
|
||||
assert.equal(forgetInactiveSessionLog(sessionId, Symbol("wrong")), false);
|
||||
assert.equal(forgetInactiveSessionLog(sessionId, first.token), true);
|
||||
assert.equal(await prepareSessionLog(sessionId), null);
|
||||
const second = startStreamToFile(sessionId, { filePath: newPath, format: "txt" });
|
||||
assert.notEqual(second.token, first.token);
|
||||
assert.equal(forgetInactiveSessionLog(sessionId, first.token), false);
|
||||
await stopStream(sessionId);
|
||||
assert.equal(forgetInactiveSessionLog(sessionId, first.token), false, "a new finalized log survives");
|
||||
assert.equal(await prepareSessionLog(sessionId), newPath);
|
||||
});
|
||||
|
||||
for (const outcome of ["success", "throw", "failure", "different-session", "new-active", "new-finalized", "manual"]) {
|
||||
test(`fallback restart wrapper preserves the correct saved log (${outcome})`, async (t) => {
|
||||
const directory = path.join(TEMP_ROOT, `fallback-${outcome}-${Date.now()}`);
|
||||
const sessionId = `fallback-${outcome}`;
|
||||
const oldPath = path.join(directory, "old.txt");
|
||||
const newPath = path.join(directory, "new.txt");
|
||||
const event = { sender: { id: 9 } };
|
||||
const payload = { sessionId, sessionLog: { enabled: false } };
|
||||
t.after(async () => {
|
||||
await stopStream(sessionId, getSessionLogToken(sessionId));
|
||||
forgetSessionLog(sessionId);
|
||||
fs.rmSync(directory, { recursive: true, force: true });
|
||||
});
|
||||
startStreamToFile(sessionId, { filePath: oldPath, format: "txt", stopRequiresToken: outcome === "manual" });
|
||||
appendData(sessionId, "previous output\n");
|
||||
if (outcome !== "manual") await stopStream(sessionId);
|
||||
const previousToken = getSessionLogToken(sessionId);
|
||||
const start = withSessionLogRestart(async (receivedEvent, receivedPayload) => {
|
||||
assert.equal(receivedEvent, event);
|
||||
assert.equal(receivedPayload, payload);
|
||||
assert.equal(await prepareSessionLog(sessionId), oldPath, "pending starts retain the old target");
|
||||
if (outcome === "throw") throw new Error("authentication failed");
|
||||
if (outcome === "failure") return { sessionId, success: false, error: "connection refused" };
|
||||
if (outcome === "different-session") return { sessionId: "different" };
|
||||
if (outcome.startsWith("new-")) {
|
||||
startStreamToFile(sessionId, { filePath: newPath, format: "txt" });
|
||||
appendData(sessionId, "new output\n");
|
||||
if (outcome === "new-finalized") await stopStream(sessionId);
|
||||
}
|
||||
return { sessionId };
|
||||
});
|
||||
if (outcome === "throw") await assert.rejects(start(event, payload), /authentication failed/);
|
||||
else {
|
||||
const result = await start(event, payload);
|
||||
assert.equal(result.sessionId, outcome === "different-session" ? "different" : sessionId);
|
||||
}
|
||||
if (outcome === "success") {
|
||||
assert.equal(await prepareSessionLog(sessionId), null);
|
||||
} else {
|
||||
const newLog = outcome.startsWith("new-");
|
||||
assert.equal(await prepareSessionLog(sessionId), newLog ? newPath : oldPath);
|
||||
if (newLog) assert.notEqual(getSessionLogToken(sessionId), previousToken);
|
||||
else assert.equal(getSessionLogToken(sessionId), previousToken);
|
||||
assert.equal(hasStream(sessionId), outcome === "manual" || outcome === "new-active");
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -6,7 +6,7 @@
|
||||
const crypto = require("node:crypto");
|
||||
const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
const { dialog } = require("electron");
|
||||
const { dialog, shell } = require("electron");
|
||||
const {
|
||||
terminalDataToHtmlContent,
|
||||
terminalDataToPlainText,
|
||||
@@ -782,13 +782,83 @@ async function stopManualSessionLog(event, payload = {}) {
|
||||
}
|
||||
}
|
||||
|
||||
async function getManualSessionLogStatus(event, payload = {}) {
|
||||
const sessionLogStreamManager = require("./sessionLogStreamManager.cjs");
|
||||
async function getManualSessionLogStatus(event, payload = {}, terminalWorkerManager = null) {
|
||||
const manager = require("./sessionLogStreamManager.cjs");
|
||||
const { sessionId } = payload;
|
||||
if (!sessionId) {
|
||||
return { success: false, isLogging: false, error: "Missing sessionId" };
|
||||
if (!validSessionLogId(sessionId)) {
|
||||
return { success: false, isLogging: false, hasSessionLog: false, error: "Missing sessionId" };
|
||||
}
|
||||
// Keep the recording toggle main-owned. Automatic logs may live in the
|
||||
// worker, and stopped logs remain available independently of active streams.
|
||||
const isLogging = manager.hasStream(sessionId);
|
||||
let hasSessionLog = manager.hasSessionLog(sessionId);
|
||||
if (!hasSessionLog && isTerminalWorkerRunning(terminalWorkerManager)) {
|
||||
try {
|
||||
const result = await terminalWorkerManager.request(
|
||||
"netcatty:sessionLog:statusLocal", { sessionId }, {},
|
||||
);
|
||||
hasSessionLog = result?.success === true && result.hasSessionLog === true;
|
||||
} catch {
|
||||
// An unavailable worker must not manufacture an openable log target.
|
||||
}
|
||||
}
|
||||
return { success: true, isLogging, hasSessionLog };
|
||||
}
|
||||
|
||||
function validSessionLogId(sessionId) {
|
||||
return typeof sessionId === "string" && sessionId.trim().length > 0;
|
||||
}
|
||||
|
||||
async function prepareLocalSessionLog(_event, payload = {}) {
|
||||
if (!validSessionLogId(payload?.sessionId)) {
|
||||
return { success: false, error: "Missing sessionId" };
|
||||
}
|
||||
try {
|
||||
const filePath = await require("./sessionLogStreamManager.cjs").prepareSessionLog(payload.sessionId);
|
||||
return filePath
|
||||
? { success: true, filePath }
|
||||
: { success: false, error: "No saved log is available for this session" };
|
||||
} catch (err) {
|
||||
return { success: false, error: err?.message || String(err) };
|
||||
}
|
||||
}
|
||||
|
||||
/** Resolve only a main/worker-owned session target. Renderer paths, host labels
|
||||
* and configured directories are deliberately not part of this operation.
|
||||
*/
|
||||
async function prepareSessionLog(event, payload = {}, terminalWorkerManager = null) {
|
||||
const sessionId = payload?.sessionId;
|
||||
if (!validSessionLogId(sessionId)) {
|
||||
return { success: false, error: "Missing sessionId" };
|
||||
}
|
||||
const manager = require("./sessionLogStreamManager.cjs");
|
||||
if (manager.hasSessionLog(sessionId)) {
|
||||
return prepareLocalSessionLog(event, { sessionId });
|
||||
}
|
||||
if (!isTerminalWorkerRunning(terminalWorkerManager)) {
|
||||
return { success: false, error: "No saved log is available for this session" };
|
||||
}
|
||||
try {
|
||||
const result = await terminalWorkerManager.request(
|
||||
"netcatty:sessionLog:prepareLocal", { sessionId }, {},
|
||||
);
|
||||
return result?.success && typeof result.filePath === "string" && result.filePath
|
||||
? { success: true, filePath: result.filePath }
|
||||
: { success: false, error: result?.error || "No saved log is available for this session" };
|
||||
} catch (err) {
|
||||
return { success: false, error: err?.message || String(err) };
|
||||
}
|
||||
}
|
||||
|
||||
async function revealSessionLog(event, payload = {}, terminalWorkerManager = null) {
|
||||
const result = await prepareSessionLog(event, payload, terminalWorkerManager);
|
||||
if (!result.success) return result;
|
||||
try {
|
||||
shell.showItemInFolder(result.filePath);
|
||||
return result;
|
||||
} catch (err) {
|
||||
return { success: false, error: err?.message || String(err) };
|
||||
}
|
||||
return { success: true, isLogging: sessionLogStreamManager.hasStream(sessionId) };
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -797,6 +867,11 @@ async function getManualSessionLogStatus(event, payload = {}) {
|
||||
*/
|
||||
function registerWorkerHandlers(ipcMain) {
|
||||
ipcMain.handle("netcatty:sessionLogs:getActivePaths", async () => getLocalActiveLogPaths());
|
||||
ipcMain.handle("netcatty:sessionLog:prepareLocal", prepareLocalSessionLog);
|
||||
ipcMain.handle("netcatty:sessionLog:statusLocal", async (_event, payload = {}) => ({
|
||||
success: true,
|
||||
hasSessionLog: require("./sessionLogStreamManager.cjs").hasSessionLog(payload.sessionId),
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -818,7 +893,34 @@ function registerHandlers(ipcMain, options = {}) {
|
||||
ipcMain.handle("netcatty:sessionLog:manualChoosePath", chooseManualSessionLogPath);
|
||||
ipcMain.handle("netcatty:sessionLog:manualStart", startManualSessionLog);
|
||||
ipcMain.handle("netcatty:sessionLog:manualStop", stopManualSessionLog);
|
||||
ipcMain.handle("netcatty:sessionLog:manualStatus", getManualSessionLogStatus);
|
||||
ipcMain.handle("netcatty:sessionLog:manualStatus", (event, payload) => (
|
||||
getManualSessionLogStatus(event, payload, terminalWorkerManager)
|
||||
));
|
||||
ipcMain.handle("netcatty:sessionLog:prepare", (event, payload) => (
|
||||
prepareSessionLog(event, payload, terminalWorkerManager)
|
||||
));
|
||||
ipcMain.handle("netcatty:sessionLog:reveal", (event, payload) => (
|
||||
revealSessionLog(event, payload, terminalWorkerManager)
|
||||
));
|
||||
|
||||
// Disconnected tabs may still open their last manual log. Once that session
|
||||
// reconnects, it must not mask the new worker-owned automatic log instead.
|
||||
const disconnectedSessions = new Set();
|
||||
terminalWorkerManager?.onSessionClosed?.(({ sessionId, explicit }) => {
|
||||
if (explicit) {
|
||||
disconnectedSessions.delete(sessionId);
|
||||
const manager = require("./sessionLogStreamManager.cjs");
|
||||
if (!manager.hasStream(sessionId)) manager.forgetSessionLog(sessionId);
|
||||
} else {
|
||||
disconnectedSessions.add(sessionId);
|
||||
}
|
||||
});
|
||||
terminalWorkerManager?.onSessionOwned?.(({ sessionId }) => {
|
||||
if (!disconnectedSessions.delete(sessionId)) return;
|
||||
const manager = require("./sessionLogStreamManager.cjs");
|
||||
// Continuous manual recording intentionally survives terminal reconnects.
|
||||
if (!manager.hasStream(sessionId)) manager.forgetSessionLog(sessionId);
|
||||
});
|
||||
|
||||
// In the default terminal-worker runtime, sessions run in a utilityProcess
|
||||
// and call appendData() on the worker's own sessionLogStreamManager module
|
||||
@@ -850,6 +952,9 @@ module.exports = {
|
||||
startManualSessionLog,
|
||||
stopManualSessionLog,
|
||||
getManualSessionLogStatus,
|
||||
prepareLocalSessionLog,
|
||||
prepareSessionLog,
|
||||
revealSessionLog,
|
||||
toLocalISOString,
|
||||
terminalDataToHtml,
|
||||
terminalPlainTextToHtml,
|
||||
|
||||
@@ -15,11 +15,11 @@ async function waitForPath(targetPath, { timeoutMs = 2000 } = {}) {
|
||||
throw new Error(`Timed out waiting for path: ${targetPath}`);
|
||||
}
|
||||
|
||||
function loadBridgeWithDialog(dialogMock) {
|
||||
function loadBridgeWithDialog(dialogMock, shellMock = {}) {
|
||||
const originalLoad = Module._load;
|
||||
Module._load = function patchedLoad(request, parent, isMain) {
|
||||
if (request === "electron") {
|
||||
return { dialog: dialogMock };
|
||||
return { dialog: dialogMock, shell: shellMock };
|
||||
}
|
||||
return originalLoad.call(this, request, parent, isMain);
|
||||
};
|
||||
@@ -1001,3 +1001,263 @@ test("screen export escapes HTML when the user selects an HTML filename", async
|
||||
fs.rmSync(directory, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
function registerSessionLogTestHandlers(bridge, terminalWorkerManager) {
|
||||
const handlers = new Map();
|
||||
bridge.registerHandlers({ handle: (channel, handler) => handlers.set(channel, handler) }, { terminalWorkerManager });
|
||||
return handlers;
|
||||
}
|
||||
|
||||
test("prepare and reveal use the current manual log before querying automatic worker logs", async (t) => {
|
||||
const directory = path.join(TEMP_ROOT, `shortcut-manual-${Date.now()}`);
|
||||
const filePath = path.join(directory, "manual.txt");
|
||||
const sessionId = "shortcut-manual";
|
||||
const revealed = [];
|
||||
const bridge = loadBridgeWithDialog(
|
||||
{ showSaveDialog: async () => ({ canceled: false, filePath }) },
|
||||
{ showItemInFolder: (target) => revealed.push(target) },
|
||||
);
|
||||
const manager = require("./sessionLogStreamManager.cjs");
|
||||
const workerRequests = [];
|
||||
const handlers = registerSessionLogTestHandlers(bridge, {
|
||||
isRunning: () => true,
|
||||
request: async (...args) => { workerRequests.push(args); return { success: true, filePath: "/worker/log.txt" }; },
|
||||
});
|
||||
t.after(async () => {
|
||||
await manager.cleanupAll();
|
||||
fs.rmSync(directory, { recursive: true, force: true });
|
||||
});
|
||||
await bridge.startManualSessionLog(null, { sessionId, format: "txt" });
|
||||
manager.appendData(sessionId, "fresh output\n");
|
||||
const prepare = handlers.get("netcatty:sessionLog:prepare");
|
||||
const reveal = handlers.get("netcatty:sessionLog:reveal");
|
||||
assert.deepEqual(await prepare(null, { sessionId, filePath: "/untrusted/path" }), { success: true, filePath });
|
||||
assert.match(fs.readFileSync(filePath, "utf8"), /fresh output/);
|
||||
assert.equal(manager.hasStream(sessionId), true);
|
||||
assert.deepEqual(await reveal(null, { sessionId, filePath: "/untrusted/path" }), { success: true, filePath });
|
||||
assert.deepEqual(revealed, [filePath]);
|
||||
await bridge.stopManualSessionLog(null, { sessionId });
|
||||
assert.deepEqual(await prepare(null, { sessionId }), { success: true, filePath });
|
||||
assert.deepEqual(workerRequests, []);
|
||||
});
|
||||
|
||||
test("prepare routes automatic logs only to an already running worker with sessionId only", async () => {
|
||||
const bridge = loadBridgeWithDialog({});
|
||||
const requests = [];
|
||||
const handlers = registerSessionLogTestHandlers(bridge, {
|
||||
isRunning: () => true,
|
||||
request: async (...args) => {
|
||||
requests.push(args);
|
||||
return { success: true, filePath: "/server-owned/automatic.html" };
|
||||
},
|
||||
});
|
||||
const result = await handlers.get("netcatty:sessionLog:prepare")(null, {
|
||||
sessionId: "shortcut-auto", filePath: "/arbitrary/renderer/path", directory: "/untrusted",
|
||||
});
|
||||
assert.deepEqual(result, { success: true, filePath: "/server-owned/automatic.html" });
|
||||
assert.deepEqual(requests, [["netcatty:sessionLog:prepareLocal", { sessionId: "shortcut-auto" }, {}]]);
|
||||
assert.equal(handlers.has("netcatty:sessionLog:prepareLocal"), false, "internal lookup is not renderer IPC");
|
||||
});
|
||||
|
||||
test("prepare rejects malformed ids and never cold-starts an idle worker", async () => {
|
||||
const bridge = loadBridgeWithDialog({});
|
||||
let requests = 0;
|
||||
const worker = { isRunning: () => false, request: async () => { requests++; } };
|
||||
for (const payload of [undefined, null, {}, { sessionId: "" }, { sessionId: " " }, { sessionId: {} }]) {
|
||||
assert.deepEqual(await bridge.prepareSessionLog(null, payload, worker), { success: false, error: "Missing sessionId" });
|
||||
}
|
||||
assert.deepEqual(await bridge.prepareSessionLog(null, { sessionId: "unknown", filePath: __filename }, worker), {
|
||||
success: false, error: "No saved log is available for this session",
|
||||
});
|
||||
assert.equal(requests, 0);
|
||||
});
|
||||
|
||||
test("prepare and reveal surface missing logs, worker errors, and shell failures", async () => {
|
||||
const bridge = loadBridgeWithDialog({}, { showItemInFolder: () => { throw new Error("file manager unavailable"); } });
|
||||
const payload = { sessionId: "shortcut-error" };
|
||||
const worker = {
|
||||
isRunning: () => true,
|
||||
request: async () => { throw new Error("worker unavailable"); },
|
||||
};
|
||||
assert.deepEqual(await bridge.prepareSessionLog(null, payload, worker), { success: false, error: "worker unavailable" });
|
||||
worker.request = async () => ({ success: false, error: "No saved log is available for this session" });
|
||||
assert.deepEqual(await bridge.revealSessionLog(null, payload, worker), { success: false, error: "No saved log is available for this session" });
|
||||
worker.request = async () => ({ success: true });
|
||||
assert.equal((await bridge.prepareSessionLog(null, payload, worker)).success, false);
|
||||
worker.request = async () => ({ success: true, filePath: "/server-owned/log.txt" });
|
||||
assert.deepEqual(await bridge.revealSessionLog(null, payload, worker), { success: false, error: "file manager unavailable" });
|
||||
});
|
||||
|
||||
test("worker prepare handler flushes and retains only the requested session's log", async (t) => {
|
||||
const directory = path.join(TEMP_ROOT, `shortcut-worker-${Date.now()}`);
|
||||
const filePath = path.join(directory, "worker.txt");
|
||||
const sessionId = "shortcut-worker";
|
||||
const bridge = loadBridgeWithDialog({});
|
||||
const manager = require("./sessionLogStreamManager.cjs");
|
||||
const handlers = new Map();
|
||||
bridge.registerWorkerHandlers({ handle: (channel, handler) => handlers.set(channel, handler) });
|
||||
const prepare = handlers.get("netcatty:sessionLog:prepareLocal");
|
||||
t.after(async () => {
|
||||
await manager.cleanupAll();
|
||||
fs.rmSync(directory, { recursive: true, force: true });
|
||||
});
|
||||
manager.startStreamToFile(sessionId, { filePath, format: "txt" });
|
||||
manager.appendData(sessionId, "worker output\n");
|
||||
assert.deepEqual(await prepare(null, { sessionId }), { success: true, filePath });
|
||||
assert.match(fs.readFileSync(filePath, "utf8"), /worker output/);
|
||||
assert.equal(manager.hasStream(sessionId), true);
|
||||
assert.equal((await prepare(null, { sessionId: "wrong-session", filePath })).success, false);
|
||||
await manager.stopStream(sessionId);
|
||||
assert.deepEqual(await prepare(null, { sessionId }), { success: true, filePath });
|
||||
});
|
||||
|
||||
test("a missing manual log reports an error instead of silently opening another worker log", async (t) => {
|
||||
const directory = path.join(TEMP_ROOT, `shortcut-missing-${Date.now()}`);
|
||||
const filePath = path.join(directory, "missing.txt");
|
||||
const sessionId = "shortcut-missing";
|
||||
const bridge = loadBridgeWithDialog({});
|
||||
const manager = require("./sessionLogStreamManager.cjs");
|
||||
t.after(async () => {
|
||||
await manager.cleanupAll();
|
||||
fs.rmSync(directory, { recursive: true, force: true });
|
||||
});
|
||||
manager.startStreamToFile(sessionId, { filePath, format: "txt" });
|
||||
await manager.stopStream(sessionId);
|
||||
fs.unlinkSync(filePath);
|
||||
const result = await bridge.prepareSessionLog(null, { sessionId }, {
|
||||
isRunning: () => true,
|
||||
request: async () => assert.fail("must not fall back to an unrelated automatic log"),
|
||||
});
|
||||
assert.equal(result.success, false);
|
||||
assert.match(result.error, /ENOENT/);
|
||||
});
|
||||
|
||||
test("disconnect retains a manual log but reconnect clears its precedence over a new automatic log", async (t) => {
|
||||
const directory = path.join(TEMP_ROOT, `shortcut-reconnect-${Date.now()}`);
|
||||
const filePath = path.join(directory, "old-manual.txt");
|
||||
const sessionId = "shortcut-reconnect";
|
||||
const bridge = loadBridgeWithDialog({});
|
||||
const manager = require("./sessionLogStreamManager.cjs");
|
||||
let onClosed;
|
||||
let onOwned;
|
||||
const handlers = registerSessionLogTestHandlers(bridge, {
|
||||
isRunning: () => true,
|
||||
onSessionClosed: (listener) => { onClosed = listener; },
|
||||
onSessionOwned: (listener) => { onOwned = listener; },
|
||||
request: async () => ({ success: true, filePath: "/worker/new-connection.txt" }),
|
||||
});
|
||||
t.after(async () => {
|
||||
await manager.cleanupAll();
|
||||
fs.rmSync(directory, { recursive: true, force: true });
|
||||
});
|
||||
manager.startStreamToFile(sessionId, { filePath, format: "txt" });
|
||||
await manager.stopStream(sessionId);
|
||||
const prepare = handlers.get("netcatty:sessionLog:prepare");
|
||||
onClosed({ sessionId, reason: "exited" });
|
||||
assert.deepEqual(await prepare(null, { sessionId }), { success: true, filePath });
|
||||
onOwned({ sessionId, webContentsId: 2 });
|
||||
assert.deepEqual(await prepare(null, { sessionId }), { success: true, filePath: "/worker/new-connection.txt" });
|
||||
});
|
||||
|
||||
test("a live manual recording keeps its target across disconnect and reconnect", async (t) => {
|
||||
const directory = path.join(TEMP_ROOT, `shortcut-live-reconnect-${Date.now()}`);
|
||||
const filePath = path.join(directory, "manual.txt");
|
||||
const sessionId = "shortcut-live-reconnect";
|
||||
const bridge = loadBridgeWithDialog({});
|
||||
const manager = require("./sessionLogStreamManager.cjs");
|
||||
let onClosed;
|
||||
let onOwned;
|
||||
const handlers = registerSessionLogTestHandlers(bridge, {
|
||||
isRunning: () => true,
|
||||
onSessionClosed: (listener) => { onClosed = listener; },
|
||||
onSessionOwned: (listener) => { onOwned = listener; },
|
||||
request: async () => assert.fail("live manual recording must retain precedence"),
|
||||
});
|
||||
t.after(async () => {
|
||||
await manager.cleanupAll();
|
||||
fs.rmSync(directory, { recursive: true, force: true });
|
||||
});
|
||||
manager.startStreamToFile(sessionId, { filePath, format: "txt" });
|
||||
manager.appendData(sessionId, "before\n");
|
||||
onClosed({ sessionId, reason: "exited" });
|
||||
onOwned({ sessionId, webContentsId: 2 });
|
||||
manager.appendData(sessionId, "after\n");
|
||||
const prepare = handlers.get("netcatty:sessionLog:prepare");
|
||||
assert.deepEqual(await prepare(null, { sessionId }), { success: true, filePath });
|
||||
assert.match(fs.readFileSync(filePath, "utf8"), /before[\s\S]*after/);
|
||||
onClosed({ sessionId, explicit: true });
|
||||
assert.deepEqual(await prepare(null, { sessionId }), { success: true, filePath });
|
||||
});
|
||||
|
||||
test("log menu availability follows the owning stream, not automatic logging settings", async () => {
|
||||
const bridge = loadBridgeWithDialog({});
|
||||
const workerHandlers = new Map();
|
||||
bridge.registerWorkerHandlers({ handle: (channel, handler) => workerHandlers.set(channel, handler) });
|
||||
const manager = require("./sessionLogStreamManager.cjs");
|
||||
const workerLogs = new Set();
|
||||
const handlers = registerSessionLogTestHandlers(bridge, {
|
||||
isRunning: () => true,
|
||||
request: async (channel, payload) => {
|
||||
assert.equal(channel, "netcatty:sessionLog:statusLocal");
|
||||
return { success: true, hasSessionLog: workerLogs.has(payload.sessionId) };
|
||||
},
|
||||
});
|
||||
const status = handlers.get("netcatty:sessionLog:manualStatus");
|
||||
for (const sessionId of ["enabled-after-connect", "failed-before-shell"]) {
|
||||
assert.equal((await status(null, { sessionId, enabled: true })).hasSessionLog, false);
|
||||
}
|
||||
workerLogs.add("recording-after-disabled");
|
||||
assert.deepEqual(await status(null, { sessionId: "recording-after-disabled", enabled: false }), {
|
||||
success: true, isLogging: false, hasSessionLog: true,
|
||||
});
|
||||
assert.equal((await status(null, { sessionId: "another-session" })).hasSessionLog, false);
|
||||
// A successful replacement without logging retires the worker-owned target.
|
||||
workerLogs.delete("recording-after-disabled");
|
||||
assert.equal((await status(null, { sessionId: "recording-after-disabled" })).hasSessionLog, false);
|
||||
assert.equal(handlers.has("netcatty:sessionLog:statusLocal"), false);
|
||||
assert.deepEqual(await workerHandlers.get("netcatty:sessionLog:statusLocal")(null, { sessionId: "missing" }), {
|
||||
success: true, hasSessionLog: false,
|
||||
});
|
||||
assert.equal(manager.hasSessionLog("missing"), false);
|
||||
});
|
||||
|
||||
test("log availability retains stopped main logs and never starts a worker to check", async (t) => {
|
||||
const directory = path.join(TEMP_ROOT, `availability-${Date.now()}`);
|
||||
const filePath = path.join(directory, "manual.txt");
|
||||
const sessionId = "availability-main";
|
||||
const bridge = loadBridgeWithDialog({ showSaveDialog: async () => ({ canceled: false, filePath }) });
|
||||
const manager = require("./sessionLogStreamManager.cjs");
|
||||
const handlers = registerSessionLogTestHandlers(bridge, {
|
||||
isRunning: () => false,
|
||||
request: () => assert.fail("must not cold-start a worker"),
|
||||
});
|
||||
t.after(async () => {
|
||||
await manager.cleanupAll();
|
||||
manager.forgetSessionLog(sessionId);
|
||||
fs.rmSync(directory, { recursive: true, force: true });
|
||||
});
|
||||
const status = handlers.get("netcatty:sessionLog:manualStatus");
|
||||
assert.equal((await status(null, { sessionId })).hasSessionLog, false);
|
||||
assert.equal((await bridge.startManualSessionLog(null, { sessionId, format: "txt" })).started, true);
|
||||
assert.deepEqual(await status(null, { sessionId }), { success: true, isLogging: true, hasSessionLog: true });
|
||||
await bridge.stopManualSessionLog(null, { sessionId });
|
||||
assert.deepEqual(await status(null, { sessionId }), { success: true, isLogging: false, hasSessionLog: true });
|
||||
const workerHandlers = new Map();
|
||||
bridge.registerWorkerHandlers({ handle: (channel, handler) => workerHandlers.set(channel, handler) });
|
||||
assert.deepEqual(await workerHandlers.get("netcatty:sessionLog:statusLocal")(null, { sessionId }), {
|
||||
success: true, hasSessionLog: true,
|
||||
}, "worker lookup also retains stopped logs without requiring an active stream");
|
||||
});
|
||||
|
||||
test("an unavailable worker does not offer a nonexistent log or change manual recording status", async () => {
|
||||
const bridge = loadBridgeWithDialog({});
|
||||
for (const request of [
|
||||
async () => { throw new Error("worker stopped"); },
|
||||
async () => ({ success: false, error: "session missing" }),
|
||||
]) {
|
||||
const handlers = registerSessionLogTestHandlers(bridge, { isRunning: () => true, request });
|
||||
assert.deepEqual(await handlers.get("netcatty:sessionLog:manualStatus")(null, { sessionId: "unavailable" }), {
|
||||
success: true, isLogging: false, hasSessionLog: false,
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -1621,7 +1621,9 @@ function registerHandlers(ipcMain, options = {}) {
|
||||
});
|
||||
});
|
||||
} else {
|
||||
ipcMain.handle("netcatty:start", startSSHSessionWrapper);
|
||||
ipcMain.handle("netcatty:start", options.manageSessionLogRestart === true
|
||||
? sessionLogStreamManager.withSessionLogRestart(startSSHSessionWrapper)
|
||||
: startSSHSessionWrapper);
|
||||
ipcMain.handle("netcatty:ssh:exec", execCommand);
|
||||
ipcMain.handle("netcatty:ssh:pwd", getSessionPwd);
|
||||
ipcMain.handle("netcatty:ssh:remoteInfo", getSessionRemoteInfo);
|
||||
|
||||
@@ -2630,11 +2630,14 @@ function registerHandlers(ipcMain, options = {}) {
|
||||
});
|
||||
return;
|
||||
}
|
||||
ipcMain.handle("netcatty:local:start", startLocalSession);
|
||||
ipcMain.handle("netcatty:telnet:start", startTelnetSession);
|
||||
ipcMain.handle("netcatty:mosh:start", startMoshSession);
|
||||
ipcMain.handle("netcatty:et:start", startEtSession);
|
||||
ipcMain.handle("netcatty:serial:start", startSerialSession);
|
||||
const wrapSessionStart = options.manageSessionLogRestart === true
|
||||
? sessionLogStreamManager.withSessionLogRestart
|
||||
: (handler) => handler;
|
||||
ipcMain.handle("netcatty:local:start", wrapSessionStart(startLocalSession));
|
||||
ipcMain.handle("netcatty:telnet:start", wrapSessionStart(startTelnetSession));
|
||||
ipcMain.handle("netcatty:mosh:start", wrapSessionStart(startMoshSession));
|
||||
ipcMain.handle("netcatty:et:start", wrapSessionStart(startEtSession));
|
||||
ipcMain.handle("netcatty:serial:start", wrapSessionStart(startSerialSession));
|
||||
ipcMain.handle("netcatty:serial:list", listSerialPorts);
|
||||
ipcMain.handle("netcatty:serial:ymodem-send", sendSerialYmodem);
|
||||
ipcMain.handle("netcatty:serial:ymodem-receive", receiveSerialYmodem);
|
||||
|
||||
@@ -360,3 +360,37 @@ test("terminal worker mode proxies system management requests", async () => {
|
||||
],
|
||||
);
|
||||
});
|
||||
|
||||
test("fallback log restart guard wraps all six starts only when main explicitly enables it", (t) => {
|
||||
const manager = require("./sessionLogStreamManager.cjs");
|
||||
const wrappers = new Map();
|
||||
t.mock.method(manager, "withSessionLogRestart", (handler) => {
|
||||
const wrapped = () => {};
|
||||
wrappers.set(handler, wrapped);
|
||||
return wrapped;
|
||||
});
|
||||
const workerHandlers = createFakeIpcMain();
|
||||
sshBridge.registerHandlers(workerHandlers);
|
||||
terminalBridge.registerHandlers(workerHandlers);
|
||||
assert.equal(wrappers.size, 0, "utility-process registration keeps the runtime generation guard");
|
||||
|
||||
const fallbackHandlers = createFakeIpcMain();
|
||||
sshBridge.registerHandlers(fallbackHandlers, { manageSessionLogRestart: true });
|
||||
terminalBridge.registerHandlers(fallbackHandlers, { manageSessionLogRestart: true });
|
||||
const startChannels = [
|
||||
"netcatty:start", "netcatty:local:start", "netcatty:telnet:start",
|
||||
"netcatty:mosh:start", "netcatty:et:start", "netcatty:serial:start",
|
||||
];
|
||||
assert.equal(wrappers.size, startChannels.length);
|
||||
for (const channel of startChannels) {
|
||||
assert.equal(fallbackHandlers.handlers.get(channel), wrappers.get(workerHandlers.handlers.get(channel)), channel);
|
||||
}
|
||||
assert.equal(fallbackHandlers.handlers.get("netcatty:serial:list"), workerHandlers.handlers.get("netcatty:serial:list"));
|
||||
|
||||
wrappers.clear();
|
||||
const proxyHandlers = createFakeIpcMain();
|
||||
const terminalWorkerManager = createFakeWorkerManager();
|
||||
sshBridge.registerHandlers(proxyHandlers, { terminalWorkerManager, manageSessionLogRestart: true });
|
||||
terminalBridge.registerHandlers(proxyHandlers, { terminalWorkerManager, manageSessionLogRestart: true });
|
||||
assert.equal(wrappers.size, 0, "worker proxies must not also apply the main fallback guard");
|
||||
});
|
||||
@@ -460,7 +460,10 @@ function createBridgeRegistrar(context) {
|
||||
tempDirBridge.ensureTempDir();
|
||||
|
||||
// Register all IPC handlers
|
||||
sshBridge.registerHandlers(ipcMain, { terminalWorkerManager });
|
||||
sshBridge.registerHandlers(ipcMain, {
|
||||
terminalWorkerManager,
|
||||
manageSessionLogRestart: !terminalWorkerManager,
|
||||
});
|
||||
const stopMonitoring = () => terminalWorkerManager
|
||||
? terminalWorkerManager.request("netcatty:monitor:stop-all", {}, {}).catch(() => {})
|
||||
: sshBridge.stopAllMonitoring();
|
||||
@@ -494,7 +497,10 @@ function createBridgeRegistrar(context) {
|
||||
});
|
||||
app.once?.("will-quit", () => hostBrowserManager.closeAll());
|
||||
}
|
||||
terminalBridge.registerHandlers(ipcMain, { terminalWorkerManager });
|
||||
terminalBridge.registerHandlers(ipcMain, {
|
||||
terminalWorkerManager,
|
||||
manageSessionLogRestart: !terminalWorkerManager,
|
||||
});
|
||||
|
||||
const scriptBridge = require("../bridges/scriptBridge.cjs");
|
||||
scriptBridge.init({
|
||||
|
||||
@@ -1680,6 +1680,10 @@ function createPreloadApi(ctx) {
|
||||
ipcRenderer.invoke("netcatty:sessionLog:manualStart", payload),
|
||||
stopManualSessionLog: (payload) =>
|
||||
ipcRenderer.invoke("netcatty:sessionLog:manualStop", payload),
|
||||
prepareSessionLog: (payload) =>
|
||||
ipcRenderer.invoke("netcatty:sessionLog:prepare", { sessionId: payload.sessionId }),
|
||||
revealSessionLog: (payload) =>
|
||||
ipcRenderer.invoke("netcatty:sessionLog:reveal", { sessionId: payload.sessionId }),
|
||||
getManualSessionLogStatus: (payload) =>
|
||||
ipcRenderer.invoke("netcatty:sessionLog:manualStatus", payload),
|
||||
|
||||
|
||||
@@ -525,6 +525,9 @@ function createTerminalWorkerRuntime(options = {}) {
|
||||
const requestedSessionGeneration = requestedSessionId
|
||||
? (sessionOutputGenerations.get(requestedSessionId) ?? 0)
|
||||
: null;
|
||||
const previousSessionLogToken = requestedSessionId
|
||||
? require("../bridges/sessionLogStreamManager.cjs").getSessionLogToken(requestedSessionId)
|
||||
: null;
|
||||
if (requestedSessionId) {
|
||||
sessionRequestIds.set(requestedSessionId, message.requestId);
|
||||
sessionLifecycleTombstoneTimes.delete(requestedSessionId);
|
||||
@@ -556,6 +559,14 @@ function createTerminalWorkerRuntime(options = {}) {
|
||||
const requestedGenerationStillCurrent = requestedSessionGeneration === null
|
||||
|| currentGeneration === requestedSessionGeneration;
|
||||
if (!exitedDuringRequest && requestedGenerationStillCurrent) {
|
||||
// Failed reconnects retain the previous saved log. After a successful
|
||||
// start without a replacement log, retire only the old inactive
|
||||
// target, never a continuing manual or newly-finalized stream.
|
||||
if (sessionId === requestedSessionId && result.success !== false && !result.error) {
|
||||
require("../bridges/sessionLogStreamManager.cjs").forgetInactiveSessionLog(
|
||||
sessionId, previousSessionLogToken,
|
||||
);
|
||||
}
|
||||
sessionRequestIds.set(sessionId, message.requestId);
|
||||
sessionStartMarkers.add(sessionId);
|
||||
sessionLifecycleTombstoneTimes.delete(sessionId);
|
||||
|
||||
@@ -1532,3 +1532,120 @@ test("runtime contains throws from fire-and-forget send listeners", () => {
|
||||
{ sessionId: "s1", data: "pwd\r" },
|
||||
]);
|
||||
});
|
||||
|
||||
test("a same-id restart with auto-save disabled forgets the prior saved-log target", async (t) => {
|
||||
const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
const manager = require("../bridges/sessionLogStreamManager.cjs");
|
||||
const directory = path.join(__dirname, `.tmp-runtime-session-log-${Date.now()}`);
|
||||
const filePath = path.join(directory, "old.txt");
|
||||
const sessionId = "runtime-log-reconnect";
|
||||
t.after(async () => {
|
||||
await manager.cleanupAll();
|
||||
fs.rmSync(directory, { recursive: true, force: true });
|
||||
});
|
||||
manager.startStreamToFile(sessionId, { filePath, format: "txt" });
|
||||
manager.appendData(sessionId, "previous connection\n");
|
||||
await manager.stopStream(sessionId);
|
||||
assert.equal(await manager.prepareSessionLog(sessionId), filePath);
|
||||
const parentPort = createParentPort();
|
||||
const runtime = createTerminalWorkerRuntime({
|
||||
parentPort,
|
||||
registerBridges(ipcMain) {
|
||||
ipcMain.handle("netcatty:start", async (_event, payload) => {
|
||||
assert.equal(manager.hasSessionLog(payload.sessionId), true, "keep the old target until the replacement succeeds");
|
||||
return { sessionId: payload.sessionId };
|
||||
});
|
||||
},
|
||||
});
|
||||
runtime.start();
|
||||
parentPort.emitMessage({
|
||||
kind: "request", requestId: "restart-no-log", channel: "netcatty:start",
|
||||
payload: { sessionId, sessionLog: { enabled: false } }, webContentsId: 7,
|
||||
});
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
assert.ok(parentPort.messages.some((message) => message.requestId === "restart-no-log" && message.result?.sessionId === sessionId));
|
||||
assert.equal(await manager.prepareSessionLog(sessionId), null);
|
||||
});
|
||||
|
||||
async function createSavedLogRestartFixture(t, name, handler, { active = false } = {}) {
|
||||
const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
const manager = require("../bridges/sessionLogStreamManager.cjs");
|
||||
const directory = path.join(__dirname, `.tmp-runtime-session-log-${name}-${Date.now()}`);
|
||||
const filePath = path.join(directory, "old.txt");
|
||||
const sessionId = `runtime-log-${name}`;
|
||||
t.after(async () => {
|
||||
await manager.cleanupAll();
|
||||
fs.rmSync(directory, { recursive: true, force: true });
|
||||
});
|
||||
manager.startStreamToFile(sessionId, { filePath, format: "txt", stopRequiresToken: active });
|
||||
manager.appendData(sessionId, "previous connection\n");
|
||||
if (!active) await manager.stopStream(sessionId);
|
||||
const oldToken = manager.getSessionLogToken(sessionId);
|
||||
const parentPort = createParentPort();
|
||||
let resolveResponse;
|
||||
const response = new Promise((resolve) => { resolveResponse = resolve; });
|
||||
const postMessage = parentPort.postMessage;
|
||||
parentPort.postMessage = (message) => {
|
||||
postMessage(message);
|
||||
if (message.kind === "response" && message.requestId === name) resolveResponse(message);
|
||||
};
|
||||
const fixture = { manager, directory, filePath, sessionId, oldToken, parentPort };
|
||||
const runtime = createTerminalWorkerRuntime({
|
||||
parentPort,
|
||||
registerBridges(ipcMain) {
|
||||
ipcMain.handle("netcatty:start", (event, payload) => handler(fixture, event, payload));
|
||||
},
|
||||
});
|
||||
runtime.start();
|
||||
parentPort.emitMessage({
|
||||
kind: "request", requestId: name, channel: "netcatty:start",
|
||||
payload: { sessionId, sessionLog: { enabled: false } }, webContentsId: 7,
|
||||
});
|
||||
return { ...fixture, response: await response };
|
||||
}
|
||||
|
||||
for (const failure of ["throw", "result"]) {
|
||||
test(`failed reconnect (${failure}) retains the previous saved log`, async (t) => {
|
||||
const fixture = await createSavedLogRestartFixture(t, `failed-${failure}`, async ({ manager, sessionId, filePath }) => {
|
||||
assert.equal(await manager.prepareSessionLog(sessionId), filePath);
|
||||
if (failure === "throw") throw new Error("authentication failed");
|
||||
return { sessionId, success: false, error: "connection refused" };
|
||||
});
|
||||
const { manager, sessionId, filePath, oldToken, response } = fixture;
|
||||
if (failure === "throw") assert.equal(response.error, "authentication failed");
|
||||
else assert.equal(response.result.success, false);
|
||||
assert.equal(manager.getSessionLogToken(sessionId), oldToken);
|
||||
assert.equal(await manager.prepareSessionLog(sessionId), filePath);
|
||||
});
|
||||
}
|
||||
|
||||
for (const finalized of [false, true]) {
|
||||
test(`successful reconnect preserves its new ${finalized ? "finalized" : "active"} log`, async (t) => {
|
||||
let newPath;
|
||||
const { manager, sessionId, oldToken, response } = await createSavedLogRestartFixture(t, `new-${finalized}`, async (fixture) => {
|
||||
newPath = require("node:path").join(fixture.directory, "new.txt");
|
||||
fixture.manager.startStreamToFile(fixture.sessionId, { filePath: newPath, format: "txt" });
|
||||
fixture.manager.appendData(fixture.sessionId, "new connection\n");
|
||||
if (finalized) await fixture.manager.stopStream(fixture.sessionId);
|
||||
return { sessionId: fixture.sessionId };
|
||||
});
|
||||
assert.equal(response.result.sessionId, sessionId);
|
||||
assert.notEqual(manager.getSessionLogToken(sessionId), oldToken);
|
||||
assert.equal(manager.hasStream(sessionId), !finalized);
|
||||
assert.equal(await manager.prepareSessionLog(sessionId), newPath);
|
||||
});
|
||||
}
|
||||
|
||||
test("successful reconnect leaves an existing active manual recording untouched", async (t) => {
|
||||
const { manager, sessionId, filePath, oldToken, response } = await createSavedLogRestartFixture(
|
||||
t, "live-manual", async (fixture) => ({ sessionId: fixture.sessionId }), { active: true },
|
||||
);
|
||||
assert.equal(response.result.sessionId, sessionId);
|
||||
assert.equal(manager.hasStream(sessionId), true);
|
||||
assert.equal(manager.getSessionLogToken(sessionId), oldToken);
|
||||
manager.appendData(sessionId, "after reconnect\n");
|
||||
assert.equal(await manager.prepareSessionLog(sessionId), filePath);
|
||||
assert.match(require("node:fs").readFileSync(filePath, "utf8"), /previous connection[\s\S]*after reconnect/);
|
||||
});
|
||||
@@ -346,3 +346,6 @@ export const STORAGE_KEY_MONITOR_PANELS = 'netcatty_monitor_panels_v1';
|
||||
export const STORAGE_KEY_MONITOR_TABLE_COLUMNS = 'netcatty_monitor_table_columns_v1';
|
||||
|
||||
export const STORAGE_KEY_MONITOR_HIDDEN_COLUMNS = "netcatty_monitor_hidden_columns_v1";
|
||||
|
||||
// Preferred application for saved terminal logs (independent of SFTP associations).
|
||||
export const STORAGE_KEY_SESSION_LOG_APPLICATION = "netcatty.sessionLogApplication";
|
||||
+3
-1
@@ -127,9 +127,11 @@ declare global {
|
||||
stopManualSessionLog?(payload: {
|
||||
sessionId: string;
|
||||
}): Promise<{ success: boolean; stopped: boolean; error?: string; filePath?: string }>;
|
||||
prepareSessionLog?(payload: { sessionId: string }): Promise<{ success: boolean; filePath?: string; error?: string }>;
|
||||
revealSessionLog?(payload: { sessionId: string }): Promise<{ success: boolean; error?: string }>;
|
||||
getManualSessionLogStatus?(payload: {
|
||||
sessionId: string;
|
||||
}): Promise<{ success: boolean; isLogging: boolean; error?: string }>;
|
||||
}): Promise<{ success: boolean; isLogging: boolean; hasSessionLog: boolean; error?: string }>;
|
||||
|
||||
// Get file path from File object (for drag-and-drop, uses Electron's webUtils)
|
||||
getPathForFile?(file: File): string | undefined;
|
||||
|
||||
Reference in new issue
Block a user