mirror of
https://github.com/binaricat/Netcatty.git
synced 2026-10-31 19:02:31 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cd15958256 | ||
|
|
0b8b8a5623 | ||
|
|
4af4af274b | ||
|
|
ccfc88cd50 | ||
|
|
602a24a991 | ||
|
|
5df2181392 | ||
|
|
451205770e | ||
|
|
231982d673 | ||
|
|
8eb52853a0 | ||
|
|
11c90b45ec | ||
|
|
ac644a5e51 | ||
|
|
0f890701d4 | ||
|
|
21e6a91f36 | ||
|
|
d3c4273886 | ||
|
|
747fdeb0f3 | ||
|
|
0bb75055c4 | ||
|
|
f82355cf1b | ||
|
|
43249cd7e0 |
No files matched your search
@@ -1202,8 +1202,7 @@ jobs:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Freeze trusted helper
|
||||
run: cp scripts/ai-automation.cjs "$RUNNER_TEMP/ai-automation.cjs"
|
||||
chmod 0444 "$RUNNER_TEMP/ai-automation.cjs"
|
||||
run: *freeze_ai_helper
|
||||
|
||||
- name: Install Claude Code CLI
|
||||
run: |
|
||||
|
||||
@@ -466,6 +466,115 @@ test('writeSessionsForStorage reduces several small sessions to fit a sub-512 KB
|
||||
}
|
||||
});
|
||||
|
||||
test('serializeSessionsForStorage truncates slightly-over-cap tool results to strictly smaller sizes', async () => {
|
||||
const { serializeSessionsForStorage } = await import('./aiStateSnapshots');
|
||||
// Just over the 8 KiB cap: the truncation marker must not make the degraded
|
||||
// copy larger than the original, or the ladder could evict a session whose
|
||||
// unmodified JSON would have fit the budget.
|
||||
for (const oversize of [8193, 8266, 20000]) {
|
||||
const session = makeSession('near-cap', 1, [{
|
||||
id: 'tool-message',
|
||||
role: 'tool',
|
||||
content: '',
|
||||
timestamp: 1,
|
||||
toolResults: [{ toolCallId: 'call-1', content: 'z'.repeat(oversize) }],
|
||||
}]);
|
||||
const fullJson = JSON.stringify([session]);
|
||||
const result = serializeSessionsForStorage([session], fullJson.length - 10);
|
||||
const content = result.sessions[0].messages[0].toolResults?.[0].content ?? '';
|
||||
assert.ok(content.length <= 8 * 1024, `cap respected for ${oversize}`);
|
||||
assert.ok(
|
||||
JSON.stringify(result.sessions).length < fullJson.length,
|
||||
`degraded copy smaller than original for ${oversize}`,
|
||||
);
|
||||
assert.ok(content.includes('truncated'));
|
||||
}
|
||||
});
|
||||
|
||||
test('serializeSessionsForStorage sheds oversized tool output before dropping visible sessions', async () => {
|
||||
const { serializeSessionsForStorage } = await import('./aiStateSnapshots');
|
||||
const newest = makeSession('newest', 2, [{
|
||||
id: 'new-message',
|
||||
role: 'assistant',
|
||||
content: 'still-open chat',
|
||||
timestamp: 2,
|
||||
}]);
|
||||
const toolBulk = makeSession('tool-bulk', 1, [{
|
||||
id: 'tool-message',
|
||||
role: 'tool',
|
||||
content: '',
|
||||
timestamp: 1,
|
||||
toolResults: [{ toolCallId: 'call-1', content: 'z'.repeat(400 * 1024) }],
|
||||
}]);
|
||||
|
||||
const result = serializeSessionsForStorage([toolBulk, newest], 100 * 1024);
|
||||
|
||||
// The bloated tool result must not evict the other visible chat.
|
||||
assert.deepEqual(result.sessions.map(session => session.id), ['newest', 'tool-bulk']);
|
||||
assert.ok(result.json.length <= 100 * 1024);
|
||||
const persistedToolContent = result.sessions[1].messages[0].toolResults?.[0].content;
|
||||
assert.ok(persistedToolContent);
|
||||
assert.ok(persistedToolContent.length <= 9 * 1024);
|
||||
assert.ok(persistedToolContent.includes('truncated'));
|
||||
assert.ok(persistedToolContent.startsWith('zzz'));
|
||||
assert.equal(result.sessions[0].messages[0].content, 'still-open chat');
|
||||
});
|
||||
|
||||
test('writeSessionsForStorage keeps every chat by shedding tool output under quota pressure', async () => {
|
||||
const { writeSessionsForStorage } = await import('./aiStateSnapshots');
|
||||
let stored: string | undefined;
|
||||
const previousLocalStorage = globalThis.localStorage;
|
||||
Object.defineProperty(globalThis, 'localStorage', {
|
||||
configurable: true,
|
||||
value: {
|
||||
getItem: () => null,
|
||||
setItem: (_key: string, value: string) => {
|
||||
if (value.length > 300 * 1024) {
|
||||
throw new DOMException('quota exceeded', 'QuotaExceededError');
|
||||
}
|
||||
stored = value;
|
||||
},
|
||||
removeItem: () => {},
|
||||
},
|
||||
});
|
||||
|
||||
try {
|
||||
const sessions = Array.from({ length: 10 }, (_, index) => makeSession(
|
||||
`session-${index}`,
|
||||
10 - index,
|
||||
[{
|
||||
id: `message-${index}`,
|
||||
role: 'tool' as const,
|
||||
content: '',
|
||||
timestamp: index,
|
||||
toolResults: [{ toolCallId: `call-${index}`, content: 'z'.repeat(40 * 1024) }],
|
||||
}],
|
||||
));
|
||||
|
||||
assert.equal(writeSessionsForStorage(sessions), true);
|
||||
assert.ok(stored);
|
||||
assert.ok(stored.length <= 300 * 1024);
|
||||
const persisted = JSON.parse(stored) as AISession[];
|
||||
assert.deepEqual(
|
||||
persisted.map(session => session.id),
|
||||
sessions.map(session => session.id),
|
||||
);
|
||||
// Degradation sheds only as many messages as the budget demands, so at
|
||||
// least one chat keeps its full tool output while the persisted set stays
|
||||
// complete. Assert completion first; truncation second.
|
||||
const shedContents = persisted.map(
|
||||
session => session.messages[0].toolResults?.[0].content ?? '',
|
||||
);
|
||||
assert.ok(shedContents.some(content => content.includes('truncated')));
|
||||
assert.ok(shedContents.some(content => content.startsWith('zzz') && !content.includes('truncated')));
|
||||
} finally {
|
||||
Object.defineProperty(globalThis, 'localStorage', {
|
||||
configurable: true,
|
||||
value: previousLocalStorage,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
test('writeSessionsForStorage makes a final attempt with only the newest session', async () => {
|
||||
const { writeSessionsForStorage } = await import('./aiStateSnapshots');
|
||||
const writes: string[] = [];
|
||||
|
||||
@@ -195,12 +195,40 @@ const RETRY_SESSIONS_JSON_BYTES = [
|
||||
128 * 1024,
|
||||
] as const;
|
||||
|
||||
/**
|
||||
* Maximum persisted length of a single tool result payload. Terminal tool
|
||||
* output can reach hundreds of KB per command, and AI-driven terminal work
|
||||
* accumulates many of them. When storage pressure forces degradation, shedding
|
||||
* this replay bulk keeps every visible conversation persisted instead of
|
||||
* evicting whole chats that happen to carry it.
|
||||
*/
|
||||
const MAX_PERSISTED_TOOL_RESULT_CHARS = 8 * 1024;
|
||||
|
||||
function truncatePersistedToolResultContent(content: string): string {
|
||||
if (content.length <= MAX_PERSISTED_TOOL_RESULT_CHARS) return content;
|
||||
// The marker consumes budget and must stay single-line: JSON-escaped
|
||||
// newlines would cost 2 characters each, letting a slightly-over-cap
|
||||
// result serialize at the same (or larger) size than the original — the
|
||||
// ladder assumes the degraded form is the smallest. Shrink the retained
|
||||
// head+tail until the whole persisted copy fits the cap.
|
||||
let half = Math.floor(MAX_PERSISTED_TOOL_RESULT_CHARS / 2);
|
||||
let omitted = content.length - 2 * half;
|
||||
let marker = `[Netcatty] persisted copy truncated; ${omitted} characters of tool output omitted`;
|
||||
while (2 * half + marker.length > MAX_PERSISTED_TOOL_RESULT_CHARS) {
|
||||
half -= Math.ceil((2 * half + marker.length - MAX_PERSISTED_TOOL_RESULT_CHARS) / 2);
|
||||
omitted = content.length - 2 * half;
|
||||
marker = `[Netcatty] persisted copy truncated; ${omitted} characters of tool output omitted`;
|
||||
}
|
||||
return `${content.slice(0, half)}${marker}${content.slice(content.length - half)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove `reasoningEncryptedContent` ciphertext from a message's persisted
|
||||
* continuation. The ciphertext exists so stateless Responses turns can replay
|
||||
* prior reasoning items; it is also by far the largest per-message payload.
|
||||
* When storage pressure forces it, dropping the ciphertext keeps the visible
|
||||
* conversation intact at the cost of reasoning replay for affected messages.
|
||||
* continuation and shed bulk tool output. The ciphertext exists so stateless
|
||||
* Responses turns can replay prior reasoning items; tool output dominates
|
||||
* persisted size in AI-driven terminal chats. When storage pressure forces
|
||||
* it, shedding this bulk keeps the visible conversation intact at the cost of
|
||||
* reasoning replay and full tool output for affected messages.
|
||||
*/
|
||||
function stripReasoningEncryptedContent(
|
||||
options: ProviderContinuationOptions,
|
||||
@@ -218,7 +246,7 @@ function stripReasoningEncryptedContent(
|
||||
return Object.keys(stripped).length ? stripped : undefined;
|
||||
}
|
||||
|
||||
function stripEncryptedReasoningFromMessage(message: AISession['messages'][number]) {
|
||||
function stripReasoningCiphertextFromMessage(message: AISession['messages'][number]) {
|
||||
const continuation = message.providerContinuation;
|
||||
if (!continuation?.reasoningParts) return message;
|
||||
let changed = false;
|
||||
@@ -239,6 +267,22 @@ function stripEncryptedReasoningFromMessage(message: AISession['messages'][numbe
|
||||
};
|
||||
}
|
||||
|
||||
function stripEncryptedReasoningFromMessage(message: AISession['messages'][number]) {
|
||||
const strippedContinuationMessage = stripReasoningCiphertextFromMessage(message);
|
||||
const toolResults = strippedContinuationMessage.toolResults;
|
||||
if (!toolResults?.length) return strippedContinuationMessage;
|
||||
let toolResultsChanged = false;
|
||||
const nextToolResults = toolResults.map((result) => {
|
||||
const content = truncatePersistedToolResultContent(result.content);
|
||||
if (content === result.content) return result;
|
||||
toolResultsChanged = true;
|
||||
return { ...result, content };
|
||||
});
|
||||
return toolResultsChanged
|
||||
? { ...strippedContinuationMessage, toolResults: nextToolResults }
|
||||
: strippedContinuationMessage;
|
||||
}
|
||||
|
||||
function stripEncryptedReasoningFromSession(session: AISession): AISession {
|
||||
let changed = false;
|
||||
const messages = session.messages.map(message => {
|
||||
@@ -259,7 +303,7 @@ function stripCompactedEncryptedReasoningFromSession(session: AISession): AISess
|
||||
let changed = false;
|
||||
const messages = session.messages.map((message, index) => {
|
||||
if (index >= compactedMessageCount) return message;
|
||||
const stripped = stripEncryptedReasoningFromMessage(message);
|
||||
const stripped = stripReasoningCiphertextFromMessage(message);
|
||||
if (stripped !== message) changed = true;
|
||||
return stripped;
|
||||
});
|
||||
@@ -310,6 +354,11 @@ export function pruneSessionsForStorage(sessions: AISession[]): AISession[] {
|
||||
* Serialize sessions for localStorage under a byte budget, escalating pruning
|
||||
* as needed. Returns the JSON to persist plus the (possibly) further-pruned
|
||||
* sessions that JSON represents.
|
||||
*
|
||||
* Degradation ladder, cheapest loss first: strip replay ciphertext, shed bulk
|
||||
* tool output (kept at a bounded head+tail per result), and only then drop
|
||||
* oldest whole sessions. Evicting visible chats to preserve tool bulk is what
|
||||
* emptied history for users under storage pressure.
|
||||
*/
|
||||
export function serializeSessionsForStorage(
|
||||
sessions: AISession[],
|
||||
@@ -321,13 +370,13 @@ export function serializeSessionsForStorage(
|
||||
// before deciding whether the newest session deserves full protection;
|
||||
// otherwise dead metadata can evict other visible conversations.
|
||||
const session = stripCompactedEncryptedReasoningFromSession(rawSession);
|
||||
const ciphertextMessages = session.messages.flatMap((message, index) => {
|
||||
const strippedMessage = stripEncryptedReasoningFromMessage(message);
|
||||
if (strippedMessage === message) return [];
|
||||
const degradableMessages = session.messages.flatMap((message, index) => {
|
||||
const degradedMessage = stripEncryptedReasoningFromMessage(message);
|
||||
if (degradedMessage === message) return [];
|
||||
return [{
|
||||
index,
|
||||
strippedMessage,
|
||||
jsonLengthDelta: JSON.stringify(strippedMessage).length - JSON.stringify(message).length,
|
||||
degradedMessage,
|
||||
jsonLengthDelta: JSON.stringify(degradedMessage).length - JSON.stringify(message).length,
|
||||
}];
|
||||
});
|
||||
const strippedSession = stripEncryptedReasoningFromSession(session);
|
||||
@@ -339,7 +388,7 @@ export function serializeSessionsForStorage(
|
||||
strippedJson: strippedSession === session
|
||||
? json
|
||||
: JSON.stringify(strippedSession),
|
||||
ciphertextMessages,
|
||||
degradableMessages,
|
||||
};
|
||||
});
|
||||
|
||||
@@ -364,11 +413,12 @@ export function serializeSessionsForStorage(
|
||||
minimalLength -= removed.strippedJson.length + 1;
|
||||
}
|
||||
|
||||
// Then keep full continuation data for the retained sessions and remove
|
||||
// replay-only ciphertext from the oldest retained sessions only as needed,
|
||||
// never touching the protected newest session. Within one session, remove
|
||||
// it one message at a time from oldest to newest so a long active chat can
|
||||
// retain its most recent replayable tool exchange when that still fits.
|
||||
// Then keep full continuation data for the retained sessions and shed
|
||||
// replay-only bulk (ciphertext, oversized tool output) from the oldest
|
||||
// retained sessions only as needed, never touching the protected newest
|
||||
// session. Within one session, shed it one message at a time from oldest to
|
||||
// newest so a long active chat can retain its most recent replayable tool
|
||||
// exchange when that still fits.
|
||||
let jsonLength = 2 + serialized.reduce((total, entry) => total + entry.json.length, 0)
|
||||
+ Math.max(0, serialized.length - 1);
|
||||
const firstStrippableIndex = protectNewestContinuation ? 1 : 0;
|
||||
@@ -378,27 +428,27 @@ export function serializeSessionsForStorage(
|
||||
index -= 1
|
||||
) {
|
||||
const current = serialized[index];
|
||||
let strippedMessageCount = 0;
|
||||
while (jsonLength > budgetBytes && strippedMessageCount < current.ciphertextMessages.length) {
|
||||
jsonLength += current.ciphertextMessages[strippedMessageCount].jsonLengthDelta;
|
||||
strippedMessageCount += 1;
|
||||
let degradedMessageCount = 0;
|
||||
while (jsonLength > budgetBytes && degradedMessageCount < current.degradableMessages.length) {
|
||||
jsonLength += current.degradableMessages[degradedMessageCount].jsonLengthDelta;
|
||||
degradedMessageCount += 1;
|
||||
}
|
||||
if (strippedMessageCount > 0) {
|
||||
const strippedMessagesByIndex = new Map(
|
||||
current.ciphertextMessages
|
||||
.slice(0, strippedMessageCount)
|
||||
.map(entry => [entry.index, entry.strippedMessage] as const),
|
||||
if (degradedMessageCount > 0) {
|
||||
const degradedMessagesByIndex = new Map(
|
||||
current.degradableMessages
|
||||
.slice(0, degradedMessageCount)
|
||||
.map(entry => [entry.index, entry.degradedMessage] as const),
|
||||
);
|
||||
const nextSession = {
|
||||
...current.session,
|
||||
messages: current.session.messages.map((message, messageIndex) => (
|
||||
strippedMessagesByIndex.get(messageIndex) ?? message
|
||||
degradedMessagesByIndex.get(messageIndex) ?? message
|
||||
)),
|
||||
};
|
||||
const nextJson = JSON.stringify(nextSession);
|
||||
const projectedJsonLength = current.json.length
|
||||
+ current.ciphertextMessages
|
||||
.slice(0, strippedMessageCount)
|
||||
+ current.degradableMessages
|
||||
.slice(0, degradedMessageCount)
|
||||
.reduce((total, entry) => total + entry.jsonLengthDelta, 0);
|
||||
jsonLength += nextJson.length - projectedJsonLength;
|
||||
serialized[index] = {
|
||||
|
||||
@@ -8,10 +8,20 @@ import {
|
||||
shouldRevealTerminalPopupOnExit,
|
||||
} from "./resolveTerminalSessionExitIntent.ts";
|
||||
|
||||
test("normal backend exited events close the session tab", () => {
|
||||
test("backend exited events close the session tab only when auto-close is enabled", () => {
|
||||
assert.deepEqual(
|
||||
resolveTerminalSessionExitIntent({ reason: "exited", exitCode: 0 }, true),
|
||||
{ kind: "closeSession" },
|
||||
);
|
||||
});
|
||||
|
||||
test("omitted auto-close setting keeps the session tab on a clean exit", () => {
|
||||
// The fallback for callers that have not loaded the terminal setting yet:
|
||||
// keep the tab so a jump host / bastion reaping a session (a clean exit)
|
||||
// never silently deletes the user's scrollback.
|
||||
assert.deepEqual(
|
||||
resolveTerminalSessionExitIntent({ reason: "exited", exitCode: 0 }),
|
||||
{ kind: "closeSession" },
|
||||
{ kind: "markDisconnected" },
|
||||
);
|
||||
});
|
||||
|
||||
@@ -57,11 +67,19 @@ test("backend closed events keep the tab and mark it disconnected", () => {
|
||||
);
|
||||
});
|
||||
|
||||
test("administrative exit-signal disconnects keep the tab", () => {
|
||||
assert.deepEqual(
|
||||
resolveTerminalSessionExitIntent({ reason: "signaled", exitSignal: "SIGHUP" }, true),
|
||||
{ kind: "markDisconnected" },
|
||||
);
|
||||
});
|
||||
|
||||
test("terminal popup only auto-closes after clean command exit", () => {
|
||||
assert.equal(shouldCloseTerminalPopupOnExit({ reason: "exited", exitCode: 0 }), true);
|
||||
assert.equal(shouldCloseTerminalPopupOnExit({ reason: "exited", exitCode: 1 }), false);
|
||||
assert.equal(shouldCloseTerminalPopupOnExit({ reason: "error", error: "connection reset" }), false);
|
||||
assert.equal(shouldCloseTerminalPopupOnExit({ reason: "closed", exitCode: 0 }), false);
|
||||
assert.equal(shouldCloseTerminalPopupOnExit({ reason: "signaled", exitSignal: "SIGHUP" }), false);
|
||||
});
|
||||
|
||||
test("disabled auto-close keeps command and attached terminal popups open", () => {
|
||||
@@ -133,6 +151,17 @@ test("exit messages show the reported code for a real remote exit", () => {
|
||||
);
|
||||
});
|
||||
|
||||
test("an administrative exit-signal renders as a remote signal, not a clean code", () => {
|
||||
assert.equal(
|
||||
formatSessionClosedExitMessage({ reason: "signaled", exitSignal: "SIGHUP" }),
|
||||
"\r\n[session closed by remote signal]",
|
||||
);
|
||||
assert.equal(
|
||||
formatSessionClosedExitMessage({ reason: "closed", exitSignal: "SIGTERM" }),
|
||||
"\r\n[session closed by remote signal]",
|
||||
);
|
||||
});
|
||||
|
||||
test("a channel closed without exit-status or exit-signal must not render as code 0", () => {
|
||||
assert.equal(
|
||||
formatSessionClosedExitMessage({
|
||||
@@ -150,3 +179,25 @@ test("exit messages without a reported code stay plain instead of showing a sent
|
||||
);
|
||||
assert.equal(formatSessionClosedExitMessage({}), "\r\n[session closed]");
|
||||
});
|
||||
|
||||
test("infrastructure teardowns must not render a numeric code as a clean exit (#3631)", () => {
|
||||
// A terminal-worker exit broadcast carries the worker's own exit status
|
||||
// (graceful drain = 0). It belongs to the infrastructure failure, not to
|
||||
// any remote shell, so it must never read as "session closed (code 0)".
|
||||
assert.equal(
|
||||
formatSessionClosedExitMessage({
|
||||
reason: "error",
|
||||
exitCode: 0,
|
||||
error: "Terminal worker exited with code 0",
|
||||
}),
|
||||
"\r\n[session closed unexpectedly]",
|
||||
);
|
||||
assert.equal(
|
||||
formatSessionClosedExitMessage({ reason: "error", exitCode: 1 }),
|
||||
"\r\n[session closed unexpectedly]",
|
||||
);
|
||||
assert.equal(
|
||||
formatSessionClosedExitMessage({ reason: "error" }),
|
||||
"\r\n[session closed unexpectedly]",
|
||||
);
|
||||
});
|
||||
@@ -4,7 +4,10 @@ export type TerminalSessionExitEvent = {
|
||||
exitCode?: number;
|
||||
signal?: number;
|
||||
error?: string;
|
||||
reason?: "exited" | "error" | "timeout" | "closed";
|
||||
reason?: "exited" | "error" | "timeout" | "closed" | "signaled";
|
||||
// ssh2 exit-signal name ("SIGHUP", "SIGTERM", ...). Set when the server
|
||||
// terminated the session process. Distinct from a transport "closed".
|
||||
exitSignal?: string;
|
||||
// True when the remote closed the channel without sending exit-status or
|
||||
// exit-signal (#3591): no numeric exit code exists, and the renderer shows
|
||||
// a distinct message instead of a misleading "code 0".
|
||||
@@ -16,6 +19,13 @@ export function formatSessionClosedExitMessage(evt: TerminalSessionExitEvent): s
|
||||
if (evt?.remoteClosedWithoutExitStatus) {
|
||||
return "\r\n[session closed by remote (no exit status)]";
|
||||
}
|
||||
if (evt?.reason === "signaled" || (typeof evt?.exitSignal === "string" && evt.exitSignal.trim().length > 0)) {
|
||||
return "\r\n[session closed by remote signal]";
|
||||
}
|
||||
// Infrastructure exit codes do not describe the remote shell's status.
|
||||
if (evt?.reason === "error") {
|
||||
return "\r\n[session closed unexpectedly]";
|
||||
}
|
||||
return `\r\n[session closed${evt?.exitCode !== undefined ? ` (code ${evt.exitCode})` : ""}]`;
|
||||
}
|
||||
|
||||
@@ -34,7 +44,11 @@ function isConfirmedCleanExit(evt: TerminalSessionExitEvent): boolean {
|
||||
|
||||
export function resolveTerminalSessionExitIntent(
|
||||
evt: TerminalSessionExitEvent,
|
||||
autoCloseOnExit = true,
|
||||
// Keep-tab is the safe default: a caller that has not loaded the terminal
|
||||
// setting yet must not silently delete a session tab. Remote graceful
|
||||
// channel closes (e.g. a jump host reaping an idle session) are reported
|
||||
// as a clean exit, so an unavailable setting must not re-enable closing.
|
||||
autoCloseOnExit = false,
|
||||
): TerminalSessionExitIntent {
|
||||
if (autoCloseOnExit && isConfirmedCleanExit(evt)) {
|
||||
return { kind: "closeSession" };
|
||||
|
||||
@@ -9,9 +9,14 @@ import {
|
||||
resolveReadableForegroundForHsl,
|
||||
resolveThemeAccentForeground,
|
||||
migrateIncomingTerminalFontId,
|
||||
migrateMaterializedAutoCloseOnExitDefault,
|
||||
} from "./settingsStateDefaults.ts";
|
||||
import { TERMINAL_FONT_AUTO } from "../../infrastructure/config/fonts.ts";
|
||||
import { STORAGE_KEY_TERM_FONT_FAMILY } from "../../infrastructure/config/storageKeys.ts";
|
||||
import {
|
||||
STORAGE_KEY_TERM_AUTO_CLOSE_EXIT_MIGRATED,
|
||||
STORAGE_KEY_TERM_FONT_FAMILY,
|
||||
STORAGE_KEY_TERM_SETTINGS,
|
||||
} from "../../infrastructure/config/storageKeys.ts";
|
||||
import type { UiThemeTokens } from "../../infrastructure/config/uiThemes.ts";
|
||||
|
||||
function installMemoryLocalStorage(): Map<string, string> {
|
||||
@@ -140,3 +145,72 @@ test("app surface theme vars isolate non-terminal pages from active terminal chr
|
||||
"--ring": "270 70% 45%",
|
||||
});
|
||||
});
|
||||
|
||||
test("migrateMaterializedAutoCloseOnExitDefault resets the materialized default-on once", () => {
|
||||
const store = installMemoryLocalStorage();
|
||||
try {
|
||||
store.set(
|
||||
STORAGE_KEY_TERM_SETTINGS,
|
||||
JSON.stringify({ scrollback: 3000, autoCloseOnExit: true }),
|
||||
);
|
||||
migrateMaterializedAutoCloseOnExitDefault();
|
||||
const migrated = JSON.parse(store.get(STORAGE_KEY_TERM_SETTINGS)!);
|
||||
assert.equal(migrated.autoCloseOnExit, false);
|
||||
// Other saved settings pass through untouched.
|
||||
assert.equal(migrated.scrollback, 3000);
|
||||
assert.equal(store.get(STORAGE_KEY_TERM_AUTO_CLOSE_EXIT_MIGRATED), "true");
|
||||
} finally {
|
||||
delete (globalThis as { localStorage?: unknown }).localStorage;
|
||||
}
|
||||
});
|
||||
|
||||
test("migrateMaterializedAutoCloseOnExitDefault runs once and respects later explicit edits", () => {
|
||||
const store = installMemoryLocalStorage();
|
||||
try {
|
||||
store.set(
|
||||
STORAGE_KEY_TERM_SETTINGS,
|
||||
JSON.stringify({ autoCloseOnExit: true }),
|
||||
);
|
||||
migrateMaterializedAutoCloseOnExitDefault();
|
||||
assert.equal(
|
||||
JSON.parse(store.get(STORAGE_KEY_TERM_SETTINGS)!).autoCloseOnExit,
|
||||
false,
|
||||
);
|
||||
// The user re-enables after upgrading: the migration must not undo it again.
|
||||
store.set(
|
||||
STORAGE_KEY_TERM_SETTINGS,
|
||||
JSON.stringify({ autoCloseOnExit: true }),
|
||||
);
|
||||
migrateMaterializedAutoCloseOnExitDefault();
|
||||
assert.equal(
|
||||
JSON.parse(store.get(STORAGE_KEY_TERM_SETTINGS)!).autoCloseOnExit,
|
||||
true,
|
||||
);
|
||||
} finally {
|
||||
delete (globalThis as { localStorage?: unknown }).localStorage;
|
||||
}
|
||||
});
|
||||
|
||||
test("migrateMaterializedAutoCloseOnExitDefault leaves fresh installs and explicit-off alone", () => {
|
||||
const store = installMemoryLocalStorage();
|
||||
try {
|
||||
// Fresh install: no saved settings at all.
|
||||
migrateMaterializedAutoCloseOnExitDefault();
|
||||
assert.equal(store.has(STORAGE_KEY_TERM_SETTINGS), false);
|
||||
assert.equal(store.get(STORAGE_KEY_TERM_AUTO_CLOSE_EXIT_MIGRATED), "true");
|
||||
|
||||
// Explicit-off (or any settings blob without the key) is never rewritten.
|
||||
delete (globalThis as { localStorage?: unknown }).localStorage;
|
||||
const store2 = installMemoryLocalStorage();
|
||||
store2.set(
|
||||
STORAGE_KEY_TERM_SETTINGS,
|
||||
JSON.stringify({ scrollback: 500 }),
|
||||
);
|
||||
migrateMaterializedAutoCloseOnExitDefault();
|
||||
assert.deepEqual(JSON.parse(store2.get(STORAGE_KEY_TERM_SETTINGS)!), {
|
||||
scrollback: 500,
|
||||
});
|
||||
} finally {
|
||||
delete (globalThis as { localStorage?: unknown }).localStorage;
|
||||
}
|
||||
});
|
||||
@@ -1,5 +1,9 @@
|
||||
import type { HotkeyScheme, SessionLogFormat, TerminalSettings } from '../../domain/models';
|
||||
import { STORAGE_KEY_TERM_FONT_FAMILY } from '../../infrastructure/config/storageKeys';
|
||||
import {
|
||||
STORAGE_KEY_TERM_AUTO_CLOSE_EXIT_MIGRATED,
|
||||
STORAGE_KEY_TERM_FONT_FAMILY,
|
||||
STORAGE_KEY_TERM_SETTINGS,
|
||||
} from '../../infrastructure/config/storageKeys';
|
||||
import {
|
||||
isDeprecatedPrimaryFontId,
|
||||
TERMINAL_FONT_AUTO,
|
||||
@@ -63,6 +67,30 @@ export function migrateIncomingTerminalFontId(raw: string | null | undefined): s
|
||||
}
|
||||
return raw;
|
||||
}
|
||||
/**
|
||||
* One-time migration for the autoCloseOnExit default flip (tabs now survive
|
||||
* session exits by default; the old default-on silently deleted tabs whenever
|
||||
* a bastion or idle-harvesting server reported a clean exit). Builds before
|
||||
* the flip shipped autoCloseOnExit: true as the default and materialized it
|
||||
* into STORAGE_KEY_TERM_SETTINGS on any settings write, so most upgraders
|
||||
* carry a value they never chose. Leaving it in place would make the new
|
||||
* default unreachable for them. Reset that materialized `true` exactly once
|
||||
* and persist the marker; afterwards every stored value is treated as the
|
||||
* user's own choice and is respected. An inbound cloud or backup payload that
|
||||
* still carries the pre-migration materialized true (no
|
||||
* autoCloseOnExitDefaultMigrated flag) is rewritten to false so sync cannot
|
||||
* resurrect auto-close. The cost: a user who deliberately enabled auto-close
|
||||
* before this release must re-enable it once.
|
||||
*/
|
||||
export function migrateMaterializedAutoCloseOnExitDefault(): void {
|
||||
if (localStorageAdapter.readBoolean(STORAGE_KEY_TERM_AUTO_CLOSE_EXIT_MIGRATED)) return;
|
||||
const stored = localStorageAdapter.read<Partial<TerminalSettings>>(STORAGE_KEY_TERM_SETTINGS);
|
||||
if (stored?.autoCloseOnExit === true) {
|
||||
localStorageAdapter.write(STORAGE_KEY_TERM_SETTINGS, { ...stored, autoCloseOnExit: false });
|
||||
}
|
||||
localStorageAdapter.writeBoolean(STORAGE_KEY_TERM_AUTO_CLOSE_EXIT_MIGRATED, true);
|
||||
}
|
||||
|
||||
// Auto-detect default hotkey scheme based on platform
|
||||
export const DEFAULT_HOTKEY_SCHEME: HotkeyScheme =
|
||||
typeof navigator !== 'undefined' && /Mac|iPhone|iPad|iPod/i.test(navigator.platform)
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import { UploadController } from "../../../lib/uploadService";
|
||||
import { UploadController, uploadEntriesDirect } from "../../../lib/uploadService";
|
||||
import {
|
||||
cancelExternalUploadRuntime,
|
||||
getExternalUploadController,
|
||||
@@ -37,3 +37,64 @@ test("external upload controls survive their originating panel unmount", async (
|
||||
assert.equal(getExternalUploadController("folder-1"), undefined);
|
||||
assert.equal(getExternalUploadController("child-1"), undefined);
|
||||
});
|
||||
|
||||
for (const selectedFile of ["a.bin", "c.bin"]) {
|
||||
test(`cancelling independent ${selectedFile} preserves its active and queued upload siblings`, async (t) => {
|
||||
resetExternalUploadRuntimeForTests();
|
||||
t.after(resetExternalUploadRuntimeForTests);
|
||||
const controller = new UploadController();
|
||||
const cancelled: string[] = [];
|
||||
const stagedCancelled: string[] = [];
|
||||
const ids = new Map<string, string>();
|
||||
const pending = new Map<string, (cancelled?: boolean) => void>();
|
||||
const started: string[] = [];
|
||||
const upload = uploadEntriesDirect(
|
||||
["a.bin", "b.bin", "c.bin"].map((name) => ({
|
||||
file: null, relativePath: name, isDirectory: false, localPath: `/local/${name}`, size: 8,
|
||||
})),
|
||||
{
|
||||
targetPath: "/remote", sftpId: "fixture", isLocal: false,
|
||||
fileTransferConcurrency: 2, joinPath: (base, name) => `${base}/${name}`,
|
||||
callbacks: {
|
||||
onTaskCreated: (task) => {
|
||||
ids.set(task.fileName, task.id);
|
||||
registerExternalUploadController(task.id, controller, "file");
|
||||
},
|
||||
},
|
||||
bridge: {
|
||||
mkdirSftp: async () => {},
|
||||
cancelStagedUploadFile: async (id) => { stagedCancelled.push(id); },
|
||||
cancelTransfer: async (id) => { cancelled.push(id); pending.get(id)?.(true); },
|
||||
startStreamTransfer: ({ transferId, sourcePath }) => new Promise((resolve) => {
|
||||
started.push(sourcePath);
|
||||
pending.set(transferId, (cancelled = false) => resolve({ transferId, cancelled }));
|
||||
}),
|
||||
},
|
||||
},
|
||||
controller,
|
||||
);
|
||||
t.after(async () => {
|
||||
await controller.cancel();
|
||||
for (const finish of pending.values()) finish(true);
|
||||
await upload;
|
||||
});
|
||||
await new Promise(setImmediate);
|
||||
assert.deepEqual(started, ["/local/a.bin", "/local/b.bin"]);
|
||||
const selectedId = ids.get(selectedFile)!;
|
||||
await cancelExternalUploadRuntime(selectedId);
|
||||
assert.deepEqual(cancelled, [selectedId]);
|
||||
assert.deepEqual(stagedCancelled, [selectedId]);
|
||||
assert.equal(controller.isCancelled(), false);
|
||||
await new Promise(setImmediate);
|
||||
assert.deepEqual(started, selectedFile === "a.bin"
|
||||
? ["/local/a.bin", "/local/b.bin", "/local/c.bin"]
|
||||
: ["/local/a.bin", "/local/b.bin"]);
|
||||
for (const [id, finish] of pending) if (id !== selectedId) finish();
|
||||
const results = (await upload).sort((a, b) => a.fileName.localeCompare(b.fileName));
|
||||
assert.deepEqual(results, ["a.bin", "b.bin", "c.bin"].map((fileName) => (
|
||||
fileName === selectedFile
|
||||
? { fileName, success: false, cancelled: true }
|
||||
: { fileName, success: true }
|
||||
)));
|
||||
});
|
||||
}
|
||||
@@ -5,11 +5,13 @@ import type { UploadController } from "../../../lib/uploadService";
|
||||
// terminal only removes the view; the global transfer runtime can still cancel
|
||||
// the upload, and the upload's own finally block releases these entries.
|
||||
const controllersByTaskId = new Map<string, UploadController>();
|
||||
const scopesByTaskId = new Map<string, "file" | "batch">();
|
||||
const taskIdsByController = new Map<UploadController, Set<string>>();
|
||||
|
||||
export function registerExternalUploadController(
|
||||
taskId: string,
|
||||
controller: UploadController,
|
||||
scope: "file" | "batch" = "batch",
|
||||
): void {
|
||||
if (!taskId) return;
|
||||
const previous = controllersByTaskId.get(taskId);
|
||||
@@ -19,6 +21,7 @@ export function registerExternalUploadController(
|
||||
if (previousIds?.size === 0) taskIdsByController.delete(previous);
|
||||
}
|
||||
controllersByTaskId.set(taskId, controller);
|
||||
scopesByTaskId.set(taskId, scope);
|
||||
const taskIds = taskIdsByController.get(controller) ?? new Set<string>();
|
||||
taskIds.add(taskId);
|
||||
taskIdsByController.set(controller, taskIds);
|
||||
@@ -30,6 +33,7 @@ export function unregisterExternalUploadController(controller: UploadController)
|
||||
for (const taskId of taskIds) {
|
||||
if (controllersByTaskId.get(taskId) === controller) {
|
||||
controllersByTaskId.delete(taskId);
|
||||
scopesByTaskId.delete(taskId);
|
||||
}
|
||||
}
|
||||
taskIdsByController.delete(controller);
|
||||
@@ -40,6 +44,10 @@ export function getExternalUploadController(taskId: string): UploadController |
|
||||
}
|
||||
|
||||
export async function cancelExternalUploadRuntime(taskId?: string): Promise<boolean> {
|
||||
if (taskId && scopesByTaskId.get(taskId) === "file") {
|
||||
await controllersByTaskId.get(taskId)?.cancelFile(taskId);
|
||||
return true;
|
||||
}
|
||||
const controllers = taskId
|
||||
? [controllersByTaskId.get(taskId)].filter((value): value is UploadController => !!value)
|
||||
: [...taskIdsByController.keys()];
|
||||
@@ -50,5 +58,6 @@ export async function cancelExternalUploadRuntime(taskId?: string): Promise<bool
|
||||
|
||||
export function resetExternalUploadRuntimeForTests(): void {
|
||||
controllersByTaskId.clear();
|
||||
scopesByTaskId.clear();
|
||||
taskIdsByController.clear();
|
||||
}
|
||||
@@ -201,8 +201,8 @@ export const useSftpExternalOperations = (
|
||||
return { sftpId, release: () => {} };
|
||||
}, [ensureRemoteSftpId, getActivePane, getPaneByConnectionId, getPaneByTabId, getSideByTabId, sftpSessionsRef]);
|
||||
|
||||
const registerUploadController = useCallback((taskId: string, controller: UploadController) => {
|
||||
registerExternalUploadController(taskId, controller);
|
||||
const registerUploadController = useCallback((taskId: string, controller: UploadController, scope: "file" | "batch" = "batch") => {
|
||||
registerExternalUploadController(taskId, controller, scope);
|
||||
}, []);
|
||||
|
||||
const unregisterUploadController = useCallback((controller: UploadController) => {
|
||||
@@ -222,7 +222,7 @@ export const useSftpExternalOperations = (
|
||||
callbacks.onScanningProgress?.(taskId, progress);
|
||||
},
|
||||
onTaskCreated: (task) => {
|
||||
registerUploadController(task.id, controller);
|
||||
registerUploadController(task.id, controller, !task.isDirectory && !task.parentTaskId ? "file" : "batch");
|
||||
if (task.parentTaskId) {
|
||||
registerUploadController(task.parentTaskId, controller);
|
||||
}
|
||||
|
||||
@@ -58,6 +58,52 @@ test("normal shell exits do not auto reconnect", () => {
|
||||
);
|
||||
});
|
||||
|
||||
test("administrative idle, timeout, and exit-signal disconnects do not auto reconnect", () => {
|
||||
for (const evt of [
|
||||
{ reason: "timeout" as const },
|
||||
{ reason: "timeout" as const, exitCode: 0 },
|
||||
{ reason: "timeout" as const, error: "Connection timeout to example.com" },
|
||||
{ reason: "signaled" as const, exitSignal: "SIGHUP" },
|
||||
{ reason: "signaled" as const, exitSignal: "SIGTERM" },
|
||||
{ reason: "signaled" as const },
|
||||
// A path that still says "closed" but plumbed the exit-signal is the same kick.
|
||||
{ reason: "closed" as const, exitSignal: "SIGUSR2" },
|
||||
{ reason: "closed" as const, exitSignal: " SIGHUP " },
|
||||
]) {
|
||||
assert.equal(
|
||||
shouldAutoReconnectAfterExit({
|
||||
evt,
|
||||
host: sshHost,
|
||||
terminalSettings: { sshAutoReconnectEnabled: true },
|
||||
hasEverConnected: true,
|
||||
}),
|
||||
false,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test("transport drops still auto reconnect", () => {
|
||||
for (const evt of [
|
||||
{ reason: "closed" as const },
|
||||
{ reason: "error" as const, error: "connection reset" },
|
||||
{ reason: "closed" as const, remoteClosedWithoutExitStatus: true },
|
||||
{ reason: "closed" as const, exitSignal: "" },
|
||||
{ reason: "closed" as const, exitSignal: " " },
|
||||
{ reason: "error" as const, error: "read ECONNRESET", exitSignal: "" },
|
||||
{ reason: "error" as const, error: "read ECONNRESET", exitSignal: "SIGHUP" },
|
||||
]) {
|
||||
assert.equal(
|
||||
shouldAutoReconnectAfterExit({
|
||||
evt,
|
||||
host: sshHost,
|
||||
terminalSettings: { sshAutoReconnectEnabled: true },
|
||||
hasEverConnected: true,
|
||||
}),
|
||||
true,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test("auto reconnect ignores protocols that already have different lifecycle semantics", () => {
|
||||
const variants = [
|
||||
{ protocol: "local" as const, hostname: "localhost" },
|
||||
|
||||
@@ -40,7 +40,16 @@ export function shouldAutoReconnectAfterExit({
|
||||
if (!hasEverConnected) return false;
|
||||
if (!isTerminalAutoReconnectEnabled(terminalSettings)) return false;
|
||||
if (!isAutoReconnectableSshHost(host)) return false;
|
||||
return evt.reason !== "exited";
|
||||
// "exited" is a shell exit. "timeout" is an administrative idle/TMOUT logout
|
||||
// (or a connect timeout). "signaled" — or a plumbed exit-signal on an
|
||||
// otherwise "closed" end — is a server kick (idle policy, SIGHUP, SIGTERM).
|
||||
// Reconnecting would fight that policy. Transport "error" and bare "closed"
|
||||
// still retry.
|
||||
if (evt.reason === "error") return true;
|
||||
if (evt.reason === "exited" || evt.reason === "timeout" || evt.reason === "signaled") {
|
||||
return false;
|
||||
}
|
||||
return typeof evt.exitSignal !== "string" || evt.exitSignal.trim().length === 0;
|
||||
}
|
||||
|
||||
export function shouldContinueAutoReconnectAfterFailure({
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { describe, it } from 'node:test';
|
||||
import { createSessionIdleManager } from '../../electron/bridges/mcpServerBridge/sessionIdleManager.cjs';
|
||||
import { STORAGE_KEY_AI_SESSION_IDLE_TIMEOUT_MINUTES } from '../../infrastructure/config/storageKeys';
|
||||
import { localStorageAdapter } from '../../infrastructure/persistence/localStorageAdapter';
|
||||
import {
|
||||
EXTERNAL_MCP_RUNTIME_STATUS_POLL_MS,
|
||||
bumpExternalMcpEnableGenerationForTests,
|
||||
@@ -18,6 +21,7 @@ import {
|
||||
resetExternalMcpStartupSyncOnceForTests,
|
||||
shouldStartExternalMcpOnStartup,
|
||||
shouldWaitForExternalMcpStartupReady,
|
||||
syncExternalMcpConfig,
|
||||
syncExternalMcpStartupState,
|
||||
syncExternalMcpStartupStateOnce,
|
||||
waitForExternalMcpStartupReady,
|
||||
@@ -63,10 +67,44 @@ describe('useExternalMcpToggleState helpers', () => {
|
||||
assert.equal(normalizeExternalMcpIdleTimeoutMinutes(null), 10);
|
||||
assert.equal(normalizeExternalMcpIdleTimeoutMinutes(0), 1);
|
||||
assert.equal(normalizeExternalMcpIdleTimeoutMinutes(99999), 24 * 60);
|
||||
assert.equal(normalizeSessionIdleTimeoutMinutes(null), 30);
|
||||
assert.equal(normalizeSessionIdleTimeoutMinutes(null), 8 * 60);
|
||||
assert.equal(normalizeSessionIdleTimeoutMinutes(0), 1);
|
||||
});
|
||||
|
||||
for (const savedMinutes of [null, 30]) {
|
||||
it(`syncs the session idle timeout without replacing saved value ${savedMinutes}`, async () => {
|
||||
const restore = installMemoryLocalStorage();
|
||||
const manager = createSessionIdleManager();
|
||||
try {
|
||||
assert.equal(manager.getTimeoutMinutes(), 8 * 60, 'main-process default');
|
||||
if (savedMinutes !== null) {
|
||||
localStorageAdapter.writeNumber(STORAGE_KEY_AI_SESSION_IDLE_TIMEOUT_MINUTES, savedMinutes);
|
||||
}
|
||||
const expected = savedMinutes ?? manager.getTimeoutMinutes();
|
||||
const received: number[] = [];
|
||||
const bridge = {
|
||||
externalMcpSetConfig: (config: { sessionIdleTimeoutMinutes: number }) => {
|
||||
received.push(config.sessionIdleTimeoutMinutes);
|
||||
manager.setTimeoutMinutes(config.sessionIdleTimeoutMinutes);
|
||||
},
|
||||
};
|
||||
await syncExternalMcpStartupState(bridge);
|
||||
assert.equal(manager.getTimeoutMinutes(), expected, 'startup keeps the intended timeout');
|
||||
syncExternalMcpConfig(bridge);
|
||||
assert.deepEqual(received, [expected, expected]);
|
||||
assert.equal(manager.getTimeoutMinutes(), expected, 'config sync keeps the intended timeout');
|
||||
assert.equal(
|
||||
localStorageAdapter.readNumber(STORAGE_KEY_AI_SESSION_IDLE_TIMEOUT_MINUTES),
|
||||
savedMinutes,
|
||||
'sync does not migrate or overwrite the stored preference',
|
||||
);
|
||||
} finally {
|
||||
manager.clearAll();
|
||||
restore();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
it('only starts on launch for persistent+enabled', () => {
|
||||
assert.equal(shouldStartExternalMcpOnStartup({ enabled: true, mode: 'persistent' }), true);
|
||||
assert.equal(shouldStartExternalMcpOnStartup({ enabled: true, mode: 'temporary' }), false);
|
||||
|
||||
@@ -18,7 +18,7 @@ export type ExternalMcpMode = 'temporary' | 'persistent';
|
||||
const DEFAULT_EXTERNAL_MCP_IDLE_TIMEOUT_MINUTES = 10;
|
||||
const MIN_EXTERNAL_MCP_IDLE_TIMEOUT_MINUTES = 1;
|
||||
const MAX_EXTERNAL_MCP_IDLE_TIMEOUT_MINUTES = 24 * 60;
|
||||
const DEFAULT_SESSION_IDLE_TIMEOUT_MINUTES = 30;
|
||||
const DEFAULT_SESSION_IDLE_TIMEOUT_MINUTES = 480;
|
||||
/** Keep top-bar / settings switch state aligned with runtime auto-disable. */
|
||||
export const EXTERNAL_MCP_RUNTIME_STATUS_POLL_MS = 3000;
|
||||
|
||||
|
||||
@@ -151,6 +151,7 @@ import {
|
||||
isValidUiFontId,
|
||||
isValidUiThemeId,
|
||||
migrateIncomingTerminalFontId,
|
||||
migrateMaterializedAutoCloseOnExitDefault,
|
||||
readStoredString,
|
||||
serializeTerminalSettings,
|
||||
type HostClickBehavior,
|
||||
@@ -355,6 +356,9 @@ export const useSettingsState = (options: { enableSettingsSync?: boolean; enable
|
||||
passwordVerifier: null,
|
||||
});
|
||||
const [terminalSettings, setTerminalSettingsState] = useState<TerminalSettings>(() => {
|
||||
// One-time reset of the pre-flip materialized autoCloseOnExit: true before
|
||||
// it is ingested; afterwards stored values are the user's own and win.
|
||||
migrateMaterializedAutoCloseOnExitDefault();
|
||||
const stored = localStorageAdapter.read<TerminalSettings>(STORAGE_KEY_TERM_SETTINGS);
|
||||
return normalizeTerminalSettings(stored);
|
||||
});
|
||||
|
||||
@@ -1667,6 +1667,50 @@ test("terminal auto-close preference survives sync round-trip", async () => {
|
||||
assert.equal(restored.autoCloseOnExit, false);
|
||||
});
|
||||
|
||||
test("inbound sync does not resurrect the materialized auto-close default after the marker", async () => {
|
||||
localStorage.setItem(storageKeys.STORAGE_KEY_TERM_AUTO_CLOSE_EXIT_MIGRATED, "true");
|
||||
try {
|
||||
localStorage.setItem(
|
||||
storageKeys.STORAGE_KEY_TERM_SETTINGS,
|
||||
JSON.stringify({ autoCloseOnExit: false, scrollback: 3000 }),
|
||||
);
|
||||
|
||||
await applySyncPayload({
|
||||
hosts: [],
|
||||
keys: [],
|
||||
identities: [],
|
||||
snippets: [],
|
||||
customGroups: [],
|
||||
syncedAt: 1,
|
||||
settings: { terminalSettings: { autoCloseOnExit: true } },
|
||||
}, { importVaultData: () => {} });
|
||||
|
||||
const resurrected = JSON.parse(localStorage.getItem(storageKeys.STORAGE_KEY_TERM_SETTINGS)!);
|
||||
assert.equal(resurrected.autoCloseOnExit, false);
|
||||
assert.equal(resurrected.scrollback, 3000);
|
||||
|
||||
// An explicit opt-in made after the migration round-trips with a marker.
|
||||
localStorage.setItem(
|
||||
storageKeys.STORAGE_KEY_TERM_SETTINGS,
|
||||
JSON.stringify({ autoCloseOnExit: true, scrollback: 3000 }),
|
||||
);
|
||||
const optedIn = buildSyncPayload(vault());
|
||||
assert.equal(optedIn.settings?.autoCloseOnExitDefaultMigrated, true);
|
||||
assert.equal(optedIn.settings?.terminalSettings?.autoCloseOnExit, true);
|
||||
|
||||
localStorage.setItem(
|
||||
storageKeys.STORAGE_KEY_TERM_SETTINGS,
|
||||
JSON.stringify({ autoCloseOnExit: false, scrollback: 3000 }),
|
||||
);
|
||||
await applySyncPayload(optedIn, { importVaultData: () => {} });
|
||||
const restoredChoice = JSON.parse(localStorage.getItem(storageKeys.STORAGE_KEY_TERM_SETTINGS)!);
|
||||
assert.equal(restoredChoice.autoCloseOnExit, true);
|
||||
assert.equal(localStorage.getItem(storageKeys.STORAGE_KEY_TERM_AUTO_CLOSE_EXIT_MIGRATED), "true");
|
||||
} finally {
|
||||
localStorage.removeItem(storageKeys.STORAGE_KEY_TERM_AUTO_CLOSE_EXIT_MIGRATED);
|
||||
}
|
||||
});
|
||||
|
||||
test("terminal disconnected notice preference survives sync round-trip", async () => {
|
||||
localStorage.setItem(
|
||||
storageKeys.STORAGE_KEY_TERM_SETTINGS,
|
||||
|
||||
@@ -25,6 +25,7 @@ import {
|
||||
SYNC_PAYLOAD_ENTITY_KEYS,
|
||||
SYNC_STORAGE_KEYS,
|
||||
hasSyncPayloadEntityData,
|
||||
reconcileInboundAutoCloseOnExit,
|
||||
retainLocalHostLastConnectedAt,
|
||||
sanitizeHostsForSync,
|
||||
type SyncPayload,
|
||||
@@ -62,6 +63,7 @@ import {
|
||||
} from './state/customAccentSync';
|
||||
import { isValidHslToken } from './state/settingsStateDefaults';
|
||||
import {
|
||||
STORAGE_KEY_TERM_AUTO_CLOSE_EXIT_MIGRATED,
|
||||
STORAGE_KEY_THEME,
|
||||
STORAGE_KEY_UI_THEME_LIGHT,
|
||||
STORAGE_KEY_UI_THEME_DARK,
|
||||
@@ -505,6 +507,9 @@ export function collectSyncableSettings(): SyncPayload['settings'] {
|
||||
if (Object.keys(subset).length > 0) settings.terminalSettings = subset;
|
||||
} catch { /* ignore corrupt data */ }
|
||||
}
|
||||
if (localStorageAdapter.readBoolean(STORAGE_KEY_TERM_AUTO_CLOSE_EXIT_MIGRATED)) {
|
||||
settings.autoCloseOnExitDefaultMigrated = true;
|
||||
}
|
||||
|
||||
// Custom terminal themes
|
||||
const customThemesRaw = localStorageAdapter.readString(STORAGE_KEY_CUSTOM_THEMES);
|
||||
@@ -795,6 +800,23 @@ async function applySyncableSettings(
|
||||
? 'disabled'
|
||||
: 'paste';
|
||||
}
|
||||
if ('autoCloseOnExit' in settings.terminalSettings) {
|
||||
merged.autoCloseOnExit = reconcileInboundAutoCloseOnExit(
|
||||
settings.terminalSettings.autoCloseOnExit,
|
||||
settings.autoCloseOnExitDefaultMigrated,
|
||||
);
|
||||
}
|
||||
// A legacy true is the old default. A migrated peer's explicit choice must
|
||||
// stick, so record the marker before the next local migration pass.
|
||||
if (
|
||||
settings.autoCloseOnExitDefaultMigrated === true
|
||||
|| (
|
||||
settings.terminalSettings.autoCloseOnExit === true
|
||||
&& settings.autoCloseOnExitDefaultMigrated !== true
|
||||
)
|
||||
) {
|
||||
localStorageAdapter.writeBoolean(STORAGE_KEY_TERM_AUTO_CLOSE_EXIT_MIGRATED, true);
|
||||
}
|
||||
localStorageAdapter.writeString(STORAGE_KEY_TERM_SETTINGS, JSON.stringify(merged));
|
||||
}
|
||||
|
||||
|
||||
@@ -669,7 +669,9 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
|
||||
const handleSessionExit = useCallback((sessionId: string, evt: TerminalSessionExitEvent) => {
|
||||
const intent = resolveTerminalSessionExitIntent(
|
||||
evt,
|
||||
terminalSettings?.autoCloseOnExit ?? true,
|
||||
// No terminal settings loaded yet -> do not close the tab; auto-close is
|
||||
// opt-in via Settings -> Terminal -> Behavior (DEFAULT_TERMINAL_SETTINGS).
|
||||
terminalSettings?.autoCloseOnExit ?? false,
|
||||
);
|
||||
if (intent.kind === "closeSession") {
|
||||
handleCloseSession(sessionId);
|
||||
|
||||
@@ -162,7 +162,7 @@ test('attach popup close preparation has a bounded timeout', () => {
|
||||
test('terminal exit auto-close setting reaches tabs, workspaces, and popups', () => {
|
||||
assert.match(
|
||||
terminalLayerSource,
|
||||
/resolveTerminalSessionExitIntent\(\s+evt,\s+terminalSettings\?\.autoCloseOnExit \?\? true,\s+\)/,
|
||||
/resolveTerminalSessionExitIntent\(\s+evt,[\s\S]*?terminalSettings\?\.autoCloseOnExit \?\? false,\s+\)/,
|
||||
);
|
||||
assert.match(
|
||||
terminalSource,
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test, { type TestContext } from "node:test";
|
||||
import React from "react";
|
||||
import { DEFAULT_KEY_BINDINGS } from "../../../domain/models/keyBindings";
|
||||
import { createDomRenderer, dispatchDomEvent, installDomEnvironment } from "../../test-support/renderReactDom";
|
||||
|
||||
async function setup(t: TestContext, label: string) {
|
||||
const env = installDomEnvironment();
|
||||
const previous = new Map<string, PropertyDescriptor | undefined>();
|
||||
const globals = {
|
||||
DocumentFragment: env.window.DocumentFragment,
|
||||
MutationObserver: env.window.MutationObserver,
|
||||
Element: env.window.Element,
|
||||
requestAnimationFrame: (callback: FrameRequestCallback) => setTimeout(() => callback(Date.now()), 0),
|
||||
cancelAnimationFrame: clearTimeout,
|
||||
};
|
||||
for (const [key, value] of Object.entries(globals)) {
|
||||
previous.set(key, Object.getOwnPropertyDescriptor(globalThis, key));
|
||||
Object.defineProperty(globalThis, key, { configurable: true, writable: true, value });
|
||||
}
|
||||
const { default: SettingsShortcutsTab } = await import("./SettingsShortcutsTab");
|
||||
const { I18nProvider } = await import("../../../application/i18n/I18nProvider");
|
||||
const { Tabs } = await import("../../ui/tabs");
|
||||
const renderer = await createDomRenderer(env.document);
|
||||
t.after(async () => {
|
||||
await renderer.unmount();
|
||||
for (const [key, descriptor] of previous) {
|
||||
if (descriptor) Object.defineProperty(globalThis, key, descriptor);
|
||||
else Reflect.deleteProperty(globalThis, key);
|
||||
}
|
||||
env.cleanup();
|
||||
});
|
||||
const updates: string[][] = [];
|
||||
await renderer.render(<I18nProvider locale="en"><Tabs value="shortcuts"><SettingsShortcutsTab
|
||||
hotkeyScheme="pc" setHotkeyScheme={() => {}}
|
||||
shellOnlyTabNumberShortcuts={false} setShellOnlyTabNumberShortcuts={() => {}}
|
||||
showTabNumberBadges={true} setShowTabNumberBadges={() => {}}
|
||||
disableTerminalFontZoom={false} setDisableTerminalFontZoom={() => {}}
|
||||
keyBindings={DEFAULT_KEY_BINDINGS} resetAllKeyBindings={() => {}}
|
||||
updateKeyBinding={(...args) => { updates.push(args); }}
|
||||
/></Tabs></I18nProvider>);
|
||||
const row = [...env.document.querySelectorAll("span")].find((span) => span.textContent === label)?.parentElement;
|
||||
const button = row?.querySelector("button");
|
||||
assert.ok(button);
|
||||
await dispatchDomEvent(button, new env.window.MouseEvent("click", { bubbles: true }));
|
||||
const key = (type: "keydown" | "keyup", init: KeyboardEventInit) => dispatchDomEvent(
|
||||
env.window, new env.window.KeyboardEvent(type, { bubbles: true, cancelable: true, ...init }),
|
||||
);
|
||||
return { updates, key, button };
|
||||
}
|
||||
|
||||
test("the tab modifier editor saves left Alt on release without requiring a digit", async (t) => {
|
||||
const { updates, key } = await setup(t, "Switch to Tab [1...9]");
|
||||
await key("keydown", { key: "Alt", code: "AltLeft", altKey: true });
|
||||
assert.deepEqual(updates, []);
|
||||
await key("keyup", { key: "Alt", code: "AltLeft" });
|
||||
assert.deepEqual(updates, [["switch-tab-1-9", "pc", "Alt + [1...9]"]]);
|
||||
});
|
||||
|
||||
test("the modifier editor waits for the full modifier chord to be released", async (t) => {
|
||||
const { updates, key } = await setup(t, "Switch to Tab [1...9]");
|
||||
await key("keydown", { key: "Control", ctrlKey: true });
|
||||
await key("keydown", { key: "Alt", ctrlKey: true, altKey: true });
|
||||
await key("keyup", { key: "Alt", ctrlKey: true });
|
||||
assert.deepEqual(updates, []);
|
||||
await key("keyup", { key: "Control" });
|
||||
assert.deepEqual(updates, [["switch-tab-1-9", "pc", "Ctrl + Alt + [1...9]"]]);
|
||||
});
|
||||
|
||||
test("ordinary shortcuts still require a non-modifier key", async (t) => {
|
||||
const { updates, key } = await setup(t, "Next Tab");
|
||||
await key("keydown", { key: "Alt", altKey: true });
|
||||
await key("keyup", { key: "Alt" });
|
||||
assert.deepEqual(updates, []);
|
||||
await key("keydown", { key: "ArrowRight", altKey: true });
|
||||
assert.deepEqual(updates, [["next-tab", "pc", "Alt + \u2192"]]);
|
||||
});
|
||||
|
||||
test("Escape cancels an unfinished modifier recording", async (t) => {
|
||||
const { updates, key, button } = await setup(t, "Switch to Tab [1...9]");
|
||||
await key("keydown", { key: "Alt", altKey: true });
|
||||
await key("keydown", { key: "Escape", altKey: true });
|
||||
await key("keyup", { key: "Alt" });
|
||||
assert.deepEqual(updates, []);
|
||||
assert.equal(button.textContent, "Ctrl");
|
||||
});
|
||||
@@ -64,6 +64,7 @@ export default function SettingsShortcutsTab(props: {
|
||||
if (!recordingBindingId || !recordingScheme) return;
|
||||
|
||||
const specialSuffix = getSpecialSuffix(recordingBindingId);
|
||||
let pendingModifierBinding: string | null = null;
|
||||
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
// Skip while app lock overlay is up so password keys never bind (Codex P2).
|
||||
@@ -77,8 +78,6 @@ export default function SettingsShortcutsTab(props: {
|
||||
}
|
||||
|
||||
if (specialSuffix) {
|
||||
if (["Meta", "Control", "Alt", "Shift"].includes(e.key)) return;
|
||||
|
||||
const parts: string[] = [];
|
||||
if (recordingScheme === "mac") {
|
||||
if (e.metaKey) parts.push("⌘");
|
||||
@@ -95,6 +94,11 @@ export default function SettingsShortcutsTab(props: {
|
||||
const modifierString = parts.length > 0 ? `${parts.join(" + ")} + ` : "";
|
||||
const fullKeyString = modifierString + specialSuffix;
|
||||
|
||||
if (["Meta", "Control", "Alt", "Shift"].includes(e.key)) {
|
||||
pendingModifierBinding = fullKeyString;
|
||||
return;
|
||||
}
|
||||
|
||||
updateKeyBinding?.(recordingBindingId, recordingScheme, fullKeyString);
|
||||
cancelRecording();
|
||||
return;
|
||||
@@ -106,6 +110,17 @@ export default function SettingsShortcutsTab(props: {
|
||||
cancelRecording();
|
||||
};
|
||||
|
||||
const handleKeyUp = (e: KeyboardEvent) => {
|
||||
if (!specialSuffix || !pendingModifierBinding || isAppLockOverlayActive()) return;
|
||||
if (!["Meta", "Control", "Alt", "Shift"].includes(e.key)) return;
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
// Wait for release so a modifier-only editor can capture Ctrl+Alt, etc.
|
||||
if (e.metaKey || e.ctrlKey || e.altKey || e.shiftKey) return;
|
||||
updateKeyBinding?.(recordingBindingId, recordingScheme, pendingModifierBinding);
|
||||
cancelRecording();
|
||||
};
|
||||
|
||||
const handleClick = () => {
|
||||
cancelRecording();
|
||||
};
|
||||
@@ -115,9 +130,11 @@ export default function SettingsShortcutsTab(props: {
|
||||
}, 100);
|
||||
|
||||
window.addEventListener("keydown", handleKeyDown, true);
|
||||
window.addEventListener("keyup", handleKeyUp, true);
|
||||
return () => {
|
||||
clearTimeout(timer);
|
||||
window.removeEventListener("keydown", handleKeyDown, true);
|
||||
window.removeEventListener("keyup", handleKeyUp, true);
|
||||
window.removeEventListener("click", handleClick, true);
|
||||
};
|
||||
}, [recordingBindingId, recordingScheme, updateKeyBinding, cancelRecording, getSpecialSuffix]);
|
||||
|
||||
@@ -0,0 +1,258 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import {
|
||||
attachWindowResizeFocusRestore,
|
||||
WINDOW_RESIZE_RESTORE_FOCUS_DELAY_MS,
|
||||
type MinimalDocument,
|
||||
} from "./restoreWindowResizeFocus";
|
||||
|
||||
type FakeWindow = {
|
||||
listeners: Map<string, Array<() => void>>;
|
||||
addEventListener: (type: string, listener: () => void) => void;
|
||||
removeEventListener: (type: string, listener: () => void) => void;
|
||||
emit: (type: string) => void;
|
||||
};
|
||||
|
||||
const createWindowStub = (): FakeWindow => {
|
||||
const listeners = new Map<string, Array<() => void>>();
|
||||
return {
|
||||
listeners,
|
||||
addEventListener(type, listener) {
|
||||
const bucket = listeners.get(type) ?? [];
|
||||
bucket.push(listener);
|
||||
listeners.set(type, bucket);
|
||||
},
|
||||
removeEventListener(type, listener) {
|
||||
const bucket = listeners.get(type) ?? [];
|
||||
const index = bucket.indexOf(listener);
|
||||
if (index >= 0) bucket.splice(index, 1);
|
||||
},
|
||||
emit(type) {
|
||||
for (const listener of [...(listeners.get(type) ?? [])]) listener();
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
type SchedulerState = {
|
||||
schedule: (callback: () => void, delay: number) => number;
|
||||
cancel: (id: number) => void;
|
||||
runNext: () => void;
|
||||
pendingCount: () => number;
|
||||
};
|
||||
|
||||
const createScheduler = (): SchedulerState => {
|
||||
type Entry = { id: number; callback: () => void; cancelled: boolean };
|
||||
const queue: Entry[] = [];
|
||||
let nextId = 1;
|
||||
return {
|
||||
schedule(callback, delay) {
|
||||
assert.equal(delay, WINDOW_RESIZE_RESTORE_FOCUS_DELAY_MS);
|
||||
queue.push({ id: nextId, callback, cancelled: false });
|
||||
return nextId++;
|
||||
},
|
||||
cancel(id) {
|
||||
for (const entry of queue) {
|
||||
if (entry.id === id) entry.cancelled = true;
|
||||
}
|
||||
},
|
||||
runNext() {
|
||||
while (queue.length) {
|
||||
const entry = queue.shift()!;
|
||||
if (!entry.cancelled) {
|
||||
entry.callback();
|
||||
return;
|
||||
}
|
||||
}
|
||||
},
|
||||
pendingCount: () => queue.filter((entry) => !entry.cancelled).length,
|
||||
};
|
||||
};
|
||||
|
||||
test("resize restores terminal focus once the resize settles", () => {
|
||||
const win = createWindowStub();
|
||||
const scheduler = createScheduler();
|
||||
let refocusCalls = 0;
|
||||
const detach = attachWindowResizeFocusRestore({
|
||||
win,
|
||||
doc: { querySelector: () => null, hasFocus: () => true } as MinimalDocument,
|
||||
refocus: () => { refocusCalls += 1; },
|
||||
scheduleTimeout: scheduler.schedule,
|
||||
cancelTimeout: scheduler.cancel,
|
||||
});
|
||||
|
||||
win.emit("resize");
|
||||
assert.equal(scheduler.pendingCount(), 1, "restore must be debounced");
|
||||
assert.equal(refocusCalls, 0);
|
||||
scheduler.runNext();
|
||||
assert.equal(refocusCalls, 1);
|
||||
|
||||
detach();
|
||||
});
|
||||
|
||||
test("a continuous frame drag only triggers one restore", () => {
|
||||
const win = createWindowStub();
|
||||
const scheduler = createScheduler();
|
||||
let refocusCalls = 0;
|
||||
const detach = attachWindowResizeFocusRestore({
|
||||
win,
|
||||
doc: { querySelector: () => null, hasFocus: () => true } as MinimalDocument,
|
||||
refocus: () => { refocusCalls += 1; },
|
||||
scheduleTimeout: scheduler.schedule,
|
||||
cancelTimeout: scheduler.cancel,
|
||||
});
|
||||
|
||||
win.emit("resize");
|
||||
win.emit("resize");
|
||||
win.emit("resize");
|
||||
scheduler.runNext();
|
||||
assert.equal(refocusCalls, 1, "debounced resizes must collapse into one restore");
|
||||
|
||||
detach();
|
||||
});
|
||||
|
||||
test("resize does not steal focus when the app window is not focused", () => {
|
||||
const win = createWindowStub();
|
||||
const scheduler = createScheduler();
|
||||
let refocusCalls = 0;
|
||||
const detach = attachWindowResizeFocusRestore({
|
||||
win,
|
||||
doc: { querySelector: () => null, hasFocus: () => false } as MinimalDocument,
|
||||
refocus: () => { refocusCalls += 1; },
|
||||
scheduleTimeout: scheduler.schedule,
|
||||
cancelTimeout: scheduler.cancel,
|
||||
});
|
||||
|
||||
win.emit("resize");
|
||||
scheduler.runNext();
|
||||
assert.equal(refocusCalls, 0);
|
||||
|
||||
detach();
|
||||
});
|
||||
|
||||
test("resize restore is skipped while a modal dialog is open", () => {
|
||||
const win = createWindowStub();
|
||||
const scheduler = createScheduler();
|
||||
let refocusCalls = 0;
|
||||
const detach = attachWindowResizeFocusRestore({
|
||||
win,
|
||||
doc: {
|
||||
querySelector: (selector: string) => (
|
||||
selector === '[role="dialog"][data-state="open"]' ? {} : null
|
||||
),
|
||||
hasFocus: () => true,
|
||||
} as MinimalDocument,
|
||||
refocus: () => { refocusCalls += 1; },
|
||||
scheduleTimeout: scheduler.schedule,
|
||||
cancelTimeout: scheduler.cancel,
|
||||
});
|
||||
|
||||
win.emit("resize");
|
||||
scheduler.runNext();
|
||||
assert.equal(refocusCalls, 0);
|
||||
|
||||
detach();
|
||||
});
|
||||
|
||||
test("resize does not steal focus from an active editable field", () => {
|
||||
const win = createWindowStub();
|
||||
const scheduler = createScheduler();
|
||||
let refocusCalls = 0;
|
||||
const detach = attachWindowResizeFocusRestore({
|
||||
win,
|
||||
doc: {
|
||||
querySelector: () => null,
|
||||
hasFocus: () => true,
|
||||
activeElement: { tagName: "INPUT", matches: () => false },
|
||||
} as unknown as MinimalDocument,
|
||||
refocus: () => { refocusCalls += 1; },
|
||||
scheduleTimeout: scheduler.schedule,
|
||||
cancelTimeout: scheduler.cancel,
|
||||
});
|
||||
|
||||
win.emit("resize");
|
||||
scheduler.runNext();
|
||||
assert.equal(refocusCalls, 0, "restore must not yank focus out of an editable field");
|
||||
|
||||
detach();
|
||||
});
|
||||
|
||||
test("resize does not steal focus from a contenteditable field", () => {
|
||||
const win = createWindowStub();
|
||||
const scheduler = createScheduler();
|
||||
let refocusCalls = 0;
|
||||
const detach = attachWindowResizeFocusRestore({
|
||||
win,
|
||||
doc: {
|
||||
querySelector: () => null,
|
||||
hasFocus: () => true,
|
||||
activeElement: { tagName: "DIV", isContentEditable: true, matches: () => false },
|
||||
} as unknown as MinimalDocument,
|
||||
refocus: () => { refocusCalls += 1; },
|
||||
scheduleTimeout: scheduler.schedule,
|
||||
cancelTimeout: scheduler.cancel,
|
||||
});
|
||||
|
||||
win.emit("resize");
|
||||
scheduler.runNext();
|
||||
assert.equal(refocusCalls, 0);
|
||||
|
||||
detach();
|
||||
});
|
||||
|
||||
test("resize still restores while the xterm helper textarea is focused", () => {
|
||||
const win = createWindowStub();
|
||||
const scheduler = createScheduler();
|
||||
let refocusCalls = 0;
|
||||
const detach = attachWindowResizeFocusRestore({
|
||||
win,
|
||||
doc: {
|
||||
querySelector: () => null,
|
||||
hasFocus: () => true,
|
||||
activeElement: {
|
||||
tagName: "TEXTAREA",
|
||||
matches: (selector: string) => selector === "textarea.xterm-helper-textarea",
|
||||
},
|
||||
} as unknown as MinimalDocument,
|
||||
refocus: () => { refocusCalls += 1; },
|
||||
scheduleTimeout: scheduler.schedule,
|
||||
cancelTimeout: scheduler.cancel,
|
||||
});
|
||||
|
||||
win.emit("resize");
|
||||
scheduler.runNext();
|
||||
assert.equal(refocusCalls, 1, "terminal-owned focus must be restored after resize");
|
||||
|
||||
detach();
|
||||
});
|
||||
|
||||
test("detach removes the listener and cancels a pending restore", () => {
|
||||
const win = createWindowStub();
|
||||
const scheduler = createScheduler();
|
||||
let refocusCalls = 0;
|
||||
const detach = attachWindowResizeFocusRestore({
|
||||
win,
|
||||
doc: { querySelector: () => null, hasFocus: () => true } as MinimalDocument,
|
||||
refocus: () => { refocusCalls += 1; },
|
||||
scheduleTimeout: scheduler.schedule,
|
||||
cancelTimeout: scheduler.cancel,
|
||||
});
|
||||
|
||||
win.emit("resize");
|
||||
detach();
|
||||
|
||||
assert.equal((win.listeners.get("resize") ?? []).length, 0, "listener must be removed");
|
||||
assert.equal(scheduler.pendingCount(), 0, "pending restore must be cancelled");
|
||||
scheduler.runNext();
|
||||
assert.equal(refocusCalls, 0);
|
||||
});
|
||||
|
||||
test("a win without listener support degrades to a no-op detach", () => {
|
||||
const detach = attachWindowResizeFocusRestore({
|
||||
win: null,
|
||||
refocus: () => {},
|
||||
scheduleTimeout: () => 1,
|
||||
cancelTimeout: () => {},
|
||||
});
|
||||
assert.doesNotThrow(() => detach());
|
||||
});
|
||||
@@ -0,0 +1,104 @@
|
||||
import { hasOpenAppDialog } from "./focusTerminalSession";
|
||||
|
||||
/** Debounce so a continuous frame drag (or maximize transition) triggers one restore. */
|
||||
export const WINDOW_RESIZE_RESTORE_FOCUS_DELAY_MS = 120;
|
||||
|
||||
export type MinimalWindow = {
|
||||
addEventListener?: (type: string, listener: () => void) => unknown;
|
||||
removeEventListener?: (type: string, listener: () => void) => unknown;
|
||||
};
|
||||
|
||||
export type MinimalActiveElement = {
|
||||
tagName?: string | null;
|
||||
isContentEditable?: boolean;
|
||||
matches?: (selector: string) => boolean;
|
||||
};
|
||||
|
||||
export type MinimalDocument = Parameters<typeof hasOpenAppDialog>[0] & {
|
||||
hasFocus?: () => boolean;
|
||||
activeElement?: MinimalActiveElement | null;
|
||||
};
|
||||
|
||||
/**
|
||||
* True when keyboard focus sits in an editable app field (search box, chat
|
||||
* input, SFTP filter, inline rename, ...) that is not an xterm helper
|
||||
* textarea. Resize-driven restores must not yank focus away from such fields.
|
||||
* Focus parked on `body`/`html` (or an xterm helper textarea) does not count
|
||||
* as editing another field.
|
||||
*/
|
||||
export const hasActiveEditableElement = (doc: MinimalDocument | null): boolean => {
|
||||
const element = doc?.activeElement;
|
||||
if (!element) return false;
|
||||
if (typeof element.matches === "function" && element.matches("textarea.xterm-helper-textarea")) {
|
||||
return false;
|
||||
}
|
||||
const tagName = typeof element.tagName === "string" ? element.tagName.toLowerCase() : "";
|
||||
return (
|
||||
tagName === "input"
|
||||
|| tagName === "textarea"
|
||||
|| tagName === "select"
|
||||
|| Boolean(element.isContentEditable)
|
||||
);
|
||||
};
|
||||
|
||||
type AttachWindowResizeFocusRestoreOptions = {
|
||||
win?: MinimalWindow | null;
|
||||
doc?: MinimalDocument | null;
|
||||
refocus?: () => void;
|
||||
delayMs?: number;
|
||||
scheduleTimeout?: (callback: () => void, delay: number) => unknown;
|
||||
cancelTimeout?: (id: unknown) => void;
|
||||
};
|
||||
|
||||
/**
|
||||
* #3647: dragging the OS window frame (or maximizing) leaves the active xterm
|
||||
* helper textarea unfocused, so keystrokes do nothing until the user clicks
|
||||
* back into the terminal. Attach a window `resize` listener that re-runs the
|
||||
* tab-change refocus path after the resize settles.
|
||||
*
|
||||
* Restore is skipped when the app window does not hold OS input focus, when a
|
||||
* modal dialog is open, or when an editable element other than the xterm
|
||||
* helper textarea already holds focus, so the restore never steals typing
|
||||
* from elsewhere.
|
||||
*/
|
||||
export const attachWindowResizeFocusRestore = (
|
||||
{
|
||||
win = typeof window !== "undefined" ? window : null,
|
||||
doc = typeof document !== "undefined" ? document : null,
|
||||
refocus,
|
||||
delayMs = WINDOW_RESIZE_RESTORE_FOCUS_DELAY_MS,
|
||||
scheduleTimeout = (callback: () => void, delay: number) => setTimeout(callback, delay),
|
||||
cancelTimeout = (id: unknown) => clearTimeout(id as ReturnType<typeof setTimeout>),
|
||||
}: AttachWindowResizeFocusRestoreOptions = {},
|
||||
): (() => void) => {
|
||||
if (!win?.addEventListener || !win.removeEventListener || typeof refocus !== "function") {
|
||||
return () => {};
|
||||
}
|
||||
|
||||
let timerId: unknown = null;
|
||||
const restoreFocus = () => {
|
||||
timerId = null;
|
||||
if (doc?.hasFocus?.() === false) return;
|
||||
if (hasOpenAppDialog(doc)) return;
|
||||
// The window holds OS focus, but the user may still be typing in a
|
||||
// non-terminal field (terminal search, AI chat, SFTP filter, rename).
|
||||
// Never steal focus from an active editable element.
|
||||
if (hasActiveEditableElement(doc)) return;
|
||||
refocus();
|
||||
};
|
||||
const handleResize = () => {
|
||||
if (timerId !== null) {
|
||||
cancelTimeout(timerId);
|
||||
}
|
||||
timerId = scheduleTimeout(restoreFocus, delayMs);
|
||||
};
|
||||
|
||||
win.addEventListener("resize", handleResize);
|
||||
return () => {
|
||||
win.removeEventListener("resize", handleResize);
|
||||
if (timerId !== null) {
|
||||
cancelTimeout(timerId);
|
||||
timerId = null;
|
||||
}
|
||||
};
|
||||
};
|
||||
@@ -33,7 +33,7 @@ const createFakeTerm = (cols = 80) => {
|
||||
onRender: (handler: (element: FakeElement) => void) => { dispose: () => void };
|
||||
onDispose: (handler: Handler) => { dispose: () => void };
|
||||
}> = [];
|
||||
const markers: Array<{ line: number; disposed: boolean }> = [];
|
||||
const markers: Array<{ line: number; disposed: boolean; dispose: () => void }> = [];
|
||||
|
||||
const term = {
|
||||
cols,
|
||||
@@ -477,6 +477,84 @@ test('CursorLineHighlighter restores after the terminal resets its decorations',
|
||||
highlighter.dispose();
|
||||
});
|
||||
|
||||
test('CursorLineHighlighter self-heals when xterm disposes its decorations without a write', () => {
|
||||
const term = createFakeTerm(80);
|
||||
const highlighter = new CursorLineHighlighter(term as never);
|
||||
highlighter.setEnabled(true);
|
||||
assert.equal(term.decorations.at(-1)?.disposed, false);
|
||||
|
||||
// xterm-side disposal (decoration service reset / term.reset): no cursor
|
||||
// move and no write follow, only the next render frame.
|
||||
term.resetDecorations();
|
||||
assert.equal(term.decorations[0]?.disposed, true);
|
||||
|
||||
term.render();
|
||||
assert.equal(term.decorations.at(-1)?.disposed, false);
|
||||
assert.equal(term.decorations.at(-1)?.options.width, 80);
|
||||
highlighter.dispose();
|
||||
});
|
||||
|
||||
test('CursorLineHighlighter recreates after a buffer clear wipes markers and decorations', () => {
|
||||
const term = createFakeTerm(80);
|
||||
const highlighter = new CursorLineHighlighter(term as never);
|
||||
highlighter.setEnabled(true);
|
||||
assert.equal(term.markers.at(-1)?.disposed, false);
|
||||
|
||||
// term.clear() disposes every marker; decorations follow their markers.
|
||||
for (const marker of term.markers) marker.dispose();
|
||||
term.resetDecorations();
|
||||
assert.equal(term.decorations.at(-1)?.disposed, true);
|
||||
|
||||
term.render();
|
||||
assert.equal(term.decorations.at(-1)?.disposed, false);
|
||||
assert.equal(term.markers.at(-1)?.line, 0);
|
||||
highlighter.dispose();
|
||||
});
|
||||
|
||||
test('CursorLineHighlighter does not duplicate decorations on redundant renders', () => {
|
||||
const term = createFakeTerm(80);
|
||||
const highlighter = new CursorLineHighlighter(term as never);
|
||||
highlighter.setEnabled(true);
|
||||
const aliveBefore = term.decorations.filter((decoration) => !decoration.disposed).length;
|
||||
|
||||
term.render();
|
||||
term.render();
|
||||
assert.equal(
|
||||
term.decorations.filter((decoration) => !decoration.disposed).length,
|
||||
aliveBefore,
|
||||
);
|
||||
highlighter.dispose();
|
||||
});
|
||||
|
||||
test('CursorLineHighlighter does not churn markers when a fully colored row has no decorations', () => {
|
||||
const cols = 8;
|
||||
const term = createFakeTerm(cols);
|
||||
// Every cell has an explicit ANSI background and the row is full, so both
|
||||
// highlight ranges and the blank tail are legitimately empty.
|
||||
term.setColoredBackgrounds(Array.from({ length: cols }, (_, column) => column));
|
||||
const highlighter = new CursorLineHighlighter(term as never);
|
||||
highlighter.setEnabled(true);
|
||||
|
||||
assert.equal(term.decorations.length, 0);
|
||||
assert.equal(term.markers.length, 1);
|
||||
const marker = term.markers[0];
|
||||
assert.ok(marker);
|
||||
assert.equal(marker.disposed, false);
|
||||
assert.equal(marker.line, 0);
|
||||
const aliveBefore = term.markers.filter((candidate) => !candidate.disposed).length;
|
||||
|
||||
term.render();
|
||||
term.render();
|
||||
term.render();
|
||||
|
||||
assert.equal(term.markers.length, 1);
|
||||
assert.equal(term.markers.filter((candidate) => !candidate.disposed).length, aliveBefore);
|
||||
assert.equal(marker.disposed, false);
|
||||
assert.equal(marker.line, 0);
|
||||
assert.equal(term.decorations.length, 0);
|
||||
highlighter.dispose();
|
||||
});
|
||||
|
||||
test('CursorLineHighlighter clears in the alternate buffer and restores in normal buffer', () => {
|
||||
const term = createFakeTerm(80);
|
||||
const highlighter = new CursorLineHighlighter(term as never);
|
||||
|
||||
@@ -37,6 +37,8 @@ export class CursorLineHighlighter implements IDisposable {
|
||||
private activeColor: string | null = null;
|
||||
private activeRanges: HighlightRange[] = [];
|
||||
private activeTailRanges: HighlightRange[] = [];
|
||||
/** False once any tracked decoration has been disposed out from under us. */
|
||||
private decorationsAlive = false;
|
||||
private readonly disposables: IDisposable[] = [];
|
||||
private pendingRefresh = false;
|
||||
private disposed = false;
|
||||
@@ -46,10 +48,16 @@ export class CursorLineHighlighter implements IDisposable {
|
||||
this.term.onCursorMove(() => this.markPendingRefresh()),
|
||||
this.term.onWriteParsed(() => this.markPendingRefresh()),
|
||||
this.term.onRender(() => {
|
||||
if (this.pendingRefresh) {
|
||||
this.pendingRefresh = false;
|
||||
this.refresh();
|
||||
}
|
||||
const pending = this.pendingRefresh;
|
||||
this.pendingRefresh = false;
|
||||
if (this.disposed || !this.enabled) return;
|
||||
// Writes and cursor moves defer to render, but xterm can also dispose
|
||||
// or clear our decorations out from under us (term.clear/reset,
|
||||
// decoration service resets, marker reindexing) without any further
|
||||
// cursor move or write. Verify the tracked set on every render so the
|
||||
// highlight can self-heal at the cursor line instead of staying
|
||||
// anchored to stale bookkeeping (wrong/missing line).
|
||||
if (pending || !this.isTrackingIntact()) this.refresh();
|
||||
}),
|
||||
this.term.onSelectionChange(() => this.refresh({ force: true })),
|
||||
this.term.onResize(() => this.refresh({ force: true })),
|
||||
@@ -111,6 +119,7 @@ export class CursorLineHighlighter implements IDisposable {
|
||||
color === this.activeColor &&
|
||||
rangesEqual(ranges, this.activeRanges) &&
|
||||
rangesEqual(tailRanges, this.activeTailRanges) &&
|
||||
this.decorationsAlive &&
|
||||
this.marker &&
|
||||
!this.marker.isDisposed &&
|
||||
this.marker.line === absoluteLine
|
||||
@@ -160,13 +169,18 @@ export class CursorLineHighlighter implements IDisposable {
|
||||
|
||||
this.marker = marker;
|
||||
this.decorations = decorations;
|
||||
// An empty set is valid when the whole row already has ANSI backgrounds or
|
||||
// inverse cells. Tracking stays intact so later frames do not churn markers.
|
||||
this.decorationsAlive = true;
|
||||
this.decorationDisposeListeners = decorations.map((decoration) =>
|
||||
decoration.onDispose(() => {
|
||||
if (this.decorations.includes(decoration)) {
|
||||
this.decorationsAlive = false;
|
||||
this.activeLine = null;
|
||||
this.activeCols = null;
|
||||
this.activeColor = null;
|
||||
this.activeRanges = [];
|
||||
this.activeTailRanges = [];
|
||||
}
|
||||
}),
|
||||
);
|
||||
@@ -244,6 +258,7 @@ export class CursorLineHighlighter implements IDisposable {
|
||||
this.marker = null;
|
||||
this.decorations = [];
|
||||
this.decorationDisposeListeners = [];
|
||||
this.decorationsAlive = false;
|
||||
this.activeLine = null;
|
||||
this.activeCols = null;
|
||||
this.activeColor = null;
|
||||
@@ -251,6 +266,19 @@ export class CursorLineHighlighter implements IDisposable {
|
||||
this.activeTailRanges = [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Cheap render-time check that the tracked marker and decorations are still
|
||||
* owned by xterm. xterm disposes highlight decorations on buffer clears,
|
||||
* resets, and marker reindexing without emitting cursor-move/write events,
|
||||
* so a stale tracking state must be recreated before the next frame paints.
|
||||
*/
|
||||
private isTrackingIntact(): boolean {
|
||||
if (this.marker === null || this.marker.isDisposed) {
|
||||
return this.marker === null && this.decorations.length === 0;
|
||||
}
|
||||
return this.decorationsAlive;
|
||||
}
|
||||
|
||||
private clearOwned(
|
||||
marker: IMarker | null,
|
||||
decorations: IDecoration[],
|
||||
|
||||
@@ -23,6 +23,7 @@ import {
|
||||
type SidePanelTabRemap,
|
||||
} from '../../domain/workspaceSidePanelTabRemap';
|
||||
import { AI_PANEL_FORCE_HIDE_SHELL } from '../ai/aiPanelDiagnostics';
|
||||
import { attachWindowResizeFocusRestore } from '../terminal/restoreWindowResizeFocus';
|
||||
import { toast } from '../ui/toast';
|
||||
import { getTerminalSidePanelShellWidth } from './TerminalLayerSidePanelSection';
|
||||
import type { RendererCwdSource } from '../terminal/sftpCwd';
|
||||
@@ -747,6 +748,23 @@ export function useTerminalLayerEffects(ctx: TerminalLayerEffectsContext) {
|
||||
refocusActiveTerminalSession();
|
||||
}, [activeTabId, isTerminalLayerVisible, refocusActiveTerminalSession]);
|
||||
|
||||
// Restore keyboard focus after the OS window is resized or maximized.
|
||||
// #3647: dragging the frame (or maximizing) leaves the active xterm helper
|
||||
// textarea unfocused on Windows, so keystrokes do nothing until a click.
|
||||
// Reuse the tab-change refocus path; the helper only fires once the resize
|
||||
// settles and is gated on the app window actually holding input focus.
|
||||
useEffect(() => {
|
||||
if (!isTerminalLayerVisible) return undefined;
|
||||
return attachWindowResizeFocusRestore({
|
||||
win: window,
|
||||
doc: document,
|
||||
refocus: () => {
|
||||
if (isComposeBarOpen) return;
|
||||
refocusActiveTerminalSession?.();
|
||||
},
|
||||
});
|
||||
}, [document, isComposeBarOpen, isTerminalLayerVisible, refocusActiveTerminalSession, window]);
|
||||
|
||||
// When focusedSessionId changes or terminal layer becomes visible,
|
||||
// focus the corresponding terminal to restore :focus-within CSS state
|
||||
useEffect(() => {
|
||||
|
||||
@@ -536,7 +536,10 @@ const DEFAULT_TERMINAL_SETTINGS: TerminalSettings = {
|
||||
middleClickPaste: true,
|
||||
wordSeparators: DEFAULT_TERMINAL_WORD_SEPARATORS,
|
||||
linkModifier: 'none',
|
||||
autoCloseOnExit: true,
|
||||
// Off by default: remote graceful channel closes can be reported as a clean
|
||||
// exit (code 0), which would auto-close tabs on network drops. Users who
|
||||
// want close-on-exit can enable it in Settings -> Terminal -> Behavior.
|
||||
autoCloseOnExit: false,
|
||||
// Issue #3087: keep terminal history visible after an established session disconnects.
|
||||
disconnectedNoticeMode: 'terminal',
|
||||
keywordHighlightEnabled: true,
|
||||
@@ -552,7 +555,11 @@ const DEFAULT_TERMINAL_SETTINGS: TerminalSettings = {
|
||||
verifyHostKeys: true,
|
||||
keepaliveInterval: 30,
|
||||
keepaliveCountMax: 10,
|
||||
sshAutoReconnectEnabled: false,
|
||||
// On by default: unexpected transport drops (VPN / Wi-Fi blips) schedule an
|
||||
// in-place reconnect every 5s with the same session id. Clean shell exits
|
||||
// still close the tab, and administrative idle/timeout exits stay
|
||||
// disconnected. Users on always-stable links can turn it off.
|
||||
sshAutoReconnectEnabled: true,
|
||||
x11Display: '', // Empty = use DISPLAY/default local X server
|
||||
moshClientPath: '', // Legacy mosh-client override; normal UI uses bundled mosh-client
|
||||
showHostInfoBar: true, // Preserve the existing host information bar by default
|
||||
|
||||
@@ -313,6 +313,12 @@ export interface SyncPayload {
|
||||
terminalFontFamily?: string;
|
||||
terminalFontSize?: number;
|
||||
terminalSettings?: Record<string, unknown>;
|
||||
/**
|
||||
* Present once the sender has run the autoCloseOnExit default migration.
|
||||
* Legacy payloads omit it; a materialized `true` in those payloads is the
|
||||
* old default, not an explicit opt-in.
|
||||
*/
|
||||
autoCloseOnExitDefaultMigrated?: boolean;
|
||||
terminalSidePanelAutoOpen?: boolean;
|
||||
terminalSidePanelAutoOpenTab?: import('./terminalSidePanelAutoOpen').TerminalSidePanelAutoOpenTab;
|
||||
localShellSidePanelAutoOpen?: boolean;
|
||||
@@ -390,6 +396,27 @@ export interface SyncPayload {
|
||||
convergentSync?: ConvergentSyncEnvelopeV2;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pre-migration snapshots stored autoCloseOnExit: true as the default and do
|
||||
* not carry `autoCloseOnExitDefaultMigrated`. Rewrite that legacy true to
|
||||
* false. A payload that records the migration keeps an explicit true.
|
||||
*/
|
||||
export function reconcileInboundAutoCloseOnExit(
|
||||
autoCloseOnExit: unknown,
|
||||
payloadRecordsMigratedChoice: unknown,
|
||||
): unknown {
|
||||
if (autoCloseOnExit === true && payloadRecordsMigratedChoice !== true) return false;
|
||||
return autoCloseOnExit;
|
||||
}
|
||||
|
||||
/** True when this side both migrated and explicitly enabled auto-close. */
|
||||
export function payloadOptsIntoAutoClose(
|
||||
settings: SyncPayload['settings'] | undefined,
|
||||
): boolean {
|
||||
return settings?.autoCloseOnExitDefaultMigrated === true
|
||||
&& settings.terminalSettings?.autoCloseOnExit === true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Strip device-local connection telemetry from hosts before any sync
|
||||
* comparison or upload (#2629). `lastConnectedAt` updates on every successful
|
||||
|
||||
@@ -631,3 +631,44 @@ test("group notes sync edits and clearing without dropping connection settings",
|
||||
const cleared = payload({ customGroups: ["Project"], groupConfigs: [{ path: "Project", username: "admin" }] });
|
||||
assert.equal(mergeSyncPayloads(merged, merged, cleared).payload.groupConfigs?.[0].notes, undefined);
|
||||
});
|
||||
|
||||
test("mergeSyncPayloads does not glue a local migration marker onto a legacy auto-close true", () => {
|
||||
const merged = mergeSyncPayloads(
|
||||
null,
|
||||
payload({
|
||||
settings: {
|
||||
autoCloseOnExitDefaultMigrated: true,
|
||||
terminalSettings: { autoCloseOnExit: false, scrollback: 3000 },
|
||||
},
|
||||
}),
|
||||
payload({
|
||||
settings: {
|
||||
terminalSettings: { autoCloseOnExit: true, scrollback: 3000 },
|
||||
},
|
||||
}),
|
||||
).payload.settings;
|
||||
|
||||
assert.equal(merged?.terminalSettings?.autoCloseOnExit, false);
|
||||
assert.equal(merged?.terminalSettings?.scrollback, 3000);
|
||||
});
|
||||
|
||||
test("mergeSyncPayloads keeps an explicit auto-close opt-in from a migrated peer", () => {
|
||||
const merged = mergeSyncPayloads(
|
||||
null,
|
||||
payload({
|
||||
settings: {
|
||||
autoCloseOnExitDefaultMigrated: true,
|
||||
terminalSettings: { autoCloseOnExit: false },
|
||||
},
|
||||
}),
|
||||
payload({
|
||||
settings: {
|
||||
autoCloseOnExitDefaultMigrated: true,
|
||||
terminalSettings: { autoCloseOnExit: true },
|
||||
},
|
||||
}),
|
||||
).payload.settings;
|
||||
|
||||
assert.equal(merged?.autoCloseOnExitDefaultMigrated, true);
|
||||
assert.equal(merged?.terminalSettings?.autoCloseOnExit, true);
|
||||
});
|
||||
@@ -21,6 +21,7 @@
|
||||
|
||||
import { carryForwardSyncDeletions, getDeletedEntityIds } from './syncReliability';
|
||||
import {
|
||||
payloadOptsIntoAutoClose,
|
||||
sanitizeHostsForSync,
|
||||
type CloudSyncPayloadEntityKey,
|
||||
type SyncPayload,
|
||||
@@ -403,6 +404,24 @@ function mergeSettings(
|
||||
}
|
||||
}
|
||||
|
||||
// A three-way merge can keep this device's migration flag while adopting a
|
||||
// legacy remote autoCloseOnExit: true. That true is the old default unless
|
||||
// one of the sides explicitly opted in after migrating.
|
||||
const terminalSettings = merged.terminalSettings;
|
||||
if (
|
||||
terminalSettings
|
||||
&& typeof terminalSettings === 'object'
|
||||
&& !Array.isArray(terminalSettings)
|
||||
&& (terminalSettings as Record<string, unknown>).autoCloseOnExit === true
|
||||
&& !payloadOptsIntoAutoClose(local)
|
||||
&& !payloadOptsIntoAutoClose(remote)
|
||||
) {
|
||||
merged.terminalSettings = {
|
||||
...(terminalSettings as Record<string, unknown>),
|
||||
autoCloseOnExit: false,
|
||||
};
|
||||
}
|
||||
|
||||
return Object.keys(merged).length > 0 ? (merged as SettingsObj) : undefined;
|
||||
}
|
||||
|
||||
|
||||
@@ -185,16 +185,16 @@ test("normalizeTerminalSettings preserves disabled font ligatures", () => {
|
||||
assert.equal(normalizeTerminalSettings({ fontLigatures: false }).fontLigatures, false);
|
||||
});
|
||||
|
||||
test("normalizeTerminalSettings enables terminal auto-close by default", () => {
|
||||
assert.equal(normalizeTerminalSettings().autoCloseOnExit, true);
|
||||
test("normalizeTerminalSettings disables terminal auto-close by default", () => {
|
||||
assert.equal(normalizeTerminalSettings().autoCloseOnExit, false);
|
||||
});
|
||||
|
||||
test("normalizeTerminalSettings preserves disabled terminal auto-close", () => {
|
||||
assert.equal(normalizeTerminalSettings({ autoCloseOnExit: false }).autoCloseOnExit, false);
|
||||
});
|
||||
|
||||
test("normalizeTerminalSettings disables SSH auto reconnect by default", () => {
|
||||
assert.equal(normalizeTerminalSettings().sshAutoReconnectEnabled, false);
|
||||
test("normalizeTerminalSettings enables SSH auto reconnect by default", () => {
|
||||
assert.equal(normalizeTerminalSettings().sshAutoReconnectEnabled, true);
|
||||
});
|
||||
|
||||
test("normalizeTerminalSettings preserves explicit SSH auto reconnect settings", () => {
|
||||
|
||||
@@ -58,11 +58,25 @@ function subscribeToPtyData(ptyStream, onData) {
|
||||
throw new Error("PTY stream does not support data subscriptions");
|
||||
}
|
||||
|
||||
// Only the end of the output can hold the idle prompt, and this check runs for
|
||||
// every PTY chunk while the retained output may be hundreds of thousands of
|
||||
// characters long. Normalising the whole text (ANSI and carriage returns) on
|
||||
// each chunk stalls the worker during a flood, so look at a tail window that is
|
||||
// comfortably larger than the prompt including the escape codes it may carry.
|
||||
const PROMPT_SUFFIX_MIN_WINDOW_CHARS = 2048;
|
||||
const PROMPT_SUFFIX_WINDOW_PER_PROMPT_CHAR = 8;
|
||||
|
||||
function hasExpectedPromptSuffix(text, expectedPrompt) {
|
||||
if (!expectedPrompt) return false;
|
||||
const normalizedText = stripAnsi(String(text || "")).replace(/\r/g, "");
|
||||
const normalizedPrompt = stripAnsi(String(expectedPrompt || "")).replace(/\r/g, "");
|
||||
return !!normalizedPrompt && normalizedText.endsWith(normalizedPrompt);
|
||||
if (!normalizedPrompt) return false;
|
||||
const fullText = String(text || "");
|
||||
const windowChars = Math.max(
|
||||
PROMPT_SUFFIX_MIN_WINDOW_CHARS,
|
||||
String(expectedPrompt).length * PROMPT_SUFFIX_WINDOW_PER_PROMPT_CHAR,
|
||||
);
|
||||
const tail = fullText.length > windowChars ? fullText.slice(-windowChars) : fullText;
|
||||
return stripAnsi(tail).replace(/\r/g, "").endsWith(normalizedPrompt);
|
||||
}
|
||||
|
||||
function escapePosixSingleQuoted(text) {
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
"use strict";
|
||||
|
||||
const test = require("node:test");
|
||||
const assert = require("node:assert/strict");
|
||||
const { hasExpectedPromptSuffix } = require("./ptyExecHelpers.cjs");
|
||||
const { stripAnsi } = require("./shellUtils.cjs");
|
||||
|
||||
// The behaviour before the tail window was introduced: normalise the whole
|
||||
// text, then compare its end.
|
||||
function referenceHasExpectedPromptSuffix(text, expectedPrompt) {
|
||||
if (!expectedPrompt) return false;
|
||||
const normalizedText = stripAnsi(String(text || "")).replace(/\r/g, "");
|
||||
const normalizedPrompt = stripAnsi(String(expectedPrompt || "")).replace(/\r/g, "");
|
||||
return !!normalizedPrompt && normalizedText.endsWith(normalizedPrompt);
|
||||
}
|
||||
|
||||
const COLORED_PROMPT = "\x1b[01;32mroot@3160T\x1b[00m:\x1b[01;34m~\x1b[00m# ";
|
||||
|
||||
const CASES = [
|
||||
["plain prompt at the end", "output\r\nroot@3160T:~# ", "root@3160T:~# "],
|
||||
["prompt not at the end", "root@3160T:~# ls\r\nfile\r\n", "root@3160T:~# "],
|
||||
["colored prompt in the text, plain expected", `out\r\n${COLORED_PROMPT}`, "root@3160T:~# "],
|
||||
["colored prompt on both sides", `out\r\n${COLORED_PROMPT}`, COLORED_PROMPT],
|
||||
["carriage returns inside the prompt", "out\r\nroot@3160T:~\r# ", "root@3160T:~# "],
|
||||
["empty text", "", "root@3160T:~# "],
|
||||
["empty expected prompt", "root@3160T:~# ", ""],
|
||||
["null inputs", null, null],
|
||||
["text shorter than the prompt", "~# ", "root@3160T:~# "],
|
||||
];
|
||||
|
||||
for (const [name, text, prompt] of CASES) {
|
||||
test(`prompt suffix matches the full-text result: ${name}`, () => {
|
||||
assert.equal(hasExpectedPromptSuffix(text, prompt), referenceHasExpectedPromptSuffix(text, prompt));
|
||||
});
|
||||
}
|
||||
|
||||
test("prompt suffix is found after a very large flood of CRLF output", () => {
|
||||
const flood = "y\r\n".repeat(700_000);
|
||||
assert.equal(hasExpectedPromptSuffix(`${flood}root@3160T:~# `, "root@3160T:~# "), true);
|
||||
assert.equal(hasExpectedPromptSuffix(`${flood}root@3160T:~# x`, "root@3160T:~# "), false);
|
||||
assert.equal(hasExpectedPromptSuffix(`${flood}${COLORED_PROMPT}`, "root@3160T:~# "), true);
|
||||
});
|
||||
|
||||
test("prompt suffix check does not scan the whole buffer on every call", () => {
|
||||
// 2M characters of CRLF output used to cost seconds per call because every
|
||||
// call stripped ANSI and carriage returns over the entire text.
|
||||
const flood = "y\r\n".repeat(700_000);
|
||||
const started = process.hrtime.bigint();
|
||||
for (let i = 0; i < 50; i += 1) hasExpectedPromptSuffix(flood, "root@3160T:~# ");
|
||||
const elapsedMs = Number(process.hrtime.bigint() - started) / 1e6;
|
||||
assert.ok(elapsedMs < 250, `50 checks took ${elapsedMs.toFixed(0)} ms`);
|
||||
});
|
||||
@@ -1,6 +1,10 @@
|
||||
"use strict";
|
||||
|
||||
const DEFAULT_SESSION_IDLE_TIMEOUT_MINUTES = 30;
|
||||
// 8h. AI/agent-opened sessions are long-lived, stateful surfaces (prod DB
|
||||
// operations, deployments); a 30-minute silent close destroyed more in-flight
|
||||
// work than it saved. 8h covers a workday without sitting on the 24h clamp
|
||||
// maximum. Users can still set a shorter window in Settings -> AI.
|
||||
const DEFAULT_SESSION_IDLE_TIMEOUT_MINUTES = 480;
|
||||
const MIN_SESSION_IDLE_TIMEOUT_MINUTES = 1;
|
||||
const MAX_SESSION_IDLE_TIMEOUT_MINUTES = 24 * 60;
|
||||
|
||||
|
||||
@@ -0,0 +1,193 @@
|
||||
"use strict";
|
||||
|
||||
const test = require("node:test");
|
||||
const assert = require("node:assert/strict");
|
||||
const crypto = require("node:crypto");
|
||||
const fs = require("node:fs");
|
||||
const net = require("node:net");
|
||||
const os = require("node:os");
|
||||
const path = require("node:path");
|
||||
const { Server } = require("ssh2");
|
||||
const bridge = require("./sshBridge.cjs");
|
||||
const tempDirBridge = require("./tempDirBridge.cjs");
|
||||
|
||||
const listen = (server, ...args) => new Promise((resolve, reject) => {
|
||||
server.once("error", reject);
|
||||
server.listen(...args, resolve);
|
||||
});
|
||||
const close = (server) => new Promise((resolve) => server.close(resolve));
|
||||
|
||||
test("normal SSH startup preserves fallback after an unavailable system agent", {
|
||||
skip: process.platform === "win32", // This fixture exercises Unix agent sockets.
|
||||
timeout: 20_000,
|
||||
}, async (t) => {
|
||||
const fixture = fs.mkdtempSync(`${tempDirBridge.getTempFilePath("a")}-`);
|
||||
const originalHomedir = os.homedir;
|
||||
const originalAgentSocket = process.env.SSH_AUTH_SOCK;
|
||||
os.homedir = () => fixture; // Never discover the developer's default keys.
|
||||
t.after(() => {
|
||||
os.homedir = originalHomedir;
|
||||
if (originalAgentSocket === undefined) delete process.env.SSH_AUTH_SOCK;
|
||||
else process.env.SSH_AUTH_SOCK = originalAgentSocket;
|
||||
fs.rmSync(fixture, { recursive: true, force: true });
|
||||
});
|
||||
const hostKey = crypto.generateKeyPairSync("rsa", {
|
||||
modulusLength: 2048,
|
||||
privateKeyEncoding: { type: "pkcs1", format: "pem" },
|
||||
publicKeyEncoding: { type: "spki", format: "pem" },
|
||||
}).privateKey;
|
||||
|
||||
let sequence = 0;
|
||||
async function run({ stale = true, useSshAgent, password = "fixture-password", stall = false, jump = false } = {}) {
|
||||
const senderId = 3640 + sequence++;
|
||||
const socketPath = path.join(fixture, "s");
|
||||
fs.rmSync(socketPath, { force: true });
|
||||
const agentClients = new Set();
|
||||
let agentChecks = 0;
|
||||
const agent = net.createServer((socket) => {
|
||||
agentClients.add(socket);
|
||||
agentChecks += 1;
|
||||
socket.on("error", () => {});
|
||||
socket.on("close", () => agentClients.delete(socket));
|
||||
// SSH_AGENT_IDENTITIES_ANSWER with zero keys.
|
||||
socket.once("data", () => socket.end(Buffer.from([0, 0, 0, 5, 12, 0, 0, 0, 0])));
|
||||
});
|
||||
await listen(agent, socketPath);
|
||||
if (stale) {
|
||||
// Preserve a real socket entry with no listener, without a timing race.
|
||||
fs.renameSync(socketPath, socketPath + ".stale");
|
||||
await close(agent);
|
||||
fs.renameSync(socketPath + ".stale", socketPath);
|
||||
assert.ok(fs.statSync(socketPath).isSocket());
|
||||
}
|
||||
process.env.SSH_AUTH_SOCK = socketPath;
|
||||
const sshClients = new Set();
|
||||
const forwardingSockets = new Set();
|
||||
const methods = [];
|
||||
let shells = 0;
|
||||
const ssh = new Server({ hostKeys: [hostKey] }, (client) => {
|
||||
sshClients.add(client);
|
||||
client.on("error", () => {});
|
||||
client.on("close", () => sshClients.delete(client));
|
||||
client.on("tcpip", (accept, reject, info) => {
|
||||
if (info.destIP !== "127.0.0.1" || info.destPort !== ssh.address().port) return reject();
|
||||
const socket = net.connect(info.destPort, info.destIP, () => {
|
||||
const stream = accept();
|
||||
stream.on("error", () => socket.destroy());
|
||||
stream.pipe(socket).pipe(stream);
|
||||
});
|
||||
forwardingSockets.add(socket);
|
||||
socket.on("close", () => forwardingSockets.delete(socket));
|
||||
socket.on("error", () => reject());
|
||||
});
|
||||
client.on("authentication", (ctx) => {
|
||||
methods.push(ctx.method);
|
||||
if (ctx.method === "password" && stall) return;
|
||||
if (ctx.method === "password" && ctx.password === "fixture-password") ctx.accept();
|
||||
else ctx.reject(["publickey", "password"]);
|
||||
});
|
||||
client.on("ready", () => client.on("session", (accept) => {
|
||||
const session = accept();
|
||||
session.on("pty", (acceptPty) => acceptPty());
|
||||
session.on("shell", (acceptShell) => {
|
||||
shells += 1;
|
||||
acceptShell().write("fixture ready\r\n");
|
||||
});
|
||||
}));
|
||||
});
|
||||
await listen(ssh, 0, "127.0.0.1");
|
||||
const handlers = new Map();
|
||||
const sent = [];
|
||||
bridge.init({ sessions: new Map(), electronModule: {} });
|
||||
bridge.registerHandlers({ handle: (name, fn) => handlers.set(name, fn), on() {} });
|
||||
const event = { sender: { id: senderId, isDestroyed: () => false,
|
||||
send: (channel, payload) => sent.push({ channel, payload }) } };
|
||||
let guard;
|
||||
try {
|
||||
const start = handlers.get("netcatty:start")(event, {
|
||||
sessionId: `agent-fallback-${senderId}`, hostname: "127.0.0.1", port: ssh.address().port,
|
||||
username: "fixture", password, authMethod: "auto", useSshAgent,
|
||||
verifyHostKeys: false, reuseTransport: false, skipShellPidDiscovery: true,
|
||||
sshTcpConnectTimeoutMs: 1000, sshAuthReadyTimeoutMs: 1000,
|
||||
...(jump ? { jumpHosts: [{
|
||||
hostname: "127.0.0.1", port: ssh.address().port, username: "fixture",
|
||||
password, authMethod: "auto", useSshAgent,
|
||||
sshTcpConnectTimeoutMs: 1000, sshAuthReadyTimeoutMs: 1000,
|
||||
}] } : {}),
|
||||
});
|
||||
let error;
|
||||
try {
|
||||
await Promise.race([start, new Promise((_, reject) => {
|
||||
guard = setTimeout(() => reject(new Error("Fixture exceeded authentication deadline")), 4000);
|
||||
})]);
|
||||
} catch (err) { error = err; }
|
||||
return { error, methods, shells, sent: sent.slice(), agentChecks };
|
||||
} finally {
|
||||
clearTimeout(guard);
|
||||
for (const client of sshClients) client.end();
|
||||
for (const socket of forwardingSockets) socket.destroy();
|
||||
for (const client of agentClients) client.destroy();
|
||||
if (agent.listening) await close(agent);
|
||||
await close(ssh);
|
||||
}
|
||||
}
|
||||
|
||||
for (const [name, options] of [
|
||||
["stale agent", {}],
|
||||
["disabled agent", { useSshAgent: false }],
|
||||
["healthy empty agent", { stale: false }],
|
||||
]) {
|
||||
await t.test(`${name} still opens a real shell using the saved password`, async () => {
|
||||
const result = await run(options);
|
||||
assert.equal(result.error, undefined);
|
||||
assert.deepEqual(result.methods, ["none", "password"]);
|
||||
assert.equal(result.shells, 1);
|
||||
assert.equal(result.agentChecks, options.stale === false ? 1 : 0);
|
||||
assert.equal(result.sent.some(({ channel }) => channel === "netcatty:auth:failed"), false);
|
||||
assert.equal(result.sent.some(({ channel }) => channel === "netcatty:exit"), false);
|
||||
});
|
||||
}
|
||||
for (const [name, password, methods] of [
|
||||
["agent without other credentials", undefined, ["none"]],
|
||||
["incorrect password", "wrong-password", ["none", "password"]],
|
||||
]) {
|
||||
await t.test(`${name} still reports a real authentication failure`, async () => {
|
||||
// null deliberately omits a password rather than taking run's default.
|
||||
const result = await run({ password: password ?? null });
|
||||
assert.equal(result.error?.level, "client-authentication");
|
||||
assert.match(result.error.message, /All configured authentication methods failed/);
|
||||
assert.deepEqual(result.methods, methods);
|
||||
assert.equal(result.shells, 0);
|
||||
assert.ok(result.sent.some(({ channel }) => channel === "netcatty:auth:failed"));
|
||||
});
|
||||
}
|
||||
await t.test("stalled password fallback retains its authentication deadline", async () => {
|
||||
const result = await run({ stall: true });
|
||||
assert.match(result.error?.message || "", /Connection timeout to 127\.0\.0\.1/);
|
||||
assert.deepEqual(result.methods, ["none", "password"]);
|
||||
assert.equal(result.shells, 0);
|
||||
assert.ok(result.sent.some(({ channel, payload }) => (
|
||||
channel === "netcatty:exit" && payload.reason === "timeout"
|
||||
)));
|
||||
});
|
||||
await t.test("a stale jump-host agent still reaches a shell through saved passwords", async () => {
|
||||
const result = await run({ jump: true });
|
||||
assert.equal(result.error, undefined);
|
||||
assert.deepEqual(result.methods, ["none", "password", "none", "password"]);
|
||||
assert.equal(result.shells, 1);
|
||||
assert.equal(result.sent.some(({ channel }) => channel === "netcatty:auth:failed"), false);
|
||||
});
|
||||
await t.test("a fatal jump-host auth error after its agent error still rejects", async () => {
|
||||
const result = await run({ jump: true, password: "wrong-password" });
|
||||
assert.equal(result.error?.level, "client-authentication");
|
||||
assert.equal(result.error?.isJumpHostAuthError, true);
|
||||
assert.deepEqual(result.methods, ["none", "password"]);
|
||||
assert.equal(result.shells, 0);
|
||||
});
|
||||
await t.test("a stalled jump-host password retains its authentication deadline", async () => {
|
||||
const result = await run({ jump: true, stall: true });
|
||||
assert.match(result.error?.message || "", /Connection timeout to 127\.0\.0\.1/);
|
||||
assert.deepEqual(result.methods, ["none", "password"]);
|
||||
assert.equal(result.shells, 0);
|
||||
});
|
||||
});
|
||||
@@ -813,7 +813,12 @@ async function connectThroughChain(event, options, jumpHosts, targetHost, target
|
||||
}
|
||||
resolve();
|
||||
});
|
||||
conn.once('error', (err) => {
|
||||
conn.on('error', (err) => {
|
||||
// ssh2 continues to the next auth method after agent errors.
|
||||
if (err.level === 'agent') {
|
||||
console.log(`[Chain] Hop ${i + 1} non-fatal agent auth error (will try next method):`, err.message);
|
||||
return;
|
||||
}
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
clearAuthReadyTimer();
|
||||
|
||||
@@ -2415,7 +2415,90 @@ test("an exit-signal with no code does not fabricate an exit code 0", async (t)
|
||||
|
||||
const exits = getExitPayloads(sender);
|
||||
assert.equal(exits.length, 1);
|
||||
assert.equal(exits[0].reason, "closed");
|
||||
assert.equal(exits[0].reason, "signaled");
|
||||
assert.equal(exits[0].exitSignal, "SIGUSR2");
|
||||
assert.equal(exits[0].exitCode, undefined);
|
||||
assert.equal(exits[0].remoteClosedWithoutExitStatus, undefined, "the remote did report a signal");
|
||||
});
|
||||
|
||||
test("a connection close after an exit-signal stays an administrative disconnect", async (t) => {
|
||||
const { bridge } = loadBridgeWithMockedSsh2(t, { connectReady: true });
|
||||
const sessions = new Map();
|
||||
const start = registerStartHandler(bridge, sessions);
|
||||
const sender = makeSender();
|
||||
const options = {
|
||||
hostname: "10.0.0.50",
|
||||
username: "alice",
|
||||
port: 22,
|
||||
authMethod: "password",
|
||||
password: "secret",
|
||||
useSshAgent: false,
|
||||
verifyHostKeys: false,
|
||||
};
|
||||
|
||||
await start({ sender }, { ...options, sessionId: "signal-then-conn-close" });
|
||||
const session = sessions.get("signal-then-conn-close");
|
||||
|
||||
// ssh2 can emit conn "close" before the channel's paced finalize. The
|
||||
// signal recorded on "exit" must still win over a generic transport close.
|
||||
session.stream.emit("exit", null, "SIGHUP");
|
||||
session.conn.emit("close");
|
||||
await waitForExitDeliveries();
|
||||
|
||||
const exits = getExitPayloads(sender);
|
||||
assert.equal(exits.length, 1);
|
||||
assert.equal(exits[0].reason, "signaled");
|
||||
assert.equal(exits[0].exitSignal, "SIGHUP");
|
||||
assert.equal(exits[0].exitCode, undefined);
|
||||
});
|
||||
|
||||
test("a connection close without an exit-signal stays a transport close", async (t) => {
|
||||
const { bridge } = loadBridgeWithMockedSsh2(t, { connectReady: true });
|
||||
const sessions = new Map();
|
||||
const start = registerStartHandler(bridge, sessions);
|
||||
const sender = makeSender();
|
||||
const options = {
|
||||
hostname: "10.0.0.50",
|
||||
username: "alice",
|
||||
port: 22,
|
||||
authMethod: "password",
|
||||
password: "secret",
|
||||
useSshAgent: false,
|
||||
verifyHostKeys: false,
|
||||
};
|
||||
|
||||
await start({ sender }, { ...options, sessionId: "transport-conn-close" });
|
||||
sessions.get("transport-conn-close").conn.emit("close");
|
||||
await waitForExitDeliveries();
|
||||
|
||||
const exits = getExitPayloads(sender);
|
||||
assert.equal(exits.length, 1);
|
||||
assert.equal(exits[0].reason, "closed");
|
||||
assert.equal(exits[0].exitCode, undefined);
|
||||
assert.equal(exits[0].remoteClosedWithoutExitStatus, true);
|
||||
assert.equal(exits[0].exitSignal, undefined);
|
||||
});
|
||||
|
||||
for (const exitCode of [0, 7]) {
|
||||
test(`connection close preserves reported shell status ${exitCode} before channel finalization`, async (t) => {
|
||||
const { bridge } = loadBridgeWithMockedSsh2(t, { connectReady: true });
|
||||
const sessions = new Map();
|
||||
const start = registerStartHandler(bridge, sessions);
|
||||
const sender = makeSender();
|
||||
const sessionId = `status-then-conn-close-${exitCode}`;
|
||||
await start({ sender }, {
|
||||
hostname: "10.0.0.50", username: "alice", port: 22,
|
||||
authMethod: "password", password: "secret",
|
||||
useSshAgent: false, verifyHostKeys: false, sessionId,
|
||||
});
|
||||
const session = sessions.get(sessionId);
|
||||
session.stream.emit("exit", exitCode);
|
||||
session.conn.emit("close");
|
||||
await waitForExitDeliveries();
|
||||
const exits = getExitPayloads(sender);
|
||||
assert.equal(exits.length, 1);
|
||||
assert.equal(exits[0].reason, "exited");
|
||||
assert.equal(exits[0].exitCode, exitCode);
|
||||
assert.equal(exits[0].remoteClosedWithoutExitStatus, undefined);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
/**
|
||||
* Classify how a remote SSH shell channel ended.
|
||||
*
|
||||
* An exit-signal (ssh2: code null, signal set) is an administrative
|
||||
* termination — idle kick, SIGHUP, SIGTERM — not a VPN/transport flap.
|
||||
* Those stay "signaled" so auto-reconnect can leave them alone.
|
||||
* A channel that closes with neither exit-status nor exit-signal stays
|
||||
* "closed" and is still eligible to reconnect.
|
||||
*/
|
||||
|
||||
function normalizeRemoteExitSignal(signal) {
|
||||
return typeof signal === "string" && signal.length > 0 ? signal : null;
|
||||
}
|
||||
|
||||
function classifyRemoteShellClose({ idleTimedOut, exitSignal, shellExited }) {
|
||||
if (idleTimedOut) return "timeout";
|
||||
const signal = normalizeRemoteExitSignal(exitSignal);
|
||||
if (signal) return "signaled";
|
||||
if (shellExited) return "exited";
|
||||
return "closed";
|
||||
}
|
||||
|
||||
function buildRemoteShellExitPayload({
|
||||
sessionId,
|
||||
generation,
|
||||
exitCode,
|
||||
exitSignal,
|
||||
shellExited,
|
||||
idleTimedOut,
|
||||
}) {
|
||||
const signal = normalizeRemoteExitSignal(exitSignal);
|
||||
const reason = classifyRemoteShellClose({
|
||||
idleTimedOut: idleTimedOut === true,
|
||||
exitSignal: signal,
|
||||
shellExited: shellExited === true,
|
||||
});
|
||||
const payload = {
|
||||
sessionId,
|
||||
reason,
|
||||
_terminalSessionGeneration: generation,
|
||||
};
|
||||
if (typeof exitCode === "number") {
|
||||
payload.exitCode = exitCode;
|
||||
} else if (!signal) {
|
||||
payload.remoteClosedWithoutExitStatus = true;
|
||||
}
|
||||
if (signal) {
|
||||
payload.exitSignal = signal;
|
||||
}
|
||||
return payload;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
normalizeRemoteExitSignal,
|
||||
classifyRemoteShellClose,
|
||||
buildRemoteShellExitPayload,
|
||||
};
|
||||
@@ -0,0 +1,73 @@
|
||||
const test = require("node:test");
|
||||
const assert = require("node:assert/strict");
|
||||
|
||||
const {
|
||||
buildRemoteShellExitPayload,
|
||||
classifyRemoteShellClose,
|
||||
normalizeRemoteExitSignal,
|
||||
} = require("./remoteShellExit.cjs");
|
||||
|
||||
test("normalizeRemoteExitSignal keeps a reported signal and drops empty values", () => {
|
||||
assert.equal(normalizeRemoteExitSignal("SIGHUP"), "SIGHUP");
|
||||
assert.equal(normalizeRemoteExitSignal("SIGUSR2"), "SIGUSR2");
|
||||
assert.equal(normalizeRemoteExitSignal(""), null);
|
||||
assert.equal(normalizeRemoteExitSignal(null), null);
|
||||
assert.equal(normalizeRemoteExitSignal(undefined), null);
|
||||
});
|
||||
|
||||
test("an exit-signal is an administrative signaled close, not a transport close", () => {
|
||||
for (const exitSignal of ["SIGHUP", "SIGTERM", "SIGUSR2"]) {
|
||||
assert.equal(
|
||||
classifyRemoteShellClose({ idleTimedOut: false, exitSignal, shellExited: false }),
|
||||
"signaled",
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test("a channel close without exit-status or exit-signal stays closed", () => {
|
||||
assert.equal(
|
||||
classifyRemoteShellClose({ idleTimedOut: false, exitSignal: null, shellExited: false }),
|
||||
"closed",
|
||||
);
|
||||
});
|
||||
|
||||
test("a numeric shell exit stays exited, and a TMOUT banner stays timeout", () => {
|
||||
assert.equal(
|
||||
classifyRemoteShellClose({ idleTimedOut: false, exitSignal: null, shellExited: true }),
|
||||
"exited",
|
||||
);
|
||||
assert.equal(
|
||||
classifyRemoteShellClose({ idleTimedOut: true, exitSignal: null, shellExited: true }),
|
||||
"timeout",
|
||||
);
|
||||
});
|
||||
|
||||
test("an exit-signal payload reports the signal and does not invent an exit code", () => {
|
||||
const payload = buildRemoteShellExitPayload({
|
||||
sessionId: "signal-exit",
|
||||
generation: 3,
|
||||
exitSignal: "SIGUSR2",
|
||||
shellExited: false,
|
||||
idleTimedOut: false,
|
||||
});
|
||||
|
||||
assert.equal(payload.reason, "signaled");
|
||||
assert.equal(payload.exitSignal, "SIGUSR2");
|
||||
assert.equal(payload.exitCode, undefined);
|
||||
assert.equal(payload.remoteClosedWithoutExitStatus, undefined);
|
||||
assert.equal(payload._terminalSessionGeneration, 3);
|
||||
});
|
||||
|
||||
test("a bare channel-close payload stays a reconnectable transport close", () => {
|
||||
const payload = buildRemoteShellExitPayload({
|
||||
sessionId: "transport-close",
|
||||
exitSignal: null,
|
||||
shellExited: false,
|
||||
idleTimedOut: false,
|
||||
});
|
||||
|
||||
assert.equal(payload.reason, "closed");
|
||||
assert.equal(payload.exitSignal, undefined);
|
||||
assert.equal(payload.exitCode, undefined);
|
||||
assert.equal(payload.remoteClosedWithoutExitStatus, true);
|
||||
});
|
||||
@@ -1734,7 +1734,7 @@ function createSessionOpsApi(ctx) {
|
||||
disks, // Array of all mounted disks
|
||||
netRxSpeed, // Total network receive speed (bytes/sec)
|
||||
netTxSpeed, // Total network transmit speed (bytes/sec)
|
||||
latencyMs, // TCP connection establishment latency to the SSH endpoint (ms)
|
||||
latencyMs, // SSH transport ping on the existing session (ms); never a second TCP dial (#3630)
|
||||
netInterfaces, // Per-interface network stats
|
||||
hostname,
|
||||
osName,
|
||||
|
||||
@@ -746,6 +746,46 @@ test("getServerStats measures latency by pinging the stats connection", async ()
|
||||
assert.deepEqual(pingedConns, [session.moshStatsConn]);
|
||||
});
|
||||
|
||||
test("getServerStats does not open an unauthenticated SSH handshake (#3630)", async (t) => {
|
||||
const net = require("node:net");
|
||||
const { createSshPingLatencyProbe } = require("./sshPingLatency.cjs");
|
||||
let accepted = 0;
|
||||
const server = net.createServer((socket) => {
|
||||
accepted += 1;
|
||||
socket.destroy();
|
||||
});
|
||||
t.after(() => new Promise((resolve) => server.close(() => resolve())));
|
||||
await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve));
|
||||
const port = server.address().port;
|
||||
|
||||
const conn = Object.assign(fakeConn(LINUX_STATS), {
|
||||
config: { keepaliveInterval: 30000 },
|
||||
});
|
||||
const sessions = new Map([["sid", {
|
||||
type: "ssh",
|
||||
conn,
|
||||
hostname: "127.0.0.1",
|
||||
port,
|
||||
_reuseEndpoint: { hostname: "127.0.0.1", port },
|
||||
}]]);
|
||||
let pingTarget = null;
|
||||
const measureSshPingLatency = createSshPingLatencyProbe();
|
||||
const api = makeSessionOps(sessions, {
|
||||
measureSshPingLatency: (target, timeoutMs) => {
|
||||
pingTarget = target;
|
||||
return measureSshPingLatency(target, timeoutMs);
|
||||
},
|
||||
});
|
||||
|
||||
const result = await api.getServerStats({ sender: {} }, { sessionId: "sid" });
|
||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||
|
||||
assert.equal(result.success, true);
|
||||
assert.equal(pingTarget, conn);
|
||||
assert.equal(result.stats.latencyMs, null);
|
||||
assert.equal(accepted, 0);
|
||||
});
|
||||
|
||||
test("getServerStats pings the companion connection for jump-host sessions", async () => {
|
||||
const sessions = new Map([["sid", {
|
||||
type: "mosh",
|
||||
|
||||
@@ -27,6 +27,10 @@ const {
|
||||
annotateMacLocalNetworkErrorMessage,
|
||||
resolveFirstTcpEndpoint,
|
||||
} = require("../macLocalNetworkAccess.cjs");
|
||||
const {
|
||||
buildRemoteShellExitPayload,
|
||||
normalizeRemoteExitSignal,
|
||||
} = require("./remoteShellExit.cjs");
|
||||
|
||||
const SSH_TCP_CONNECT_TIMEOUT_MS = 20000;
|
||||
const SSH_AUTH_READY_TIMEOUT_MS = 120000;
|
||||
@@ -544,6 +548,15 @@ function createStartSessionApi(ctx) {
|
||||
),
|
||||
});
|
||||
},
|
||||
// Upload progress tracks bytes the transport actually accepted instead
|
||||
// of bytes queued (issue #3663): ssh2 keeps writableLength unchanged
|
||||
// until one queued write fully completes but shrinks _chunk as the
|
||||
// channel window opens, so summing both approximates the outstanding
|
||||
// backlog while it drains. Without this, an rz progress bar freezes
|
||||
// mid-file for as long as the channel window takes to flush.
|
||||
getTransportPendingBytes() {
|
||||
return (Number(stream.writableLength) || 0) + (stream._chunk?.length || 0);
|
||||
},
|
||||
interruptRemote() {
|
||||
try { stream.signal?.("INT"); } catch { /* ignore */ }
|
||||
},
|
||||
@@ -665,7 +678,8 @@ function createStartSessionApi(ctx) {
|
||||
// "close" fires whenever the channel closes (could be network drop).
|
||||
// Only treat it as user-initiated exit if "exit" fired with a numeric
|
||||
// code and no signal. Signal terminations (e.g. server kill, idle
|
||||
// timeout) have code=null and signal set — those are not user exits.
|
||||
// timeout) have code=null and signal set — those are not user exits
|
||||
// and are reported as "signaled", not the reconnectable "closed".
|
||||
// A third case exists: a remote that closes the channel without sending
|
||||
// exit-status or exit-signal never fires "exit" at all (#3591). The
|
||||
// initial values below stay null in that case and must not be reported
|
||||
@@ -676,8 +690,17 @@ function createStartSessionApi(ctx) {
|
||||
stream.on("exit", (code, signal) => {
|
||||
log("shell exit", { sessionId, hostname: options.hostname, code, signal, reused: !!isReused });
|
||||
streamExitCode = typeof code === "number" ? code : null;
|
||||
streamExitSignal = typeof signal === "string" && signal ? signal : null;
|
||||
streamExitSignal = normalizeRemoteExitSignal(signal);
|
||||
streamExited = typeof code === "number" && !streamExitSignal;
|
||||
// conn "close" can win the race against this channel's paced
|
||||
// finalize. Stamp the signal so that path can still tell an
|
||||
// administrative exit-signal from a transport drop.
|
||||
if (streamExitSignal && sessions.get(sessionId) === session) {
|
||||
session._remoteExitSignal = streamExitSignal;
|
||||
}
|
||||
if (streamExited && sessions.get(sessionId) === session) {
|
||||
session._remoteExitCode = streamExitCode;
|
||||
}
|
||||
});
|
||||
|
||||
let closeFinalized = false;
|
||||
@@ -686,6 +709,7 @@ function createStartSessionApi(ctx) {
|
||||
sessionId,
|
||||
hostname: options.hostname,
|
||||
streamExitCode,
|
||||
streamExitSignal,
|
||||
streamExited,
|
||||
reused: !!isReused,
|
||||
transportError: sessions.get(sessionId)?._transportError,
|
||||
@@ -717,27 +741,20 @@ function createStartSessionApi(ctx) {
|
||||
_terminalSessionGeneration: liveSession?._terminalSessionGeneration,
|
||||
});
|
||||
} else {
|
||||
// A shell TMOUT auto-logout is a clean exit (numeric code, no
|
||||
// signal) — identical to a user-typed `exit` by code/signal —
|
||||
// so detect it via the banner the shell prints just before
|
||||
// exiting and report it as a timeout. That keeps the tab open
|
||||
// for reconnect instead of auto-closing it (#1062 / #977).
|
||||
// TMOUT auto-logout is a clean exit (numeric code, no signal) and
|
||||
// is reported as "timeout" via the shell banner (#1062 / #977).
|
||||
// An exit-signal is a separate administrative kick ("signaled").
|
||||
// A channel close with neither exit-status nor exit-signal stays
|
||||
// "closed" and must not be reported as code 0 (#3591).
|
||||
const idleTimedOut = streamExited && looksLikeIdleAutoLogout(liveSession?._promptTrackTail);
|
||||
const reason = idleTimedOut ? "timeout" : (streamExited ? "exited" : "closed");
|
||||
const exitPayload = {
|
||||
const exitPayload = buildRemoteShellExitPayload({
|
||||
sessionId,
|
||||
reason,
|
||||
_terminalSessionGeneration: liveSession?._terminalSessionGeneration,
|
||||
};
|
||||
// Report only exit facts the remote actually sent (#3591). When
|
||||
// the peer closed the channel without exit-status/exit-signal,
|
||||
// there is no code to show — flag it instead so the renderer can
|
||||
// distinguish an abnormal teardown from a user-typed `exit 0`.
|
||||
if (typeof streamExitCode === "number") {
|
||||
exitPayload.exitCode = streamExitCode;
|
||||
} else if (streamExitSignal === null) {
|
||||
exitPayload.remoteClosedWithoutExitStatus = true;
|
||||
}
|
||||
generation: liveSession?._terminalSessionGeneration,
|
||||
exitCode: streamExitCode,
|
||||
exitSignal: streamExitSignal,
|
||||
shellExited: streamExited,
|
||||
idleTimedOut,
|
||||
});
|
||||
safeSendSessionExit({ safeSend, electronModule, sessions }, contents, sessionId, exitPayload);
|
||||
}
|
||||
liveSession?.zmodemSentry?.cancel();
|
||||
@@ -2464,6 +2481,16 @@ function createStartSessionApi(ctx) {
|
||||
});
|
||||
|
||||
conn.on("error", (err) => {
|
||||
// ssh2 reports agent failures before trying the next auth method.
|
||||
// Keep the connection and its authentication deadline alive.
|
||||
if (err.level === "agent") {
|
||||
log("non-fatal agent auth error (will try next method)", {
|
||||
sessionId,
|
||||
hostname: options.hostname,
|
||||
error: err.message,
|
||||
});
|
||||
return;
|
||||
}
|
||||
clearAuthReadyTimer();
|
||||
// After the promise is settled, we can't reject again. But if the
|
||||
// session was already established (resolved), we still need to notify
|
||||
@@ -2638,13 +2665,28 @@ function createStartSessionApi(ctx) {
|
||||
reason: "error",
|
||||
_terminalSessionGeneration: session?._terminalSessionGeneration,
|
||||
});
|
||||
} else {
|
||||
safeSendSessionExit({ safeSend, electronModule, sessions }, contents, sessionId, {
|
||||
} else if (normalizeRemoteExitSignal(session?._remoteExitSignal)) {
|
||||
// The shell already reported an exit-signal. This connection
|
||||
// close is the server finishing that administrative kick, not
|
||||
// a transport flap — keep it out of the reconnect loop.
|
||||
safeSendSessionExit({ safeSend, electronModule, sessions }, contents, sessionId, buildRemoteShellExitPayload({
|
||||
sessionId,
|
||||
exitCode: 0,
|
||||
reason: "closed",
|
||||
_terminalSessionGeneration: session?._terminalSessionGeneration,
|
||||
});
|
||||
generation: session?._terminalSessionGeneration,
|
||||
exitSignal: session._remoteExitSignal,
|
||||
shellExited: false,
|
||||
idleTimedOut: false,
|
||||
}));
|
||||
} else {
|
||||
// Preserve a shell status already received before transport
|
||||
// close; otherwise do not invent a successful exit code.
|
||||
const shellExited = typeof session?._remoteExitCode === "number";
|
||||
safeSendSessionExit({ safeSend, electronModule, sessions }, contents, sessionId, buildRemoteShellExitPayload({
|
||||
sessionId,
|
||||
generation: session?._terminalSessionGeneration,
|
||||
exitCode: session?._remoteExitCode,
|
||||
shellExited,
|
||||
idleTimedOut: shellExited && looksLikeIdleAutoLogout(session?._promptTrackTail),
|
||||
}));
|
||||
}
|
||||
// Use this connection's captured token so a late close from an
|
||||
// old transport can't stop a newer same-sessionId stream (#916).
|
||||
|
||||
@@ -76,6 +76,9 @@ const DEFAULT_WINDOW_WIDTH = 1400;
|
||||
const DEFAULT_WINDOW_HEIGHT = 900;
|
||||
// Minimum window size: enough to render the expanded sidebar + a usable
|
||||
// host list + the 420px host details / new-host aside panel without overflow.
|
||||
// Small displays shrink this fit to their work area at window-creation time
|
||||
// (see resolveWindowMinimumSize in windowManager/mainWindow.cjs, #3647);
|
||||
// the minimum tracks the display the window currently occupies.
|
||||
const MIN_WINDOW_WIDTH = 1100;
|
||||
const MIN_WINDOW_HEIGHT = 640;
|
||||
|
||||
|
||||
@@ -6,6 +6,104 @@ const { attachDisplayRecovery } = require("./displayRecovery.cjs");
|
||||
|
||||
const TERMINAL_KEYBOARD_FOCUS = Symbol("netcattyTerminalKeyboardFocus");
|
||||
|
||||
// #3647: the 1100×640 layout minimum is larger than the usable workspace on
|
||||
// small laptops (e.g. 1366×768 at 125% Windows scaling leaves only ~1093×574
|
||||
// logical px), which made those windows impossible to fit — let alone shrink.
|
||||
// Keep the layout minimum on capable screens, but shrink it to the work area
|
||||
// of the display the window targets so the window can always fit with a small
|
||||
// margin.
|
||||
const MIN_SIZE_FIT_FACTOR = 0.9;
|
||||
const MIN_WINDOW_FLOOR_WIDTH = 320;
|
||||
const MIN_WINDOW_FLOOR_HEIGHT = 240;
|
||||
|
||||
function pickTargetDisplay(screenModule, bounds) {
|
||||
if (!screenModule || typeof screenModule.getDisplayMatching !== "function") {
|
||||
return screenModule?.getPrimaryDisplay?.() || null;
|
||||
}
|
||||
try {
|
||||
return screenModule.getDisplayMatching(bounds) || screenModule.getPrimaryDisplay?.() || null;
|
||||
} catch {
|
||||
try {
|
||||
return screenModule.getPrimaryDisplay?.() || null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function resolveWindowMinimumSize(screenModule, minWidth, minHeight, bounds) {
|
||||
let effectiveWidth = minWidth;
|
||||
let effectiveHeight = minHeight;
|
||||
try {
|
||||
// Size for the display the window actually lives on (saved position or
|
||||
// current placement), falling back to the primary display.
|
||||
const display = bounds
|
||||
? pickTargetDisplay(screenModule, bounds)
|
||||
: screenModule?.getPrimaryDisplay?.();
|
||||
const workArea = display?.workArea;
|
||||
if (Number.isFinite(workArea?.width) && workArea.width > 0) {
|
||||
effectiveWidth = Math.min(effectiveWidth, Math.floor(workArea.width * MIN_SIZE_FIT_FACTOR));
|
||||
}
|
||||
if (Number.isFinite(workArea?.height) && workArea.height > 0) {
|
||||
effectiveHeight = Math.min(effectiveHeight, Math.floor(workArea.height * MIN_SIZE_FIT_FACTOR));
|
||||
}
|
||||
} catch {
|
||||
// screen unavailable (e.g. before app ready): keep the static minimums
|
||||
}
|
||||
return {
|
||||
width: Math.max(MIN_WINDOW_FLOOR_WIDTH, effectiveWidth),
|
||||
height: Math.max(MIN_WINDOW_FLOOR_HEIGHT, effectiveHeight),
|
||||
};
|
||||
}
|
||||
|
||||
// Keep the BrowserWindow minimum in sync with the display it currently lives
|
||||
// on: docking, unplugging a screen, or display-scaling changes can move the
|
||||
// window onto a display with a different work area.
|
||||
function attachDisplayMinimumSync({ win, screen: screenModule, minWidth, minHeight }) {
|
||||
if (!win || !screenModule || typeof screenModule.on !== "function") return () => {};
|
||||
|
||||
const applyMinimumForCurrentDisplay = () => {
|
||||
try {
|
||||
if (win.isDestroyed?.()) return;
|
||||
const next = resolveWindowMinimumSize(screenModule, minWidth, minHeight, win.getBounds());
|
||||
const [currentWidth, currentHeight] = win.getMinimumSize?.() || [];
|
||||
if (next.width === currentWidth && next.height === currentHeight) return;
|
||||
win.setMinimumSize(next.width, next.height);
|
||||
} catch {
|
||||
// Best-effort only.
|
||||
}
|
||||
};
|
||||
|
||||
const onDisplayMetricsChanged = () => applyMinimumForCurrentDisplay();
|
||||
const onDisplayRemoved = () => applyMinimumForCurrentDisplay();
|
||||
const onWindowMoved = () => applyMinimumForCurrentDisplay();
|
||||
try {
|
||||
screenModule.on("display-metrics-changed", onDisplayMetricsChanged);
|
||||
screenModule.on("display-removed", onDisplayRemoved);
|
||||
win.on("move", onWindowMoved);
|
||||
} catch {
|
||||
try {
|
||||
win.removeListener?.("move", onWindowMoved);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
return () => {};
|
||||
}
|
||||
return function detachDisplayMinimumSync() {
|
||||
try {
|
||||
screenModule.removeListener?.("display-metrics-changed", onDisplayMetricsChanged);
|
||||
screenModule.removeListener?.("display-removed", onDisplayRemoved);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
try {
|
||||
win.removeListener?.("move", onWindowMoved);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function setTerminalKeyboardFocusForWindow(win, focused) {
|
||||
if (!win || win.isDestroyed?.() || !win.webContents) return false;
|
||||
const isFocused = focused === true;
|
||||
@@ -113,11 +211,24 @@ function createMainWindowApi(ctx) {
|
||||
height: DEFAULT_WINDOW_HEIGHT,
|
||||
};
|
||||
|
||||
// Size the minimum to the display the restored window will live on
|
||||
// (a saved position may target a smaller secondary display), falling
|
||||
// back to the primary display when there is no usable saved position.
|
||||
const savedTargetBounds = savedState && Number.isFinite(savedState.x) && Number.isFinite(savedState.y)
|
||||
? { x: savedState.x, y: savedState.y, width: savedState.width, height: savedState.height }
|
||||
: null;
|
||||
const windowMinimumSize = resolveWindowMinimumSize(
|
||||
screen,
|
||||
MIN_WINDOW_WIDTH,
|
||||
MIN_WINDOW_HEIGHT,
|
||||
savedTargetBounds,
|
||||
);
|
||||
|
||||
if (savedState) {
|
||||
// Use saved dimensions, but clamp to the minimum so a previously
|
||||
// shrunk window from an older build cannot start below the minimum.
|
||||
windowBounds.width = Math.max(savedState.width, MIN_WINDOW_WIDTH);
|
||||
windowBounds.height = Math.max(savedState.height, MIN_WINDOW_HEIGHT);
|
||||
windowBounds.width = Math.max(savedState.width, windowMinimumSize.width);
|
||||
windowBounds.height = Math.max(savedState.height, windowMinimumSize.height);
|
||||
|
||||
// Only use saved position if the screen is available at that location
|
||||
if (typeof savedState.x === "number" && typeof savedState.y === "number") {
|
||||
@@ -149,8 +260,8 @@ function createMainWindowApi(ctx) {
|
||||
|
||||
const win = new BrowserWindow({
|
||||
...windowBounds,
|
||||
minWidth: MIN_WINDOW_WIDTH,
|
||||
minHeight: MIN_WINDOW_HEIGHT,
|
||||
minWidth: windowMinimumSize.width,
|
||||
minHeight: windowMinimumSize.height,
|
||||
backgroundColor,
|
||||
icon: appIcon,
|
||||
show: false,
|
||||
@@ -190,6 +301,20 @@ function createMainWindowApi(ctx) {
|
||||
detachDisplayRecovery = null;
|
||||
}
|
||||
|
||||
// #3647: docking / display-scaling changes can move the window onto a
|
||||
// display with a different work area; keep the minimum in sync so the
|
||||
// window can always fit its current display.
|
||||
const detachDisplayMinimumSync = attachDisplayMinimumSync({
|
||||
win,
|
||||
screen,
|
||||
// Pass the configured (layout) minimum as the baseline, not the
|
||||
// display-fitted value resolved above: otherwise a reduced minimum
|
||||
// would become a permanent ceiling and could never grow back to
|
||||
// MIN_WINDOW_WIDTH × MIN_WINDOW_HEIGHT on a larger display.
|
||||
minWidth: MIN_WINDOW_WIDTH,
|
||||
minHeight: MIN_WINDOW_HEIGHT,
|
||||
});
|
||||
|
||||
// Clear reference when the main window is destroyed
|
||||
win.on('closed', () => {
|
||||
try {
|
||||
@@ -197,6 +322,11 @@ function createMainWindowApi(ctx) {
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
try {
|
||||
detachDisplayMinimumSync?.();
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
try {
|
||||
if (win?.webContents?.id) {
|
||||
unhealthyWebContentsIds.delete(win.webContents.id);
|
||||
@@ -595,6 +725,8 @@ function createMainWindowApi(ctx) {
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
attachDisplayMinimumSync,
|
||||
createMainWindowApi,
|
||||
resolveWindowMinimumSize,
|
||||
setTerminalKeyboardFocusForWindow,
|
||||
};
|
||||
@@ -0,0 +1,194 @@
|
||||
const test = require("node:test");
|
||||
const assert = require("node:assert/strict");
|
||||
|
||||
const { resolveWindowMinimumSize, attachDisplayMinimumSync } = require("./mainWindow.cjs");
|
||||
|
||||
test("uses the static layout minimums when they fit the display", () => {
|
||||
const size = resolveWindowMinimumSize(
|
||||
{ getPrimaryDisplay: () => ({ workArea: { width: 1920, height: 1080 } }) },
|
||||
1100,
|
||||
640,
|
||||
);
|
||||
assert.deepEqual(size, { width: 1100, height: 640 });
|
||||
});
|
||||
|
||||
test("sizes the minimum for the display containing the target bounds", () => {
|
||||
const primary = { workArea: { width: 1920, height: 1080 } };
|
||||
const secondary = { workArea: { width: 1093, height: 585 } };
|
||||
const size = resolveWindowMinimumSize(
|
||||
{
|
||||
getPrimaryDisplay: () => primary,
|
||||
getDisplayMatching: (bounds) => (bounds.x > 1920 ? secondary : primary),
|
||||
},
|
||||
1100,
|
||||
640,
|
||||
{ x: 2000, y: 0, width: 1000, height: 700 },
|
||||
);
|
||||
assert.deepEqual(size, { width: Math.floor(1093 * 0.9), height: Math.floor(585 * 0.9) });
|
||||
});
|
||||
|
||||
test("falls back to the primary display when the target cannot be matched", () => {
|
||||
const size = resolveWindowMinimumSize(
|
||||
{
|
||||
getPrimaryDisplay: () => ({ workArea: { width: 1093, height: 585 } }),
|
||||
getDisplayMatching: () => null,
|
||||
},
|
||||
1100,
|
||||
640,
|
||||
{ x: 0, y: 0, width: 1000, height: 700 },
|
||||
);
|
||||
assert.deepEqual(size, { width: Math.floor(1093 * 0.9), height: Math.floor(585 * 0.9) });
|
||||
});
|
||||
|
||||
test("refreshes the window minimum when display metrics change", () => {
|
||||
const { EventEmitter } = require("node:events");
|
||||
const screenModule = new EventEmitter();
|
||||
screenModule.getPrimaryDisplay = () => ({ workArea: { width: 1920, height: 1080 } });
|
||||
const win = new EventEmitter();
|
||||
win.isDestroyed = () => false;
|
||||
win.getBounds = () => ({ x: 0, y: 0, width: 1100, height: 640 });
|
||||
let minimum = { width: 1100, height: 640 };
|
||||
win.getMinimumSize = () => [minimum.width, minimum.height];
|
||||
win.setMinimumSize = (width, height) => { minimum = { width, height }; };
|
||||
|
||||
const detach = attachDisplayMinimumSync({ win, screen: screenModule, minWidth: 1100, minHeight: 640 });
|
||||
try {
|
||||
// Docking change shrinks the primary work area under the window.
|
||||
screenModule.getPrimaryDisplay = () => ({ workArea: { width: 1093, height: 585 } });
|
||||
screenModule.emit("display-metrics-changed");
|
||||
assert.deepEqual(minimum, { width: Math.floor(1093 * 0.9), height: Math.floor(585 * 0.9) });
|
||||
|
||||
// No-op emission keeps the same minimum.
|
||||
screenModule.emit("display-metrics-changed");
|
||||
assert.deepEqual(minimum, { width: Math.floor(1093 * 0.9), height: Math.floor(585 * 0.9) });
|
||||
} finally {
|
||||
detach();
|
||||
}
|
||||
});
|
||||
|
||||
test("ignores display events after detach", () => {
|
||||
const { EventEmitter } = require("node:events");
|
||||
const screenModule = new EventEmitter();
|
||||
const win = new EventEmitter();
|
||||
win.isDestroyed = () => false;
|
||||
win.getBounds = () => ({ x: 0, y: 0, width: 1100, height: 640 });
|
||||
win.getMinimumSize = () => [1100, 640];
|
||||
let called = 0;
|
||||
win.setMinimumSize = () => { called += 1; };
|
||||
|
||||
const detach = attachDisplayMinimumSync({ win, screen: screenModule, minWidth: 1100, minHeight: 640 });
|
||||
detach();
|
||||
screenModule.emit("display-metrics-changed");
|
||||
screenModule.emit("display-removed");
|
||||
assert.equal(called, 0);
|
||||
});
|
||||
|
||||
test("shrinks the minimum to fit a small primary work area (#3647)", () => {
|
||||
// 1366×768 at 125% Windows scaling leaves ~1093×585 logical px of work area.
|
||||
const size = resolveWindowMinimumSize(
|
||||
{ getPrimaryDisplay: () => ({ workArea: { width: 1093, height: 585 } }) },
|
||||
1100,
|
||||
640,
|
||||
);
|
||||
assert.equal(size.width, Math.floor(1093 * 0.9));
|
||||
assert.equal(size.height, Math.floor(585 * 0.9));
|
||||
assert.ok(size.width < 1100 && size.height < 640);
|
||||
});
|
||||
|
||||
test("never shrinks below a small usable floor", () => {
|
||||
const size = resolveWindowMinimumSize(
|
||||
{ getPrimaryDisplay: () => ({ workArea: { width: 300, height: 200 } }) },
|
||||
1100,
|
||||
640,
|
||||
);
|
||||
assert.deepEqual(size, { width: 320, height: 240 });
|
||||
});
|
||||
|
||||
test("keeps the static minimums when screen is unavailable", () => {
|
||||
assert.deepEqual(resolveWindowMinimumSize(null, 1100, 640), { width: 1100, height: 640 });
|
||||
assert.deepEqual(resolveWindowMinimumSize({}, 1100, 640), { width: 1100, height: 640 });
|
||||
assert.deepEqual(
|
||||
resolveWindowMinimumSize(
|
||||
{ getPrimaryDisplay: () => { throw new Error("screen not ready"); } },
|
||||
1100,
|
||||
640,
|
||||
),
|
||||
{ width: 1100, height: 640 },
|
||||
);
|
||||
});
|
||||
|
||||
test("createWindow clamps saved bounds and BrowserWindow minima to the fitted size", async () => {
|
||||
const { EventEmitter } = require("node:events");
|
||||
const { createMainWindowApi } = require("./mainWindow.cjs");
|
||||
|
||||
let constructedOptions = null;
|
||||
class Window extends EventEmitter {
|
||||
constructor(options) {
|
||||
super();
|
||||
constructedOptions = options;
|
||||
this.destroyed = false;
|
||||
this.webContents = Object.assign(new EventEmitter(), {
|
||||
id: 71,
|
||||
isDestroyed: () => this.destroyed,
|
||||
setWindowOpenHandler() {},
|
||||
openDevTools() {},
|
||||
send() {},
|
||||
});
|
||||
}
|
||||
close() {}
|
||||
isDestroyed() { return this.destroyed; }
|
||||
isMaximized() { return false; }
|
||||
isFullScreen() { return false; }
|
||||
getBounds() { return { width: 1000, height: 700 }; }
|
||||
setBackgroundColor() {}
|
||||
loadURL() { return Promise.resolve(); }
|
||||
}
|
||||
|
||||
const api = createMainWindowApi({
|
||||
mainWindow: null, electronApp: null, currentTheme: "light", isQuitting: false,
|
||||
pendingWindowStateWrite: null, queuedWindowState: null, windowStateCloseRequested: false,
|
||||
DEFAULT_WINDOW_WIDTH: 1400, DEFAULT_WINDOW_HEIGHT: 900, MIN_WINDOW_WIDTH: 1100, MIN_WINDOW_HEIGHT: 640,
|
||||
V8_CACHE_OPTIONS: "code", THEME_COLORS: { light: {} },
|
||||
unhealthyWebContentsIds: new Set(), rendererReadySeenByWebContentsId: new Set(),
|
||||
__dirname, URL, require, console, setTimeout, clearTimeout,
|
||||
getGlobalShortcutBridge: () => ({ handleWindowClose: () => false }),
|
||||
getMainWindowCount: () => 1,
|
||||
debugLog() {}, resolveFrontendBackgroundColor() { return null; },
|
||||
loadWindowState() { return { width: 980, height: 560, x: 0, y: 0 }; },
|
||||
getDevRendererBaseUrl: (url) => url, getWindowBoundsState() { return null; },
|
||||
queueWindowStateSave() {}, saveWindowStateSync() {}, setupDeferredShow() {},
|
||||
createExternalOnlyWindowOpenHandler() { return {}; }, createAppWindowOpenHandler() { return {}; },
|
||||
attachOAuthLoadingOverlay() {}, registerWindowHandlers() {}, requestWindowCommandClose() {},
|
||||
shouldCloseWindowFromInput() { return false; },
|
||||
registerMainWindow() {}, unregisterMainWindow() {}, registerAppContentWindow() {}, unregisterAppContentWindow() {},
|
||||
closeSettingsWindow() {}, hideSettingsWindow() {}, applyWindowOpacityToWindow() {},
|
||||
});
|
||||
|
||||
await api.createWindow(
|
||||
{
|
||||
BrowserWindow: Window,
|
||||
nativeTheme: {},
|
||||
app: {},
|
||||
screen: {
|
||||
getPrimaryDisplay: () => ({ workArea: { width: 1093, height: 585 } }),
|
||||
getAllDisplays: () => [],
|
||||
},
|
||||
shell: {},
|
||||
ipcMain: {},
|
||||
},
|
||||
{
|
||||
preload: "/tmp/preload.cjs",
|
||||
devServerUrl: "http://localhost:5173",
|
||||
isDev: true,
|
||||
appIcon: null,
|
||||
isMac: true,
|
||||
electronDir: __dirname,
|
||||
},
|
||||
);
|
||||
|
||||
assert.ok(constructedOptions);
|
||||
assert.equal(constructedOptions.minWidth, Math.floor(1093 * 0.9));
|
||||
assert.equal(constructedOptions.minHeight, Math.floor(585 * 0.9));
|
||||
assert.equal(constructedOptions.width, Math.max(980, Math.floor(1093 * 0.9)));
|
||||
assert.equal(constructedOptions.height, Math.max(560, Math.floor(585 * 0.9)));
|
||||
});
|
||||
@@ -107,6 +107,11 @@ function buildModeRestores(dir, names, removeIndices, modes) {
|
||||
* Optional. When writeToRemote returns false, the upload loop awaits this
|
||||
* before sending more file data (typically waitForWritableDrain(stream)).
|
||||
* Callers should forward `opts.signal` so cancel can abort a blocked drain.
|
||||
* @param {() => number} [opts.getTransportPendingBytes]
|
||||
* Optional. Return the bytes the transport has queued but not delivered yet
|
||||
* (e.g. the SSH channel backlog). Enables delivered-byte upload progress:
|
||||
* while parked on backpressure the loop reports queued-minus-outstanding
|
||||
* bytes so the progress indicator tracks actual delivery (issue #3663).
|
||||
* @param {() => import('electron').WebContents | null} opts.getWebContents
|
||||
* Returns the Electron WebContents for sending progress IPC events.
|
||||
* @param {string} [opts.label]
|
||||
@@ -131,6 +136,11 @@ function createZmodemSentry(opts) {
|
||||
const pendingEchoes = [];
|
||||
let pendingTerminalSuppression = null;
|
||||
let pendingTerminalSuppressionAt = 0;
|
||||
let pendingHexHeaderFragment = null;
|
||||
let pendingHexHeaderFragmentTimer = null;
|
||||
// Set when a stripped hex header's trailing XON (0x11) may arrive in the
|
||||
// next terminal-output chunk; at most one byte is then dropped.
|
||||
let suppressNextTerminalXon = false;
|
||||
|
||||
function setPendingTerminalSuppression(buf) {
|
||||
pendingTerminalSuppression = buf;
|
||||
@@ -152,6 +162,16 @@ function createZmodemSentry(opts) {
|
||||
const COOLDOWN_MS = 2000;
|
||||
const ECHO_TTL_MS = 1500;
|
||||
const ECHO_MAX_BYTES = 256;
|
||||
// "**" + ZDLE + 'B': the start of every ZMODEM hex header.
|
||||
const ZMODEM_HEX_HEADER_PREFIX = Buffer.from([0x2a, 0x2a, 0x18, 0x42]);
|
||||
// 4-byte pattern + 14 hex digits + CR is the most a still-incomplete
|
||||
// header can look like before the final LF (plus optional XON) arrives.
|
||||
const ZMODEM_HEX_HEADER_MAX_PARTIAL = 19;
|
||||
// How long a withheld partial-header fragment may sit unresolved before
|
||||
// it is flushed through to the terminal anyway.
|
||||
const HEX_HEADER_FRAGMENT_TTL_MS = Number.isFinite(opts.hexHeaderFragmentTtlMs)
|
||||
? Math.max(1, opts.hexHeaderFragmentTtlMs)
|
||||
: 1000;
|
||||
/**
|
||||
* Upper bound on how long a drag-drop echo suppression may stay armed.
|
||||
* The suppression hides the terminal echo of the upload command, whose echo
|
||||
@@ -240,6 +260,14 @@ function createZmodemSentry(opts) {
|
||||
return buf;
|
||||
}
|
||||
|
||||
function isHexDigitByte(byte) {
|
||||
return (
|
||||
(byte >= 0x30 && byte <= 0x39)
|
||||
|| (byte >= 0x41 && byte <= 0x46)
|
||||
|| (byte >= 0x61 && byte <= 0x66)
|
||||
);
|
||||
}
|
||||
|
||||
function stripVisibleZmodemHeaders(data) {
|
||||
let buf = Buffer.isBuffer(data) ? data : Buffer.from(data);
|
||||
let searchFrom = 0;
|
||||
@@ -253,12 +281,7 @@ function createZmodemSentry(opts) {
|
||||
|
||||
let isHexHeader = true;
|
||||
for (let i = 0; i < 14; i += 1) {
|
||||
const byte = buf[prefixAt + 4 + i];
|
||||
const isHexDigit =
|
||||
(byte >= 0x30 && byte <= 0x39) ||
|
||||
(byte >= 0x41 && byte <= 0x46) ||
|
||||
(byte >= 0x61 && byte <= 0x66);
|
||||
if (!isHexDigit) {
|
||||
if (!isHexDigitByte(buf[prefixAt + 4 + i])) {
|
||||
isHexHeader = false;
|
||||
break;
|
||||
}
|
||||
@@ -274,6 +297,10 @@ function createZmodemSentry(opts) {
|
||||
headerLength = 20;
|
||||
if (buf[prefixAt + 20] === 0x11) {
|
||||
headerLength = 21;
|
||||
} else if (prefixAt + 20 === buf.length) {
|
||||
// The optional XON that follows the CR/LF may arrive in the next
|
||||
// chunk; strip at most one leading XON byte from it (#3651).
|
||||
suppressNextTerminalXon = true;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -287,6 +314,119 @@ function createZmodemSentry(opts) {
|
||||
return buf;
|
||||
}
|
||||
|
||||
function flushPendingHexHeaderFragment() {
|
||||
pendingHexHeaderFragmentTimer = null;
|
||||
const buf = pendingHexHeaderFragment;
|
||||
pendingHexHeaderFragment = null;
|
||||
if (buf?.length) onData(buf);
|
||||
}
|
||||
|
||||
/**
|
||||
* A ZMODEM hex header (the ZRQINIT/ZRINIT preamble of `sz`/`rz`) can be
|
||||
* split across transport chunks. zmodem.js hands each original chunk to
|
||||
* to_terminal verbatim and only complete headers can be stripped, so a
|
||||
* chunk that ends in the middle of the preamble painted it as terminal
|
||||
* garble (#3651). Detect the trailing bytes that may still form one and
|
||||
* hold them back; the next chunk either completes the header (stripped
|
||||
* above) or proves the buffer invalid. A TTL guard flushes held bytes so
|
||||
* deferrals can never lose legitimate terminal output.
|
||||
*
|
||||
* Returns the index from which the buffer should be withheld, or -1.
|
||||
*/
|
||||
function findTrailingHexHeaderFragment(buf) {
|
||||
if (!buf.length) return -1;
|
||||
|
||||
// Longest still-incomplete header tail: the 4-byte pattern, 14 hex
|
||||
// digits, and a CR — i.e. everything up to the final LF (+ optional XON).
|
||||
const windowStart = Math.max(0, buf.length - ZMODEM_HEX_HEADER_MAX_PARTIAL);
|
||||
|
||||
for (let i = windowStart; i <= buf.length - 4; i += 1) {
|
||||
let matched = true;
|
||||
for (let j = 0; j < 4; j += 1) {
|
||||
if (buf[i + j] !== ZMODEM_HEX_HEADER_PREFIX[j]) {
|
||||
matched = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!matched) continue;
|
||||
|
||||
let digits = 0;
|
||||
while (i + 4 + digits < buf.length && isHexDigitByte(buf[i + 4 + digits])) {
|
||||
digits += 1;
|
||||
}
|
||||
|
||||
// Chunk ends inside the hex digits — the header may still complete.
|
||||
if (i + 4 + digits === buf.length) return i;
|
||||
|
||||
// Digit run ended before the 14 required digits, or overshoots them:
|
||||
// not a hex header.
|
||||
if (digits !== 14) continue;
|
||||
|
||||
// A CR (but not yet the trailing LF) may still complete the header.
|
||||
if (buf.length - i <= 19) return i;
|
||||
|
||||
break;
|
||||
}
|
||||
|
||||
// The "**" + ZDLE + 'B' pattern itself may be split: withhold any
|
||||
// trailing bytes that are a prefix of it until the next chunk resolves.
|
||||
for (let k = Math.min(3, buf.length); k >= 1; k -= 1) {
|
||||
let matches = true;
|
||||
for (let j = 0; j < k; j += 1) {
|
||||
if (buf[buf.length - k + j] !== ZMODEM_HEX_HEADER_PREFIX[j]) {
|
||||
matches = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (matches) return buf.length - k;
|
||||
}
|
||||
|
||||
return -1;
|
||||
}
|
||||
|
||||
/**
|
||||
* Join any previously withheld partial-header bytes onto the new chunk so
|
||||
* the suppression/stripping stages can see a complete header again. This
|
||||
* must run BEFORE the stripping stages, or a header split across two
|
||||
* chunks would be judged complete too late and reach the terminal.
|
||||
*/
|
||||
function assembleTerminalChunk(octets) {
|
||||
const buf = Buffer.isBuffer(octets) ? octets : Buffer.from(octets);
|
||||
if (!pendingHexHeaderFragment?.length) {
|
||||
return buf;
|
||||
}
|
||||
if (pendingHexHeaderFragmentTimer) {
|
||||
clearTimeout(pendingHexHeaderFragmentTimer);
|
||||
pendingHexHeaderFragmentTimer = null;
|
||||
}
|
||||
const merged = Buffer.concat([pendingHexHeaderFragment, buf]);
|
||||
pendingHexHeaderFragment = null;
|
||||
return merged;
|
||||
}
|
||||
|
||||
/**
|
||||
* Withhold a trailing partial hex header (if any) and arm a timer that
|
||||
* flushes it when no more bytes arrive to resolve it.
|
||||
*/
|
||||
function deferIncompleteHexHeader(data) {
|
||||
const buf = Buffer.isBuffer(data) ? data : Buffer.from(data);
|
||||
|
||||
const fragmentAt = findTrailingHexHeaderFragment(buf);
|
||||
if (fragmentAt === -1) return buf;
|
||||
|
||||
if (fragmentAt < buf.length) {
|
||||
pendingHexHeaderFragment = Buffer.from(buf.subarray(fragmentAt));
|
||||
if (!pendingHexHeaderFragmentTimer) {
|
||||
pendingHexHeaderFragmentTimer = setTimeout(
|
||||
flushPendingHexHeaderFragment,
|
||||
HEX_HEADER_FRAGMENT_TTL_MS,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return fragmentAt ? buf.subarray(0, fragmentAt) : Buffer.alloc(0);
|
||||
}
|
||||
|
||||
function looksLikeResidualZmodemData(data) {
|
||||
const buf = Buffer.isBuffer(data) ? data : Buffer.from(data);
|
||||
if (!buf.length) return true;
|
||||
@@ -453,8 +593,19 @@ function createZmodemSentry(opts) {
|
||||
const sentry = new Zmodem.Sentry({
|
||||
to_terminal(octets) {
|
||||
// Normal data – pass raw bytes to the caller for charset-aware decoding.
|
||||
let sanitizedOctets = stripPendingTerminalSuppression(Buffer.from(octets));
|
||||
// Bytes that may be the start of a ZMODEM hex header split across
|
||||
// chunks are withheld first (and later stripped or flushed) so the
|
||||
// preamble of sz/rz never reaches the terminal as garble (#3651).
|
||||
let sanitizedOctets = assembleTerminalChunk(octets);
|
||||
if (suppressNextTerminalXon) {
|
||||
suppressNextTerminalXon = false;
|
||||
if (sanitizedOctets[0] === 0x11) {
|
||||
sanitizedOctets = sanitizedOctets.subarray(1);
|
||||
}
|
||||
}
|
||||
sanitizedOctets = stripPendingTerminalSuppression(sanitizedOctets);
|
||||
sanitizedOctets = stripVisibleZmodemHeaders(sanitizedOctets);
|
||||
sanitizedOctets = deferIncompleteHexHeader(sanitizedOctets);
|
||||
if (!sanitizedOctets.length) return;
|
||||
onData(sanitizedOctets);
|
||||
},
|
||||
@@ -766,6 +917,8 @@ const UPLOAD_DRAIN_TIMEOUT_MS = 60000;
|
||||
const UPLOAD_CHUNK_SIZE = 64 * 1024;
|
||||
/** Default interval between non-final upload progress IPC events. */
|
||||
const DEFAULT_UPLOAD_PROGRESS_THROTTLE_MS = 100;
|
||||
/** Interval between delivered-byte progress reports while parked on drain. */
|
||||
const UPLOAD_DRAIN_PROGRESS_INTERVAL_MS = 100;
|
||||
|
||||
function resolveTimeoutMs(value, fallback) {
|
||||
return Number.isFinite(value) && value >= 0 ? value : fallback;
|
||||
@@ -981,6 +1134,76 @@ function createZmodemUploadDrainWaiter(opts) {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Park the upload loop on transport backpressure while keeping progress
|
||||
* updates alive (issue #3663).
|
||||
*
|
||||
* Once a large write backlog fills the transport, the upload loop otherwise
|
||||
* sits silent on the "drain" event while the transport keeps delivering the
|
||||
* queued backlog at full link speed: on SSH the entire channel window drains
|
||||
* between two upload-loop steps, so the progress indicator freezes mid-file
|
||||
* and jumps when the loop resumes. While the drain wait stays pending, poll
|
||||
* it and let the caller emit progress events showing bytes the transport has
|
||||
* actually accepted instead.
|
||||
*
|
||||
* Falls back to a plain drain wait when the transport cannot report its
|
||||
* outstanding byte count. Rejections (timeout, cancellation) propagate.
|
||||
*
|
||||
* @param {{
|
||||
* waitForDrain?: () => Promise<void>,
|
||||
* getTransportPendingBytes?: () => unknown,
|
||||
* progressThrottleMs?: number,
|
||||
* }} opts
|
||||
* @param {() => void} emitProgress Called at most once per throttle interval
|
||||
* while the drain wait stays pending.
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
async function awaitUploadDrainWithProgress(opts, emitProgress) {
|
||||
if (!opts.waitForDrain) return;
|
||||
if (typeof opts.getTransportPendingBytes !== "function") {
|
||||
await opts.waitForDrain();
|
||||
return;
|
||||
}
|
||||
|
||||
let settled = false;
|
||||
const drain = opts.waitForDrain().finally(() => {
|
||||
settled = true;
|
||||
});
|
||||
drain.catch(() => {}); // observed at the final await; never surface early
|
||||
// A drain wait without pending backpressure resolves after one event-loop
|
||||
// turn; yield once so backpressure-free uploads do not gain polling delay.
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
if (settled) {
|
||||
await drain;
|
||||
return;
|
||||
}
|
||||
|
||||
const intervalMs = Math.max(
|
||||
UPLOAD_DRAIN_PROGRESS_INTERVAL_MS,
|
||||
resolveProgressThrottleMs(opts.progressThrottleMs),
|
||||
);
|
||||
// Parked: report accepted bytes right away, then once per interval while
|
||||
// the transport drains its backlog. Progress emissions must never break
|
||||
// (or slow) the transfer itself.
|
||||
try { emitProgress(); } catch { /* ignore */ }
|
||||
// Race each poll delay against drain. A wait that settles mid-interval
|
||||
// must resume the upload immediately; sleeping out the rest of the
|
||||
// interval throttles every backpressure cycle by up to ~100ms.
|
||||
while (!settled) {
|
||||
let timer;
|
||||
await Promise.race([
|
||||
drain.then(() => {}, () => {}),
|
||||
new Promise((resolve) => {
|
||||
timer = setTimeout(resolve, intervalMs);
|
||||
}),
|
||||
]);
|
||||
clearTimeout(timer);
|
||||
if (settled) break;
|
||||
try { emitProgress(); } catch { /* ignore */ }
|
||||
}
|
||||
await drain;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve when `promise` settles, or reject with NETCATTY_ZMODEM_CANCELLED
|
||||
* if `signal` aborts first. Used so cancel unblocks upload drain waits even
|
||||
@@ -1183,19 +1406,42 @@ async function handleUpload(zsession, opts) {
|
||||
const { originalIndex, filePath, stat, name } = offers[i];
|
||||
opts.resetUploadBackpressure?.();
|
||||
|
||||
safeSend(getWebContents(), "netcatty:zmodem:progress", {
|
||||
sessionId,
|
||||
filename: name,
|
||||
transferred: 0,
|
||||
total: stat.size,
|
||||
fileIndex: i,
|
||||
fileCount: offers.length,
|
||||
transferType: "upload",
|
||||
});
|
||||
|
||||
let bytesRemaining = 0;
|
||||
for (let j = i; j < offers.length; j++) bytesRemaining += offers[j].stat.size;
|
||||
|
||||
// Progress should track bytes the transport accepted, not just bytes
|
||||
// queued (#3663): on SSH the channel can buffer its whole window before
|
||||
// write() returns false, so queued-only reporting freezes the progress
|
||||
// bar while the backlog drains at link speed. When the transport can
|
||||
// report outstanding bytes, show `sent - outstanding` and never move
|
||||
// the bar backwards.
|
||||
const getOutstandingBytes = typeof opts.getTransportPendingBytes === "function"
|
||||
? opts.getTransportPendingBytes
|
||||
: null;
|
||||
let sent = 0;
|
||||
let displayedTransferred = 0;
|
||||
const emitFileProgress = () => {
|
||||
const outstanding = getOutstandingBytes ? Number(getOutstandingBytes()) : NaN;
|
||||
const delivered = Number.isFinite(outstanding)
|
||||
? sent - Math.max(outstanding, 0)
|
||||
: sent;
|
||||
displayedTransferred = Math.min(
|
||||
Math.max(delivered, displayedTransferred),
|
||||
stat.size,
|
||||
);
|
||||
safeSend(getWebContents(), "netcatty:zmodem:progress", {
|
||||
sessionId,
|
||||
filename: name,
|
||||
transferred: displayedTransferred,
|
||||
total: stat.size,
|
||||
fileIndex: i,
|
||||
fileCount: offers.length,
|
||||
transferType: "upload",
|
||||
});
|
||||
};
|
||||
|
||||
emitFileProgress();
|
||||
|
||||
// The offer handshake is the only upload step without a built-in
|
||||
// deadline: if rz dies before answering ZFILE (crash, YMODEM fallback),
|
||||
// send_offer() would park this loop forever and block all future
|
||||
@@ -1223,7 +1469,6 @@ async function handleUpload(zsession, opts) {
|
||||
// Read and send in chunks
|
||||
const fd = fs.openSync(filePath, "r");
|
||||
const buf = Buffer.alloc(UPLOAD_CHUNK_SIZE);
|
||||
let sent = 0;
|
||||
// Progress IPC is throttled by default for every transport. Pass 0 only
|
||||
// when a caller explicitly needs one event per chunk.
|
||||
const progressThrottleMs = resolveProgressThrottleMs(opts.progressThrottleMs);
|
||||
@@ -1243,21 +1488,16 @@ async function handleUpload(zsession, opts) {
|
||||
|
||||
const now = Date.now();
|
||||
if (progressThrottleMs === 0 || now - lastProgressEmitAt >= progressThrottleMs) {
|
||||
safeSend(getWebContents(), "netcatty:zmodem:progress", {
|
||||
sessionId,
|
||||
filename: name,
|
||||
transferred: sent,
|
||||
total: stat.size,
|
||||
fileIndex: i,
|
||||
fileCount: offers.length,
|
||||
transferType: "upload",
|
||||
});
|
||||
emitFileProgress();
|
||||
lastProgressEmitAt = now;
|
||||
}
|
||||
|
||||
// Wait for transport to drain if its buffer is full, then yield
|
||||
// so inbound ZMODEM control frames can be processed.
|
||||
if (opts.waitForDrain) await opts.waitForDrain();
|
||||
// Wait for transport to drain if its buffer is full (emitting
|
||||
// delivered-byte progress while parked, #3663), then yield so
|
||||
// inbound ZMODEM control frames can be processed.
|
||||
if (opts.waitForDrain) {
|
||||
await awaitUploadDrainWithProgress(opts, emitFileProgress);
|
||||
}
|
||||
await yieldToIO();
|
||||
}
|
||||
// All data written to Node.js buffer — but TCP may still be
|
||||
@@ -1542,6 +1782,8 @@ module.exports = {
|
||||
handleDownload,
|
||||
waitForWritableDrain,
|
||||
createZmodemUploadDrainWaiter,
|
||||
awaitUploadDrainWithProgress,
|
||||
UPLOAD_CHUNK_SIZE,
|
||||
UPLOAD_DRAIN_TIMEOUT_MS,
|
||||
UPLOAD_DRAIN_PROGRESS_INTERVAL_MS,
|
||||
};
|
||||
@@ -11,6 +11,7 @@ const {
|
||||
handleDownload,
|
||||
waitForWritableDrain,
|
||||
createZmodemUploadDrainWaiter,
|
||||
awaitUploadDrainWithProgress,
|
||||
UPLOAD_CHUNK_SIZE,
|
||||
UPLOAD_DRAIN_TIMEOUT_MS,
|
||||
} = require("./zmodemHelper.cjs");
|
||||
@@ -422,6 +423,282 @@ test("handleUpload does not read the next chunk until transport backpressure cle
|
||||
fs.rmSync(tempDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
test("handleUpload reports transport-accepted bytes instead of the queued backlog", async () => {
|
||||
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "netcatty-zmodem-delivered-"));
|
||||
const filePath = path.join(tempDir, "upload.bin");
|
||||
const size = UPLOAD_CHUNK_SIZE * 2;
|
||||
fs.writeFileSync(filePath, Buffer.alloc(size, 0x5a));
|
||||
const progress = [];
|
||||
|
||||
const zsession = {
|
||||
async send_offer() {
|
||||
return {
|
||||
send() {},
|
||||
async end() {},
|
||||
};
|
||||
},
|
||||
async close() {},
|
||||
};
|
||||
|
||||
await handleUpload(zsession, {
|
||||
sessionId: "session-1",
|
||||
getWebContents: () => ({
|
||||
isDestroyed: () => false,
|
||||
send(channel, data) {
|
||||
if (channel === "netcatty:zmodem:progress") progress.push(data);
|
||||
},
|
||||
}),
|
||||
takeDragDropUpload: () => ({
|
||||
filePaths: [filePath],
|
||||
remoteNames: ["upload.bin"],
|
||||
}),
|
||||
progressThrottleMs: 0,
|
||||
// 4096 bytes of every queued amount are still outstanding in the transport.
|
||||
getTransportPendingBytes: () => 4096,
|
||||
});
|
||||
|
||||
assert.deepEqual(
|
||||
progress.map((event) => [event.transferred, Boolean(event.finalizing)]),
|
||||
[
|
||||
[0, false],
|
||||
[UPLOAD_CHUNK_SIZE - 4096, false],
|
||||
[size - 4096, false],
|
||||
[size, true],
|
||||
],
|
||||
);
|
||||
fs.rmSync(tempDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
test("handleUpload emits delivered-byte progress while parked on transport drain", async () => {
|
||||
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "netcatty-zmodem-park-"));
|
||||
const filePath = path.join(tempDir, "upload.bin");
|
||||
const size = UPLOAD_CHUNK_SIZE * 2 + 1;
|
||||
fs.writeFileSync(filePath, Buffer.alloc(size, 0x5a));
|
||||
const progress = [];
|
||||
// Bytes queued into the transport (never consumed; delivery is simulated
|
||||
// below on the pending-byte readout).
|
||||
let outstanding = 0;
|
||||
let drainStartedAt = null;
|
||||
let releaseFirstDrain;
|
||||
const firstDrain = new Promise((resolve) => {
|
||||
releaseFirstDrain = resolve;
|
||||
});
|
||||
let drainCalls = 0;
|
||||
|
||||
const zsession = {
|
||||
async send_offer() {
|
||||
return {
|
||||
send(chunk) {
|
||||
outstanding += chunk.byteLength;
|
||||
},
|
||||
async end() {},
|
||||
};
|
||||
},
|
||||
async close() {},
|
||||
};
|
||||
|
||||
const upload = handleUpload(zsession, {
|
||||
sessionId: "session-1",
|
||||
getWebContents: () => ({
|
||||
isDestroyed: () => false,
|
||||
send(channel, data) {
|
||||
if (channel === "netcatty:zmodem:progress") progress.push(data);
|
||||
},
|
||||
}),
|
||||
takeDragDropUpload: () => ({
|
||||
filePaths: [filePath],
|
||||
remoteNames: ["upload.bin"],
|
||||
}),
|
||||
progressThrottleMs: 0,
|
||||
getTransportPendingBytes: () => {
|
||||
if (drainStartedAt === null) return outstanding;
|
||||
// The backlog drains at 512 B/ms once the parked wait begins.
|
||||
const delivered = Math.min(outstanding, (Date.now() - drainStartedAt) * 512);
|
||||
return outstanding - delivered;
|
||||
},
|
||||
async waitForDrain() {
|
||||
drainCalls += 1;
|
||||
if (drainCalls === 1) {
|
||||
drainStartedAt = Date.now();
|
||||
await firstDrain;
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
while (drainCalls === 0) {
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
}
|
||||
const progressedBefore = progress.length;
|
||||
await new Promise((resolve) => setTimeout(resolve, 250));
|
||||
const parkedEvents = progress.slice(progressedBefore);
|
||||
releaseFirstDrain();
|
||||
await upload;
|
||||
|
||||
// While parked, delivered-byte progress must keep flowing (previously the
|
||||
// indicator stayed frozen until the whole backlog drained).
|
||||
assert.ok(
|
||||
parkedEvents.some((event) => event.transferred > 0 && !event.finalizing),
|
||||
`expected delivered-byte emissions while parked, got ${JSON.stringify(parkedEvents.map((e) => [e.transferred, Boolean(e.finalizing)]))}`,
|
||||
);
|
||||
|
||||
const values = progress.map((event) => event.transferred);
|
||||
for (let k = 1; k < values.length; k++) {
|
||||
assert.ok(
|
||||
values[k] >= values[k - 1],
|
||||
`progress must never move backwards: ${values.join(",")}`,
|
||||
);
|
||||
assert.ok(values[k] <= size, `progress must not exceed the file size: ${values.join(",")}`);
|
||||
}
|
||||
assert.equal(progress.at(-1).finalizing, true);
|
||||
fs.rmSync(tempDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
test("awaitUploadDrainWithProgress falls back to a plain drain wait without pending-byte accounting", async () => {
|
||||
let drainCalls = 0;
|
||||
await awaitUploadDrainWithProgress(
|
||||
{
|
||||
waitForDrain: () => {
|
||||
drainCalls += 1;
|
||||
return Promise.resolve();
|
||||
},
|
||||
},
|
||||
() => {
|
||||
throw new Error("no progress emissions without pending-byte accounting");
|
||||
},
|
||||
);
|
||||
assert.equal(drainCalls, 1);
|
||||
});
|
||||
|
||||
test("awaitUploadDrainWithProgress adds no polling delay when drain resolves immediately", async () => {
|
||||
const startedAt = Date.now();
|
||||
await awaitUploadDrainWithProgress(
|
||||
{
|
||||
waitForDrain: () => Promise.resolve(),
|
||||
getTransportPendingBytes: () => 0,
|
||||
},
|
||||
() => {
|
||||
throw new Error("no emissions without parked backpressure");
|
||||
},
|
||||
);
|
||||
assert.ok(Date.now() - startedAt < 50);
|
||||
});
|
||||
|
||||
test("awaitUploadDrainWithProgress returns promptly when drain settles during the poll interval", async () => {
|
||||
let releaseDrain;
|
||||
const drainGate = new Promise((resolve) => {
|
||||
releaseDrain = resolve;
|
||||
});
|
||||
let parked = false;
|
||||
const done = awaitUploadDrainWithProgress(
|
||||
{
|
||||
waitForDrain: () => drainGate,
|
||||
getTransportPendingBytes: () => 2048,
|
||||
},
|
||||
() => {
|
||||
parked = true;
|
||||
},
|
||||
);
|
||||
|
||||
const waitStarted = Date.now();
|
||||
while (!parked) {
|
||||
if (Date.now() - waitStarted > 1000) {
|
||||
throw new Error("upload drain wait never parked");
|
||||
}
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
}
|
||||
// The immediate park emission has run and the poll timer (~100ms) is armed.
|
||||
// Settle on the next turn, still inside that interval.
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
const releasedAt = Date.now();
|
||||
releaseDrain();
|
||||
await done;
|
||||
const elapsed = Date.now() - releasedAt;
|
||||
assert.ok(
|
||||
elapsed < 50,
|
||||
`drain settlement during a poll interval resumed in ${elapsed}ms`,
|
||||
);
|
||||
});
|
||||
|
||||
test("awaitUploadDrainWithProgress propagates a rejection that arrives during the poll interval", async () => {
|
||||
let rejectDrain;
|
||||
const drainGate = new Promise((_, reject) => {
|
||||
rejectDrain = reject;
|
||||
});
|
||||
const err = new Error("Transport drain timeout");
|
||||
err.code = "NETCATTY_ZMODEM_TIMEOUT";
|
||||
let parked = false;
|
||||
const done = awaitUploadDrainWithProgress(
|
||||
{
|
||||
waitForDrain: () => drainGate,
|
||||
getTransportPendingBytes: () => 2048,
|
||||
},
|
||||
() => {
|
||||
parked = true;
|
||||
},
|
||||
);
|
||||
|
||||
const waitStarted = Date.now();
|
||||
while (!parked) {
|
||||
if (Date.now() - waitStarted > 1000) {
|
||||
throw new Error("upload drain wait never parked");
|
||||
}
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
}
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
const releasedAt = Date.now();
|
||||
rejectDrain(err);
|
||||
await assert.rejects(done, (caught) => caught === err);
|
||||
const elapsed = Date.now() - releasedAt;
|
||||
assert.ok(
|
||||
elapsed < 50,
|
||||
`drain rejection during a poll interval resumed in ${elapsed}ms`,
|
||||
);
|
||||
});
|
||||
|
||||
test("awaitUploadDrainWithProgress emits progress while parked until drain settles", async () => {
|
||||
let releaseDrain;
|
||||
const drainGate = new Promise((resolve) => {
|
||||
releaseDrain = resolve;
|
||||
});
|
||||
let displayed = 0;
|
||||
const emitted = [];
|
||||
const deliverTimer = setInterval(() => {
|
||||
displayed += 512;
|
||||
}, 40);
|
||||
|
||||
try {
|
||||
const parked = awaitUploadDrainWithProgress(
|
||||
{
|
||||
waitForDrain: () => drainGate,
|
||||
getTransportPendingBytes: () => 1024,
|
||||
},
|
||||
() => emitted.push(displayed),
|
||||
);
|
||||
await new Promise((resolve) => setTimeout(resolve, 280));
|
||||
assert.ok(emitted.length >= 2, `expected poll emissions, got ${emitted.length}`);
|
||||
assert.ok(emitted.every((v) => v === 0 || v % 512 === 0));
|
||||
releaseDrain();
|
||||
await parked;
|
||||
} finally {
|
||||
clearInterval(deliverTimer);
|
||||
}
|
||||
});
|
||||
|
||||
test("awaitUploadDrainWithProgress propagates drain-wait rejections", async () => {
|
||||
const err = new Error("Transport drain timeout");
|
||||
err.code = "NETCATTY_ZMODEM_TIMEOUT";
|
||||
await assert.rejects(
|
||||
awaitUploadDrainWithProgress(
|
||||
{
|
||||
waitForDrain: () => Promise.reject(err),
|
||||
getTransportPendingBytes: () => 0,
|
||||
},
|
||||
() => {},
|
||||
),
|
||||
(caught) => caught === err,
|
||||
);
|
||||
});
|
||||
|
||||
test("handleUpload throttles progress IPC by default for every transport", async () => {
|
||||
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "netcatty-zmodem-progress-"));
|
||||
const filePath = path.join(tempDir, "large-upload.bin");
|
||||
@@ -1489,3 +1766,132 @@ test("createZmodemUploadDrainWaiter falls back to a single yield without transpo
|
||||
await waitForDrain();
|
||||
assert.equal(needsDrain, false);
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Split hex-header preamble (#3651): the ZRQINIT/ZRINIT hex header that opens
|
||||
// every sz/rz session can be fragmented across transport chunks; it must
|
||||
// never reach the terminal as garble, and withheld bytes must never be lost.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const ZmodemLib = require("zmodem.js");
|
||||
|
||||
const DOWNLOAD_PREAMBLE_TEXT = "sz: Sending test.bin\r\nrz\r";
|
||||
|
||||
function buildHexHeaderBytes(name, args) {
|
||||
return Buffer.from(ZmodemLib.Header.build(name, ...(args || [])).to_hex());
|
||||
}
|
||||
|
||||
function createProbeSentry(extra) {
|
||||
const terminal = [];
|
||||
const sentry = createZmodemSentry({
|
||||
sessionId: "probe",
|
||||
onData: (buf) => terminal.push(Buffer.from(buf)),
|
||||
writeToRemote: () => true,
|
||||
getWebContents: () => null,
|
||||
selectDownloadDirectory: async () => ({ canceled: true }),
|
||||
selectUploadFiles: async () => null,
|
||||
hexHeaderFragmentTtlMs: 30,
|
||||
...extra,
|
||||
});
|
||||
return { sentry, terminal };
|
||||
}
|
||||
|
||||
function joinedText(terminal) {
|
||||
return Buffer.concat(terminal).toString("latin1");
|
||||
}
|
||||
|
||||
function assertNoZmodemHeaderBytes(terminal, context) {
|
||||
const joined = Buffer.concat(terminal);
|
||||
assert.equal(
|
||||
joined.indexOf(Buffer.from([0x2a, 0x2a, 0x18, 0x42])),
|
||||
-1,
|
||||
`${context}: ZRQINIT/ZRINIT preamble leaked to the terminal`,
|
||||
);
|
||||
assert.equal(
|
||||
joined.includes(Buffer.from([0x18])),
|
||||
false,
|
||||
`${context}: ZDLE/CAN byte leaked to the terminal`,
|
||||
);
|
||||
}
|
||||
|
||||
test("fragmented sz ZRQINIT preamble never reaches the terminal", () => {
|
||||
const { sentry, terminal } = createProbeSentry();
|
||||
const stream = Buffer.concat([
|
||||
Buffer.from(DOWNLOAD_PREAMBLE_TEXT),
|
||||
buildHexHeaderBytes("ZRQINIT"),
|
||||
Buffer.from("$ "),
|
||||
]);
|
||||
|
||||
for (const byte of stream) sentry.consume(Buffer.from([byte]));
|
||||
|
||||
assert.equal(
|
||||
joinedText(terminal),
|
||||
`${DOWNLOAD_PREAMBLE_TEXT}$ `,
|
||||
"only the human-readable sz preamble text should be shown",
|
||||
);
|
||||
assertNoZmodemHeaderBytes(terminal, "byte-at-a-time");
|
||||
});
|
||||
|
||||
test("split sz preamble leaks nothing at any chunk boundary", () => {
|
||||
const header = buildHexHeaderBytes("ZRQINIT");
|
||||
// Header.build().to_hex() already includes the trailing XON byte.
|
||||
const stream = Buffer.concat([
|
||||
Buffer.from(DOWNLOAD_PREAMBLE_TEXT),
|
||||
header,
|
||||
Buffer.from("$ "),
|
||||
]);
|
||||
|
||||
for (let chunkSize = 1; chunkSize <= 24; chunkSize += 1) {
|
||||
const { sentry, terminal } = createProbeSentry();
|
||||
for (let i = 0; i < stream.length; i += chunkSize) {
|
||||
sentry.consume(stream.subarray(i, i + chunkSize));
|
||||
}
|
||||
assert.equal(joinedText(terminal), `${DOWNLOAD_PREAMBLE_TEXT}$ `);
|
||||
assertNoZmodemHeaderBytes(terminal, `chunkSize=${chunkSize}`);
|
||||
}
|
||||
});
|
||||
|
||||
test("fragmented rz ZRINIT preamble never reaches the terminal", () => {
|
||||
const header = buildHexHeaderBytes("ZRINIT", [["CANFDX", "CANOVIO", "CANFC32"]]);
|
||||
const stream = Buffer.concat([Buffer.from("root@host:~$ "), header]);
|
||||
|
||||
for (let chunkSize = 1; chunkSize <= 24; chunkSize += 1) {
|
||||
const { sentry, terminal } = createProbeSentry();
|
||||
for (let i = 0; i < stream.length; i += chunkSize) {
|
||||
sentry.consume(stream.subarray(i, i + chunkSize));
|
||||
}
|
||||
assert.equal(
|
||||
joinedText(terminal),
|
||||
"root@host:~$ ",
|
||||
`split ZRINIT preamble leaked for chunkSize=${chunkSize}`,
|
||||
);
|
||||
assertNoZmodemHeaderBytes(terminal, `rz chunkSize=${chunkSize}`);
|
||||
}
|
||||
});
|
||||
|
||||
test("bytes withheld as a header fragment are flushed after the TTL", async () => {
|
||||
const { sentry, terminal } = createProbeSentry();
|
||||
const stream = Buffer.from("hello *");
|
||||
for (const byte of stream) sentry.consume(Buffer.from([byte]));
|
||||
|
||||
// The trailing "*" may be the start of a hex header, so it is withheld.
|
||||
assert.equal(joinedText(terminal), "hello ");
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 80));
|
||||
assert.equal(joinedText(terminal), "hello *", "TTL flush must not lose terminal output");
|
||||
});
|
||||
|
||||
test("output that disproves a header fragment is emitted without delay", () => {
|
||||
const stream = Buffer.from("text **\x18Bxy more");
|
||||
for (let chunkSize = 1; chunkSize <= 6; chunkSize += 1) {
|
||||
const { sentry, terminal } = createProbeSentry();
|
||||
for (let i = 0; i < stream.length; i += chunkSize) {
|
||||
sentry.consume(stream.subarray(i, i + chunkSize));
|
||||
}
|
||||
assert.equal(
|
||||
joinedText(terminal),
|
||||
"text **\x18Bxy more",
|
||||
"a non-hex byte after the pattern stops the deferral",
|
||||
);
|
||||
}
|
||||
});
|
||||
@@ -24,6 +24,8 @@ export const STORAGE_KEY_TERM_THEME_LIGHT = 'netcatty_term_theme_light_v1';
|
||||
export const STORAGE_KEY_TERM_FONT_FAMILY = 'netcatty_term_font_family_v1';
|
||||
export const STORAGE_KEY_TERM_FONT_SIZE = 'netcatty_term_font_size_v1';
|
||||
export const STORAGE_KEY_TERM_SETTINGS = 'netcatty_term_settings_v1';
|
||||
/** One-time marker: pre-#3619 builds materialized autoCloseOnExit: true into saved settings. */
|
||||
export const STORAGE_KEY_TERM_AUTO_CLOSE_EXIT_MIGRATED = 'netcatty_term_auto_close_exit_migrated_v1';
|
||||
export const STORAGE_KEY_HOTKEY_SCHEME = 'netcatty_hotkey_scheme_v1';
|
||||
export const STORAGE_KEY_CUSTOM_KEY_BINDINGS = 'netcatty_custom_key_bindings_v1';
|
||||
export const STORAGE_KEY_HOTKEY_RECORDING = 'netcatty_hotkey_recording_v1';
|
||||
|
||||
+16
-12
@@ -2,6 +2,7 @@ import type { UploadBridge } from "./uploadService.types";
|
||||
|
||||
export class UploadController {
|
||||
private cancelled = false;
|
||||
private cancelledFileIds = new Set<string>();
|
||||
private activeFileTransferIds = new Set<string>();
|
||||
private activeCompressionIds = new Set<string>();
|
||||
private currentTransferId = "";
|
||||
@@ -47,16 +48,7 @@ export class UploadController {
|
||||
// Cancel all active file uploads
|
||||
const activeIds = Array.from(this.activeFileTransferIds);
|
||||
for (const transferId of activeIds) {
|
||||
try {
|
||||
if (this.bridge?.cancelStagedUploadFile) {
|
||||
await this.bridge.cancelStagedUploadFile(transferId);
|
||||
}
|
||||
if (this.bridge?.cancelTransfer) {
|
||||
await this.bridge.cancelTransfer(transferId);
|
||||
}
|
||||
} catch {
|
||||
// Ignore cancel errors
|
||||
}
|
||||
await this.cancelFile(transferId);
|
||||
}
|
||||
|
||||
// Also cancel current one if not in the set
|
||||
@@ -71,11 +63,22 @@ export class UploadController {
|
||||
}
|
||||
}
|
||||
|
||||
/** Cancel one standalone file without stopping the shared upload queue. */
|
||||
async cancelFile(transferId: string): Promise<void> {
|
||||
this.cancelledFileIds.add(transferId);
|
||||
try {
|
||||
await this.bridge?.cancelStagedUploadFile?.(transferId);
|
||||
await this.bridge?.cancelTransfer?.(transferId);
|
||||
} catch {
|
||||
// The transfer runtime reconciles backend cancellation failures.
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if upload was cancelled
|
||||
*/
|
||||
isCancelled(): boolean {
|
||||
return this.cancelled;
|
||||
isCancelled(fileId?: string): boolean {
|
||||
return this.cancelled || (!!fileId && this.cancelledFileIds.has(fileId));
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -98,6 +101,7 @@ export class UploadController {
|
||||
*/
|
||||
reset(): void {
|
||||
this.cancelled = false;
|
||||
this.cancelledFileIds.clear();
|
||||
this.cancelListeners.clear();
|
||||
this.activeFileTransferIds.clear();
|
||||
this.activeCompressionIds.clear();
|
||||
|
||||
@@ -769,6 +769,7 @@ async function uploadEntries(
|
||||
standaloneTransferId: string,
|
||||
fileTotalBytes: number,
|
||||
): Promise<{ cancelled?: boolean; error?: string }> => {
|
||||
if (controller?.isCancelled(standaloneTransferId)) return { cancelled: true };
|
||||
let localFilePath = getDropEntryLocalPath(entry);
|
||||
let ownedTempPath: string | undefined;
|
||||
|
||||
@@ -784,13 +785,13 @@ async function uploadEntries(
|
||||
ownedTempPath = await bridge.stageUploadFile(entry.file, standaloneTransferId);
|
||||
} catch (error) {
|
||||
controller?.removeActiveTransfer(standaloneTransferId);
|
||||
if (controller?.isCancelled() || /cancel/i.test(error instanceof Error ? error.message : String(error))) {
|
||||
if (controller?.isCancelled(standaloneTransferId) || /cancel/i.test(error instanceof Error ? error.message : String(error))) {
|
||||
return { cancelled: true };
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
localFilePath = ownedTempPath;
|
||||
if (controller?.isCancelled()) {
|
||||
if (controller?.isCancelled(standaloneTransferId)) {
|
||||
await bridge.deleteTempFile(ownedTempPath).catch(() => {});
|
||||
return { cancelled: true };
|
||||
}
|
||||
@@ -956,6 +957,11 @@ async function uploadEntries(
|
||||
);
|
||||
|
||||
if (uploadResult.cancelled) {
|
||||
if (!bundleTaskId && !controller?.isCancelled()) {
|
||||
settleFileTask(standaloneTransferId, lifecycleManaged, (taskId) => callbacks?.onTaskCancelled?.(taskId));
|
||||
results.push({ fileName: entry.relativePath, success: false, cancelled: true });
|
||||
continue;
|
||||
}
|
||||
wasCancelled = true;
|
||||
settleFileTask(bundledChildTaskId, lifecycleManaged, (taskId) => callbacks?.onTaskCancelled?.(taskId));
|
||||
settleTask(bundleTaskId ?? "", (taskId) => callbacks?.onTaskCancelled?.(taskId));
|
||||
|
||||
+2
-2
@@ -318,7 +318,7 @@ declare global {
|
||||
}>;
|
||||
netRxSpeed: number; // Total network receive speed (bytes/sec)
|
||||
netTxSpeed: number; // Total network transmit speed (bytes/sec)
|
||||
latencyMs: number | null; // TCP connection establishment latency to the SSH endpoint
|
||||
latencyMs: number | null; // SSH transport ping on the existing session, not a second TCP dial
|
||||
netInterfaces: Array<{ // Per-interface network stats
|
||||
name: string; // Interface name (e.g., eth0, ens33)
|
||||
rxBytes: number; // Total received bytes
|
||||
@@ -531,7 +531,7 @@ declare global {
|
||||
): () => void;
|
||||
onSessionExit(
|
||||
sessionId: string,
|
||||
cb: (evt: { exitCode?: number; signal?: number; error?: string; reason?: "exited" | "error" | "timeout" | "closed" }) => void
|
||||
cb: (evt: { exitCode?: number; signal?: number; exitSignal?: string; error?: string; reason?: "exited" | "error" | "timeout" | "closed" | "signaled"; remoteClosedWithoutExitStatus?: boolean }) => void
|
||||
): () => void;
|
||||
onTelnetAutoLoginComplete?(
|
||||
sessionId: string,
|
||||
|
||||
Reference in new issue
Block a user