Compare commits

...

6 Commits

Author SHA1 Message Date
陈大猫
02c80ae7d2 chore: silence two production build warnings (#1072)
Some checks failed
build-packages / ${{ needs.dedupe.outputs.skip_heavy_ci == 'true' && 'deduped build-linux-x64' || 'build-linux-x64' }} (push) Has been cancelled
build-packages / ${{ needs.dedupe.outputs.skip_heavy_ci == 'true' && 'deduped build-linux-arm64' || 'build-linux-arm64' }} (push) Has been cancelled
build-packages / release (push) Has been cancelled
build-packages / dedupe push run (push) Has been cancelled
build-packages / dedupe result (push) Has been cancelled
build-packages / resolve bundled mosh-client (push) Has been cancelled
build-packages / build-macos (push) Has been cancelled
build-packages / build-windows (push) Has been cancelled
build-packages / bump homebrew tap (push) Has been cancelled
- Drop the manualChunks 'vendor-react' entry: react/react-dom already land
  in another chunk, so it only ever produced an empty chunk + a build
  warning, with no caching benefit.
- Import domain/syncMerge statically in useAutoSync. It's already in the
  eager graph via CloudSyncManager's static import, so the dynamic
  `import()` couldn't be code-split anyway and only emitted a mixed
  static/dynamic-import warning.

No behavior change; production build is warning-free.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-23 13:50:33 +08:00
陈大猫
e5d3d02b17 fix #1063: give each terminal its own WebGL texture atlas (disable cross-terminal sharing) (#1071)
Root cause of the persistent split-view 花屏: xterm's WebGL addon shares
ONE TextureAtlas across terminal instances with equal config (font / size
/ theme / DPR) — acquireTextureAtlas does `if (configEquals) { ownedBy.push;
return atlas }`. Two split panes then share an atlas, so the
clearTextureAtlas calls netcatty makes to recover from glyph corruption
(on resize / DPR / font change / tab show, from #1049 and #1066) clobber
the *other* pane's rendering. That's why the earlier redraw/clear-based
recovery attempts didn't help and only bounced the garble between panes.

Disable the sharing: remove the "reuse a matching atlas" loop so every
terminal creates its own atlas. The published bundle is minified, so this
is done with a small idempotent postinstall script (a patch-package patch
would be a ~550KB unreadable blob of the whole minified line). It
string-replaces the exact loop in the CJS + ESM builds, runs after
patch-package, and warns without failing if @xterm/addon-webgl changes.

Verified: split-view WebGL no longer garbles; script is idempotent
(patched=2 → already=2) and the production build is unaffected.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-23 13:45:05 +08:00
陈大猫
78186d8d46 feat #1064: prompt to overwrite when rz upload hits a remote filename conflict (#1070)
* feat #1064: add buildUploadPlan for rz overwrite/skip/cancel resolution

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat #1064: handle remote filename conflicts in rz handleUpload

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat #1064: SSH exec probe + remove for rz upload conflicts

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat #1064: IPC for rz overwrite-conflict prompt

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat #1064: renderer prompt for rz overwrite conflicts

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix #1064: repair sshBridge test mock (ipcMain.on) and i18n the overwrite dialog

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix #1064: make upload plan index-based to preserve per-file decisions

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-23 12:20:20 +08:00
陈大猫
c899653621 fix #1065: resolve terminal cwd through su/sudo for the SFTP locate (#1068)
* fix #1065: resolve terminal cwd through su/sudo for the SFTP locate

The SFTP "locate to terminal's current directory" feature kept showing the
login user's home (e.g. /root) after the user switched accounts with su /
sudo -s and cd'd elsewhere.

getSessionPwd walks the remote process tree from a sibling exec channel to
find the interactive shell's cwd, but it only followed children whose comm
is a shell name (bash/zsh/...). su and sudo are named "su"/"sudo", so the
walk stopped at the login shell and read its cwd. The actual shell the user
is typing in lives *under* su/sudo as the controlling tty's foreground
process group.

Rewrite the walk to pick the deepest foreground shell ("+" in stat) within
the login shell's whole process subtree, which transparently follows
through su/sudo to the active shell, falling back to the login shell when
no foreground shell is found.

Verified on a real server (root -> su user -> cd /tmp):
  before: /root   after: /tmp
and confirmed the no-su case is unchanged (cd /var -> /var).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* Fall back to login shell cwd when the active shell's /proc is unreadable (Codex review)

When an unprivileged user runs `sudo -s` / `su root`, find_active_shell
correctly selects the root-owned foreground shell, but the exec channel
(running as the login user) cannot readlink another uid's /proc/<pid>/cwd
due to ptrace permissions. Without a fallback the script dropped straight
to the home directory, regressing user→root sessions.

Retry readlink on the same-uid login shell before falling back to home.

Verified live (user -> cd /var -> sudo -s -> cd /tmp): the root shell's
cwd is unreadable, and the result is now /var (login shell cwd) instead of
/home/<user>.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* Select the interactive (tty-bearing) login shell deterministically (Codex review)

find_login_shell picked the first shell child of sshd and exited, but ps
output is unsorted, so when other exec channels (server-stats polls, etc.)
are running on the same connection their transient sh could be chosen,
making find_active_shell walk the wrong subtree.

Prefer the shell child that has a controlling tty: the interactive shell
has a pts, while non-PTY probe exec channels have tty "?". This is
deterministic regardless of ps order, in both the su and no-su cases (the
old "prefer foreground" heuristic was itself nondeterministic under su).
Falls back to any shell child if none has a tty.

Verified live with a concurrent no-tty `sh -c sleep` under the same sshd:
the pts/0 bash is selected and the result is /tmp, not the probe shell.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-23 11:26:20 +08:00
陈大猫
a91fbcdd68 fix #1062: treat SSH shell TMOUT auto-logout as a timeout, not a normal exit (#1067)
* fix #1062: treat SSH shell TMOUT auto-logout as a timeout, not a normal exit

A shell-level TMOUT idle auto-logout makes bash/csh exit cleanly (numeric
exit code, no signal), which is byte-for-byte indistinguishable from a
user-typed `exit` at the SSH protocol level. PR #1057 keyed the
close-vs-keep decision on `streamExited` (numeric code + no signal), so
TMOUT exits were reported as reason "exited" and the tab was auto-closed —
reintroducing the problem from #977.

Verified against a real server that bash TMOUT exits with code 0 / no
signal and prints "timed out waiting for input: auto-logout" to the
channel before it closes. Since exit code/signal can't distinguish it from
an intentional exit, detect that banner in the session's existing rolling
output tail (_promptTrackTail) and report reason "timeout" instead, which
routes to the existing markDisconnected path (keep tab + reconnect). A
normal `exit`/`logout` (no "auto-" prefix) still auto-closes the tab, so
PR #1057's behavior is preserved.

zsh's TMOUT raises SIGALRM (a signal), so it already took the
keep-tab/reconnect path and is unaffected.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* Anchor TMOUT auto-logout match to the banner's final line (Codex review)

The detector matched "auto-logout" as an unanchored substring within the
last 256 chars, so command output that merely mentions it (e.g. `grep
auto-logout /etc/profile` while investigating TMOUT) followed by an
intentional `exit` could be misclassified as a timeout and wrongly keep
the tab open. Anchor on the final non-empty line of output instead — the
banner the shell prints right before exiting — which loses no true
positives (verified against the real-server output shape) while rejecting
mid-stream mentions.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-23 10:53:03 +08:00
陈大猫
74b315e285 fix #1063: force WebGL redraw on tab show to recover from garbled multi-tab terminals (#1066)
Hidden tabs stay mounted off-screen (visibility:hidden) so each keeps a
live WebGL context. Creating another terminal's WebGL context — or the GPU
dropping a non-composited off-screen canvas — leaves the hidden terminals'
drawing buffers corrupted ("花屏"). This reproduces on both Windows and
macOS: opening 2 tabs garbles the 1st, opening 3 garbles the 1st and 2nd,
while the just-created (visible) one is always fine. The DOM renderer is
immune because it uses real DOM nodes.

A window resize recovers the display because it triggers a full repaint
(clearTextureAtlas + RenderService._renderRows). A tab switch did not:
the visibility effect only calls safeFit, which early-returns when the
pane's dimensions are unchanged, so no redraw happened.

Perform the same recovery a resize does when a tab becomes visible:
clear the texture atlas (no-op on the DOM renderer) and synchronously
repaint every row. Verified against xterm core that _renderRows draws
unconditionally, independent of dimension changes or dirty-row tracking.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-23 09:56:30 +08:00
18 changed files with 545 additions and 30 deletions

View File

@@ -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',
};

View File

@@ -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': 'Сбросить по умолчанию',
};

View File

@@ -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': '重置为默认',
};

View File

@@ -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

View File

@@ -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) */}

View 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>
);
};

View File

@@ -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 };
}

View File

@@ -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,

View File

@@ -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);
});

View File

@@ -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

View File

@@ -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");

View File

@@ -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 };

View 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 });
});

View File

@@ -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
View File

@@ -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,

View File

@@ -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 .",

View 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}`,
);

View File

@@ -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',