Compare commits

...

3 Commits

Author SHA1 Message Date
陈大猫
c0199c43cf fix: prevent zombie processes and improve window recovery on restart (#201)
Some checks failed
build-packages / build-macos-latest (push) Has been cancelled
build-packages / build-ubuntu-latest (push) Has been cancelled
build-packages / build-windows-latest (push) Has been cancelled
build-packages / release (push) Has been cancelled
- Destroy trayPanelWindow and clear refresh timer during cleanup, preventing
  hidden BrowserWindows from keeping the Electron process alive
- Add SIGTERM/SIGINT handlers for graceful shutdown
- Detect crashed webContents in focusMainWindow() and recreate the window
  instead of silently failing on second-instance activation

Closes the issue where restarting the app shows "Failed to load the UI"
and leaves multiple zombie processes in the task manager.

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-12 22:16:42 +08:00
陈大猫
7940b9a0a7 fix: tray quit button, tree view multi-select, and SFTP banner handling (#200)
* fix: tray quit button, tree view multi-select, and SFTP banner handling

- Add "Quit Netcatty" button pinned to the bottom of TrayPanel so users
  can exit the app when close-to-tray is enabled
- Support multi-select mode in HostTreeView (checkboxes, click-to-select)
  so tree view behaves the same as grid/list views
- Patch ssh2 SFTP parser to skip non-SFTP preamble data (MOTD/banner text)
  that causes "Packet length exceeds max length" errors on misconfigured
  servers, with proper cross-frame buffering

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: gate SFTP preamble scan to client-mode only

Server-mode SFTP expects SSH_FXP_INIT (0x01) as the first packet, not
SSH_FXP_VERSION (0x02). Skip the preamble scan entirely when running in
server mode to avoid stalling server-side SFTP sessions.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-12 21:31:01 +08:00
Copilot
920914e3ee Fix ERR_FAILED on second instance by moving single-instance lock before app.whenReady() (#199)
* Initial plan

* Fix ERR_FAILED when second instance launches by moving single-instance lock before app.whenReady()

Move app.requestSingleInstanceLock() before app.whenReady() registration
and wrap all lifecycle handlers (whenReady, window-all-closed, before-quit,
will-quit) inside the else block. This prevents a second instance from
attempting to register the app:// protocol or create a BrowserWindow,
which would fail with ERR_FAILED.

Co-authored-by: binaricat <16399091+binaricat@users.noreply.github.com>

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: binaricat <16399091+binaricat@users.noreply.github.com>
2026-02-12 16:53:50 +08:00
11 changed files with 332 additions and 144 deletions

View File

@@ -122,6 +122,7 @@ const en: Messages = {
'tray.recentHosts': 'Recent Hosts',
'tray.empty.title': 'Nothing here yet',
'tray.empty.subtitle': 'Go connect to a server, they miss you 🚀',
'tray.quit': 'Quit Netcatty',
// Vault Sidebar
'vault.sidebar.collapse': 'Collapse sidebar',

View File

@@ -107,6 +107,7 @@ const zhCN: Messages = {
'tray.recentHosts': '最近连接的主机',
'tray.empty.title': '一切都很安静',
'tray.empty.subtitle': '去连接个服务器吧,它们想念你了 🚀',
'tray.quit': '退出 Netcatty',
// Vault Sidebar
'vault.sidebar.collapse': '收起侧边栏',

View File

@@ -12,6 +12,11 @@ export const useTrayPanelBackend = () => {
await bridge?.openMainWindow?.();
}, []);
const quitApp = useCallback(async () => {
const bridge = netcattyBridge.get();
await bridge?.quitApp?.();
}, []);
const jumpToSession = useCallback(async (sessionId: string) => {
const bridge = netcattyBridge.get();
await bridge?.jumpToSessionFromTrayPanel?.(sessionId);
@@ -57,6 +62,7 @@ export const useTrayPanelBackend = () => {
return {
hideTrayPanel,
openMainWindow,
quitApp,
jumpToSession,
connectToHostFromTrayPanel,
onTrayPanelCloseRequest,

View File

@@ -1,4 +1,4 @@
import { ChevronRight, FileSymlink, Folder, FolderOpen, Monitor, Server, Expand, Minimize2 } from 'lucide-react';
import { CheckSquare, ChevronRight, FileSymlink, Folder, FolderOpen, Monitor, Server, Square, Expand, Minimize2 } from 'lucide-react';
import React, { useMemo } from 'react';
import { useI18n } from '../application/i18n/I18nProvider';
import { useTreeExpandedState } from '../application/state/useTreeExpandedState';
@@ -32,6 +32,9 @@ interface HostTreeViewProps {
moveGroup: (sourcePath: string, targetPath: string) => void;
managedGroupPaths?: Set<string>;
onUnmanageGroup?: (groupPath: string) => void;
isMultiSelectMode?: boolean;
selectedHostIds?: Set<string>;
toggleHostSelection?: (hostId: string) => void;
}
interface TreeNodeProps {
@@ -53,6 +56,9 @@ interface TreeNodeProps {
moveGroup: (sourcePath: string, targetPath: string) => void;
managedGroupPaths?: Set<string>;
onUnmanageGroup?: (groupPath: string) => void;
isMultiSelectMode?: boolean;
selectedHostIds?: Set<string>;
toggleHostSelection?: (hostId: string) => void;
}
const TreeNode: React.FC<TreeNodeProps> = ({
@@ -74,6 +80,9 @@ const TreeNode: React.FC<TreeNodeProps> = ({
moveGroup,
managedGroupPaths,
onUnmanageGroup,
isMultiSelectMode,
selectedHostIds,
toggleHostSelection,
}) => {
const { t } = useI18n();
const isExpanded = expandedPaths.has(node.path);
@@ -215,9 +224,12 @@ const TreeNode: React.FC<TreeNodeProps> = ({
moveGroup={moveGroup}
managedGroupPaths={managedGroupPaths}
onUnmanageGroup={onUnmanageGroup}
isMultiSelectMode={isMultiSelectMode}
selectedHostIds={selectedHostIds}
toggleHostSelection={toggleHostSelection}
/>
))}
{/* Hosts in this group */}
{sortedHosts.map((host) => (
<HostTreeItem
@@ -230,6 +242,9 @@ const TreeNode: React.FC<TreeNodeProps> = ({
onDeleteHost={onDeleteHost}
onCopyCredentials={onCopyCredentials}
moveHostToGroup={moveHostToGroup}
isMultiSelectMode={isMultiSelectMode}
selectedHostIds={selectedHostIds}
toggleHostSelection={toggleHostSelection}
/>
))}
</CollapsibleContent>
@@ -247,6 +262,9 @@ interface HostTreeItemProps {
onDeleteHost: (host: Host) => void;
onCopyCredentials: (host: Host) => void;
moveHostToGroup: (hostId: string, groupPath: string | null) => void;
isMultiSelectMode?: boolean;
selectedHostIds?: Set<string>;
toggleHostSelection?: (hostId: string) => void;
}
const HostTreeItem: React.FC<HostTreeItemProps> = ({
@@ -258,6 +276,9 @@ const HostTreeItem: React.FC<HostTreeItemProps> = ({
onDeleteHost,
onCopyCredentials,
moveHostToGroup: _moveHostToGroup,
isMultiSelectMode,
selectedHostIds,
toggleHostSelection,
}) => {
const { t } = useI18n();
const paddingLeft = `${depth * 20 + 12}px`;
@@ -270,18 +291,40 @@ const HostTreeItem: React.FC<HostTreeItemProps> = ({
const displayPort = isTelnet
? (host.telnetPort ?? host.port ?? 23)
: (host.port ?? 22);
const isSelected = isMultiSelectMode && selectedHostIds?.has(host.id);
return (
<ContextMenu>
<ContextMenuTrigger>
<div
className="flex items-center py-2 pr-3 text-sm cursor-pointer transition-colors select-none group hover:bg-secondary/40 rounded-lg"
className={cn(
"flex items-center py-2 pr-3 text-sm cursor-pointer transition-colors select-none group hover:bg-secondary/40 rounded-lg",
isSelected ? "bg-primary/10" : "",
)}
style={{ paddingLeft }}
draggable
draggable={!isMultiSelectMode}
onDragStart={(e) => e.dataTransfer.setData("host-id", host.id)}
onClick={() => onConnect(safeHost)}
onClick={() => {
if (isMultiSelectMode && toggleHostSelection) {
toggleHostSelection(host.id);
} else {
onConnect(safeHost);
}
}}
>
<div className="mr-2 flex-shrink-0 w-4 h-4" />
{isMultiSelectMode && (
<div className="mr-2 flex-shrink-0" onClick={(e) => {
e.stopPropagation();
toggleHostSelection?.(host.id);
}}>
{isSelected ? (
<CheckSquare size={18} className="text-primary" />
) : (
<Square size={18} className="text-muted-foreground" />
)}
</div>
)}
{!isMultiSelectMode && <div className="mr-2 flex-shrink-0 w-4 h-4" />}
<div className="mr-3 flex-shrink-0">
<DistroAvatar host={host} fallback={(host.os || "L")[0].toUpperCase()} size="sm" />
</div>
@@ -351,6 +394,9 @@ export const HostTreeView: React.FC<HostTreeViewProps> = ({
moveGroup,
managedGroupPaths,
onUnmanageGroup,
isMultiSelectMode,
selectedHostIds,
toggleHostSelection,
}) => {
const { t } = useI18n();
@@ -471,6 +517,9 @@ export const HostTreeView: React.FC<HostTreeViewProps> = ({
moveGroup={moveGroup}
managedGroupPaths={managedGroupPaths}
onUnmanageGroup={onUnmanageGroup}
isMultiSelectMode={isMultiSelectMode}
selectedHostIds={selectedHostIds}
toggleHostSelection={toggleHostSelection}
/>
))}
@@ -486,6 +535,9 @@ export const HostTreeView: React.FC<HostTreeViewProps> = ({
onDeleteHost={onDeleteHost}
onCopyCredentials={onCopyCredentials}
moveHostToGroup={moveHostToGroup}
isMultiSelectMode={isMultiSelectMode}
selectedHostIds={selectedHostIds}
toggleHostSelection={toggleHostSelection}
/>
))}

View File

@@ -10,7 +10,7 @@ import { I18nProvider } from "../application/i18n/I18nProvider";
import { useSettingsState } from "../application/state/useSettingsState";
import { useTrayPanelBackend } from "../application/state/useTrayPanelBackend";
import { useActiveTabId } from "../application/state/activeTabStore";
import { X, Maximize2, ChevronRight, ChevronDown } from "lucide-react";
import { X, Maximize2, ChevronRight, ChevronDown, Power } from "lucide-react";
import { AppLogo } from "./AppLogo";
const StatusDot: React.FC<{ status: "success" | "warning" | "error" | "neutral"; spinning?: boolean }> = ({
@@ -109,6 +109,7 @@ const TrayPanelContent: React.FC = () => {
const {
hideTrayPanel,
openMainWindow,
quitApp,
jumpToSession,
onTrayPanelCloseRequest,
onTrayPanelRefresh,
@@ -200,8 +201,12 @@ const TrayPanelContent: React.FC = () => {
void openMainWindow();
}, [openMainWindow]);
const handleQuit = useCallback(() => {
void quitApp();
}, [quitApp]);
return (
<div id="tray-panel-root" className="w-full h-full bg-background/95 backdrop-blur border border-border/60 rounded-lg shadow-lg overflow-hidden">
<div id="tray-panel-root" className="w-full h-full bg-background/95 backdrop-blur border border-border/60 rounded-lg shadow-lg overflow-hidden flex flex-col">
<div className="px-3 py-2 border-b border-border/60 flex items-center justify-between app-no-drag">
<div className="flex items-center gap-2">
<AppLogo className="w-5 h-5" />
@@ -225,7 +230,7 @@ const TrayPanelContent: React.FC = () => {
</div>
</div>
<div className="p-2 space-y-3 text-sm">
<div className="p-2 space-y-3 text-sm flex-1 overflow-y-auto min-h-0">
{jumpableSessions.length > 0 && (() => {
// Group sessions by workspace
@@ -378,6 +383,17 @@ const TrayPanelContent: React.FC = () => {
</div>
)}
</div>
{/* Quit button at the bottom */}
<div className="px-3 py-2 border-t border-border/60">
<button
className="w-full text-left px-2 py-1.5 rounded hover:bg-destructive/10 flex items-center gap-2 text-sm text-muted-foreground hover:text-destructive transition-colors"
onClick={handleQuit}
>
<Power size={14} />
<span>{t("tray.quit")}</span>
</button>
</div>
</div>
);
};

View File

@@ -1857,6 +1857,9 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
moveGroup={moveGroup}
managedGroupPaths={managedGroupPaths}
onUnmanageGroup={handleUnmanageGroup}
isMultiSelectMode={isMultiSelectMode}
selectedHostIds={selectedHostIds}
toggleHostSelection={toggleHostSelection}
/>
) : sortMode === "group" && groupedDisplayHosts ? (
<div className="space-y-6">

View File

@@ -691,6 +691,13 @@ function registerHandlers(ipcMain) {
return { success: true };
});
ipcMain.handle("netcatty:trayPanel:quitApp", async () => {
const { app } = electronModule;
closeToTray = false;
app.quit();
return { success: true };
});
console.log("[GlobalShortcut] IPC handlers registered");
}
@@ -700,6 +707,20 @@ function registerHandlers(ipcMain) {
function cleanup() {
unregisterGlobalHotkey();
destroyTray();
if (trayPanelRefreshTimer) {
clearInterval(trayPanelRefreshTimer);
trayPanelRefreshTimer = null;
}
if (trayPanelWindow && !trayPanelWindow.isDestroyed()) {
try {
trayPanelWindow.destroy();
} catch {
// ignore
}
trayPanelWindow = null;
}
}
module.exports = {

View File

@@ -241,6 +241,15 @@ function focusMainWindow() {
const win = wins && wins.length ? wins[0] : null;
if (!win) return false;
// Check if the webContents has crashed or been destroyed
try {
if (win.webContents?.isCrashed?.()) {
console.warn('[Main] Main window webContents has crashed, destroying window');
win.destroy();
return false;
}
} catch {}
try {
if (win.isMinimized && win.isMinimized()) win.restore();
} catch {}
@@ -685,100 +694,114 @@ function showStartupError(err) {
}
}
// Application lifecycle
app.whenReady().then(() => {
registerAppProtocol();
// Set dock icon on macOS
if (isMac && appIcon && app.dock?.setIcon) {
try {
app.dock.setIcon(appIcon);
} catch (err) {
console.warn("Failed to set dock icon", err);
}
}
// Build and set application menu
const menu = windowManager.buildAppMenu(Menu, app, isMac);
Menu.setApplicationMenu(menu);
app.on("browser-window-created", (_event, win) => {
try {
const mainWin = windowManager.getMainWindow();
const settingsWin = windowManager.getSettingsWindow();
const isPrimary = win === mainWin || win === settingsWin;
if (!isPrimary) {
win.setMenuBarVisibility(false);
win.autoHideMenuBar = true;
win.setMenu(null);
if (appIcon && win.setIcon) win.setIcon(appIcon);
}
} catch {
// ignore
}
});
// Create the main window
void createWindow().catch((err) => {
console.error("[Main] Failed to create main window:", err);
showStartupError(err);
try {
app.quit();
} catch {}
});
// Re-create window on macOS dock click
app.on("activate", () => {
if (BrowserWindow.getAllWindows().length === 0) {
// Ensure single-instance behavior — must run before app.whenReady() so
// the second instance never attempts to register the app:// protocol or
// create a BrowserWindow (which would fail with ERR_FAILED).
const gotLock = app.requestSingleInstanceLock();
if (!gotLock) {
app.quit();
} else {
app.on("second-instance", () => {
if (!focusMainWindow()) {
// Window is missing or crashed — try to recreate it
void createWindow().catch((err) => {
console.error("[Main] Failed to create window on activate:", err);
console.error("[Main] Failed to recreate window on second-instance:", err);
showStartupError(err);
});
}
});
});
// Ensure single-instance behavior focuses existing window
try {
const gotLock = app.requestSingleInstanceLock();
if (!gotLock) {
app.quit();
} else {
app.on("second-instance", () => {
focusMainWindow();
// Application lifecycle
app.whenReady().then(() => {
registerAppProtocol();
// Set dock icon on macOS
if (isMac && appIcon && app.dock?.setIcon) {
try {
app.dock.setIcon(appIcon);
} catch (err) {
console.warn("Failed to set dock icon", err);
}
}
// Build and set application menu
const menu = windowManager.buildAppMenu(Menu, app, isMac);
Menu.setApplicationMenu(menu);
app.on("browser-window-created", (_event, win) => {
try {
const mainWin = windowManager.getMainWindow();
const settingsWin = windowManager.getSettingsWindow();
const isPrimary = win === mainWin || win === settingsWin;
if (!isPrimary) {
win.setMenuBarVisibility(false);
win.autoHideMenuBar = true;
win.setMenu(null);
if (appIcon && win.setIcon) win.setIcon(appIcon);
}
} catch {
// ignore
}
});
}
} catch {}
// Cleanup on all windows closed
app.on("window-all-closed", () => {
if (process.platform !== "darwin") {
// Create the main window
void createWindow().catch((err) => {
console.error("[Main] Failed to create main window:", err);
showStartupError(err);
try {
app.quit();
} catch {}
});
// Re-create window on macOS dock click
app.on("activate", () => {
if (BrowserWindow.getAllWindows().length === 0) {
void createWindow().catch((err) => {
console.error("[Main] Failed to create window on activate:", err);
showStartupError(err);
});
}
});
});
// Cleanup on all windows closed
app.on("window-all-closed", () => {
if (process.platform !== "darwin") {
app.quit();
}
});
app.on("before-quit", () => {
windowManager.setIsQuitting(true);
});
// Cleanup all PTY sessions and port forwarding tunnels before quitting
app.on("will-quit", () => {
try {
terminalBridge.cleanupAllSessions();
} catch (err) {
console.warn("Error during terminal cleanup:", err);
}
try {
portForwardingBridge.stopAllPortForwards();
} catch (err) {
console.warn("Error during port forwarding cleanup:", err);
}
try {
globalShortcutBridge.cleanup();
} catch (err) {
console.warn("Error during global shortcut cleanup:", err);
}
});
}
// Graceful shutdown on SIGTERM/SIGINT to prevent zombie processes
for (const sig of ['SIGTERM', 'SIGINT']) {
process.on(sig, () => {
console.log(`[Main] Received ${sig}, quitting…`);
app.quit();
}
});
app.on("before-quit", () => {
windowManager.setIsQuitting(true);
});
// Cleanup all PTY sessions and port forwarding tunnels before quitting
app.on("will-quit", () => {
try {
terminalBridge.cleanupAllSessions();
} catch (err) {
console.warn("Error during terminal cleanup:", err);
}
try {
portForwardingBridge.stopAllPortForwards();
} catch (err) {
console.warn("Error during port forwarding cleanup:", err);
}
try {
globalShortcutBridge.cleanup();
} catch (err) {
console.warn("Error during global shortcut cleanup:", err);
}
});
});
}
// Export for testing
module.exports = {

View File

@@ -801,6 +801,7 @@ const api = {
// Tray panel window
hideTrayPanel: () => ipcRenderer.invoke("netcatty:trayPanel:hide"),
openMainWindow: () => ipcRenderer.invoke("netcatty:trayPanel:openMainWindow"),
quitApp: () => ipcRenderer.invoke("netcatty:trayPanel:quitApp"),
jumpToSessionFromTrayPanel: (sessionId) =>
ipcRenderer.invoke("netcatty:trayPanel:jumpToSession", sessionId),
connectToHostFromTrayPanel: (hostId) =>

1
global.d.ts vendored
View File

@@ -610,6 +610,7 @@ declare global {
hideTrayPanel?(): Promise<{ success: boolean }>;
openMainWindow?(): Promise<{ success: boolean }>;
quitApp?(): Promise<{ success: boolean }>;
jumpToSessionFromTrayPanel?(sessionId: string): Promise<{ success: boolean }>;
connectToHostFromTrayPanel?(hostId: string): Promise<{ success: boolean }>;
onTrayPanelCloseRequest?(callback: () => void): () => void;

View File

@@ -1,5 +1,5 @@
diff --git a/node_modules/ssh2/lib/protocol/SFTP.js b/node_modules/ssh2/lib/protocol/SFTP.js
index 9f33c02..c311d3a 100644
index 9f33c02..9751164 100644
--- a/node_modules/ssh2/lib/protocol/SFTP.js
+++ b/node_modules/ssh2/lib/protocol/SFTP.js
@@ -117,6 +117,20 @@ const OPENSSH_MAX_PKT_LEN = 256 * 1024;
@@ -23,7 +23,70 @@ index 9f33c02..c311d3a 100644
const fakeStderr = {
readable: false,
writable: false,
@@ -339,7 +351,7 @@ class SFTP extends EventEmitter {
@@ -155,6 +169,8 @@ class SFTP extends EventEmitter {
this._writeReqid = -1;
this._requests = {};
this._maxInPktLen = OPENSSH_MAX_PKT_LEN;
+ this._preambleSkipped = false; // Track if we've found the start of SFTP binary data
+ this._preambleBuf = null; // Buffer for partial preamble data across frames
this._maxOutPktLen = 34000;
this._maxReadLen =
(this._isOpenSSH ? OPENSSH_MAX_PKT_LEN : 34000) - PKT_RW_OVERHEAD;
@@ -196,6 +212,53 @@ class SFTP extends EventEmitter {
this.emit('end');
return;
}
+
+ // Skip non-SFTP preamble data (e.g. MOTD/banner text from misconfigured servers)
+ // Only applies to client mode; server mode expects SSH_FXP_INIT directly.
+ if (!this._preambleSkipped) {
+ if (this.server) {
+ // Server mode: no preamble skipping, proceed to normal parsing
+ this._preambleSkipped = true;
+ } else {
+ // Concatenate with any previously buffered partial data
+ if (this._preambleBuf) {
+ data = Buffer.concat([this._preambleBuf, data]);
+ this._preambleBuf = null;
+ }
+
+ // Look for the start of a valid SFTP packet in the data.
+ // The first SFTP packet from the server is SSH_FXP_VERSION (type=2).
+ // Format: uint32 length, byte type=0x02, uint32 version, ...
+ // The length should be >= 5 (1 byte type + 4 bytes version).
+ let found = -1;
+ for (let i = 0; i <= data.length - 5; i++) {
+ const len = (data[i] << 24) | (data[i+1] << 16) | (data[i+2] << 8) | data[i+3];
+ if (len >= 5 && len <= this._maxInPktLen && data[i+4] === 0x02) {
+ found = i;
+ break;
+ }
+ }
+ if (found === -1) {
+ // No valid SFTP packet header found yet.
+ // Keep up to the last 4 bytes in case a valid header spans this and the
+ // next chunk (the uint32 length could be split across frames).
+ const keep = Math.min(data.length, 4);
+ this._preambleBuf = Buffer.from(data.slice(data.length - keep));
+ this._debug && this._debug(
+ 'SFTP: Skipping non-SFTP preamble data (' + data.length + ' bytes, buffered last ' + keep + ')'
+ );
+ return;
+ }
+ if (found > 0) {
+ this._debug && this._debug(
+ 'SFTP: Skipped ' + found + ' bytes of non-SFTP preamble data'
+ );
+ data = data.slice(found);
+ }
+ this._preambleSkipped = true;
+ }
+ }
+
/*
uint32 length
byte type
@@ -339,7 +402,7 @@ class SFTP extends EventEmitter {
uint32 pflags
ATTRS attrs
*/
@@ -32,7 +95,7 @@ index 9f33c02..c311d3a 100644
let p = 9;
const buf = Buffer.allocUnsafe(4 + 1 + 4 + 4 + pathLen + 4 + 4 + attrsLen);
@@ -349,7 +361,7 @@ class SFTP extends EventEmitter {
@@ -349,7 +412,7 @@ class SFTP extends EventEmitter {
writeUInt32BE(buf, reqid, 5);
writeUInt32BE(buf, pathLen, p);
@@ -41,7 +104,7 @@ index 9f33c02..c311d3a 100644
writeUInt32BE(buf, flags, p += pathLen);
writeUInt32BE(buf, attrsFlags, p += 4);
if (attrsLen) {
@@ -734,7 +746,7 @@ class SFTP extends EventEmitter {
@@ -734,7 +797,7 @@ class SFTP extends EventEmitter {
uint32 id
string filename
*/
@@ -50,7 +113,7 @@ index 9f33c02..c311d3a 100644
let p = 9;
const buf = Buffer.allocUnsafe(4 + 1 + 4 + 4 + fnameLen);
@@ -744,7 +756,7 @@ class SFTP extends EventEmitter {
@@ -744,7 +807,7 @@ class SFTP extends EventEmitter {
writeUInt32BE(buf, reqid, 5);
writeUInt32BE(buf, fnameLen, p);
@@ -59,7 +122,7 @@ index 9f33c02..c311d3a 100644
this._requests[reqid] = { cb };
@@ -762,8 +774,8 @@ class SFTP extends EventEmitter {
@@ -762,8 +825,8 @@ class SFTP extends EventEmitter {
string oldpath
string newpath
*/
@@ -70,7 +133,7 @@ index 9f33c02..c311d3a 100644
let p = 9;
const buf = Buffer.allocUnsafe(4 + 1 + 4 + 4 + oldLen + 4 + newLen);
@@ -773,9 +785,9 @@ class SFTP extends EventEmitter {
@@ -773,9 +836,9 @@ class SFTP extends EventEmitter {
writeUInt32BE(buf, reqid, 5);
writeUInt32BE(buf, oldLen, p);
@@ -82,7 +145,7 @@ index 9f33c02..c311d3a 100644
this._requests[reqid] = { cb };
@@ -806,7 +818,7 @@ class SFTP extends EventEmitter {
@@ -806,7 +869,7 @@ class SFTP extends EventEmitter {
string path
ATTRS attrs
*/
@@ -91,7 +154,7 @@ index 9f33c02..c311d3a 100644
let p = 9;
const buf = Buffer.allocUnsafe(4 + 1 + 4 + 4 + pathLen + 4 + attrsLen);
@@ -816,7 +828,7 @@ class SFTP extends EventEmitter {
@@ -816,7 +879,7 @@ class SFTP extends EventEmitter {
writeUInt32BE(buf, reqid, 5);
writeUInt32BE(buf, pathLen, p);
@@ -100,7 +163,7 @@ index 9f33c02..c311d3a 100644
writeUInt32BE(buf, flags, p += pathLen);
if (attrsLen) {
p += 4;
@@ -844,7 +856,7 @@ class SFTP extends EventEmitter {
@@ -844,7 +907,7 @@ class SFTP extends EventEmitter {
uint32 id
string path
*/
@@ -109,7 +172,7 @@ index 9f33c02..c311d3a 100644
let p = 9;
const buf = Buffer.allocUnsafe(4 + 1 + 4 + 4 + pathLen);
@@ -854,7 +866,7 @@ class SFTP extends EventEmitter {
@@ -854,7 +917,7 @@ class SFTP extends EventEmitter {
writeUInt32BE(buf, reqid, 5);
writeUInt32BE(buf, pathLen, p);
@@ -118,7 +181,7 @@ index 9f33c02..c311d3a 100644
this._requests[reqid] = { cb };
@@ -987,7 +999,7 @@ class SFTP extends EventEmitter {
@@ -987,7 +1050,7 @@ class SFTP extends EventEmitter {
uint32 id
string path
*/
@@ -127,7 +190,7 @@ index 9f33c02..c311d3a 100644
let p = 9;
const buf = Buffer.allocUnsafe(4 + 1 + 4 + 4 + pathLen);
@@ -997,7 +1009,7 @@ class SFTP extends EventEmitter {
@@ -997,7 +1060,7 @@ class SFTP extends EventEmitter {
writeUInt32BE(buf, reqid, 5);
writeUInt32BE(buf, pathLen, p);
@@ -136,7 +199,7 @@ index 9f33c02..c311d3a 100644
this._requests[reqid] = { cb };
@@ -1014,7 +1026,7 @@ class SFTP extends EventEmitter {
@@ -1014,7 +1077,7 @@ class SFTP extends EventEmitter {
uint32 id
string path
*/
@@ -145,7 +208,7 @@ index 9f33c02..c311d3a 100644
let p = 9;
const buf = Buffer.allocUnsafe(4 + 1 + 4 + 4 + pathLen);
@@ -1024,7 +1036,7 @@ class SFTP extends EventEmitter {
@@ -1024,7 +1087,7 @@ class SFTP extends EventEmitter {
writeUInt32BE(buf, reqid, 5);
writeUInt32BE(buf, pathLen, p);
@@ -154,7 +217,7 @@ index 9f33c02..c311d3a 100644
this._requests[reqid] = { cb };
@@ -1041,7 +1053,7 @@ class SFTP extends EventEmitter {
@@ -1041,7 +1104,7 @@ class SFTP extends EventEmitter {
uint32 id
string path
*/
@@ -163,7 +226,7 @@ index 9f33c02..c311d3a 100644
let p = 9;
const buf = Buffer.allocUnsafe(4 + 1 + 4 + 4 + pathLen);
@@ -1051,7 +1063,7 @@ class SFTP extends EventEmitter {
@@ -1051,7 +1114,7 @@ class SFTP extends EventEmitter {
writeUInt32BE(buf, reqid, 5);
writeUInt32BE(buf, pathLen, p);
@@ -172,7 +235,7 @@ index 9f33c02..c311d3a 100644
this._requests[reqid] = { cb };
@@ -1080,7 +1092,7 @@ class SFTP extends EventEmitter {
@@ -1080,7 +1143,7 @@ class SFTP extends EventEmitter {
string path
ATTRS attrs
*/
@@ -181,7 +244,7 @@ index 9f33c02..c311d3a 100644
let p = 9;
const buf = Buffer.allocUnsafe(4 + 1 + 4 + 4 + pathLen + 4 + attrsLen);
@@ -1090,7 +1102,7 @@ class SFTP extends EventEmitter {
@@ -1090,7 +1153,7 @@ class SFTP extends EventEmitter {
writeUInt32BE(buf, reqid, 5);
writeUInt32BE(buf, pathLen, p);
@@ -190,7 +253,7 @@ index 9f33c02..c311d3a 100644
writeUInt32BE(buf, flags, p += pathLen);
if (attrsLen) {
p += 4;
@@ -1205,7 +1217,7 @@ class SFTP extends EventEmitter {
@@ -1205,7 +1268,7 @@ class SFTP extends EventEmitter {
uint32 id
string path
*/
@@ -199,7 +262,7 @@ index 9f33c02..c311d3a 100644
let p = 9;
const buf = Buffer.allocUnsafe(4 + 1 + 4 + 4 + pathLen);
@@ -1215,7 +1227,7 @@ class SFTP extends EventEmitter {
@@ -1215,7 +1278,7 @@ class SFTP extends EventEmitter {
writeUInt32BE(buf, reqid, 5);
writeUInt32BE(buf, pathLen, p);
@@ -208,7 +271,7 @@ index 9f33c02..c311d3a 100644
this._requests[reqid] = {
cb: (err, names) => {
@@ -1243,8 +1255,8 @@ class SFTP extends EventEmitter {
@@ -1243,8 +1306,8 @@ class SFTP extends EventEmitter {
string linkpath
string targetpath
*/
@@ -219,7 +282,7 @@ index 9f33c02..c311d3a 100644
let p = 9;
const buf = Buffer.allocUnsafe(4 + 1 + 4 + 4 + linkLen + 4 + targetLen);
@@ -1256,14 +1268,14 @@ class SFTP extends EventEmitter {
@@ -1256,14 +1319,14 @@ class SFTP extends EventEmitter {
if (this._isOpenSSH) {
// OpenSSH has linkpath and targetpath positions switched
writeUInt32BE(buf, targetLen, p);
@@ -238,7 +301,7 @@ index 9f33c02..c311d3a 100644
}
this._requests[reqid] = { cb };
@@ -1281,7 +1293,7 @@ class SFTP extends EventEmitter {
@@ -1281,7 +1344,7 @@ class SFTP extends EventEmitter {
uint32 id
string path
*/
@@ -247,7 +310,7 @@ index 9f33c02..c311d3a 100644
let p = 9;
const buf = Buffer.allocUnsafe(4 + 1 + 4 + 4 + pathLen);
@@ -1291,7 +1303,7 @@ class SFTP extends EventEmitter {
@@ -1291,7 +1354,7 @@ class SFTP extends EventEmitter {
writeUInt32BE(buf, reqid, 5);
writeUInt32BE(buf, pathLen, p);
@@ -256,7 +319,7 @@ index 9f33c02..c311d3a 100644
this._requests[reqid] = {
cb: (err, names) => {
@@ -1325,8 +1337,8 @@ class SFTP extends EventEmitter {
@@ -1325,8 +1388,8 @@ class SFTP extends EventEmitter {
string oldpath
string newpath
*/
@@ -267,7 +330,7 @@ index 9f33c02..c311d3a 100644
let p = 9;
const buf =
Buffer.allocUnsafe(4 + 1 + 4 + 4 + 24 + 4 + oldLen + 4 + newLen);
@@ -1337,11 +1349,11 @@ class SFTP extends EventEmitter {
@@ -1337,11 +1400,11 @@ class SFTP extends EventEmitter {
writeUInt32BE(buf, reqid, 5);
writeUInt32BE(buf, 24, p);
@@ -282,7 +345,7 @@ index 9f33c02..c311d3a 100644
this._requests[reqid] = { cb };
@@ -1364,7 +1376,7 @@ class SFTP extends EventEmitter {
@@ -1364,7 +1427,7 @@ class SFTP extends EventEmitter {
string "statvfs@openssh.com"
string path
*/
@@ -291,7 +354,7 @@ index 9f33c02..c311d3a 100644
let p = 9;
const buf = Buffer.allocUnsafe(4 + 1 + 4 + 4 + 19 + 4 + pathLen);
@@ -1374,9 +1386,9 @@ class SFTP extends EventEmitter {
@@ -1374,9 +1437,9 @@ class SFTP extends EventEmitter {
writeUInt32BE(buf, reqid, 5);
writeUInt32BE(buf, 19, p);
@@ -303,7 +366,7 @@ index 9f33c02..c311d3a 100644
this._requests[reqid] = { extended: 'statvfs@openssh.com', cb };
@@ -1411,7 +1423,7 @@ class SFTP extends EventEmitter {
@@ -1411,7 +1474,7 @@ class SFTP extends EventEmitter {
writeUInt32BE(buf, reqid, 5);
writeUInt32BE(buf, 20, p);
@@ -312,7 +375,7 @@ index 9f33c02..c311d3a 100644
writeUInt32BE(buf, handleLen, p += 20);
buf.set(handle, p += 4);
@@ -1437,8 +1449,8 @@ class SFTP extends EventEmitter {
@@ -1437,8 +1500,8 @@ class SFTP extends EventEmitter {
string oldpath
string newpath
*/
@@ -323,7 +386,7 @@ index 9f33c02..c311d3a 100644
let p = 9;
const buf =
Buffer.allocUnsafe(4 + 1 + 4 + 4 + 20 + 4 + oldLen + 4 + newLen);
@@ -1449,11 +1461,11 @@ class SFTP extends EventEmitter {
@@ -1449,11 +1512,11 @@ class SFTP extends EventEmitter {
writeUInt32BE(buf, reqid, 5);
writeUInt32BE(buf, 20, p);
@@ -338,7 +401,7 @@ index 9f33c02..c311d3a 100644
this._requests[reqid] = { cb };
@@ -1488,7 +1500,7 @@ class SFTP extends EventEmitter {
@@ -1488,7 +1551,7 @@ class SFTP extends EventEmitter {
writeUInt32BE(buf, reqid, 5);
writeUInt32BE(buf, 17, p);
@@ -347,7 +410,7 @@ index 9f33c02..c311d3a 100644
writeUInt32BE(buf, handleLen, p += 17);
buf.set(handle, p += 4);
@@ -1524,7 +1536,7 @@ class SFTP extends EventEmitter {
@@ -1524,7 +1587,7 @@ class SFTP extends EventEmitter {
string path
ATTRS attrs
*/
@@ -356,7 +419,7 @@ index 9f33c02..c311d3a 100644
let p = 9;
const buf =
Buffer.allocUnsafe(4 + 1 + 4 + 4 + 20 + 4 + pathLen + 4 + attrsLen);
@@ -1535,10 +1547,10 @@ class SFTP extends EventEmitter {
@@ -1535,10 +1598,10 @@ class SFTP extends EventEmitter {
writeUInt32BE(buf, reqid, 5);
writeUInt32BE(buf, 20, p);
@@ -369,7 +432,7 @@ index 9f33c02..c311d3a 100644
writeUInt32BE(buf, flags, p += pathLen);
if (attrsLen) {
@@ -1573,7 +1585,7 @@ class SFTP extends EventEmitter {
@@ -1573,7 +1636,7 @@ class SFTP extends EventEmitter {
string "expand-path@openssh.com"
string path
*/
@@ -378,7 +441,7 @@ index 9f33c02..c311d3a 100644
let p = 9;
const buf = Buffer.allocUnsafe(4 + 1 + 4 + 4 + 23 + 4 + pathLen);
@@ -1583,10 +1595,10 @@ class SFTP extends EventEmitter {
@@ -1583,10 +1646,10 @@ class SFTP extends EventEmitter {
writeUInt32BE(buf, reqid, 5);
writeUInt32BE(buf, 23, p);
@@ -391,7 +454,7 @@ index 9f33c02..c311d3a 100644
this._requests[reqid] = {
cb: (err, names) => {
@@ -1653,7 +1665,7 @@ class SFTP extends EventEmitter {
@@ -1653,7 +1716,7 @@ class SFTP extends EventEmitter {
writeUInt32BE(buf, 9, p);
p += 4;
@@ -400,7 +463,7 @@ index 9f33c02..c311d3a 100644
p += 9;
writeUInt32BE(buf, srcHandle.length, p);
@@ -1708,7 +1720,7 @@ class SFTP extends EventEmitter {
@@ -1708,7 +1771,7 @@ class SFTP extends EventEmitter {
string username
*/
let p = 0;
@@ -409,7 +472,7 @@ index 9f33c02..c311d3a 100644
const buf = Buffer.allocUnsafe(
4 + 1
+ 4
@@ -1728,12 +1740,12 @@ class SFTP extends EventEmitter {
@@ -1728,12 +1791,12 @@ class SFTP extends EventEmitter {
writeUInt32BE(buf, 14, p);
p += 4;
@@ -424,7 +487,7 @@ index 9f33c02..c311d3a 100644
p += usernameLen;
this._requests[reqid] = {
@@ -1806,7 +1818,7 @@ class SFTP extends EventEmitter {
@@ -1806,7 +1869,7 @@ class SFTP extends EventEmitter {
writeUInt32BE(buf, 30, p);
p += 4;
@@ -433,7 +496,7 @@ index 9f33c02..c311d3a 100644
p += 30;
writeUInt32BE(buf, 4 * uids.length, p);
@@ -1871,7 +1883,7 @@ class SFTP extends EventEmitter {
@@ -1871,7 +1934,7 @@ class SFTP extends EventEmitter {
message || (message = '');
@@ -442,7 +505,7 @@ index 9f33c02..c311d3a 100644
let p = 9;
const buf = Buffer.allocUnsafe(4 + 1 + 4 + 4 + 4 + msgLen + 4);
@@ -1884,7 +1896,7 @@ class SFTP extends EventEmitter {
@@ -1884,7 +1947,7 @@ class SFTP extends EventEmitter {
writeUInt32BE(buf, msgLen, p += 4);
p += 4;
if (msgLen) {
@@ -451,7 +514,7 @@ index 9f33c02..c311d3a 100644
p += msgLen;
}
@@ -1913,7 +1925,7 @@ class SFTP extends EventEmitter {
@@ -1913,7 +1976,7 @@ class SFTP extends EventEmitter {
const dataLen = (
isBuffer
? data.length
@@ -460,7 +523,7 @@ index 9f33c02..c311d3a 100644
);
let p = 9;
const buf = Buffer.allocUnsafe(4 + 1 + 4 + 4 + dataLen);
@@ -1927,7 +1939,7 @@ class SFTP extends EventEmitter {
@@ -1927,7 +1990,7 @@ class SFTP extends EventEmitter {
if (isBuffer)
buf.set(data, p += 4);
else if (isUTF8)
@@ -469,7 +532,7 @@ index 9f33c02..c311d3a 100644
else
buf.write(data, p += 4, dataLen, encoding);
}
@@ -1959,13 +1971,13 @@ class SFTP extends EventEmitter {
@@ -1959,13 +2022,13 @@ class SFTP extends EventEmitter {
? ''
: name.filename
);
@@ -485,7 +548,7 @@ index 9f33c02..c311d3a 100644
if (typeof name.attrs === 'object' && name.attrs !== null) {
nameAttrs = attrsToBytes(name.attrs);
@@ -2011,11 +2023,11 @@ class SFTP extends EventEmitter {
@@ -2011,11 +2074,11 @@ class SFTP extends EventEmitter {
? ''
: name.filename
);
@@ -499,7 +562,7 @@ index 9f33c02..c311d3a 100644
p += len;
}
}
@@ -2026,11 +2038,11 @@ class SFTP extends EventEmitter {
@@ -2026,11 +2089,11 @@ class SFTP extends EventEmitter {
? ''
: name.longname
);
@@ -513,7 +576,7 @@ index 9f33c02..c311d3a 100644
p += len;
}
}
@@ -2749,7 +2761,7 @@ function requestLimits(sftp, cb) {
@@ -2749,7 +2812,7 @@ function requestLimits(sftp, cb) {
writeUInt32BE(buf, reqid, 5);
writeUInt32BE(buf, 18, p);
@@ -522,7 +585,7 @@ index 9f33c02..c311d3a 100644
sftp._requests[reqid] = { extended: 'limits@openssh.com', cb };
@@ -2953,18 +2965,28 @@ const CLIENT_HANDLERS = {
@@ -2953,18 +3016,28 @@ const CLIENT_HANDLERS = {
// spec not specifying an encoding because the specs for newer
// versions of the protocol all explicitly specify UTF-8 for
// filenames