Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
02c80ae7d2 | ||
|
|
e5d3d02b17 | ||
|
|
78186d8d46 | ||
|
|
c899653621 | ||
|
|
a91fbcdd68 | ||
|
|
74b315e285 |
@@ -2092,6 +2092,11 @@ const en: Messages = {
|
||||
'zmodem.uploading': 'Uploading',
|
||||
'zmodem.downloading': 'Downloading',
|
||||
'zmodem.cancelTransfer': 'Cancel transfer (Ctrl+C)',
|
||||
'zmodem.overwrite.title': 'Remote file already exists',
|
||||
'zmodem.overwrite.applyToRest': 'Apply to remaining conflicts',
|
||||
'zmodem.overwrite.overwrite': 'Overwrite',
|
||||
'zmodem.overwrite.skip': 'Skip',
|
||||
'zmodem.overwrite.cancel': 'Cancel',
|
||||
'settings.shortcuts.resetToDefault': 'Reset to default',
|
||||
};
|
||||
|
||||
|
||||
@@ -2124,6 +2124,11 @@ const ru: Messages = {
|
||||
'zmodem.uploading': 'Загрузка',
|
||||
'zmodem.downloading': 'Скачивание',
|
||||
'zmodem.cancelTransfer': 'Отменить передачу (Ctrl+C)',
|
||||
'zmodem.overwrite.title': 'Remote file already exists',
|
||||
'zmodem.overwrite.applyToRest': 'Apply to remaining conflicts',
|
||||
'zmodem.overwrite.overwrite': 'Overwrite',
|
||||
'zmodem.overwrite.skip': 'Skip',
|
||||
'zmodem.overwrite.cancel': 'Cancel',
|
||||
'settings.shortcuts.resetToDefault': 'Сбросить по умолчанию',
|
||||
};
|
||||
|
||||
|
||||
@@ -2101,6 +2101,11 @@ const zhCN: Messages = {
|
||||
'zmodem.uploading': '上传中',
|
||||
'zmodem.downloading': '下载中',
|
||||
'zmodem.cancelTransfer': '取消传输 (Ctrl+C)',
|
||||
'zmodem.overwrite.title': '远端已存在同名文件',
|
||||
'zmodem.overwrite.applyToRest': '应用到其余冲突文件',
|
||||
'zmodem.overwrite.overwrite': '覆盖',
|
||||
'zmodem.overwrite.skip': '跳过',
|
||||
'zmodem.overwrite.cancel': '取消',
|
||||
'settings.shortcuts.resetToDefault': '重置为默认',
|
||||
};
|
||||
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
findSyncPayloadEncryptedCredentialPaths,
|
||||
} from '../../domain/credentials';
|
||||
import { isProviderReadyForSync, type CloudProvider, type SyncPayload } from '../../domain/sync';
|
||||
import { mergeSyncPayloads } from '../../domain/syncMerge';
|
||||
import {
|
||||
SYNCABLE_SETTING_STORAGE_KEYS,
|
||||
collectSyncableSettings,
|
||||
@@ -506,7 +507,6 @@ export const useAutoSync = (config: AutoSyncConfig) => {
|
||||
return;
|
||||
}
|
||||
|
||||
const { mergeSyncPayloads } = await import('../../domain/syncMerge');
|
||||
const mergeResult = mergeSyncPayloads(base, localPayload, remotePayload);
|
||||
|
||||
// Apply merged payload to local state BEFORE committing. If the apply
|
||||
|
||||
@@ -49,6 +49,7 @@ import { TerminalToolbar } from "./terminal/TerminalToolbar";
|
||||
import { TerminalComposeBar } from "./terminal/TerminalComposeBar";
|
||||
import { TerminalContextMenu } from "./terminal/TerminalContextMenu";
|
||||
import { TerminalSearchBar } from "./terminal/TerminalSearchBar";
|
||||
import { ZmodemOverwriteDialog } from "./terminal/ZmodemOverwriteDialog";
|
||||
import { ZmodemProgressIndicator } from "./terminal/ZmodemProgressIndicator";
|
||||
import { createReplaySafeTerminalLogSanitizer } from "./terminal/replaySafeTerminalLog";
|
||||
import { useZmodemTransfer } from "./terminal/hooks/useZmodemTransfer";
|
||||
@@ -1269,6 +1270,18 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
if (!isVisible) return;
|
||||
const timer = setTimeout(() => {
|
||||
safeFit({ requireVisible: true });
|
||||
// Recover the WebGL renderer now that this tab is visible again. Hidden
|
||||
// panes stay mounted off-screen (visibility:hidden) so each keeps a live
|
||||
// WebGL context; creating another terminal's context — or the GPU dropping
|
||||
// a non-composited off-screen canvas — can leave this terminal's drawing
|
||||
// buffer corrupted ("花屏", issue #1063). Because a hidden pane keeps its
|
||||
// dimensions, becoming visible triggers no resize and therefore no redraw,
|
||||
// so the corruption persists until the user resizes the window. Force the
|
||||
// same recovery a resize performs: clear the texture atlas (no-op on the
|
||||
// DOM renderer) and synchronously repaint every row.
|
||||
xtermRuntimeRef.current?.clearTextureAtlas();
|
||||
const visibleTerm = termRef.current;
|
||||
if (visibleTerm) forceSyncRenderAfterResize(visibleTerm);
|
||||
if (pendingOutputScrollRef.current) {
|
||||
termRef.current?.scrollToBottom();
|
||||
if (typeof requestAnimationFrame === "function") {
|
||||
@@ -2481,6 +2494,13 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{/* ZMODEM overwrite conflict dialog */}
|
||||
{zmodem.overwriteRequest && (
|
||||
<ZmodemOverwriteDialog
|
||||
filename={zmodem.overwriteRequest.filename}
|
||||
onRespond={zmodem.respondOverwrite}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Compose Bar (solo sessions only; workspace uses TerminalLayer's global bar) */}
|
||||
|
||||
33
components/terminal/ZmodemOverwriteDialog.tsx
Normal file
33
components/terminal/ZmodemOverwriteDialog.tsx
Normal file
@@ -0,0 +1,33 @@
|
||||
import React, { useState } from "react";
|
||||
import { useI18n } from "../../application/i18n/I18nProvider";
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from "../ui/dialog";
|
||||
import { Button } from "../ui/button";
|
||||
|
||||
interface Props {
|
||||
filename: string;
|
||||
onRespond: (action: "overwrite" | "skip" | "cancel", applyToRest: boolean) => void;
|
||||
}
|
||||
|
||||
export const ZmodemOverwriteDialog: React.FC<Props> = ({ filename, onRespond }) => {
|
||||
const { t } = useI18n();
|
||||
const [applyToRest, setApplyToRest] = useState(false);
|
||||
return (
|
||||
<Dialog open onOpenChange={(o) => { if (!o) onRespond("cancel", false); }}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t("zmodem.overwrite.title")}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<p className="text-sm text-muted-foreground break-all">{filename}</p>
|
||||
<label className="flex items-center gap-2 text-sm mt-2">
|
||||
<input type="checkbox" checked={applyToRest} onChange={(e) => setApplyToRest(e.target.checked)} />
|
||||
{t("zmodem.overwrite.applyToRest")}
|
||||
</label>
|
||||
<DialogFooter>
|
||||
<Button variant="ghost" onClick={() => onRespond("cancel", applyToRest)}>{t("zmodem.overwrite.cancel")}</Button>
|
||||
<Button variant="outline" onClick={() => onRespond("skip", applyToRest)}>{t("zmodem.overwrite.skip")}</Button>
|
||||
<Button onClick={() => onRespond("overwrite", applyToRest)}>{t("zmodem.overwrite.overwrite")}</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
@@ -27,6 +27,7 @@ const initialState: ZmodemTransferState = {
|
||||
|
||||
export function useZmodemTransfer(sessionId: string | null) {
|
||||
const [state, setState] = useState<ZmodemTransferState>(initialState);
|
||||
const [overwriteRequest, setOverwriteRequest] = useState<{ requestId: string; filename: string } | null>(null);
|
||||
const disposeRef = useRef<(() => void) | null>(null);
|
||||
|
||||
const disposeExitRef = useRef<(() => void) | null>(null);
|
||||
@@ -77,6 +78,10 @@ export function useZmodemTransfer(sessionId: string | null) {
|
||||
}
|
||||
});
|
||||
|
||||
const disposeOverwrite = bridge.onZmodemOverwriteRequest?.(sessionId, (payload) => {
|
||||
setOverwriteRequest({ requestId: payload.requestId, filename: payload.filename });
|
||||
});
|
||||
|
||||
// If the session exits mid-transfer (disconnect, shell exit, etc.),
|
||||
// reset state so the progress indicator doesn't stay stuck.
|
||||
disposeExitRef.current = bridge.onSessionExit(sessionId, () => {
|
||||
@@ -86,9 +91,11 @@ export function useZmodemTransfer(sessionId: string | null) {
|
||||
return () => {
|
||||
disposeRef.current?.();
|
||||
disposeRef.current = null;
|
||||
disposeOverwrite?.();
|
||||
disposeExitRef.current?.();
|
||||
disposeExitRef.current = null;
|
||||
setState(initialState);
|
||||
setOverwriteRequest(null);
|
||||
};
|
||||
}, [sessionId]);
|
||||
|
||||
@@ -98,5 +105,12 @@ export function useZmodemTransfer(sessionId: string | null) {
|
||||
bridge?.cancelZmodem?.(sessionId);
|
||||
}, [sessionId]);
|
||||
|
||||
return { ...state, cancel };
|
||||
const respondOverwrite = useCallback((action: "overwrite" | "skip" | "cancel", applyToRest: boolean) => {
|
||||
setOverwriteRequest((req) => {
|
||||
if (req) netcattyBridge.get()?.respondZmodemOverwrite?.({ requestId: req.requestId, action, applyToRest });
|
||||
return null;
|
||||
});
|
||||
}, []);
|
||||
|
||||
return { ...state, cancel, overwriteRequest, respondOverwrite };
|
||||
}
|
||||
|
||||
@@ -58,6 +58,32 @@ function extractTrailingIdlePrompt(output) {
|
||||
return "";
|
||||
}
|
||||
|
||||
// bash and csh/tcsh print a banner to the terminal right before exiting due to
|
||||
// the shell's TMOUT idle-timeout setting ("timed out waiting for input:
|
||||
// auto-logout" / "auto-logout"). That exit is a clean shell exit — numeric
|
||||
// code, no signal — so it is indistinguishable from a user-typed `exit` by
|
||||
// exit code alone (verified: bash auto-logout exits 0). The banner is the only
|
||||
// reliable discriminator, letting the SSH bridge keep the tab open for
|
||||
// reconnect instead of auto-closing it (#1062, regression of #977).
|
||||
const IDLE_AUTO_LOGOUT_PATTERN = /(?:timed out waiting for input:\s*)?auto-?logout$/i;
|
||||
|
||||
function looksLikeIdleAutoLogout(outputTail) {
|
||||
if (typeof outputTail !== "string" || !outputTail) return false;
|
||||
// The shell prints this banner on its own line as the very last thing before
|
||||
// it exits, so anchor on the final non-empty line rather than a loose
|
||||
// substring. Otherwise unrelated output that merely mentions "auto-logout"
|
||||
// (e.g. `grep auto-logout /etc/profile`) followed by an intentional `exit`
|
||||
// would be misclassified as a timeout and wrongly keep the tab open.
|
||||
const lines = stripAnsi(outputTail.slice(-512)).replace(/\r/g, "\n").split("\n");
|
||||
for (let i = lines.length - 1; i >= 0; i--) {
|
||||
// Drop control bytes (e.g. the BEL bash rings before the banner) and trim.
|
||||
const line = lines[i].replace(/[\x00-\x1f\x7f]/g, "").trim();
|
||||
if (!line) continue;
|
||||
return IDLE_AUTO_LOGOUT_PATTERN.test(line);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function trackSessionIdlePrompt(session, chunk) {
|
||||
if (!session || typeof chunk !== "string" || !chunk) return "";
|
||||
|
||||
@@ -399,6 +425,7 @@ module.exports = {
|
||||
getFreshIdlePrompt,
|
||||
isDefaultPowerShellPromptLine,
|
||||
trackSessionIdlePrompt,
|
||||
looksLikeIdleAutoLogout,
|
||||
isLocalhostHostname,
|
||||
extractFirstNonLocalhostUrl,
|
||||
normalizeCliPathForPlatform,
|
||||
|
||||
@@ -7,6 +7,7 @@ const {
|
||||
getFreshIdlePrompt,
|
||||
isDefaultPowerShellPromptLine,
|
||||
isPlausibleCliVersionOutput,
|
||||
looksLikeIdleAutoLogout,
|
||||
prepareCommandForSpawn,
|
||||
trackSessionIdlePrompt,
|
||||
} = require("./shellUtils.cjs");
|
||||
@@ -175,3 +176,64 @@ test("getFreshIdlePrompt and trackSessionIdlePrompt round-trip through a real PT
|
||||
// with the cached PS line, so downstream wrapper selection sees "".
|
||||
assert.equal(getFreshIdlePrompt(session), "");
|
||||
});
|
||||
|
||||
test("looksLikeIdleAutoLogout detects the bash TMOUT banner at the tail", () => {
|
||||
// bash prints this immediately before a TMOUT auto-logout exit. The exit
|
||||
// itself is a clean shell exit (code 0, no signal), so the banner is the
|
||||
// only reliable discriminator from a user-typed `exit` (#1062 / #977).
|
||||
assert.equal(
|
||||
looksLikeIdleAutoLogout("user@host:~$ \x07timed out waiting for input: auto-logout\r\n"),
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
test("looksLikeIdleAutoLogout detects the csh/tcsh auto-logout banner", () => {
|
||||
assert.equal(looksLikeIdleAutoLogout("\r\nauto-logout\r\n"), true);
|
||||
});
|
||||
|
||||
test("looksLikeIdleAutoLogout sees through ANSI escapes around the banner", () => {
|
||||
assert.equal(
|
||||
looksLikeIdleAutoLogout("\x1b[0m\x1b[33mtimed out waiting for input: auto-logout\x1b[0m\r\n"),
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
test("looksLikeIdleAutoLogout ignores a plain (non-timeout) logout", () => {
|
||||
// A normal login-shell exit prints "logout" — without the "auto-" prefix —
|
||||
// and must still auto-close the tab.
|
||||
assert.equal(looksLikeIdleAutoLogout("user@host:~$ logout\r\n"), false);
|
||||
});
|
||||
|
||||
test("looksLikeIdleAutoLogout ignores the banner when it is not at the tail", () => {
|
||||
// "auto-logout" scrolled past long ago; the user then ran more commands and
|
||||
// exited normally. Only the tail end is inspected, so this is not a timeout.
|
||||
const tail = "auto-logout\n" + "x".repeat(400) + "\nuser@host:~$ logout\r\n";
|
||||
assert.equal(looksLikeIdleAutoLogout(tail), false);
|
||||
});
|
||||
|
||||
test("looksLikeIdleAutoLogout ignores auto-logout in command output before an intentional exit", () => {
|
||||
// Investigating TMOUT: the user greps the profile (output mentions
|
||||
// "auto-logout"), reads it, then exits on purpose. The banner is not the
|
||||
// final line, so the tab must still auto-close. Guards against matching an
|
||||
// unanchored substring anywhere in the recent output.
|
||||
const tail =
|
||||
"root@h:~# grep -i auto-logout /etc/profile\r\n" +
|
||||
"# bash TMOUT auto-logout setting\r\nTMOUT=300\r\n" +
|
||||
"root@h:~# exit\r\nlogout\r\n";
|
||||
assert.equal(looksLikeIdleAutoLogout(tail), false);
|
||||
});
|
||||
|
||||
test("looksLikeIdleAutoLogout matches the real-server banner shape (prompt + banner on one line)", () => {
|
||||
// The banner can share a line with the trailing prompt after ANSI/control
|
||||
// bytes are stripped (observed over real SSH); anchoring on the line end
|
||||
// must still match.
|
||||
const tail =
|
||||
"\x1b]0;root@VM:~\x07root@VM:~# \x1b[?2004l\x07timed out waiting for input: auto-logout\n";
|
||||
assert.equal(looksLikeIdleAutoLogout(tail), true);
|
||||
});
|
||||
|
||||
test("looksLikeIdleAutoLogout returns false for empty / non-string input", () => {
|
||||
assert.equal(looksLikeIdleAutoLogout(""), false);
|
||||
assert.equal(looksLikeIdleAutoLogout(undefined), false);
|
||||
assert.equal(looksLikeIdleAutoLogout(null), false);
|
||||
});
|
||||
|
||||
@@ -33,7 +33,7 @@ const {
|
||||
isPassphraseCancelledError,
|
||||
} = require("./sshAuthHelper.cjs");
|
||||
const sessionLogStreamManager = require("./sessionLogStreamManager.cjs");
|
||||
const { trackSessionIdlePrompt } = require("./ai/shellUtils.cjs");
|
||||
const { trackSessionIdlePrompt, looksLikeIdleAutoLogout } = require("./ai/shellUtils.cjs");
|
||||
const { createZmodemSentry } = require("./zmodemHelper.cjs");
|
||||
const {
|
||||
buildAlgorithms,
|
||||
@@ -365,6 +365,8 @@ function resolveLangFromCharset(charset) {
|
||||
|
||||
const { safeSend } = require("./ipcUtils.cjs");
|
||||
|
||||
const zmodemOverwritePending = new Map(); // requestId -> (decision) => void
|
||||
|
||||
/**
|
||||
* Initialize the SSH bridge with dependencies
|
||||
*/
|
||||
@@ -1330,6 +1332,28 @@ async function startSSHSession(event, options) {
|
||||
interruptRemote() {
|
||||
try { stream.signal?.("INT"); } catch { /* ignore */ }
|
||||
},
|
||||
probeReceiveConflicts(names) {
|
||||
return probeReceiveConflicts(sessions.get(sessionId), names);
|
||||
},
|
||||
removeRemoteFiles(paths) {
|
||||
return removeRemoteFiles(sessions.get(sessionId), paths);
|
||||
},
|
||||
requestOverwriteDecision(filename) {
|
||||
return new Promise((resolve) => {
|
||||
const requestId = randomUUID();
|
||||
const timer = setTimeout(() => {
|
||||
zmodemOverwritePending.delete(requestId);
|
||||
resolve({ action: "skip", applyToRest: false });
|
||||
}, 120000);
|
||||
zmodemOverwritePending.set(requestId, (payload) => {
|
||||
clearTimeout(timer);
|
||||
resolve({ action: payload.action, applyToRest: !!payload.applyToRest });
|
||||
});
|
||||
safeSend(event.sender, "netcatty:zmodem:overwrite-request", {
|
||||
sessionId, requestId, filename,
|
||||
});
|
||||
});
|
||||
},
|
||||
getWebContents() {
|
||||
return event.sender;
|
||||
},
|
||||
@@ -1386,7 +1410,14 @@ async function startSSHSession(event, options) {
|
||||
if (transportError) {
|
||||
safeSend(contents, "netcatty:exit", { sessionId, exitCode: 1, error: transportError, reason: "error" });
|
||||
} else {
|
||||
safeSend(contents, "netcatty:exit", { sessionId, exitCode: streamExitCode, reason: streamExited ? "exited" : "closed" });
|
||||
// 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).
|
||||
const idleTimedOut = streamExited && looksLikeIdleAutoLogout(session?._promptTrackTail);
|
||||
const reason = idleTimedOut ? "timeout" : (streamExited ? "exited" : "closed");
|
||||
safeSend(contents, "netcatty:exit", { sessionId, exitCode: streamExitCode, reason });
|
||||
}
|
||||
sessions.get(sessionId)?.zmodemSentry?.cancel();
|
||||
sessions.delete(sessionId);
|
||||
@@ -2012,24 +2043,58 @@ async function getSessionPwd(event, payload) {
|
||||
// so sh keeps the same PID and $PPID = sshd. Starting another shell
|
||||
// without exec would make $PPID point at the intermediate shell instead.
|
||||
const posixScript = `SELF=$$
|
||||
find_child_shell() {
|
||||
mode=$2
|
||||
ps -e -o pid=,ppid=,stat=,comm= 2>/dev/null | awk -v pp="$1" -v self="$SELF" -v mode="$mode" '
|
||||
$1 != self && $2 == pp && $4 ~ /^(ba|z|fi|k|da)?sh$/ {
|
||||
if (index($3, "+") > 0) { print $1; found=1; exit }
|
||||
if (mode != "foreground" && pid == "") pid=$1
|
||||
# Find the interactive shell child of this exec channel's sshd ($PPID).
|
||||
# Prefer the one attached to a controlling tty (the user's shell): probe exec
|
||||
# channels like this one have no tty ("?"), and ps output is unsorted, so
|
||||
# without the tty preference a concurrent probe's shell could be picked when
|
||||
# several exist under the same sshd (#1065 review). Falls back to any shell
|
||||
# child if none has a tty.
|
||||
find_login_shell() {
|
||||
ps -e -o pid=,ppid=,tty=,comm= 2>/dev/null | awk -v pp="$1" -v self="$SELF" '
|
||||
$1 != self && $2 == pp && $4 ~ /^-?(ba|z|fi|k|da|a)?sh$/ {
|
||||
if ($3 != "?") { print $1; found=1; exit }
|
||||
if (any == "") any=$1
|
||||
}
|
||||
END { if (!found && mode != "foreground" && pid != "") print pid }
|
||||
END { if (!found && any != "") print any }
|
||||
'
|
||||
}
|
||||
pid=$(find_child_shell "$PPID" any)
|
||||
while [ -n "$pid" ]; do
|
||||
child=$(find_child_shell "$pid" foreground)
|
||||
[ -n "$child" ] || break
|
||||
pid="$child"
|
||||
done
|
||||
if [ -n "$pid" ]; then
|
||||
# From the login shell, pick the DEEPEST foreground shell in its process
|
||||
# subtree. "Foreground" = the controlling tty's foreground process group ("+"
|
||||
# in stat), i.e. the shell the user is actually typing in. Walking the whole
|
||||
# subtree (rather than only direct shell children) lets us follow through
|
||||
# non-shell foreground parents like su / sudo, so we read the cwd of the
|
||||
# su'd / sudo'd shell instead of stopping at the login shell (#1065). Falls
|
||||
# back to the login shell when no foreground shell is found.
|
||||
find_active_shell() {
|
||||
ps -e -o pid=,ppid=,stat=,comm= 2>/dev/null | awk -v start="$1" '
|
||||
{ pp[$1]=$2; st[$1]=$3; cm[$1]=$4; ord[NR]=$1 }
|
||||
function isshell(c) { return c ~ /^-?(ba|z|fi|k|da|a)?sh$/ }
|
||||
function depth(p, d) { d=0; while (p != "" && d < 64) { if (p == start) return d; p=pp[p]; d++ } return -1 }
|
||||
END {
|
||||
best=-1; bp="";
|
||||
for (i=1; i<=NR; i++) {
|
||||
p=ord[i];
|
||||
if (!isshell(cm[p])) continue;
|
||||
if (index(st[p], "+") == 0) continue;
|
||||
d=depth(p); if (d < 0) continue;
|
||||
if (d > best) { best=d; bp=p }
|
||||
}
|
||||
print (bp != "" ? bp : start)
|
||||
}
|
||||
'
|
||||
}
|
||||
login=$(find_login_shell "$PPID")
|
||||
if [ -n "$login" ]; then
|
||||
pid=$(find_active_shell "$login")
|
||||
[ -n "$pid" ] || pid="$login"
|
||||
cwd=$(readlink /proc/$pid/cwd 2>/dev/null)
|
||||
# /proc/<pid>/cwd is only readable for same-uid processes (ptrace perms), so
|
||||
# this unprivileged exec channel cannot read a su'd / sudo'd shell owned by
|
||||
# another user. Fall back to the same-uid login shell's cwd before giving up
|
||||
# to the home directory (#1065 review).
|
||||
if [ -z "$cwd" ] && [ "$pid" != "$login" ]; then
|
||||
cwd=$(readlink /proc/$login/cwd 2>/dev/null)
|
||||
fi
|
||||
[ -n "$cwd" ] && printf '%s\\n' "$cwd" && exit 0
|
||||
fi
|
||||
emit_home() {
|
||||
@@ -2077,6 +2142,74 @@ exit 1`;
|
||||
});
|
||||
}
|
||||
|
||||
// Resolve the directory the running `rz` writes to (its own cwd) and report
|
||||
// which of `names` already exist there. Returns { dir, existing } or null.
|
||||
function probeReceiveConflicts(session, names) {
|
||||
return new Promise((resolve) => {
|
||||
if (!session || !session.conn || !Array.isArray(names) || names.length === 0) {
|
||||
return resolve(null);
|
||||
}
|
||||
const timer = setTimeout(() => resolve(null), 5000);
|
||||
const script = `SELF=$$
|
||||
find_login_shell() {
|
||||
ps -e -o pid=,ppid=,tty=,comm= 2>/dev/null | awk -v pp="$1" -v self="$SELF" '
|
||||
$1 != self && $2 == pp && $4 ~ /^-?(ba|z|fi|k|da|a)?sh$/ {
|
||||
if ($3 != "?") { print $1; found=1; exit }
|
||||
if (any == "") any=$1
|
||||
}
|
||||
END { if (!found && any != "") print any }'
|
||||
}
|
||||
find_fg_leaf() {
|
||||
ps -e -o pid=,ppid=,stat=,comm= 2>/dev/null | awk -v start="$1" '
|
||||
{ pp[$1]=$2; st[$1]=$3; ord[NR]=$1 }
|
||||
function depth(p, d){ d=0; while(p!="" && d<64){ if(p==start) return d; p=pp[p]; d++ } return -1 }
|
||||
END { best=-1; bp=""; for(i=1;i<=NR;i++){ p=ord[i];
|
||||
if(index(st[p],"+")==0) continue; d=depth(p); if(d<0) continue;
|
||||
if(d>best){best=d; bp=p} } print bp }'
|
||||
}
|
||||
login=$(find_login_shell "$PPID")
|
||||
[ -n "$login" ] || exit 0
|
||||
leaf=$(find_fg_leaf "$login")
|
||||
[ -n "$leaf" ] || leaf="$login"
|
||||
dir=$(readlink /proc/$leaf/cwd 2>/dev/null)
|
||||
[ -n "$dir" ] || exit 0
|
||||
printf 'DIR\\t%s\\n' "$dir"
|
||||
cd "$dir" 2>/dev/null || exit 0
|
||||
for n in "$@"; do [ -e "$n" ] && printf 'EXIST\\t%s\\n' "$n"; done`;
|
||||
const argv = names.map((n) => quoteShellArg(n)).join(" ");
|
||||
const cmd = `exec sh -c ${quoteShellArg(script)} sh ${argv}`;
|
||||
session.conn.exec(cmd, (err, stream) => {
|
||||
if (err) { clearTimeout(timer); return resolve(null); }
|
||||
let out = "";
|
||||
stream.on("data", (d) => { out += d.toString(); });
|
||||
stream.on("close", () => {
|
||||
clearTimeout(timer);
|
||||
let dir = null; const existing = [];
|
||||
for (const line of out.split("\n")) {
|
||||
const [tag, val] = line.split("\t");
|
||||
if (tag === "DIR") dir = val;
|
||||
else if (tag === "EXIST" && val) existing.push(val);
|
||||
}
|
||||
resolve(dir ? { dir, existing } : null);
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// rm -f the given absolute remote paths (quoted; injection-safe).
|
||||
function removeRemoteFiles(session, paths) {
|
||||
return new Promise((resolve) => {
|
||||
if (!session || !session.conn || !Array.isArray(paths) || paths.length === 0) return resolve();
|
||||
const argv = paths.map((p) => quoteShellArg(p)).join(" ");
|
||||
const timer = setTimeout(resolve, 5000);
|
||||
session.conn.exec(`exec sh -c 'rm -f -- "$@"' sh ${argv}`, (err, stream) => {
|
||||
if (err) { clearTimeout(timer); return resolve(); }
|
||||
stream.on("data", () => {}); stream.stderr?.on("data", () => {});
|
||||
stream.on("close", () => { clearTimeout(timer); resolve(); });
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* List directory contents on remote machine for path autocomplete.
|
||||
* Uses a separate exec channel — does not touch the interactive shell.
|
||||
@@ -2653,6 +2786,10 @@ function registerHandlers(ipcMain) {
|
||||
}
|
||||
return keys;
|
||||
});
|
||||
ipcMain.on("netcatty:zmodem:overwrite-response", (_event, payload) => {
|
||||
const resolve = zmodemOverwritePending.get(payload?.requestId);
|
||||
if (resolve) { zmodemOverwritePending.delete(payload.requestId); resolve(payload); }
|
||||
});
|
||||
// Register the shared keyboard-interactive response handler
|
||||
keyboardInteractiveHandler.registerHandler(ipcMain);
|
||||
// Register the passphrase response handler
|
||||
|
||||
@@ -81,6 +81,7 @@ test("execCommand stops when an identity file passphrase prompt is cancelled", a
|
||||
handle(channel, handler) {
|
||||
this.handlers.set(channel, handler);
|
||||
},
|
||||
on() {},
|
||||
};
|
||||
bridge.registerHandlers(ipcMain);
|
||||
const execHandler = ipcMain.handlers.get("netcatty:ssh:exec");
|
||||
|
||||
@@ -20,6 +20,35 @@ function getElectron() {
|
||||
return _electron;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve per-file overwrite choices into an upload plan. Pure (no I/O):
|
||||
* `resolveDecision(name)` is awaited only for files in `existingList`, in input
|
||||
* order; `{ applyToRest: true }` reuses that action for the remaining conflicts.
|
||||
* Returns indices into the original `names` array so callers preserve per-file
|
||||
* identity even when two files share a basename.
|
||||
* Actions: 'overwrite' (rm remote then send), 'skip' (don't send), 'cancel' (abort all).
|
||||
*/
|
||||
async function buildUploadPlan(names, existingList, resolveDecision) {
|
||||
const existing = new Set(existingList);
|
||||
const offerIndices = [];
|
||||
const removeIndices = [];
|
||||
let bulkAction = null;
|
||||
for (let idx = 0; idx < names.length; idx++) {
|
||||
const name = names[idx];
|
||||
if (!existing.has(name)) { offerIndices.push(idx); continue; }
|
||||
let action = bulkAction;
|
||||
if (!action) {
|
||||
const decision = (await resolveDecision(name)) || { action: "skip" };
|
||||
action = decision.action;
|
||||
if (decision.applyToRest && action !== "cancel") bulkAction = action;
|
||||
}
|
||||
if (action === "cancel") return { offerIndices: [], removeIndices: [], aborted: true };
|
||||
if (action === "overwrite") { removeIndices.push(idx); offerIndices.push(idx); }
|
||||
// 'skip' → omit from both
|
||||
}
|
||||
return { offerIndices, removeIndices, aborted: false };
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a ZMODEM sentry that wraps a session's data stream.
|
||||
*
|
||||
@@ -524,10 +553,41 @@ async function handleUpload(zsession, opts) {
|
||||
const filePaths = result.filePaths;
|
||||
const fileStats = filePaths.map((fp) => fs.statSync(fp));
|
||||
|
||||
for (let i = 0; i < filePaths.length; i++) {
|
||||
const filePath = filePaths[i];
|
||||
const stat = fileStats[i];
|
||||
const name = path.basename(filePath);
|
||||
const allNames = filePaths.map((fp) => path.basename(fp));
|
||||
|
||||
// Conflict handling (SSH only — callbacks absent on local/telnet/serial).
|
||||
// On any failure we fall back to today's behavior (rz silently skips).
|
||||
let plan = { offerIndices: allNames.map((_, i) => i), removeIndices: [], aborted: false };
|
||||
if (opts.probeReceiveConflicts && opts.requestOverwriteDecision) {
|
||||
try {
|
||||
const probe = await opts.probeReceiveConflicts(allNames);
|
||||
if (probe && probe.dir && Array.isArray(probe.existing) && probe.existing.length > 0) {
|
||||
plan = await buildUploadPlan(allNames, probe.existing, opts.requestOverwriteDecision);
|
||||
if (plan.aborted) {
|
||||
try { zsession.abort(); } catch { /* ignore */ }
|
||||
abortRemoteProcess(opts.writeToRemote);
|
||||
throw new Error("Transfer cancelled");
|
||||
}
|
||||
if (plan.removeIndices.length && opts.removeRemoteFiles) {
|
||||
const base = probe.dir.replace(/\/+$/, "");
|
||||
const targets = [...new Set(plan.removeIndices.map((i) => `${base}/${allNames[i]}`))];
|
||||
try {
|
||||
await opts.removeRemoteFiles(targets);
|
||||
} catch (err) {
|
||||
console.warn("[ZMODEM] removeRemoteFiles failed; rz will skip:", err?.message || err);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
if (err instanceof Error && err.message === "Transfer cancelled") throw err;
|
||||
console.warn("[ZMODEM] conflict probe failed; proceeding:", err?.message || err);
|
||||
}
|
||||
}
|
||||
|
||||
const offers = plan.offerIndices.map((i) => ({ filePath: filePaths[i], stat: fileStats[i], name: allNames[i] }));
|
||||
|
||||
for (let i = 0; i < offers.length; i++) {
|
||||
const { filePath, stat, name } = offers[i];
|
||||
|
||||
safeSend(contents, "netcatty:zmodem:progress", {
|
||||
sessionId,
|
||||
@@ -535,18 +595,18 @@ async function handleUpload(zsession, opts) {
|
||||
transferred: 0,
|
||||
total: stat.size,
|
||||
fileIndex: i,
|
||||
fileCount: filePaths.length,
|
||||
fileCount: offers.length,
|
||||
transferType: "upload",
|
||||
});
|
||||
|
||||
let bytesRemaining = 0;
|
||||
for (let j = i; j < fileStats.length; j++) bytesRemaining += fileStats[j].size;
|
||||
for (let j = i; j < offers.length; j++) bytesRemaining += offers[j].stat.size;
|
||||
|
||||
const xfer = await zsession.send_offer({
|
||||
name,
|
||||
size: stat.size,
|
||||
mtime: new Date(stat.mtimeMs),
|
||||
files_remaining: filePaths.length - i,
|
||||
files_remaining: offers.length - i,
|
||||
bytes_remaining: bytesRemaining,
|
||||
});
|
||||
|
||||
@@ -579,7 +639,7 @@ async function handleUpload(zsession, opts) {
|
||||
transferred: sent,
|
||||
total: stat.size,
|
||||
fileIndex: i,
|
||||
fileCount: filePaths.length,
|
||||
fileCount: offers.length,
|
||||
transferType: "upload",
|
||||
});
|
||||
|
||||
@@ -597,7 +657,7 @@ async function handleUpload(zsession, opts) {
|
||||
transferred: stat.size,
|
||||
total: stat.size,
|
||||
fileIndex: i,
|
||||
fileCount: filePaths.length,
|
||||
fileCount: offers.length,
|
||||
transferType: "upload",
|
||||
finalizing: true,
|
||||
});
|
||||
@@ -791,4 +851,4 @@ function safeSend(contents, channel, data) {
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { createZmodemSentry };
|
||||
module.exports = { createZmodemSentry, buildUploadPlan };
|
||||
|
||||
50
electron/bridges/zmodemHelper.test.cjs
Normal file
50
electron/bridges/zmodemHelper.test.cjs
Normal file
@@ -0,0 +1,50 @@
|
||||
const test = require("node:test");
|
||||
const assert = require("node:assert/strict");
|
||||
const { buildUploadPlan } = require("./zmodemHelper.cjs");
|
||||
|
||||
const never = () => { throw new Error("resolver should not be called"); };
|
||||
|
||||
test("no conflicts: all indices offered, none removed, resolver untouched", async () => {
|
||||
const plan = await buildUploadPlan(["a.txt", "b.txt"], [], never);
|
||||
assert.deepEqual(plan, { offerIndices: [0, 1], removeIndices: [], aborted: false });
|
||||
});
|
||||
|
||||
test("overwrite a conflict: index both removed and offered", async () => {
|
||||
const plan = await buildUploadPlan(["a.txt", "b.txt"], ["b.txt"], async () => ({ action: "overwrite" }));
|
||||
assert.deepEqual(plan, { offerIndices: [0, 1], removeIndices: [1], aborted: false });
|
||||
});
|
||||
|
||||
test("skip a conflict: index omitted from offer and remove", async () => {
|
||||
const plan = await buildUploadPlan(["a.txt", "b.txt"], ["b.txt"], async () => ({ action: "skip" }));
|
||||
assert.deepEqual(plan, { offerIndices: [0], removeIndices: [], aborted: false });
|
||||
});
|
||||
|
||||
test("cancel aborts the whole transfer", async () => {
|
||||
const plan = await buildUploadPlan(["a.txt", "b.txt"], ["b.txt"], async () => ({ action: "cancel" }));
|
||||
assert.deepEqual(plan, { offerIndices: [], removeIndices: [], aborted: true });
|
||||
});
|
||||
|
||||
test("applyToRest reuses the action and stops prompting", async () => {
|
||||
let calls = 0;
|
||||
const plan = await buildUploadPlan(["a", "b", "c"], ["a", "b", "c"],
|
||||
async () => { calls++; return { action: "overwrite", applyToRest: true }; });
|
||||
assert.equal(calls, 1);
|
||||
assert.deepEqual(plan, { offerIndices: [0, 1, 2], removeIndices: [0, 1, 2], aborted: false });
|
||||
});
|
||||
|
||||
test("only conflicting files invoke the resolver; order preserved", async () => {
|
||||
const seen = [];
|
||||
const plan = await buildUploadPlan(["a", "b", "c"], ["b"],
|
||||
async (n) => { seen.push(n); return { action: "skip" }; });
|
||||
assert.deepEqual(seen, ["b"]);
|
||||
assert.deepEqual(plan.offerIndices, [0, 2]);
|
||||
});
|
||||
|
||||
test("duplicate basenames keep independent per-file decisions", async () => {
|
||||
// Two different local files share a basename; skip the first, overwrite the second.
|
||||
const actions = ["skip", "overwrite"];
|
||||
let i = 0;
|
||||
const plan = await buildUploadPlan(["x.txt", "x.txt"], ["x.txt"],
|
||||
async () => ({ action: actions[i++] }));
|
||||
assert.deepEqual(plan, { offerIndices: [1], removeIndices: [1], aborted: false });
|
||||
});
|
||||
@@ -10,6 +10,7 @@ const transferErrorListeners = new Map();
|
||||
const transferCancelledListeners = new Map();
|
||||
const chainProgressListeners = new Map();
|
||||
const zmodemListeners = new Map();
|
||||
const zmodemOverwriteListeners = new Map(); // sessionId -> Set<cb>
|
||||
const sftpConnectionProgressListeners = new Set();
|
||||
const authFailedListeners = new Map();
|
||||
const telnetAutoLoginCompleteListeners = new Map();
|
||||
@@ -137,6 +138,10 @@ ipcRenderer.on("netcatty:zmodem:error", (_event, payload) => {
|
||||
if (!set) return;
|
||||
set.forEach((cb) => { try { cb({ type: "error", ...payload }); } catch {} });
|
||||
});
|
||||
ipcRenderer.on("netcatty:zmodem:overwrite-request", (_event, payload) => {
|
||||
const set = zmodemOverwriteListeners.get(payload.sessionId);
|
||||
if (set) set.forEach((cb) => cb(payload));
|
||||
});
|
||||
|
||||
ipcRenderer.on("netcatty:data", (_event, payload) => {
|
||||
const set = dataListeners.get(payload.sessionId);
|
||||
@@ -185,6 +190,7 @@ ipcRenderer.on("netcatty:exit", (_event, payload) => {
|
||||
telnetAutoLoginCompleteListeners.delete(payload.sessionId);
|
||||
telnetAutoLoginCancelledListeners.delete(payload.sessionId);
|
||||
zmodemListeners.delete(payload.sessionId);
|
||||
zmodemOverwriteListeners.delete(payload.sessionId);
|
||||
const pendingTimer = _mcpFlushTimers.get(payload.sessionId);
|
||||
if (pendingTimer) {
|
||||
clearTimeout(pendingTimer);
|
||||
@@ -682,6 +688,14 @@ const api = {
|
||||
cancelZmodem: (sessionId) => {
|
||||
ipcRenderer.send("netcatty:zmodem:cancel", { sessionId });
|
||||
},
|
||||
onZmodemOverwriteRequest: (sessionId, cb) => {
|
||||
if (!zmodemOverwriteListeners.has(sessionId)) zmodemOverwriteListeners.set(sessionId, new Set());
|
||||
zmodemOverwriteListeners.get(sessionId).add(cb);
|
||||
return () => zmodemOverwriteListeners.get(sessionId)?.delete(cb);
|
||||
},
|
||||
respondZmodemOverwrite: (payload) => {
|
||||
ipcRenderer.send("netcatty:zmodem:overwrite-response", payload);
|
||||
},
|
||||
onSessionData: (sessionId, cb) => {
|
||||
if (!dataListeners.has(sessionId)) dataListeners.set(sessionId, new Set());
|
||||
dataListeners.get(sessionId).add(cb);
|
||||
|
||||
9
global.d.ts
vendored
9
global.d.ts
vendored
@@ -341,6 +341,15 @@ declare global {
|
||||
}) => void
|
||||
): () => void;
|
||||
cancelZmodem?(sessionId: string): void;
|
||||
onZmodemOverwriteRequest?(
|
||||
sessionId: string,
|
||||
cb: (payload: { sessionId: string; requestId: string; filename: string }) => void
|
||||
): () => void;
|
||||
respondZmodemOverwrite?(payload: {
|
||||
requestId: string;
|
||||
action: "overwrite" | "skip" | "cancel";
|
||||
applyToRest: boolean;
|
||||
}): void;
|
||||
onSessionData(sessionId: string, cb: (data: string) => void): () => void;
|
||||
onSessionExit(
|
||||
sessionId: string,
|
||||
|
||||
@@ -28,7 +28,7 @@
|
||||
"pack:linux": "npm run build && cross-env NODE_OPTIONS=--disable-warning=DEP0190 electron-builder --config electron-builder.config.cjs --linux --publish=never",
|
||||
"pack:linux-x64": "npm run build && cross-env npm_config_arch=x64 NODE_OPTIONS=--disable-warning=DEP0190 electron-builder --config electron-builder.config.cjs --linux --x64 --publish=never",
|
||||
"pack:linux-arm64": "npm run build && cross-env npm_config_arch=arm64 NODE_OPTIONS=--disable-warning=DEP0190 electron-builder --config electron-builder.config.cjs --linux --arm64 --publish=never",
|
||||
"postinstall": "electron-builder install-app-deps && patch-package",
|
||||
"postinstall": "electron-builder install-app-deps && patch-package && node scripts/patch-xterm-webgl-atlas.cjs",
|
||||
"rebuild": "electron-builder install-app-deps",
|
||||
"tool:cli": "node electron/cli/netcatty-tool-cli.cjs",
|
||||
"lint": "eslint .",
|
||||
|
||||
74
scripts/patch-xterm-webgl-atlas.cjs
Normal file
74
scripts/patch-xterm-webgl-atlas.cjs
Normal file
@@ -0,0 +1,74 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Disable @xterm/addon-webgl's cross-terminal texture-atlas sharing.
|
||||
*
|
||||
* xterm's WebGL addon shares ONE TextureAtlas across terminal instances whose
|
||||
* config (font / size / theme / device-pixel-ratio) is equal — see
|
||||
* `acquireTextureAtlas`, which does `if (configEquals) { ownedBy.push; return
|
||||
* atlas }`. In a split workspace two panes then share an atlas, so clearing or
|
||||
* rebuilding it for one pane (which netcatty does on resize / DPR change / font
|
||||
* change / tab show to recover from glyph corruption) corrupts the OTHER pane's
|
||||
* rendering — the persistent "花屏 / garbled" report in issue #1063, most
|
||||
* visible in split view where both panes stay on screen.
|
||||
*
|
||||
* Fix: give every terminal its own atlas by removing the "reuse a matching
|
||||
* atlas" loop, so each terminal falls through to creating its own. The published
|
||||
* package is minified, so we string-replace the exact loop in both the CJS and
|
||||
* ESM builds. This runs from `postinstall` (after patch-package).
|
||||
*
|
||||
* Idempotent. If the upstream code changes (e.g. an @xterm/addon-webgl upgrade)
|
||||
* the loop won't be found; we warn loudly but do not fail the install, and the
|
||||
* strings below must then be refreshed for the new version.
|
||||
*/
|
||||
"use strict";
|
||||
const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
|
||||
const MARKER = "/*netcatty:#1063 atlas-isolation*/";
|
||||
|
||||
// Exact (minified) "reuse a shared atlas" loop, per @xterm/addon-webgl@0.19.0.
|
||||
const TARGETS = [
|
||||
{
|
||||
file: "node_modules/@xterm/addon-webgl/lib/addon-webgl.mjs",
|
||||
loop: "for(let h=0;h<le.length;h++){let f=le[h];if(Mi(f.config,u))return f.ownedBy.push(i),f.atlas}",
|
||||
},
|
||||
{
|
||||
file: "node_modules/@xterm/addon-webgl/lib/addon-webgl.js",
|
||||
loop: "for(let t=0;t<r.length;t++){const i=r[t];if((0,n.configEquals)(i.config,d))return i.ownedBy.push(e),i.atlas}",
|
||||
},
|
||||
];
|
||||
|
||||
let patched = 0;
|
||||
let already = 0;
|
||||
let missing = 0;
|
||||
|
||||
for (const { file, loop } of TARGETS) {
|
||||
const abs = path.resolve(process.cwd(), file);
|
||||
let src;
|
||||
try {
|
||||
src = fs.readFileSync(abs, "utf8");
|
||||
} catch {
|
||||
console.warn(`[patch-xterm-webgl-atlas] skip (not found): ${file}`);
|
||||
missing++;
|
||||
continue;
|
||||
}
|
||||
if (src.includes(MARKER)) {
|
||||
already++;
|
||||
continue;
|
||||
}
|
||||
if (!src.includes(loop)) {
|
||||
console.warn(
|
||||
`[patch-xterm-webgl-atlas] WARNING: atlas-sharing loop not found in ${file}. ` +
|
||||
"@xterm/addon-webgl likely changed — split-view WebGL may garble again (#1063). " +
|
||||
"Refresh the minified target strings in scripts/patch-xterm-webgl-atlas.cjs.",
|
||||
);
|
||||
missing++;
|
||||
continue;
|
||||
}
|
||||
fs.writeFileSync(abs, src.replace(loop, MARKER));
|
||||
patched++;
|
||||
}
|
||||
|
||||
console.log(
|
||||
`[patch-xterm-webgl-atlas] atlas isolation: patched=${patched} already=${already} missing=${missing}`,
|
||||
);
|
||||
@@ -44,7 +44,6 @@ export default defineConfig(() => {
|
||||
output: {
|
||||
manualChunks: {
|
||||
// Vendor chunks - rarely change, can be cached aggressively
|
||||
'vendor-react': ['react', 'react-dom'],
|
||||
'vendor-radix': [
|
||||
'@radix-ui/react-collapsible',
|
||||
'@radix-ui/react-context-menu',
|
||||
|
||||
Reference in New Issue
Block a user