Compare commits

...

30 Commits

Author SHA1 Message Date
Copilot
cfaee48553 Remove extra space next to close button on Windows (#207)
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
* Initial plan

* fix: remove extra right spacing next to close button on Windows

On Windows/Linux, the frameless title bar had ~20px of dead space
(12px right padding + 8px drag shim) to the right of the close button.

- Replace Tailwind `px-3` with conditional inline paddingRight (0 on
  Windows, 12px on macOS) so the close button sits at the window edge
- Render the drag shim only on macOS where it's useful as a drag region

macOS layout is unchanged.

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-13 23:51:26 +08:00
bincxz
1f05fe3efa fix: remove extra space next to close button on Windows (#207) 2026-02-13 23:50:13 +08:00
copilot-swe-agent[bot]
e9c3b82c16 fix: remove extra right spacing next to close button on Windows
On Windows/Linux, the frameless title bar had ~20px of dead space
(12px right padding + 8px drag shim) to the right of the close button.

- Replace Tailwind `px-3` with conditional inline paddingRight (0 on
  Windows, 12px on macOS) so the close button sits at the window edge
- Render the drag shim only on macOS where it's useful as a drag region

macOS layout is unchanged.

Co-authored-by: binaricat <16399091+binaricat@users.noreply.github.com>
2026-02-13 23:45:22 +08:00
copilot-swe-agent[bot]
83fce70b20 Initial plan 2026-02-13 23:45:22 +08:00
bincxz
d36c8bcbea fix: add missing </p> closing tags in VaultView empty hosts state
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-13 23:44:50 +08:00
bincxz
5346752994 Merge branch 'fix/encrypt-credentials-at-rest' - encrypt sensitive credentials at rest via safeStorage (#203) 2026-02-13 23:40:17 +08:00
bincxz
d267c4b6fc fix: prevent stale cross-window writes and deferred-read init races
- CloudSyncManager: bump providerWriteSeq on storage events so an
  in-flight local save is discarded when newer cross-window data arrives
- useVaultState: defer reads of keys/identities/groups/snippets to just
  before their processing stage instead of reading all upfront, so data
  updated during a prior async decrypt gap is not overwritten by a stale
  pre-await snapshot

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-13 23:20:46 +08:00
bincxz
1a1da02e92 fix: guard storage decrypt callbacks against local writes and sync updates
- useVaultState: storage-event decrypt callbacks now also check
  writeVersion so a local edit during the decrypt gap causes the stale
  cross-window result to be discarded
- CloudSyncManager: bump providerDecryptSeq in uploadToProvider before
  mutating lastSync/lastSyncVersion so a pending cross-window decrypt
  cannot overwrite the newer sync metadata

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-13 22:54:28 +08:00
bincxz
1adcffa7a8 fix: split provider sequence counters so status-only updates don't drop writes
Split the single providerSeq into providerDecryptSeq (bumped by all state
mutations to guard async decrypt callbacks) and providerWriteSeq (bumped
only by saveProviderConnection). This prevents status-only transitions
like 'error' or 'syncing' from discarding an in-flight encrypted write
from disconnect/auth, which would leave stale credentials in localStorage.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-13 22:43:09 +08:00
bincxz
7a2bedc4f4 fix: guard provider save writes and validate sentinel prefix on encrypt
- CloudSyncManager: add providerSeq write guard to saveProviderConnection
  so overlapping async saves don't let an older encryption overwrite newer
  provider state in localStorage
- credentialBridge: verify enc:v1: prefix by attempting trial decryption
  instead of prefix-only check, so plaintext values that happen to start
  with the sentinel are still encrypted rather than silently skipped

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-13 22:33:52 +08:00
bincxz
5e753334ed fix: capture write version before async init decryption to prevent startup race
Move hostsWriteVersion/keysWriteVersion/identitiesWriteVersion increments
to before the await decryptHosts/Keys/Identities calls, and guard both
setstate and re-encrypt with the version check. This prevents a write
that occurs during the decryption await (storage event, user edit) from
being overwritten by stale init data.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-13 22:23:28 +08:00
bincxz
a488bc466b fix: invalidate in-flight async operations on local and cross-window state changes
- useVaultState: bump writeVersion counters on storage events so pending
  local encrypts are discarded when newer cross-window data arrives
- CloudSyncManager: bump providerSeq on all local provider mutations
  (connect, disconnect, status updates, save) so in-flight decrypt
  callbacks from startup or storage events cannot revert newer state

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-13 22:14:52 +08:00
bincxz
2748cd5363 fix: add sequence guards to all async decrypt paths
Prevent out-of-order async decrypt results from overwriting newer state:

- useVaultState: add per-key readSeq counters for cross-window storage
  event decrypt callbacks (hosts, keys, identities)
- CloudSyncManager: add per-provider sequence counters shared between
  initProviderDecryption and cross-window storage handler, so stale
  decrypt results are discarded in both paths

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-13 22:04:59 +08:00
bincxz
033165561d fix: add version guards to migration writes and fix stale prev in cross-window sync
Addresses remaining Codex review feedback:
- Add writeVersion checks to startup migration re-encrypt paths to prevent
  stale async writes from overwriting newer user edits
- Move `prev` read inside .then() in CloudSyncManager storage event handler
  so it compares against latest state rather than a stale snapshot

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-13 21:52:43 +08:00
陈大猫
8e514f1008 fix: localize vault hosts empty state copy 2026-02-13 20:32:33 +08:00
Misaka21
0acd39603f feat: localize empty hosts message to Chinese 2026-02-13 19:40:08 +08:00
rorychou
4bdb0bbbf7 fix: address Codex review — serialize async writes & fix WebDAV token detection
1. Race condition: rapid updateHosts/Keys/Identities calls could cause
   out-of-order async writes. Added per-collection write-version counters
   so only the latest encryption Promise persists to localStorage.

2. WebDAV token-auth: using "password" in config as discriminator failed
   for token-auth configs because JSON.stringify drops undefined keys.
   Switched to "authType" in config which is a required WebDAVConfig field.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-13 19:12:21 +08:00
rorychou
6b2c58f8f0 fix: encrypt sensitive credentials at rest via safeStorage
Passwords, OAuth tokens, SSH private keys, and cloud sync secrets were
stored as plaintext JSON in browser localStorage.  Any XSS or local
file read could extract all credentials in one shot.

This commit adds field-level encryption using Electron's safeStorage
API.  Encrypted values are stored with an `enc:v1:` prefix sentinel
so plaintext values migrate transparently on first read — no version
bumps or flags needed.

New files:
- electron/bridges/credentialBridge.cjs — IPC handlers (encrypt/decrypt/available)
- infrastructure/persistence/secureFieldAdapter.ts — per-model encrypt/decrypt helpers

Modified files:
- electron/main.cjs, preload.cjs, global.d.ts — bridge wiring + types
- useVaultState.ts — async encrypted writes, decrypted reads, migration on init
- CloudSyncManager.ts — async provider token/config encryption

Sensitive fields encrypted:
- Host: password, telnetPassword, proxyConfig.password
- SSHKey: passphrase, privateKey
- Identity: password
- CloudSync: accessToken, refreshToken, WebDAV password/token, S3 secretAccessKey

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-13 18:30:37 +08:00
陈大猫
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
Copilot
b5feb888d2 Fix incorrect character encoding over Telnet and Serial connections (#196)
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
* Initial plan

* fix: use UTF-8 encoding for Telnet and Serial data instead of binary (latin1)

Fixes incorrect character encoding where accented characters (e.g. ç, ã, é)
were displayed as garbled text (e.g. ç, ã, é) over Telnet connections.

The root cause was Buffer.toString('binary') which uses latin1 encoding,
corrupting multi-byte UTF-8 sequences. Changed to toString('utf8') for both
Telnet and Serial data handlers.

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

* fix: use streaming StringDecoder for UTF-8 decoding in Telnet and Serial

Buffer.toString('utf8') on individual chunks loses multibyte characters
when a UTF-8 sequence is split across TCP/serial data events. Use
StringDecoder to carry incomplete trailing bytes into the next event.

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

* fix: respect configured charset for Telnet and use latin1 for Serial

Telnet: use the user-configured charset (options.charset) to select the
StringDecoder encoding instead of hardcoding UTF-8, so non-UTF-8
endpoints (e.g. ISO-8859-1) decode correctly.

Serial: use latin1 (byte-preserving) instead of UTF-8 to avoid
corrupting 8-bit/binary serial traffic and legacy single-byte encodings.

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

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: binaricat <16399091+binaricat@users.noreply.github.com>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-11 12:05:05 +08:00
陈大猫
62d19974c9 fix: show sessions on first TrayPanel open (#192)
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
2026-02-07 15:13:25 +08:00
陈大猫
932bb5032d fix: wrap terminal paste in bracketed paste escape sequences (#191)
All three paste paths (hotkey, context menu, middle-click) were sending
raw clipboard text directly to the session backend via writeToSession(),
bypassing xterm's built-in term.paste() which handles bracketed paste
wrapping. When a remote application like vim enables bracketed paste
mode (CSI ?2004h), pasted text must be wrapped in \e[200~ / \e[201~
so the application can distinguish paste from typed input.

Without these markers, vim's autoindent treats each pasted newline as
a manual Enter keypress, causing indentation to accumulate
progressively with each line (the "staircase effect").

Now checks term.modes.bracketedPasteMode before sending and wraps
the text accordingly on all paste paths.

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-07 14:33:51 +08:00
陈大猫
3020d422fe fix: restore built-in text editor paste behavior (#190)
* fix: restore built-in editor paste reliability

* fix: prevent Cmd+R window reload while editing in Monaco

Replace the Electron menu `{ role: "reload" }` with a manual click
handler so that Cmd+R no longer registers as a native accelerator.
This prevents accidental window reloads (and loss of unsaved edits)
when the text editor has focus, since the app's hotkey early-return
skips preventDefault for editor surfaces.

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

* fix: fall back to Monaco native paste when clipboard read is unavailable

When both navigator.clipboard.readText() and the Electron bridge fail,
readClipboardText now returns null instead of '' so handlePaste can
distinguish "read failed" from "clipboard empty" and trigger Monaco's
built-in paste action as a fallback.

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

* fix: let clipboard bridge errors propagate for proper paste fallback

useClipboardBackend.readClipboardText was swallowing bridge
absence/errors as "", making TextEditorModal's catch-based null
fallback unreachable. Now throws when the bridge is unavailable or
the call fails, so the caller can detect failure and fall back to
Monaco's native paste action.

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

* fix: preserve multi-cursor paste distribution semantics

When multiple cursors are active and the clipboard line count matches
the cursor count, distribute one line per cursor instead of pasting
the full text at every cursor. This matches Monaco's default
multicursorPaste:'spread' behavior.

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

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-07 13:44:19 +08:00
bincxz
bb526601bb fix(settings): prevent local terminal updates from being swallowed during sync 2026-02-07 10:26:23 +08:00
bincxz
d349c31cd6 fix(settings): avoid terminal settings sync race when skipping rebroadcast 2026-02-07 08:30:23 +08:00
bincxz
8313cf780d fix(settings): stop terminal sync echo and decouple editor modal state 2026-02-07 08:29:24 +08:00
陈大猫
29c0cc30a4 fix(settings): sync editor word wrap across windows (#189) 2026-02-07 08:29:24 +08:00
lolo
ee80048ece fix: correct Linux artifact naming in release notes
- Fix electron-builder Linux package architecture naming differences:
  - AppImage x64: x64 -> x86_64
  - deb x64: x64 -> amd64
  - rpm x64: x64 -> x86_64
  - rpm arm64: arm64 -> aarch64

- Update electron-builder config with separate artifactName for Windows NSIS

- Optimize Windows build config to build x64 and arm64 separately
2026-02-06 07:49:32 +08:00
34 changed files with 1344 additions and 445 deletions

View File

@@ -46,6 +46,10 @@ const tag = (process.env.GITHUB_REF_NAME && /^v\d+\.\d+\.\d+/.test(process.env.G
const baseUrl = `https://github.com/${repo}/releases/download/${tag}`;
// Filename patterns based on electron-builder.config.cjs artifactName: '${productName}-${version}-${os}-${arch}.${ext}'
// Note: electron-builder uses different arch names for Linux packages:
// - AppImage: x64 -> x86_64, arm64 -> arm64
// - deb: x64 -> amd64, arm64 -> arm64
// - rpm: x64 -> x86_64, arm64 -> aarch64
const files = {
mac: {
arm64: `Netcatty-${version}-mac-arm64.dmg`,
@@ -57,16 +61,16 @@ const files = {
},
linux: {
appimage: {
x64: `Netcatty-${version}-linux-x64.AppImage`,
x64: `Netcatty-${version}-linux-x86_64.AppImage`,
arm64: `Netcatty-${version}-linux-arm64.AppImage`
},
deb: {
x64: `Netcatty-${version}-linux-x64.deb`,
x64: `Netcatty-${version}-linux-amd64.deb`,
arm64: `Netcatty-${version}-linux-arm64.deb`
},
rpm: {
x64: `Netcatty-${version}-linux-x64.rpm`,
arm64: `Netcatty-${version}-linux-arm64.rpm`
x64: `Netcatty-${version}-linux-x86_64.rpm`,
arm64: `Netcatty-${version}-linux-aarch64.rpm`
}
}
};

View File

@@ -1,42 +0,0 @@
name: Sync Upstream
env:
UPSTREAM_URL: "https://github.com/binaricat/Netcatty.git"
UPSTREAM_BRANCH: "main"
TARGET_BRANCH: "main"
on:
schedule:
- cron: '0 0 * * *' # Run daily at midnight
workflow_dispatch: # Allow manual trigger
jobs:
sync:
runs-on: ubuntu-latest
permissions:
contents: write
steps:
- name: Checkout
uses: actions/checkout@v4
with:
ref: ${{ env.TARGET_BRANCH }}
fetch-depth: 0
token: ${{ secrets.GITHUB_TOKEN }}
- name: Configure Git
run: |
git config --global user.name "github-actions[bot]"
git config --global user.email "github-actions[bot]@users.noreply.github.com"
- name: Merge Upstream
run: |
echo "Adding upstream remote..."
git remote add upstream ${{ env.UPSTREAM_URL }}
git fetch upstream ${{ env.UPSTREAM_BRANCH }}
echo "Merging upstream/${{ env.UPSTREAM_BRANCH }} into ${{ env.TARGET_BRANCH }}..."
# This will fail if there are conflicts, which is the desired behavior (notify user via failure)
git merge upstream/${{ env.UPSTREAM_BRANCH }} --no-edit
echo "Pushing changes..."
git push origin ${{ env.TARGET_BRANCH }}

40
App.tsx
View File

@@ -769,11 +769,15 @@ function App({ settings }: { settings: SettingsState }) {
// Note: xterm terminal handles its own key interception via attachCustomKeyEventHandler
const target = e.target as HTMLElement;
const isFormElement = target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.isContentEditable;
const isMonacoElement =
target instanceof HTMLElement &&
!!target.closest?.('.monaco-editor, .monaco-diff-editor, .monaco-inputbox');
const isXtermInput =
target instanceof HTMLElement &&
!!target.closest?.(".xterm, .xterm-helper-textarea, .xterm-screen, .xterm-viewport");
if (isFormElement && !isXtermInput && e.key !== 'Escape') {
// Monaco is not always contentEditable/input, so treat it as an editor surface.
if ((isFormElement || isMonacoElement) && !isXtermInput && e.key !== 'Escape') {
return;
}
@@ -797,6 +801,10 @@ function App({ settings }: { settings: SettingsState }) {
const keyStr = isMac ? binding.mac : binding.pc;
if (matchesKeyBinding(e, keyStr, isMac)) {
if (HOTKEY_DEBUG) console.log('[Hotkeys] Matched binding:', binding.action, keyStr);
// SFTP shortcuts are handled by SFTP-specific hooks.
if (binding.category === 'sftp') {
continue;
}
// Terminal-specific actions should be handled by the terminal
// Don't handle them at app level
const terminalActions = ['copy', 'paste', 'selectAll', 'clearBuffer', 'searchTerminal'];
@@ -1078,8 +1086,36 @@ function App({ settings }: { settings: SettingsState }) {
setDraggingSessionId(null);
}, [setDraggingSessionId]);
const handleRootContextMenu = useCallback((e: React.MouseEvent<HTMLDivElement>) => {
const editableSelector =
"input, textarea, [contenteditable], .monaco-editor, .monaco-diff-editor, .monaco-inputbox, .monaco-menu-container";
const nativeEvent = e.nativeEvent;
const path = typeof nativeEvent.composedPath === "function" ? nativeEvent.composedPath() : [];
const allowFromPath = path.some(
(node) => node instanceof Element && !!node.closest(editableSelector),
);
const target = e.target;
const targetElement =
target instanceof Element
? target
: target instanceof Node
? target.parentElement
: null;
const allowFromTarget = !!targetElement?.closest(editableSelector);
const allowNativeContextMenu = allowFromPath || allowFromTarget;
if (allowNativeContextMenu) {
return;
}
e.preventDefault();
}, []);
return (
<div className="flex flex-col h-screen text-foreground font-sans netcatty-shell" onContextMenu={(e) => e.preventDefault()}>
<div className="flex flex-col h-screen text-foreground font-sans netcatty-shell" onContextMenu={handleRootContextMenu}>
<TopTabs
theme={theme}
sessions={sessions}

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',
@@ -389,6 +390,8 @@ const en: Messages = {
'vault.hosts.deselectAll': 'Deselect All',
'vault.hosts.deleteSelected': 'Delete ({count})',
'vault.hosts.deleteMultiple.success': 'Deleted {count} hosts',
'vault.hosts.empty.title': 'Set up your hosts',
'vault.hosts.empty.desc': 'Save hosts to quickly connect to your servers, VMs, and containers.',
// Vault import
'vault.import.title': 'Add data to your vault',
@@ -1355,6 +1358,9 @@ const en: Messages = {
'passphrase.unlock': 'Unlock',
'passphrase.unlocking': 'Unlocking...',
'passphrase.skip': 'Skip',
// Text Editor
'sftp.editor.wordWrap': 'Word Wrap',
};
export default en;

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': '收起侧边栏',
@@ -256,6 +257,8 @@ const zhCN: Messages = {
'vault.hosts.deselectAll': '取消全选',
'vault.hosts.deleteSelected': '删除 ({count})',
'vault.hosts.deleteMultiple.success': '已删除 {count} 个主机',
'vault.hosts.empty.title': '设置你的主机',
'vault.hosts.empty.desc': '保存主机以快速连接到你的服务器、虚拟机和容器。',
// Vault import
'vault.import.title': '添加数据到你的 Vault',
@@ -1341,6 +1344,9 @@ const zhCN: Messages = {
'passphrase.unlock': '解锁',
'passphrase.unlocking': '解锁中...',
'passphrase.skip': '跳过',
// Text Editor
'sftp.editor.wordWrap': '自动换行',
};
export default zhCN;

View File

@@ -0,0 +1,14 @@
import { useCallback } from "react";
import { netcattyBridge } from "../../infrastructure/services/netcattyBridge";
export const useClipboardBackend = () => {
const readClipboardText = useCallback(async (): Promise<string> => {
const bridge = netcattyBridge.get();
if (!bridge?.readClipboardText) throw new Error("clipboard bridge unavailable");
const text = await bridge.readClipboardText();
return typeof text === "string" ? text : "";
}, []);
return { readClipboardText };
};

View File

@@ -1,31 +1,32 @@
import { useCallback,useEffect,useLayoutEffect,useMemo,useState } from 'react';
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState, type SetStateAction } from 'react';
import { SyncConfig, TerminalSettings, DEFAULT_TERMINAL_SETTINGS, HotkeyScheme, CustomKeyBindings, DEFAULT_KEY_BINDINGS, KeyBinding, UILanguage, SessionLogFormat } from '../../domain/models';
import {
STORAGE_KEY_COLOR,
STORAGE_KEY_SYNC,
STORAGE_KEY_TERM_THEME,
STORAGE_KEY_THEME,
STORAGE_KEY_TERM_FONT_FAMILY,
STORAGE_KEY_TERM_FONT_SIZE,
STORAGE_KEY_TERM_SETTINGS,
STORAGE_KEY_HOTKEY_SCHEME,
STORAGE_KEY_CUSTOM_KEY_BINDINGS,
STORAGE_KEY_HOTKEY_RECORDING,
STORAGE_KEY_CUSTOM_CSS,
STORAGE_KEY_UI_LANGUAGE,
STORAGE_KEY_ACCENT_MODE,
STORAGE_KEY_UI_THEME_LIGHT,
STORAGE_KEY_UI_THEME_DARK,
STORAGE_KEY_UI_FONT_FAMILY,
STORAGE_KEY_SFTP_DOUBLE_CLICK_BEHAVIOR,
STORAGE_KEY_SFTP_AUTO_SYNC,
STORAGE_KEY_SFTP_SHOW_HIDDEN_FILES,
STORAGE_KEY_SFTP_USE_COMPRESSED_UPLOAD,
STORAGE_KEY_SESSION_LOGS_ENABLED,
STORAGE_KEY_SESSION_LOGS_DIR,
STORAGE_KEY_SESSION_LOGS_FORMAT,
STORAGE_KEY_TOGGLE_WINDOW_HOTKEY,
STORAGE_KEY_CLOSE_TO_TRAY,
STORAGE_KEY_COLOR,
STORAGE_KEY_SYNC,
STORAGE_KEY_TERM_THEME,
STORAGE_KEY_THEME,
STORAGE_KEY_TERM_FONT_FAMILY,
STORAGE_KEY_TERM_FONT_SIZE,
STORAGE_KEY_TERM_SETTINGS,
STORAGE_KEY_HOTKEY_SCHEME,
STORAGE_KEY_CUSTOM_KEY_BINDINGS,
STORAGE_KEY_HOTKEY_RECORDING,
STORAGE_KEY_CUSTOM_CSS,
STORAGE_KEY_UI_LANGUAGE,
STORAGE_KEY_ACCENT_MODE,
STORAGE_KEY_UI_THEME_LIGHT,
STORAGE_KEY_UI_THEME_DARK,
STORAGE_KEY_UI_FONT_FAMILY,
STORAGE_KEY_SFTP_DOUBLE_CLICK_BEHAVIOR,
STORAGE_KEY_SFTP_AUTO_SYNC,
STORAGE_KEY_SFTP_SHOW_HIDDEN_FILES,
STORAGE_KEY_SFTP_USE_COMPRESSED_UPLOAD,
STORAGE_KEY_EDITOR_WORD_WRAP,
STORAGE_KEY_SESSION_LOGS_ENABLED,
STORAGE_KEY_SESSION_LOGS_DIR,
STORAGE_KEY_SESSION_LOGS_FORMAT,
STORAGE_KEY_TOGGLE_WINDOW_HOTKEY,
STORAGE_KEY_CLOSE_TO_TRAY,
} from '../../infrastructure/config/storageKeys';
import { DEFAULT_UI_LOCALE, resolveSupportedLocale } from '../../infrastructure/config/i18n';
import { TERMINAL_THEMES } from '../../infrastructure/config/terminalThemes';
@@ -54,6 +55,9 @@ const DEFAULT_SFTP_AUTO_SYNC = false;
const DEFAULT_SFTP_SHOW_HIDDEN_FILES = false;
const DEFAULT_SFTP_USE_COMPRESSED_UPLOAD = true;
// Editor defaults
const DEFAULT_EDITOR_WORD_WRAP = false;
// Session Logs defaults
const DEFAULT_SESSION_LOGS_ENABLED = false;
const DEFAULT_SESSION_LOGS_FORMAT: SessionLogFormat = 'txt';
@@ -89,9 +93,15 @@ const isValidUiFontId = (value: string): boolean => {
if (value.startsWith('local-')) return true;
// Check bundled fonts first, then check dynamically loaded fonts
return UI_FONTS.some((font) => font.id === value) ||
uiFontStore.getAvailableFonts().some((font) => font.id === value);
uiFontStore.getAvailableFonts().some((font) => font.id === value);
};
const serializeTerminalSettings = (settings: TerminalSettings): string =>
JSON.stringify(settings);
const areTerminalSettingsEqual = (a: TerminalSettings, b: TerminalSettings): boolean =>
serializeTerminalSettings(a) === serializeTerminalSettings(b);
const applyThemeTokens = (
theme: 'light' | 'dark',
tokens: UiThemeTokens,
@@ -169,7 +179,7 @@ export const useSettingsState = () => {
const stored = readStoredString(STORAGE_KEY_UI_LANGUAGE);
return resolveSupportedLocale(stored || DEFAULT_UI_LOCALE);
});
const [terminalSettings, setTerminalSettings] = useState<TerminalSettings>(() => {
const [terminalSettings, setTerminalSettingsState] = useState<TerminalSettings>(() => {
const stored = localStorageAdapter.read<TerminalSettings>(STORAGE_KEY_TERM_SETTINGS);
return stored ? { ...DEFAULT_TERMINAL_SETTINGS, ...stored } : DEFAULT_TERMINAL_SETTINGS;
});
@@ -208,6 +218,12 @@ export const useSettingsState = () => {
return DEFAULT_SFTP_USE_COMPRESSED_UPLOAD;
});
// Editor Settings
const [editorWordWrap, setEditorWordWrapState] = useState<boolean>(() => {
const stored = readStoredString(STORAGE_KEY_EDITOR_WORD_WRAP);
return stored === 'true' ? true : DEFAULT_EDITOR_WORD_WRAP;
});
// Session Logs Settings
const [sessionLogsEnabled, setSessionLogsEnabled] = useState<boolean>(() => {
const stored = readStoredString(STORAGE_KEY_SESSION_LOGS_ENABLED);
@@ -237,6 +253,34 @@ export const useSettingsState = () => {
return stored === 'true';
});
const [hotkeyRegistrationError, setHotkeyRegistrationError] = useState<string | null>(null);
const incomingTerminalSettingsSignatureRef = useRef<string | null>(null);
const localTerminalSettingsVersionRef = useRef(0);
const broadcastedLocalTerminalSettingsVersionRef = useRef(0);
const setTerminalSettings = useCallback((nextValue: SetStateAction<TerminalSettings>) => {
setTerminalSettingsState((prev) => {
const next = typeof nextValue === 'function'
? (nextValue as (prevState: TerminalSettings) => TerminalSettings)(prev)
: nextValue;
if (areTerminalSettingsEqual(prev, next)) {
return prev;
}
localTerminalSettingsVersionRef.current += 1;
return next;
});
}, []);
const mergeIncomingTerminalSettings = useCallback((incoming: Partial<TerminalSettings>) => {
setTerminalSettingsState((prev) => {
const next = { ...prev, ...incoming };
if (areTerminalSettingsEqual(prev, next)) {
return prev;
}
// Mark the exact incoming snapshot so only this state is skipped for IPC rebroadcast.
incomingTerminalSettingsSignatureRef.current = serializeTerminalSettings(next);
return next;
});
}, []);
// Helper to notify other windows about settings changes via IPC
const notifySettingsChanged = useCallback((key: string, value: unknown) => {
@@ -307,11 +351,11 @@ export const useSettingsState = () => {
}, [uiFontFamilyId, uiFontsLoaded, notifySettingsChanged]);
// Listen for settings changes from other windows via IPC
useEffect(() => {
const bridge = netcattyBridge.get();
if (!bridge?.onSettingsChanged) return;
const unsubscribe = bridge.onSettingsChanged((payload) => {
const { key, value } = payload;
useEffect(() => {
const bridge = netcattyBridge.get();
if (!bridge?.onSettingsChanged) return;
const unsubscribe = bridge.onSettingsChanged((payload) => {
const { key, value } = payload;
if (
key === STORAGE_KEY_THEME ||
key === STORAGE_KEY_UI_THEME_LIGHT ||
@@ -325,8 +369,8 @@ export const useSettingsState = () => {
if (key === STORAGE_KEY_UI_LANGUAGE && typeof value === 'string') {
const next = resolveSupportedLocale(value);
setUiLanguage((prev) => (prev === next ? prev : next));
document.documentElement.lang = next;
}
document.documentElement.lang = next;
}
if (key === STORAGE_KEY_CUSTOM_CSS && typeof value === 'string') {
syncCustomCssFromStorage();
}
@@ -344,6 +388,21 @@ export const useSettingsState = () => {
if (key === STORAGE_KEY_TERM_FONT_SIZE && typeof value === 'number') {
setTerminalFontSize(value);
}
if (key === STORAGE_KEY_TERM_SETTINGS) {
if (typeof value === 'string') {
try {
const parsed = JSON.parse(value) as Partial<TerminalSettings>;
mergeIncomingTerminalSettings(parsed);
} catch {
// ignore parse errors
}
} else if (value && typeof value === 'object') {
mergeIncomingTerminalSettings(value as Partial<TerminalSettings>);
}
}
if (key === STORAGE_KEY_EDITOR_WORD_WRAP && typeof value === 'boolean') {
setEditorWordWrapState((prev) => (prev === value ? prev : value));
}
if (key === STORAGE_KEY_HOTKEY_SCHEME && (value === 'disabled' || value === 'mac' || value === 'pc')) {
setHotkeyScheme(value);
}
@@ -369,7 +428,7 @@ export const useSettingsState = () => {
// ignore
}
};
}, [syncAppearanceFromStorage, syncCustomCssFromStorage]);
}, [mergeIncomingTerminalSettings, syncAppearanceFromStorage, syncCustomCssFromStorage]);
useEffect(() => {
const bridge = netcattyBridge.get();
@@ -447,14 +506,14 @@ export const useSettingsState = () => {
}
}
// Sync terminal settings from other windows
if (e.key === STORAGE_KEY_TERM_SETTINGS && e.newValue) {
try {
const newSettings = JSON.parse(e.newValue) as TerminalSettings;
setTerminalSettings(_prev => ({ ...DEFAULT_TERMINAL_SETTINGS, ...newSettings }));
} catch {
// ignore parse errors
}
}
if (e.key === STORAGE_KEY_TERM_SETTINGS && e.newValue) {
try {
const newSettings = JSON.parse(e.newValue) as TerminalSettings;
mergeIncomingTerminalSettings({ ...DEFAULT_TERMINAL_SETTINGS, ...newSettings });
} catch {
// ignore parse errors
}
}
// Sync terminal theme from other windows
if (e.key === STORAGE_KEY_TERM_THEME && e.newValue) {
if (e.newValue !== terminalThemeId) {
@@ -494,6 +553,12 @@ export const useSettingsState = () => {
setSftpShowHiddenFiles(newValue);
}
}
if (e.key === STORAGE_KEY_EDITOR_WORD_WRAP && e.newValue !== null) {
const newValue = e.newValue === 'true';
if (newValue !== editorWordWrap) {
setEditorWordWrapState(newValue);
}
}
// Sync SFTP compressed upload setting from other windows
if (e.key === STORAGE_KEY_SFTP_USE_COMPRESSED_UPLOAD && e.newValue !== null) {
const newValue = e.newValue === 'true' || e.newValue === 'enabled';
@@ -505,7 +570,7 @@ export const useSettingsState = () => {
window.addEventListener('storage', handleStorageChange);
return () => window.removeEventListener('storage', handleStorageChange);
}, [theme, lightUiThemeId, darkUiThemeId, accentMode, customAccent, customCSS, uiFontFamilyId, hotkeyScheme, uiLanguage, terminalThemeId, terminalFontFamilyId, terminalFontSize, sftpDoubleClickBehavior, sftpAutoSync, sftpShowHiddenFiles, sftpUseCompressedUpload]);
}, [theme, lightUiThemeId, darkUiThemeId, accentMode, customAccent, customCSS, uiFontFamilyId, hotkeyScheme, uiLanguage, terminalThemeId, terminalFontFamilyId, terminalFontSize, sftpDoubleClickBehavior, sftpAutoSync, sftpShowHiddenFiles, sftpUseCompressedUpload, editorWordWrap, mergeIncomingTerminalSettings]);
useEffect(() => {
localStorageAdapter.writeString(STORAGE_KEY_TERM_THEME, terminalThemeId);
@@ -524,7 +589,17 @@ export const useSettingsState = () => {
useEffect(() => {
localStorageAdapter.write(STORAGE_KEY_TERM_SETTINGS, terminalSettings);
}, [terminalSettings]);
const currentSignature = serializeTerminalSettings(terminalSettings);
const hasPendingUnbroadcastLocalChanges =
localTerminalSettingsVersionRef.current !== broadcastedLocalTerminalSettingsVersionRef.current;
if (incomingTerminalSettingsSignatureRef.current === currentSignature && !hasPendingUnbroadcastLocalChanges) {
incomingTerminalSettingsSignatureRef.current = null;
return;
}
incomingTerminalSettingsSignatureRef.current = null;
notifySettingsChanged(STORAGE_KEY_TERM_SETTINGS, terminalSettings);
broadcastedLocalTerminalSettingsVersionRef.current = localTerminalSettingsVersionRef.current;
}, [terminalSettings, notifySettingsChanged]);
useEffect(() => {
localStorageAdapter.writeString(STORAGE_KEY_HOTKEY_SCHEME, hotkeyScheme);
@@ -706,7 +781,7 @@ export const useSettingsState = () => {
value: TerminalSettings[K]
) => {
setTerminalSettings(prev => ({ ...prev, [key]: value }));
}, []);
}, [setTerminalSettings]);
return {
theme,
@@ -755,6 +830,13 @@ export const useSettingsState = () => {
setSftpShowHiddenFiles,
sftpUseCompressedUpload,
setSftpUseCompressedUpload,
// Editor Settings
editorWordWrap,
setEditorWordWrap: useCallback((enabled: boolean) => {
setEditorWordWrapState(enabled);
localStorageAdapter.writeString(STORAGE_KEY_EDITOR_WORD_WRAP, String(enabled));
notifySettingsChanged(STORAGE_KEY_EDITOR_WORD_WRAP, enabled);
}, [notifySettingsChanged]),
availableFonts,
// Session Logs
sessionLogsEnabled,

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 { useCallback, useEffect, useState } from "react";
import { useCallback, useEffect, useRef, useState } from "react";
import { normalizeDistroId, sanitizeHost } from "../../domain/host";
import {
ConnectionLog,
@@ -29,6 +29,14 @@ import {
STORAGE_KEY_SNIPPETS,
} from "../../infrastructure/config/storageKeys";
import { localStorageAdapter } from "../../infrastructure/persistence/localStorageAdapter";
import {
decryptHosts,
decryptIdentities,
decryptKeys,
encryptHosts,
encryptIdentities,
encryptKeys,
} from "../../infrastructure/persistence/secureFieldAdapter";
type ExportableVaultData = {
hosts: Host[];
@@ -99,20 +107,47 @@ export const useVaultState = () => {
const [connectionLogs, setConnectionLogs] = useState<ConnectionLog[]>([]);
const [managedSources, setManagedSources] = useState<ManagedSource[]>([]);
// Write-version counters prevent out-of-order async writes from overwriting
// newer data. Each update bumps the counter; the .then() callback only
// persists if its version still matches the latest.
const hostsWriteVersion = useRef(0);
const keysWriteVersion = useRef(0);
const identitiesWriteVersion = useRef(0);
// Read-sequence counters for cross-window storage events. Each incoming
// event bumps the counter; the async decrypt callback only applies state if
// its sequence still matches, preventing stale decrypts from overwriting
// newer data when multiple events arrive in quick succession.
const hostsReadSeq = useRef(0);
const keysReadSeq = useRef(0);
const identitiesReadSeq = useRef(0);
const updateHosts = useCallback((data: Host[]) => {
const cleaned = data.map(sanitizeHost);
setHosts(cleaned);
localStorageAdapter.write(STORAGE_KEY_HOSTS, cleaned);
const ver = ++hostsWriteVersion.current;
encryptHosts(cleaned).then((enc) => {
if (ver === hostsWriteVersion.current)
localStorageAdapter.write(STORAGE_KEY_HOSTS, enc);
});
}, []);
const updateKeys = useCallback((data: SSHKey[]) => {
setKeys(data);
localStorageAdapter.write(STORAGE_KEY_KEYS, data);
const ver = ++keysWriteVersion.current;
encryptKeys(data).then((enc) => {
if (ver === keysWriteVersion.current)
localStorageAdapter.write(STORAGE_KEY_KEYS, enc);
});
}, []);
const updateIdentities = useCallback((data: Identity[]) => {
setIdentities(data);
localStorageAdapter.write(STORAGE_KEY_IDENTITIES, data);
const ver = ++identitiesWriteVersion.current;
encryptIdentities(data).then((enc) => {
if (ver === identitiesWriteVersion.current)
localStorageAdapter.write(STORAGE_KEY_IDENTITIES, enc);
});
}, []);
const updateSnippets = useCallback((data: Snippet[]) => {
@@ -271,7 +306,11 @@ export const useVaultState = () => {
// Add to hosts using functional update
setHosts((prevHosts) => {
const updated = [...prevHosts, sanitizeHost(newHost)];
localStorageAdapter.write(STORAGE_KEY_HOSTS, updated);
const ver = ++hostsWriteVersion.current;
encryptHosts(updated).then((enc) => {
if (ver === hostsWriteVersion.current)
localStorageAdapter.write(STORAGE_KEY_HOSTS, enc);
});
return updated;
});
@@ -279,82 +318,120 @@ export const useVaultState = () => {
}, []);
useEffect(() => {
const savedHosts = localStorageAdapter.read<Host[]>(STORAGE_KEY_HOSTS);
const savedKeysRaw = localStorageAdapter.read<unknown[]>(STORAGE_KEY_KEYS);
const savedIdentities =
localStorageAdapter.read<Identity[]>(STORAGE_KEY_IDENTITIES);
const savedGroups = localStorageAdapter.read<string[]>(STORAGE_KEY_GROUPS);
const savedSnippets =
localStorageAdapter.read<Snippet[]>(STORAGE_KEY_SNIPPETS);
const savedSnippetPackages = localStorageAdapter.read<string[]>(
STORAGE_KEY_SNIPPET_PACKAGES,
);
const init = async () => {
const savedHosts = localStorageAdapter.read<Host[]>(STORAGE_KEY_HOSTS);
if (savedHosts) {
const sanitized = savedHosts.map(sanitizeHost);
setHosts(sanitized);
localStorageAdapter.write(STORAGE_KEY_HOSTS, sanitized);
} else {
updateHosts(INITIAL_HOSTS);
}
if (savedHosts) {
// Capture version before the async gap so that any write occurring
// during decryption (storage event, user edit) advances the counter
// and causes this stale result to be discarded.
const ver = ++hostsWriteVersion.current;
const decrypted = await decryptHosts(savedHosts);
if (ver === hostsWriteVersion.current) {
const sanitized = decrypted.map(sanitizeHost);
setHosts(sanitized);
encryptHosts(sanitized).then((enc) => {
if (ver === hostsWriteVersion.current)
localStorageAdapter.write(STORAGE_KEY_HOSTS, enc);
});
}
} else {
updateHosts(INITIAL_HOSTS);
}
// Migrate old keys to new format with source/category fields
if (savedKeysRaw?.length) {
const migratedKeys: SSHKey[] = [];
const legacyKeys: LegacyKeyRecord[] = [];
// Read keys fresh here (not before the hosts await) so we don't apply
// a stale snapshot if keys were updated during host decryption.
const savedKeysRaw = localStorageAdapter.read<unknown[]>(STORAGE_KEY_KEYS);
for (const entry of savedKeysRaw) {
const record =
entry && typeof entry === "object" ? (entry as LegacyKeyRecord) : null;
if (!record) continue;
// Migrate old keys to new format with source/category fields
if (savedKeysRaw?.length) {
const migratedKeys: SSHKey[] = [];
const legacyKeys: LegacyKeyRecord[] = [];
if (isLegacyUnsupportedKey(record)) {
legacyKeys.push(record);
continue;
for (const entry of savedKeysRaw) {
const record =
entry && typeof entry === "object" ? (entry as LegacyKeyRecord) : null;
if (!record) continue;
if (isLegacyUnsupportedKey(record)) {
legacyKeys.push(record);
continue;
}
migratedKeys.push(migrateKey(record as Partial<SSHKey>));
}
migratedKeys.push(migrateKey(record as Partial<SSHKey>));
// Decrypt sensitive fields (passphrase, privateKey)
const keyVer = ++keysWriteVersion.current;
const decryptedKeys = await decryptKeys(migratedKeys);
if (keyVer === keysWriteVersion.current) {
setKeys(decryptedKeys);
encryptKeys(decryptedKeys).then((enc) => {
if (keyVer === keysWriteVersion.current)
localStorageAdapter.write(STORAGE_KEY_KEYS, enc);
});
}
if (legacyKeys.length) {
localStorageAdapter.write(STORAGE_KEY_LEGACY_KEYS, legacyKeys);
}
}
setKeys(migratedKeys);
// Persist migrated keys
localStorageAdapter.write(STORAGE_KEY_KEYS, migratedKeys);
if (legacyKeys.length) {
localStorageAdapter.write(STORAGE_KEY_LEGACY_KEYS, legacyKeys);
// Read identities fresh here (not before the hosts/keys awaits) so we
// don't apply a stale snapshot if identities were updated during prior decryption.
const savedIdentities =
localStorageAdapter.read<Identity[]>(STORAGE_KEY_IDENTITIES);
if (savedIdentities) {
const idVer = ++identitiesWriteVersion.current;
const decryptedIds = await decryptIdentities(savedIdentities);
if (idVer === identitiesWriteVersion.current) {
setIdentities(decryptedIds);
encryptIdentities(decryptedIds).then((enc) => {
if (idVer === identitiesWriteVersion.current)
localStorageAdapter.write(STORAGE_KEY_IDENTITIES, enc);
});
}
}
}
if (savedIdentities) setIdentities(savedIdentities);
// Read remaining non-encrypted data fresh after all async gaps above
const savedGroups = localStorageAdapter.read<string[]>(STORAGE_KEY_GROUPS);
const savedSnippets =
localStorageAdapter.read<Snippet[]>(STORAGE_KEY_SNIPPETS);
const savedSnippetPackages = localStorageAdapter.read<string[]>(
STORAGE_KEY_SNIPPET_PACKAGES,
);
if (savedSnippets) setSnippets(savedSnippets);
else updateSnippets(INITIAL_SNIPPETS);
if (savedSnippets) setSnippets(savedSnippets);
else updateSnippets(INITIAL_SNIPPETS);
if (savedGroups) setCustomGroups(savedGroups);
if (savedSnippetPackages) setSnippetPackages(savedSnippetPackages);
if (savedGroups) setCustomGroups(savedGroups);
if (savedSnippetPackages) setSnippetPackages(savedSnippetPackages);
// Load known hosts
const savedKnownHosts = localStorageAdapter.read<KnownHost[]>(
STORAGE_KEY_KNOWN_HOSTS,
);
if (savedKnownHosts) setKnownHosts(savedKnownHosts);
// Load known hosts
const savedKnownHosts = localStorageAdapter.read<KnownHost[]>(
STORAGE_KEY_KNOWN_HOSTS,
);
if (savedKnownHosts) setKnownHosts(savedKnownHosts);
// Load shell history
const savedShellHistory = localStorageAdapter.read<ShellHistoryEntry[]>(
STORAGE_KEY_SHELL_HISTORY,
);
if (savedShellHistory) setShellHistory(savedShellHistory);
// Load shell history
const savedShellHistory = localStorageAdapter.read<ShellHistoryEntry[]>(
STORAGE_KEY_SHELL_HISTORY,
);
if (savedShellHistory) setShellHistory(savedShellHistory);
// Load connection logs
const savedConnectionLogs = localStorageAdapter.read<ConnectionLog[]>(
STORAGE_KEY_CONNECTION_LOGS,
);
if (savedConnectionLogs) setConnectionLogs(savedConnectionLogs);
// Load connection logs
const savedConnectionLogs = localStorageAdapter.read<ConnectionLog[]>(
STORAGE_KEY_CONNECTION_LOGS,
);
if (savedConnectionLogs) setConnectionLogs(savedConnectionLogs);
// Load managed sources
const savedManagedSources = localStorageAdapter.read<ManagedSource[]>(
STORAGE_KEY_MANAGED_SOURCES,
);
if (savedManagedSources) setManagedSources(savedManagedSources);
// Load managed sources
const savedManagedSources = localStorageAdapter.read<ManagedSource[]>(
STORAGE_KEY_MANAGED_SOURCES,
);
if (savedManagedSources) setManagedSources(savedManagedSources);
};
init();
}, [updateHosts, updateSnippets]);
useEffect(() => {
@@ -367,7 +444,17 @@ export const useVaultState = () => {
if (key === STORAGE_KEY_HOSTS) {
const next = safeParse<Host[]>(event.newValue) ?? [];
setHosts(next.map(sanitizeHost));
// Bump write version to invalidate any in-flight encrypt from this
// window — the cross-window data is newer and must not be overwritten.
++hostsWriteVersion.current;
const seq = ++hostsReadSeq.current;
const writeAtStart = hostsWriteVersion.current;
decryptHosts(next).then((dec) => {
// Discard if a newer storage event arrived OR a local write occurred
// during the decrypt (writeVersion would have advanced).
if (seq === hostsReadSeq.current && writeAtStart === hostsWriteVersion.current)
setHosts(dec.map(sanitizeHost));
});
return;
}
@@ -380,13 +467,25 @@ export const useVaultState = () => {
if (!record || isLegacyUnsupportedKey(record)) continue;
migratedKeys.push(migrateKey(record as Partial<SSHKey>));
}
setKeys(migratedKeys);
++keysWriteVersion.current;
const seq = ++keysReadSeq.current;
const writeAtStart = keysWriteVersion.current;
decryptKeys(migratedKeys).then((dec) => {
if (seq === keysReadSeq.current && writeAtStart === keysWriteVersion.current)
setKeys(dec);
});
return;
}
if (key === STORAGE_KEY_IDENTITIES) {
const next = safeParse<Identity[]>(event.newValue) ?? [];
setIdentities(next);
++identitiesWriteVersion.current;
const seq = ++identitiesReadSeq.current;
const writeAtStart = identitiesWriteVersion.current;
decryptIdentities(next).then((dec) => {
if (seq === identitiesReadSeq.current && writeAtStart === identitiesWriteVersion.current)
setIdentities(dec);
});
return;
}
@@ -442,7 +541,11 @@ export const useVaultState = () => {
const next = prev.map((h) =>
h.id === hostId ? { ...h, distro: normalized } : h,
);
localStorageAdapter.write(STORAGE_KEY_HOSTS, next);
const ver = ++hostsWriteVersion.current;
encryptHosts(next).then((enc) => {
if (ver === hostsWriteVersion.current)
localStorageAdapter.write(STORAGE_KEY_HOSTS, enc);
});
return next;
});
}, []);

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

@@ -86,7 +86,15 @@ const SFTPModal: React.FC<SFTPModalProps> = ({
showSaveDialog,
} = useSftpBackend();
const { t } = useI18n();
const { sftpAutoSync, sftpShowHiddenFiles, sftpUseCompressedUpload, hotkeyScheme, keyBindings } = useSettingsState();
const {
sftpAutoSync,
sftpShowHiddenFiles,
sftpUseCompressedUpload,
hotkeyScheme,
keyBindings,
editorWordWrap,
setEditorWordWrap,
} = useSettingsState();
const isLocalSession = host.protocol === "local";
const [filenameEncoding, setFilenameEncoding] = useState<SftpFilenameEncoding>(
host.sftpEncoding ?? "auto"
@@ -735,6 +743,8 @@ const SFTPModal: React.FC<SFTPModalProps> = ({
fileName={textEditorTarget?.name || ""}
initialContent={textEditorContent}
onSave={handleSaveTextFile}
editorWordWrap={editorWordWrap}
onToggleWordWrap={() => setEditorWordWrap(!editorWordWrap)}
/>
</Dialog>
);

View File

@@ -53,7 +53,15 @@ interface SftpViewProps {
const SftpViewInner: React.FC<SftpViewProps> = ({ hosts, keys, identities }) => {
const { t } = useI18n();
const isActive = useIsSftpActive();
const { sftpDoubleClickBehavior, sftpAutoSync, sftpShowHiddenFiles, hotkeyScheme, keyBindings } = useSettingsState();
const {
sftpDoubleClickBehavior,
sftpAutoSync,
sftpShowHiddenFiles,
hotkeyScheme,
keyBindings,
editorWordWrap,
setEditorWordWrap,
} = useSettingsState();
// File watch event handlers (stable refs to avoid re-creating the useSftpState options)
const fileWatchHandlers = useMemo(() => ({
@@ -356,6 +364,8 @@ const SftpViewInner: React.FC<SftpViewProps> = ({ hosts, keys, identities }) =>
textEditorContent={textEditorContent}
setTextEditorContent={setTextEditorContent}
handleSaveTextFile={handleSaveTextFile}
editorWordWrap={editorWordWrap}
setEditorWordWrap={setEditorWordWrap}
showFileOpenerDialog={showFileOpenerDialog}
setShowFileOpenerDialog={setShowFileOpenerDialog}
fileOpenerTarget={fileOpenerTarget}

View File

@@ -5,6 +5,7 @@ import {
CloudUpload,
Loader2,
Search,
WrapText,
X,
} from 'lucide-react';
import Editor, { type OnMount, loader, useMonaco } from '@monaco-editor/react';
@@ -18,6 +19,7 @@ const monacoBasePath = import.meta.env.DEV
loader.config({ paths: { vs: monacoBasePath } });
import { useI18n } from '../application/i18n/I18nProvider';
import { useClipboardBackend } from '../application/state/useClipboardBackend';
import { getLanguageId, getLanguageName, getSupportedLanguages } from '../lib/sftpFileUtils';
import { Button } from './ui/button';
import { Dialog, DialogContent, DialogHeader, DialogTitle } from './ui/dialog';
@@ -30,6 +32,8 @@ interface TextEditorModalProps {
fileName: string;
initialContent: string;
onSave: (content: string) => Promise<void>;
editorWordWrap: boolean;
onToggleWordWrap: () => void;
}
// Map our language IDs to Monaco language IDs
@@ -132,8 +136,11 @@ export const TextEditorModal: React.FC<TextEditorModalProps> = ({
fileName,
initialContent,
onSave,
editorWordWrap,
onToggleWordWrap,
}) => {
const { t } = useI18n();
const { readClipboardText: readClipboardTextFromBridge } = useClipboardBackend();
const monaco = useMonaco();
const [content, setContent] = useState(initialContent);
const [saving, setSaving] = useState(false);
@@ -143,6 +150,7 @@ export const TextEditorModal: React.FC<TextEditorModalProps> = ({
// Ref to store the latest save function to avoid stale closure in keyboard shortcut
const handleSaveRef = useRef<() => Promise<void>>(() => Promise.resolve());
const handlePasteRef = useRef<() => Promise<void>>(() => Promise.resolve());
// Track theme from document.documentElement class (syncs with app theme)
const [isDarkTheme, setIsDarkTheme] = useState(() =>
@@ -229,6 +237,58 @@ export const TextEditorModal: React.FC<TextEditorModalProps> = ({
handleSaveRef.current = handleSave;
}, [handleSave]);
const readClipboardText = useCallback(async (): Promise<string | null> => {
try {
if (navigator.clipboard?.readText) {
return await navigator.clipboard.readText();
}
} catch {
// Fall through to Electron bridge
}
try {
return await readClipboardTextFromBridge();
} catch {
// Both clipboard APIs unavailable; signal failure so caller can fall back.
return null;
}
}, [readClipboardTextFromBridge]);
const handlePaste = useCallback(async () => {
const editor = editorRef.current;
if (!editor) return;
const text = await readClipboardText();
if (text === null) {
// Clipboard read unavailable; fall back to Monaco's native paste.
editor.trigger('keyboard', 'editor.action.clipboardPasteAction', null);
return;
}
if (!text) return;
const selections = editor.getSelections();
if (!selections || selections.length === 0) return;
// Match Monaco's default multicursorPaste:'spread' behavior:
// distribute one line per cursor when line count equals cursor count.
const lines = text.split(/\r\n|\n/);
const distribute = selections.length > 1 && lines.length === selections.length;
editor.executeEdits(
'netcatty-paste',
selections.map((selection, i) => ({
range: selection,
text: distribute ? lines[i] : text,
forceMoveMarkers: true,
})),
);
editor.focus();
}, [readClipboardText]);
useEffect(() => {
handlePasteRef.current = handlePaste;
}, [handlePaste]);
const handleClose = useCallback(() => {
if (hasChanges) {
const confirmed = confirm(t('sftp.editor.unsavedChanges'));
@@ -254,6 +314,11 @@ export const TextEditorModal: React.FC<TextEditorModalProps> = ({
// Trigger Monaco's built-in find widget
editor.trigger('keyboard', 'actions.find', null);
});
// Fallback paste path for Electron environments where Monaco paste can fail.
editor.addCommand(monaco.KeyMod.CtrlCmd | monaco.KeyCode.KeyV, () => {
void handlePasteRef.current();
});
}, []);
// Trigger search dialog
@@ -299,6 +364,17 @@ export const TextEditorModal: React.FC<TextEditorModalProps> = ({
<Search size={14} />
</Button>
{/* Word wrap toggle */}
<Button
variant={editorWordWrap ? 'secondary' : 'ghost'}
size="icon"
className="h-7 w-7"
onClick={onToggleWordWrap}
title={t('sftp.editor.wordWrap')}
>
<WrapText size={14} />
</Button>
{/* Language selector */}
<Combobox
options={languageOptions}
@@ -352,6 +428,8 @@ export const TextEditorModal: React.FC<TextEditorModalProps> = ({
</div>
}
options={{
// Prefer native context menu in Electron so right-click Paste uses OS clipboard path.
contextmenu: false,
minimap: { enabled: true },
fontSize: 14,
lineNumbers: 'on',
@@ -360,7 +438,7 @@ export const TextEditorModal: React.FC<TextEditorModalProps> = ({
automaticLayout: true,
tabSize: 2,
insertSpaces: true,
wordWrap: 'off',
wordWrap: editorWordWrap ? 'on' : 'off',
folding: true,
renderWhitespace: 'selection',
bracketPairColorization: { enabled: true },

View File

@@ -543,8 +543,8 @@ const TopTabsInner: React.FC<TopTabsProps> = ({
{/* Always-on drag stripe so the window can be moved even when tabs fill the bar */}
<div className="absolute inset-x-0 top-0 h-1 app-drag pointer-events-auto z-10" style={dragRegionStyle} aria-hidden />
<div
className="h-8 px-3 flex items-center gap-2 app-drag"
style={{ ...dragRegionStyle, paddingLeft: isMacClient && !isWindowFullscreen ? 76 : 12 }}
className="h-8 flex items-center gap-2 app-drag"
style={{ ...dragRegionStyle, paddingLeft: isMacClient && !isWindowFullscreen ? 76 : 12, paddingRight: isMacClient ? 12 : 0 }}
>
{/* Fixed left tabs: Vaults and SFTP */}
<div className="flex items-center gap-2 flex-shrink-0 app-drag">
@@ -654,8 +654,8 @@ const TopTabsInner: React.FC<TopTabsProps> = ({
</div>
{/* Custom window controls for Windows/Linux */}
{!isMacClient && <WindowControls />}
{/* Small drag shim to the right edge */}
<div className="w-2 h-8 app-drag flex-shrink-0" />
{/* Small drag shim to the right edge (macOS only on Windows the close button should touch the edge) */}
{isMacClient && <div className="w-2 h-8 app-drag flex-shrink-0" />}
</div>
</div>
);

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">
@@ -2000,11 +2003,10 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
<LayoutGrid size={32} className="opacity-60" />
</div>
<h3 className="text-lg font-semibold text-foreground mb-2">
Set up your hosts
{t('vault.hosts.empty.title')}
</h3>
<p className="text-sm text-center max-w-sm">
Save hosts to quickly connect to your servers, VMs,
and containers.
{t('vault.hosts.empty.desc')}
</p>
</div>
)}
@@ -2136,11 +2138,10 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
<LayoutGrid size={32} className="opacity-60" />
</div>
<h3 className="text-lg font-semibold text-foreground mb-2">
Set up your hosts
{t('vault.hosts.empty.title')}
</h3>
<p className="text-sm text-center max-w-sm">
Save hosts to quickly connect to your servers, VMs,
and containers.
{t('vault.hosts.empty.desc')}
</p>
</div>
)}

View File

@@ -71,11 +71,12 @@ export const useSftpModalKeyboardShortcuts = ({
// Skip if focus is on an input element
const target = e.target as HTMLElement;
if (
const isEditableTarget =
target.tagName === "INPUT" ||
target.tagName === "TEXTAREA" ||
target.isContentEditable
) {
target.isContentEditable ||
!!target.closest?.(".monaco-editor, .monaco-diff-editor, .monaco-inputbox");
if (isEditableTarget) {
return;
}

View File

@@ -32,6 +32,8 @@ interface SftpOverlaysProps {
textEditorContent: string;
setTextEditorContent: (content: string) => void;
handleSaveTextFile: (content: string) => Promise<void>;
editorWordWrap: boolean;
setEditorWordWrap: (enabled: boolean) => void;
showFileOpenerDialog: boolean;
setShowFileOpenerDialog: (open: boolean) => void;
fileOpenerTarget: { file: SftpFileEntry; side: "left" | "right"; fullPath: string } | null;
@@ -63,6 +65,8 @@ export const SftpOverlays: React.FC<SftpOverlaysProps> = ({
textEditorContent,
setTextEditorContent,
handleSaveTextFile,
editorWordWrap,
setEditorWordWrap,
showFileOpenerDialog,
setShowFileOpenerDialog,
fileOpenerTarget,
@@ -178,6 +182,8 @@ export const SftpOverlays: React.FC<SftpOverlaysProps> = ({
fileName={textEditorTarget?.file.name || ""}
initialContent={textEditorContent}
onSave={handleSaveTextFile}
editorWordWrap={editorWordWrap}
onToggleWordWrap={() => setEditorWordWrap(!editorWordWrap)}
/>
{/* File Opener Dialog */}

View File

@@ -67,11 +67,12 @@ export const useSftpKeyboardShortcuts = ({
// Skip if focus is on an input element
const target = e.target as HTMLElement;
if (
const isEditableTarget =
target.tagName === "INPUT" ||
target.tagName === "TEXTAREA" ||
target.isContentEditable
) {
target.isContentEditable ||
!!target.closest?.(".monaco-editor, .monaco-diff-editor, .monaco-inputbox");
if (isEditableTarget) {
return;
}

View File

@@ -2,7 +2,7 @@ import type { Terminal as XTerm } from "@xterm/xterm";
import { useCallback } from "react";
import type { RefObject } from "react";
import { logger } from "../../../lib/logger";
import { normalizeLineEndings } from "../../../lib/utils";
import { normalizeLineEndings, wrapBracketedPaste } from "../../../lib/utils";
type TerminalBackendWriteApi = {
writeToSession: (sessionId: string, data: string) => void;
@@ -33,7 +33,11 @@ export const useTerminalContextActions = ({
if (!term) return;
try {
const text = await navigator.clipboard.readText();
if (text && sessionRef.current) terminalBackend.writeToSession(sessionRef.current, normalizeLineEndings(text));
if (text && sessionRef.current) {
let data = normalizeLineEndings(text);
if (term.modes.bracketedPasteMode) data = wrapBracketedPaste(data);
terminalBackend.writeToSession(sessionRef.current, data);
}
} catch (err) {
logger.warn("Failed to paste from clipboard", err);
}

View File

@@ -18,7 +18,7 @@ import {
resolveXTermPerformanceConfig,
} from "../../../infrastructure/config/xtermPerformance";
import { logger } from "../../../lib/logger";
import { isMacPlatform, normalizeLineEndings } from "../../../lib/utils";
import { isMacPlatform, normalizeLineEndings, wrapBracketedPaste } from "../../../lib/utils";
import type {
Host,
KeyBinding,
@@ -407,7 +407,11 @@ export const createXTermRuntime = (ctx: CreateXTermRuntimeContext): XTermRuntime
case "paste": {
navigator.clipboard.readText().then((text) => {
const id = ctx.sessionRef.current;
if (id) ctx.terminalBackend.writeToSession(id, normalizeLineEndings(text));
if (id) {
let data = normalizeLineEndings(text);
if (term.modes.bracketedPasteMode) data = wrapBracketedPaste(data);
ctx.terminalBackend.writeToSession(id, data);
}
});
break;
}
@@ -439,7 +443,9 @@ export const createXTermRuntime = (ctx: CreateXTermRuntimeContext): XTermRuntime
try {
const text = await navigator.clipboard.readText();
if (text && ctx.sessionRef.current) {
ctx.terminalBackend.writeToSession(ctx.sessionRef.current, normalizeLineEndings(text));
let data = normalizeLineEndings(text);
if (term.modes.bracketedPasteMode) data = wrapBracketedPaste(data);
ctx.terminalBackend.writeToSession(ctx.sessionRef.current, data);
}
} catch (err) {
logger.warn("[Terminal] Failed to paste from clipboard:", err);

View File

@@ -0,0 +1,85 @@
/**
* Credential Bridge - Field-level encryption for sensitive data at rest
*
* Uses Electron's safeStorage API to encrypt individual sensitive fields
* (passwords, tokens, private keys) before they are persisted to localStorage.
*
* Sentinel prefix "enc:v1:" on encrypted values enables:
* - Detection of already-encrypted vs plaintext (migration)
* - No double-encryption
* - Future re-keying with enc:v2: etc.
*
* When safeStorage is unavailable (e.g. Linux without libsecret), all values
* pass through unmodified so the app still works.
*/
const ENC_PREFIX = "enc:v1:";
let safeStorage = null;
/**
* Register IPC handlers for credential encryption/decryption
* @param {Electron.IpcMain} ipcMain
* @param {typeof Electron} electronModule
*/
function registerHandlers(ipcMain, electronModule) {
safeStorage = electronModule?.safeStorage ?? null;
ipcMain.handle("netcatty:credentials:available", () => {
return Boolean(safeStorage?.isEncryptionAvailable?.());
});
ipcMain.handle("netcatty:credentials:encrypt", (_event, plaintext) => {
if (typeof plaintext !== "string" || plaintext.length === 0) {
return plaintext ?? "";
}
if (!safeStorage?.isEncryptionAvailable?.()) {
return plaintext;
}
// If value looks like it might already be encrypted, verify by attempting
// to decode and decrypt. If it succeeds the value is genuinely encrypted
// and we return it as-is; if it fails, the prefix was a coincidence and
// we proceed to encrypt the raw plaintext.
if (plaintext.startsWith(ENC_PREFIX)) {
try {
const base64 = plaintext.slice(ENC_PREFIX.length);
const buf = Buffer.from(base64, "base64");
safeStorage.decryptString(buf); // throws on invalid ciphertext
return plaintext; // verified — already encrypted
} catch {
// Not valid ciphertext — fall through to encrypt
}
}
try {
const encrypted = safeStorage.encryptString(plaintext);
return ENC_PREFIX + encrypted.toString("base64");
} catch (err) {
console.warn("[Credentials] encrypt failed, returning plaintext:", err?.message || err);
return plaintext;
}
});
ipcMain.handle("netcatty:credentials:decrypt", (_event, value) => {
if (typeof value !== "string" || value.length === 0) {
return value ?? "";
}
// Not encrypted — pass through (supports migration from plaintext)
if (!value.startsWith(ENC_PREFIX)) {
return value;
}
if (!safeStorage?.isEncryptionAvailable?.()) {
// Cannot decrypt without safeStorage; return raw value
return value;
}
try {
const base64 = value.slice(ENC_PREFIX.length);
const buf = Buffer.from(base64, "base64");
return safeStorage.decryptString(buf);
} catch (err) {
console.warn("[Credentials] decrypt failed:", err?.message || err);
return value;
}
});
}
module.exports = { registerHandlers };

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

@@ -7,6 +7,7 @@ const os = require("node:os");
const fs = require("node:fs");
const net = require("node:net");
const path = require("node:path");
const { StringDecoder } = require("node:string_decoder");
const pty = require("node-pty");
const { SerialPort } = require("serialport");
@@ -315,15 +316,30 @@ async function startTelnetSession(event, options) {
resolve({ sessionId });
});
const charsetToNodeEncoding = (charset) => {
if (!charset) return 'utf8';
const normalized = String(charset).trim().toLowerCase().replace(/[^a-z0-9]/g, '');
if (['utf8', 'utf-8'].includes(normalized)) return 'utf8';
if (['latin1', 'iso88591', 'iso-8859-1', 'binary'].includes(normalized)) return 'latin1';
if (normalized === 'ascii') return 'ascii';
if (['utf16le', 'ucs2'].includes(normalized)) return 'utf16le';
return 'utf8';
};
const telnetDecoder = new StringDecoder(charsetToNodeEncoding(options.charset));
socket.on('data', (data) => {
const session = sessions.get(sessionId);
if (!session) return;
const cleanData = handleTelnetNegotiation(data);
if (cleanData.length > 0) {
const contents = electronModule.webContents.fromId(session.webContentsId);
contents?.send("netcatty:data", { sessionId, data: cleanData.toString('binary') });
const decoded = telnetDecoder.write(cleanData);
if (decoded) {
const contents = electronModule.webContents.fromId(session.webContentsId);
contents?.send("netcatty:data", { sessionId, data: decoded });
}
}
});
@@ -513,9 +529,14 @@ async function startSerialSession(event, options) {
};
sessions.set(sessionId, session);
const serialDecoder = new StringDecoder('latin1');
serialPort.on('data', (data) => {
const contents = electronModule.webContents.fromId(session.webContentsId);
contents?.send("netcatty:data", { sessionId, data: data.toString('binary') });
const decoded = serialDecoder.write(data);
if (decoded) {
const contents = electronModule.webContents.fromId(session.webContentsId);
contents?.send("netcatty:data", { sessionId, data: decoded });
}
});
serialPort.on('error', (err) => {

View File

@@ -167,8 +167,8 @@ function getWindowBoundsState(win, overrideBounds) {
}
const MENU_LABELS = {
en: { edit: "Edit", view: "View", window: "Window" },
"zh-CN": { edit: "编辑", view: "视图", window: "窗口" },
en: { edit: "Edit", view: "View", window: "Window", reload: "Reload" },
"zh-CN": { edit: "编辑", view: "视图", window: "窗口", reload: "重新加载" },
};
function tMenu(language, key) {
@@ -1079,7 +1079,7 @@ function buildAppMenu(Menu, app, isMac, language = currentLanguage) {
{
label: tMenu(language, "view"),
submenu: [
{ role: "reload" },
{ label: tMenu(language, "reload"), click: (_, win) => { if (win) win.reload(); } },
{ role: "forceReload" },
{ role: "toggleDevTools" },
{ type: "separator" },

View File

@@ -36,7 +36,7 @@ try {
electronModule = require("electron");
}
const { app, BrowserWindow, Menu, protocol, shell } = electronModule || {};
const { app, BrowserWindow, Menu, protocol, shell, clipboard } = electronModule || {};
if (!app || !BrowserWindow) {
throw new Error("Failed to load Electron runtime. Ensure the app is launched with the Electron binary.");
}
@@ -81,6 +81,7 @@ const tempDirBridge = require("./bridges/tempDirBridge.cjs");
const sessionLogsBridge = require("./bridges/sessionLogsBridge.cjs");
const compressUploadBridge = require("./bridges/compressUploadBridge.cjs");
const globalShortcutBridge = require("./bridges/globalShortcutBridge.cjs");
const credentialBridge = require("./bridges/credentialBridge.cjs");
const windowManager = require("./bridges/windowManager.cjs");
// GPU settings
@@ -241,6 +242,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 {}
@@ -393,6 +403,7 @@ const registerBridges = (win) => {
sessionLogsBridge.registerHandlers(ipcMain);
compressUploadBridge.registerHandlers(ipcMain);
globalShortcutBridge.registerHandlers(ipcMain);
credentialBridge.registerHandlers(ipcMain, electronModule);
// Settings window handler
ipcMain.handle("netcatty:settings:open", async () => {
@@ -453,6 +464,15 @@ const registerBridges = (win) => {
};
});
// Clipboard helpers for renderer fallback paths (e.g. Monaco paste in Electron)
ipcMain.handle("netcatty:clipboard:readText", async () => {
try {
return clipboard?.readText?.() || "";
} catch {
return "";
}
});
// Select an application from system file picker
ipcMain.handle("netcatty:selectApplication", async () => {
const { dialog } = electronModule;
@@ -676,100 +696,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

@@ -312,6 +312,13 @@ ipcRenderer.on("netcatty:filewatch:error", (_event, payload) => {
});
});
// Buffer the latest tray menu data so it can be replayed when the React
// component subscribes after lazy-mount (avoiding the first-open race).
let _lastTrayMenuData = null;
ipcRenderer.on("netcatty:trayPanel:setMenuData", (_event, data) => {
_lastTrayMenuData = data;
});
const api = {
startSSHSession: async (options) => {
const result = await ipcRenderer.invoke("netcatty:start", options);
@@ -794,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) =>
@@ -811,7 +819,15 @@ const api = {
},
onTrayPanelMenuData: (callback) => {
const handler = (_event, data) => callback(data);
// Replay buffered data so late subscribers (e.g. after React lazy-mount) don't miss
// the initial payload that was sent before the useEffect listener was registered.
if (_lastTrayMenuData) {
queueMicrotask(() => callback(_lastTrayMenuData));
}
const handler = (_event, data) => {
_lastTrayMenuData = data;
callback(data);
};
ipcRenderer.on("netcatty:trayPanel:setMenuData", handler);
return () => ipcRenderer.removeListener("netcatty:trayPanel:setMenuData", handler);
},
@@ -824,6 +840,16 @@ const api = {
return undefined;
}
},
// Clipboard fallback helpers
readClipboardText: async () => {
return ipcRenderer.invoke("netcatty:clipboard:readText");
},
// Credential encryption (field-level safeStorage)
credentialsAvailable: () => ipcRenderer.invoke("netcatty:credentials:available"),
credentialsEncrypt: (plaintext) => ipcRenderer.invoke("netcatty:credentials:encrypt", plaintext),
credentialsDecrypt: (value) => ipcRenderer.invoke("netcatty:credentials:decrypt", value),
};
// Merge with existing netcatty (if any) to avoid stale objects on hot reload

7
global.d.ts vendored
View File

@@ -580,6 +580,12 @@ declare global {
// Get file path from File object (for drag-and-drop, uses Electron's webUtils)
getPathForFile?(file: File): string | undefined;
readClipboardText?(): Promise<string>;
// Credential encryption (field-level safeStorage for sensitive data at rest)
credentialsAvailable?(): Promise<boolean>;
credentialsEncrypt?(plaintext: string): Promise<string>;
credentialsDecrypt?(value: string): Promise<string>;
// Global Toggle Hotkey (Quake Mode)
registerGlobalHotkey?(hotkey: string): Promise<{ success: boolean; enabled?: boolean; error?: string; accelerator?: string }>;
@@ -609,6 +615,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

@@ -47,6 +47,9 @@ export const STORAGE_KEY_SFTP_AUTO_SYNC = 'netcatty_sftp_auto_sync_v1';
export const STORAGE_KEY_SFTP_SHOW_HIDDEN_FILES = 'netcatty_sftp_show_hidden_files_v1';
export const STORAGE_KEY_SFTP_USE_COMPRESSED_UPLOAD = 'netcatty_sftp_use_compressed_upload_v1';
// Editor Settings
export const STORAGE_KEY_EDITOR_WORD_WRAP = 'netcatty_editor_word_wrap_v1';
// Session Logs Settings
export const STORAGE_KEY_SESSION_LOGS_ENABLED = 'netcatty_session_logs_enabled_v1';
export const STORAGE_KEY_SESSION_LOGS_DIR = 'netcatty_session_logs_dir_v1';

View File

@@ -0,0 +1,184 @@
/**
* Secure Field Adapter — Renderer-side helpers for field-level encryption
*
* Encrypts / decrypts individual sensitive fields within domain models before
* they are written to (or after they are read from) localStorage.
*
* The heavy lifting is done by Electron's safeStorage via the credential
* bridge IPC. When the bridge is unavailable (web fallback, tests) every
* function degrades to a no-op — values pass through unmodified.
*/
import type { Host, Identity, SSHKey } from "../../domain/models";
import type { ProviderConnection, S3Config, WebDAVConfig } from "../../domain/sync";
import { netcattyBridge } from "../services/netcattyBridge";
// ---------------------------------------------------------------------------
// Primitive helpers
// ---------------------------------------------------------------------------
const bridge = () => netcattyBridge.get();
export async function encryptField(value: string | undefined): Promise<string | undefined> {
if (!value) return value;
const b = bridge();
if (!b?.credentialsEncrypt) return value;
return b.credentialsEncrypt(value);
}
export async function decryptField(value: string | undefined): Promise<string | undefined> {
if (!value) return value;
const b = bridge();
if (!b?.credentialsDecrypt) return value;
return b.credentialsDecrypt(value);
}
// ---------------------------------------------------------------------------
// Host
// ---------------------------------------------------------------------------
export async function encryptHostSecrets(host: Host): Promise<Host> {
const out = { ...host };
out.password = await encryptField(out.password);
out.telnetPassword = await encryptField(out.telnetPassword);
if (out.proxyConfig?.password) {
out.proxyConfig = { ...out.proxyConfig, password: await encryptField(out.proxyConfig.password) };
}
return out;
}
export async function decryptHostSecrets(host: Host): Promise<Host> {
const out = { ...host };
out.password = await decryptField(out.password);
out.telnetPassword = await decryptField(out.telnetPassword);
if (out.proxyConfig?.password) {
out.proxyConfig = { ...out.proxyConfig, password: await decryptField(out.proxyConfig.password) };
}
return out;
}
// ---------------------------------------------------------------------------
// SSHKey
// ---------------------------------------------------------------------------
export async function encryptKeySecrets(key: SSHKey): Promise<SSHKey> {
const out = { ...key };
out.passphrase = await encryptField(out.passphrase);
out.privateKey = (await encryptField(out.privateKey)) ?? "";
return out;
}
export async function decryptKeySecrets(key: SSHKey): Promise<SSHKey> {
const out = { ...key };
out.passphrase = await decryptField(out.passphrase);
out.privateKey = (await decryptField(out.privateKey)) ?? "";
return out;
}
// ---------------------------------------------------------------------------
// Identity
// ---------------------------------------------------------------------------
export async function encryptIdentitySecrets(identity: Identity): Promise<Identity> {
const out = { ...identity };
out.password = await encryptField(out.password);
return out;
}
export async function decryptIdentitySecrets(identity: Identity): Promise<Identity> {
const out = { ...identity };
out.password = await decryptField(out.password);
return out;
}
// ---------------------------------------------------------------------------
// Provider Connection (Cloud Sync)
// ---------------------------------------------------------------------------
export async function encryptProviderSecrets(conn: ProviderConnection): Promise<ProviderConnection> {
const out = { ...conn };
if (out.tokens) {
const t = { ...out.tokens };
t.accessToken = (await encryptField(t.accessToken)) ?? "";
t.refreshToken = await encryptField(t.refreshToken);
out.tokens = t;
}
if (out.config) {
// WebDAV — use authType (required field unique to WebDAVConfig) as discriminator
// so that token-auth configs (which may lack a password key after JSON round-trip)
// still get their token field encrypted.
if ("authType" in out.config) {
const c = { ...out.config } as WebDAVConfig;
c.password = await encryptField(c.password);
c.token = await encryptField(c.token);
out.config = c;
}
// S3
if ("secretAccessKey" in out.config) {
const c = { ...out.config } as S3Config;
c.secretAccessKey = (await encryptField(c.secretAccessKey)) ?? "";
c.sessionToken = await encryptField(c.sessionToken);
out.config = c;
}
}
return out;
}
export async function decryptProviderSecrets(conn: ProviderConnection): Promise<ProviderConnection> {
const out = { ...conn };
if (out.tokens) {
const t = { ...out.tokens };
t.accessToken = (await decryptField(t.accessToken)) ?? "";
t.refreshToken = await decryptField(t.refreshToken);
out.tokens = t;
}
if (out.config) {
if ("authType" in out.config) {
const c = { ...out.config } as WebDAVConfig;
c.password = await decryptField(c.password);
c.token = await decryptField(c.token);
out.config = c;
}
if ("secretAccessKey" in out.config) {
const c = { ...out.config } as S3Config;
c.secretAccessKey = (await decryptField(c.secretAccessKey)) ?? "";
c.sessionToken = await decryptField(c.sessionToken);
out.config = c;
}
}
return out;
}
// ---------------------------------------------------------------------------
// Batch helpers
// ---------------------------------------------------------------------------
export function encryptHosts(hosts: Host[]): Promise<Host[]> {
return Promise.all(hosts.map(encryptHostSecrets));
}
export function decryptHosts(hosts: Host[]): Promise<Host[]> {
return Promise.all(hosts.map(decryptHostSecrets));
}
export function encryptKeys(keys: SSHKey[]): Promise<SSHKey[]> {
return Promise.all(keys.map(encryptKeySecrets));
}
export function decryptKeys(keys: SSHKey[]): Promise<SSHKey[]> {
return Promise.all(keys.map(decryptKeySecrets));
}
export function encryptIdentities(identities: Identity[]): Promise<Identity[]> {
return Promise.all(identities.map(encryptIdentitySecrets));
}
export function decryptIdentities(identities: Identity[]): Promise<Identity[]> {
return Promise.all(identities.map(decryptIdentitySecrets));
}

View File

@@ -38,6 +38,10 @@ import { createAdapter, type CloudAdapter } from './adapters';
import type { GitHubAdapter } from './adapters/GitHubAdapter';
import type { GoogleDriveAdapter } from './adapters/GoogleDriveAdapter';
import type { OneDriveAdapter } from './adapters/OneDriveAdapter';
import {
decryptProviderSecrets,
encryptProviderSecrets,
} from '../persistence/secureFieldAdapter';
const SYNC_HISTORY_STORAGE_KEY = 'netcatty_sync_history_v1';
@@ -79,11 +83,25 @@ export class CloudSyncManager {
private autoSyncTimer: ReturnType<typeof setInterval> | null = null;
private masterPassword: string | null = null; // In memory only!
private hasStorageListener = false;
// Per-provider sequence counters for async decrypt callbacks (startup,
// cross-window storage events). Bumped by any state mutation so stale
// decrypt results are discarded.
private providerDecryptSeq: Record<CloudProvider, number> = {
github: 0, google: 0, onedrive: 0, webdav: 0, s3: 0,
};
// Per-provider write sequence counters for saveProviderConnection.
// Only bumped when a new save is initiated, so status-only updates
// (which don't persist) cannot discard an in-flight encrypted write.
private providerWriteSeq: Record<CloudProvider, number> = {
github: 0, google: 0, onedrive: 0, webdav: 0, s3: 0,
};
constructor() {
this.state = this.loadInitialState();
this.stateSnapshot = { ...this.state };
this.setupCrossWindowSync();
// Decrypt provider secrets asynchronously after initial load
this.initProviderDecryption();
}
// ==========================================================================
@@ -167,11 +185,41 @@ export class CloudSyncManager {
} as ProviderConnection;
}
private saveProviderConnection(provider: CloudProvider, connection: ProviderConnection): void {
/**
* Asynchronously decrypt provider connection secrets after initial load.
* Runs once at construction; decrypted tokens replace the encrypted ones
* in-memory so adapters can use them.
*/
private async initProviderDecryption(): Promise<void> {
const providers: CloudProvider[] = ['github', 'google', 'onedrive', 'webdav', 's3'];
for (const p of providers) {
try {
const conn = this.state.providers[p];
if (conn.tokens || conn.config) {
const seq = ++this.providerDecryptSeq[p];
const decrypted = await decryptProviderSecrets(conn);
// Only apply if no newer update has occurred during the async gap
if (seq === this.providerDecryptSeq[p]) {
this.state.providers[p] = decrypted;
}
}
} catch {
// Decryption failure is non-fatal; the adapter will fail on use
}
}
this.notifyStateChange();
}
private async saveProviderConnection(provider: CloudProvider, connection: ProviderConnection): Promise<void> {
const key = SYNC_STORAGE_KEYS[`PROVIDER_${provider.toUpperCase()}` as keyof typeof SYNC_STORAGE_KEYS];
// Don't persist sensitive tokens directly - use safeStorage in production
const { tokens, ...safeData } = connection;
this.saveToStorage(key, { ...safeData, tokens }); // In production, encrypt tokens/config
// Use write-specific counter so status-only updates cannot discard
// an in-flight encrypted write that must be persisted.
const seq = ++this.providerWriteSeq[provider];
const encrypted = await encryptProviderSecrets(connection);
// Only persist if no newer save has started during the async gap
if (seq === this.providerWriteSeq[provider]) {
this.saveToStorage(key, encrypted);
}
}
private loadFromStorage<T>(key: string): T | null {
@@ -292,48 +340,61 @@ export class CloudSyncManager {
};
const provider = providerByKey[key];
if (provider) {
const prev = this.state.providers[provider];
const next = this.loadProviderConnection(provider);
const rawNext = this.loadProviderConnection(provider);
const seq = ++this.providerDecryptSeq[provider];
// Also bump write seq so any in-flight save from this window for the
// same provider is discarded — the cross-window data is newer.
++this.providerWriteSeq[provider];
const preserveTransientStatus =
prev.status === 'connecting' || prev.status === 'syncing';
// Decrypt secrets asynchronously, then update state.
// Use sequence counter to discard stale results when multiple events
// for the same provider arrive in quick succession.
decryptProviderSecrets(rawNext).then((next) => {
if (seq !== this.providerDecryptSeq[provider]) return; // stale — discard
this.state.providers[provider] = {
...next,
status: preserveTransientStatus ? prev.status : next.status,
error: preserveTransientStatus ? prev.error : next.error,
};
const prev = this.state.providers[provider];
const preserveTransientStatus =
prev.status === 'connecting' || prev.status === 'syncing';
const nextTokens = next.tokens;
const nextConfig = next.config;
const adapter = this.adapters.get(provider);
if (!nextTokens && !nextConfig) {
if (adapter) {
this.state.providers[provider] = {
...next,
status: preserveTransientStatus ? prev.status : next.status,
error: preserveTransientStatus ? prev.error : next.error,
};
const nextTokens = next.tokens;
const nextConfig = next.config;
const adapter = this.adapters.get(provider);
if (!nextTokens && !nextConfig) {
if (adapter) {
adapter.signOut();
this.adapters.delete(provider);
}
this.notifyStateChange();
return;
}
const tokenChanged =
(prev.tokens?.accessToken || null) !== (nextTokens?.accessToken || null) ||
(prev.tokens?.refreshToken || null) !== (nextTokens?.refreshToken || null) ||
(prev.tokens?.expiresAt || null) !== (nextTokens?.expiresAt || null) ||
(prev.tokens?.tokenType || null) !== (nextTokens?.tokenType || null) ||
(prev.tokens?.scope || null) !== (nextTokens?.scope || null);
const configChanged =
JSON.stringify(prev.config || null) !== JSON.stringify(nextConfig || null);
const resourceChanged = (adapter?.resourceId || null) !== (next.resourceId || null);
if (adapter && (tokenChanged || configChanged || resourceChanged)) {
adapter.signOut();
this.adapters.delete(provider);
}
this.notifyStateChange();
return;
}
const tokenChanged =
(prev.tokens?.accessToken || null) !== (nextTokens?.accessToken || null) ||
(prev.tokens?.refreshToken || null) !== (nextTokens?.refreshToken || null) ||
(prev.tokens?.expiresAt || null) !== (nextTokens?.expiresAt || null) ||
(prev.tokens?.tokenType || null) !== (nextTokens?.tokenType || null) ||
(prev.tokens?.scope || null) !== (nextTokens?.scope || null);
const configChanged =
JSON.stringify(prev.config || null) !== JSON.stringify(nextConfig || null);
const resourceChanged = (adapter?.resourceId || null) !== (next.resourceId || null);
if (adapter && (tokenChanged || configChanged || resourceChanged)) {
adapter.signOut();
this.adapters.delete(provider);
}
this.notifyStateChange();
}).catch(() => {
// Decryption failure in cross-window handler is non-fatal
});
}
};
@@ -637,6 +698,7 @@ export class CloudSyncManager {
try {
const tokens = await ghAdapter.completeAuth(deviceCode, interval, expiresAt, onPending);
++this.providerDecryptSeq.github;
this.state.providers.github = {
...this.state.providers.github,
status: 'connected',
@@ -650,7 +712,7 @@ export class CloudSyncManager {
this.state.providers.github.resourceId = resourceId;
}
this.saveProviderConnection('github', this.state.providers.github);
await this.saveProviderConnection('github', this.state.providers.github);
this.emit({
type: 'AUTH_COMPLETED',
provider: 'github',
@@ -689,6 +751,7 @@ export class CloudSyncManager {
account = odAdapter.accountInfo;
}
++this.providerDecryptSeq[provider];
this.state.providers[provider] = {
...this.state.providers[provider],
status: 'connected',
@@ -702,7 +765,7 @@ export class CloudSyncManager {
this.state.providers[provider].resourceId = resourceId;
}
this.saveProviderConnection(provider, this.state.providers[provider]);
await this.saveProviderConnection(provider, this.state.providers[provider]);
this.emit({
type: 'AUTH_COMPLETED',
provider,
@@ -729,6 +792,7 @@ export class CloudSyncManager {
const resourceId = await adapter.initializeSync();
const account = adapter.accountInfo || this.buildAccountFromConfig(provider, config);
++this.providerDecryptSeq[provider];
this.state.providers[provider] = {
provider,
status: 'connected',
@@ -737,7 +801,7 @@ export class CloudSyncManager {
resourceId: resourceId || undefined,
};
this.saveProviderConnection(provider, this.state.providers[provider]);
await this.saveProviderConnection(provider, this.state.providers[provider]);
this.emit({
type: 'AUTH_COMPLETED',
provider,
@@ -759,12 +823,13 @@ export class CloudSyncManager {
this.adapters.delete(provider);
}
++this.providerDecryptSeq[provider];
this.state.providers[provider] = {
provider,
status: 'disconnected',
};
this.saveProviderConnection(provider, this.state.providers[provider]);
await this.saveProviderConnection(provider, this.state.providers[provider]);
this.notifyStateChange(); // Ensure UI updates immediately after disconnect
}
@@ -773,6 +838,8 @@ export class CloudSyncManager {
status: ProviderConnection['status'],
error?: string
): void {
// Bump sequence to invalidate any in-flight async decrypt for this provider
++this.providerDecryptSeq[provider];
this.state.providers[provider] = {
...this.state.providers[provider],
status,
@@ -842,11 +909,14 @@ export class CloudSyncManager {
this.state.localUpdatedAt = syncedFile.meta.updatedAt;
this.state.remoteVersion = syncedFile.meta.version;
this.state.remoteUpdatedAt = syncedFile.meta.updatedAt;
// Invalidate any pending provider decrypt so it cannot overwrite
// the lastSync/lastSyncVersion we are about to set.
++this.providerDecryptSeq[provider];
this.state.providers[provider].lastSync = Date.now();
this.state.providers[provider].lastSyncVersion = syncedFile.meta.version;
this.saveSyncConfig();
this.saveProviderConnection(provider, this.state.providers[provider]);
await this.saveProviderConnection(provider, this.state.providers[provider]);
this.notifyStateChange();
// Add to sync history

View File

@@ -14,6 +14,16 @@ export function normalizeLineEndings(text: string): string {
return text.replace(/\r\n/g, "\n").replace(/\r/g, "\n");
}
/**
* Wrap text in bracketed paste escape sequences.
* When a terminal application enables bracketed paste mode (CSI ?2004h),
* pasted text should be wrapped so the application can distinguish paste
* from typed input (e.g. vim disables autoindent during paste).
*/
export function wrapBracketedPaste(text: string): string {
return `\x1b[200~${text}\x1b[201~`;
}
/**
* Detect if the current platform is macOS.
* Used for keyboard shortcut handling to differentiate between Mac and PC shortcuts.

93
package-lock.json generated
View File

@@ -955,13 +955,13 @@
}
},
"node_modules/@aws-sdk/xml-builder": {
"version": "3.972.2",
"resolved": "https://registry.npmjs.org/@aws-sdk/xml-builder/-/xml-builder-3.972.2.tgz",
"integrity": "sha512-jGOOV/bV1DhkkUhHiZ3/1GZ67cZyOXaDb7d1rYD6ZiXf5V9tBNOcgqXwRRPvrCbYaFRa1pPMFb3ZjqjWpR3YfA==",
"version": "3.972.4",
"resolved": "https://registry.npmjs.org/@aws-sdk/xml-builder/-/xml-builder-3.972.4.tgz",
"integrity": "sha512-0zJ05ANfYqI6+rGqj8samZBFod0dPPousBjLEqg8WdxSgbMAkRgLyn81lP215Do0rFJ/17LIXwr7q0yK24mP6Q==",
"license": "Apache-2.0",
"dependencies": {
"@smithy/types": "^4.12.0",
"fast-xml-parser": "5.2.5",
"fast-xml-parser": "5.3.4",
"tslib": "^2.6.2"
},
"engines": {
@@ -1008,6 +1008,7 @@
"integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/code-frame": "^7.29.0",
"@babel/generator": "^7.29.0",
@@ -1653,7 +1654,6 @@
"dev": true,
"license": "BSD-2-Clause",
"optional": true,
"peer": true,
"dependencies": {
"cross-dirname": "^0.1.0",
"debug": "^4.3.4",
@@ -1675,7 +1675,6 @@
"dev": true,
"license": "MIT",
"optional": true,
"peer": true,
"dependencies": {
"graceful-fs": "^4.2.0",
"jsonfile": "^6.0.1",
@@ -1692,7 +1691,6 @@
"dev": true,
"license": "MIT",
"optional": true,
"peer": true,
"dependencies": {
"universalify": "^2.0.0"
},
@@ -1707,7 +1705,6 @@
"dev": true,
"license": "MIT",
"optional": true,
"peer": true,
"engines": {
"node": ">= 10.0.0"
}
@@ -2544,9 +2541,9 @@
}
},
"node_modules/@isaacs/brace-expansion": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/@isaacs/brace-expansion/-/brace-expansion-5.0.0.tgz",
"integrity": "sha512-ZT55BDLV0yv0RBm2czMiZ+SqCGO7AvmOM3G/w2xhVPH+te0aKgFjmBvGlL1dH+ql2tgGO3MVrbb3jCKyvpgnxA==",
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/@isaacs/brace-expansion/-/brace-expansion-5.0.1.tgz",
"integrity": "sha512-WMz71T1JS624nWj2n2fnYAuPovhv7EUhk69R6i9dsVyzxt5eM3bjwvgk9L+APE1TRscGysAVMANkB0jh0LQZrQ==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -5673,6 +5670,7 @@
"integrity": "sha512-hAAP5io/7csFStuOmR782YmTthKBJ9ND3WVL60hcOjvtGFb+HJxH4O5huAcmcZ9v9G8P+JETiZ/G1B8MALnWZQ==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@eslint-community/regexpp": "^4.12.2",
"@typescript-eslint/scope-manager": "8.54.0",
@@ -5702,6 +5700,7 @@
"integrity": "sha512-BtE0k6cjwjLZoZixN0t5AKP0kSzlGu7FctRXYuPAm//aaiZhmfq1JwdYpYr1brzEspYyFeF+8XF5j2VK6oalrA==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@typescript-eslint/scope-manager": "8.54.0",
"@typescript-eslint/types": "8.54.0",
@@ -5980,7 +5979,8 @@
"version": "5.5.0",
"resolved": "https://registry.npmjs.org/@xterm/xterm/-/xterm-5.5.0.tgz",
"integrity": "sha512-hqJHYaQb5OptNunnyAnkHyM8aCjZ1MEIDTQu1iIbbTD/xops91NB5yq1ZK/dC2JDbVWtF23zUtl9JE2NqwT87A==",
"license": "MIT"
"license": "MIT",
"peer": true
},
"node_modules/@yarnpkg/lockfile": {
"version": "1.1.0",
@@ -6012,6 +6012,7 @@
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
"dev": true,
"license": "MIT",
"peer": true,
"bin": {
"acorn": "bin/acorn"
},
@@ -6044,6 +6045,7 @@
"integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"fast-deep-equal": "^3.1.1",
"fast-json-stable-stringify": "^2.0.0",
@@ -6516,6 +6518,7 @@
}
],
"license": "MIT",
"peer": true,
"dependencies": {
"baseline-browser-mapping": "^2.9.0",
"caniuse-lite": "^1.0.30001759",
@@ -7123,8 +7126,7 @@
"integrity": "sha512-+R08/oI0nl3vfPcqftZRpytksBXDzOUveBq/NBVx0sUp1axwzPQrKinNx5yd5sxPu8j1wIy8AfnVQ+5eFdha6Q==",
"dev": true,
"license": "MIT",
"optional": true,
"peer": true
"optional": true
},
"node_modules/cross-env": {
"version": "10.1.0",
@@ -7364,6 +7366,7 @@
"integrity": "sha512-uOOBA3f+kW3o4KpSoMQ6SNpdXU7WtxlJRb9vCZgOvqhTz4b3GjcoWKstdisizNZLsylhTMv8TLHFPFW0Uxsj/g==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"app-builder-lib": "26.7.0",
"builder-util": "26.4.1",
@@ -7689,7 +7692,6 @@
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@electron/asar": "^3.2.1",
"debug": "^4.1.1",
@@ -7710,7 +7712,6 @@
"integrity": "sha512-YJDaCJZEnBmcbw13fvdAM9AwNOJwOzrE4pqMqBq5nFiEqXUqHwlK4B+3pUw6JNvfSPtX05xFHtYy/1ni01eGCw==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"graceful-fs": "^4.1.2",
"jsonfile": "^4.0.0",
@@ -7928,6 +7929,7 @@
"integrity": "sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@eslint-community/eslint-utils": "^4.8.0",
"@eslint-community/regexpp": "^4.12.1",
@@ -8239,9 +8241,9 @@
"license": "MIT"
},
"node_modules/fast-xml-parser": {
"version": "5.2.5",
"resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-5.2.5.tgz",
"integrity": "sha512-pfX9uG9Ki0yekDHx2SiuRIyFdyAr1kMIMitPvb0YBo8SUfKvia7w7FIyd/l6av85pFYRhZscS75MwMnbvY+hcQ==",
"version": "5.3.4",
"resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-5.3.4.tgz",
"integrity": "sha512-EFd6afGmXlCx8H8WTZHhAoDaWaGyuIBoZJ2mknrNxug+aZKjkp0a0dlars9Izl+jF+7Gu1/5f/2h68cQpe0IiA==",
"funding": [
{
"type": "github",
@@ -10167,7 +10169,6 @@
"integrity": "sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"minimist": "^1.2.6"
},
@@ -10180,6 +10181,7 @@
"resolved": "https://registry.npmjs.org/monaco-editor/-/monaco-editor-0.55.1.tgz",
"integrity": "sha512-jz4x+TJNFHwHtwuV9vA9rMujcZRb0CEilTEwG2rRSpe/A7Jdkuj8xPKttCgOh+v/lkHy7HsZ64oj+q3xoAFl9A==",
"license": "MIT",
"peer": true,
"dependencies": {
"dompurify": "3.2.7",
"marked": "14.0.0"
@@ -10826,7 +10828,6 @@
"dev": true,
"license": "MIT",
"optional": true,
"peer": true,
"dependencies": {
"commander": "^9.4.0"
},
@@ -10844,7 +10845,6 @@
"dev": true,
"license": "MIT",
"optional": true,
"peer": true,
"engines": {
"node": "^12.20.0 || >=14"
}
@@ -10957,6 +10957,7 @@
"resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz",
"integrity": "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=0.10.0"
}
@@ -10966,6 +10967,7 @@
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.4.tgz",
"integrity": "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"scheduler": "^0.27.0"
},
@@ -11833,7 +11835,6 @@
"integrity": "sha512-yYrrsWnrXMcdsnu/7YMYAofM1ktpL5By7vZhf15CrXijWWrEYZks5AXBudalfSWJLlnen/QUJUB5aoB0kqZUGA==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"mkdirp": "^0.5.1",
"rimraf": "~2.6.2"
@@ -11898,7 +11899,6 @@
"deprecated": "Rimraf versions prior to v4 are no longer supported",
"dev": true,
"license": "ISC",
"peer": true,
"dependencies": {
"glob": "^7.1.3"
},
@@ -11967,6 +11967,7 @@
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"dev": true,
"license": "MIT",
"peer": true,
"engines": {
"node": ">=12"
},
@@ -12091,6 +12092,7 @@
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"dev": true,
"license": "Apache-2.0",
"peer": true,
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
@@ -12293,6 +12295,7 @@
"integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"esbuild": "^0.27.0",
"fdir": "^6.5.0",
@@ -12386,6 +12389,7 @@
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"dev": true,
"license": "MIT",
"peer": true,
"engines": {
"node": ">=12"
},
@@ -12433,16 +12437,16 @@
}
},
"node_modules/webdav": {
"version": "5.8.0",
"resolved": "https://registry.npmjs.org/webdav/-/webdav-5.8.0.tgz",
"integrity": "sha512-iuFG7NamJ41Oshg4930iQgfIpRrUiatPWIekeznYgEf2EOraTRcDPTjy7gIOMtkdpKTaqPk1E68NO5PAGtJahA==",
"version": "5.9.0",
"resolved": "https://registry.npmjs.org/webdav/-/webdav-5.9.0.tgz",
"integrity": "sha512-OMJ6wtK1WvCO++aOLoQgE96S8KT4e5aaClWHmHXfFU369r4eyELN569B7EqT4OOUb99mmO58GkyuiCv/Ag6J0Q==",
"license": "MIT",
"dependencies": {
"@buttercup/fetch": "^0.2.1",
"base-64": "^1.0.0",
"byte-length": "^1.0.2",
"entities": "^6.0.0",
"fast-xml-parser": "^4.5.1",
"entities": "^6.0.1",
"fast-xml-parser": "^5.3.4",
"hot-patcher": "^2.0.1",
"layerr": "^3.0.0",
"md5": "^2.3.0",
@@ -12457,36 +12461,6 @@
"node": ">=14"
}
},
"node_modules/webdav/node_modules/fast-xml-parser": {
"version": "4.5.3",
"resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-4.5.3.tgz",
"integrity": "sha512-RKihhV+SHsIUGXObeVy9AXiBbFwkVk7Syp8XgwN5U3JV416+Gwp/GO9i0JYKmikykgz/UHRrrV4ROuZEo/T0ig==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/NaturalIntelligence"
}
],
"license": "MIT",
"dependencies": {
"strnum": "^1.1.1"
},
"bin": {
"fxparser": "src/cli/cli.js"
}
},
"node_modules/webdav/node_modules/strnum": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/strnum/-/strnum-1.1.2.tgz",
"integrity": "sha512-vrN+B7DBIoTTZjnPNewwhx6cBA/H+IS7rfW68n7XxC1y7uoiGQBxaKzqucGUgavX15dJgiGztLJ8vxuEzwqBdA==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/NaturalIntelligence"
}
],
"license": "MIT"
},
"node_modules/which": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
@@ -12678,6 +12652,7 @@
"integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==",
"dev": true,
"license": "MIT",
"peer": true,
"funding": {
"url": "https://github.com/sponsors/colinhacks"
}

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