Compare commits

...
Author SHA1 Message Date
Cursor Agentand陈大猫 7b58f554e8 fix(terminal): nest session log actions in one More actions submenu
Collapse Open, Show in folder, and the two open-with actions into a Session log submenu that follows the Terminal Encoding menu, and drop the remembered-application hint.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>
2026-10-22 13:48:07 +00:00
陈大猫 ae56dafac0 fix(terminal): derive log menu availability from saved session logs 2026-10-22 21:03:45 +08:00
Cursor Agentand陈大猫 e654025024 fix(terminal): keep open-log actions after automatic logging is turned off
The current automatic log keeps recording until reconnect, so the menu
stays available for that connection and clears on the next successful
connect.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>
2026-10-22 11:54:50 +00:00
Cursor Agentand陈大猫 1e4b0d5e6a fix(logging): back off failed snapshot writes and drop retired log actions
Persistent snapshot failures retry on a growing delay instead of a tight
loop, and still flush immediately when new output arrives. Hide open-log
actions after a successful reconnect retires a stopped manual log.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>
2026-10-22 11:47:37 +00:00
Cursor Agentand陈大猫 59ecf671f9 fix(terminal): keep log cleanup and tuck open-log into More actions
Always end a raw session-log write stream on finalize, including when a
flush already disabled it. Keep immediate snapshot retries, and pause
them only while prepare is waiting so a disk error can be reported.
Show open, reveal, and open-with in the toolbar overflow menu when
logging is active, instead of a default toolbar button.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>
2026-10-22 11:39:28 +00:00
Cursor Agentand陈大猫 8505d0c640 Merge remote-tracking branch 'origin/main' into feat/session-log-shortcut-3807
Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>
2026-10-22 11:25:25 +00:00
陈大猫 ac50272ce0 fix(logging): enable log lifecycle cleanup for main-process fallback 2026-10-21 23:56:24 +08:00
陈大猫 e50647de66 fix(logging): guard successful main-process session replacement
Add shared inactive-log cleanup to fallback start handlers with regression coverage. Registration activation follows separately.
2026-10-21 23:55:43 +08:00
陈大猫 3446a34399 fix(logging): preserve saved logs on failed reconnect and localize failures
Address both Codex review findings with generation- and identity-checked replacement cleanup and localized user-facing error handling.
2026-10-21 23:20:15 +08:00
陈大猫 8e5212f0a7 feat: open saved session logs from terminal toolbar
Add one-click access to the current automatic or manual session log, flush before opening, and reuse application selection with a remembered log preference. Preserve recording controls and current-session ownership across worker routing and reconnects.

Refs #3807
2026-10-21 23:01:27 +08:00
27 changed files with 1472 additions and 48 deletions

No files matched your search

+9
View File
@@ -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)
+9
View File
@@ -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)
+9
View File
@@ -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/);
});
+48
View File
@@ -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);
}
}
+1 -1
View File
@@ -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
View File
@@ -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,
+24 -1
View File
@@ -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);
});
+105 -6
View File
@@ -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();
}
}}
+27
View File
@@ -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");
}
});
}
+188 -18
View File
@@ -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");
}
});
}
+112 -7
View File
@@ -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,
+262 -2
View File
@@ -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,
});
}
});
+3 -1
View File
@@ -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);
+8 -5
View File
@@ -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");
});
+8 -2
View File
@@ -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({
+4
View File
@@ -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),
+11
View File
@@ -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);
+117
View File
@@ -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/);
});
+3
View File
@@ -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
View File
@@ -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;