Compare commits

..

48 Commits

Author SHA1 Message Date
陈大猫
e50a087a07 Merge pull request #272 from binaricat/feat/issue-261-terminal-encoding-switcher
Some checks failed
build-packages / build-macos (push) Has been cancelled
build-packages / build-windows (push) Has been cancelled
build-packages / build-linux-x64 (push) Has been cancelled
build-packages / build-linux-arm64 (push) Has been cancelled
build-packages / release (push) Has been cancelled
feat: add terminal encoding switcher for SSH sessions (#261)
2026-03-05 02:23:31 +08:00
bincxz
5839c00b67 fix: validate SSH session type and exclude localhost from encoding UI
- Check session.stream in setSessionEncoding to reject non-SSH sessions
  that share the sessions map (local/telnet/serial)
- Add hostname !== 'localhost' guard to isSSHSession in toolbar and
  onSessionAttached, since localhost routes through startLocal

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 02:17:59 +08:00
bincxz
f5cb590e0c fix: reject encoding updates for inactive SSH sessions
Check that sessionId exists in the sessions map before writing to
sessionEncodings/sessionDecoders, preventing stale map entries and
misleading ok:true responses for disconnected sessions.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 02:11:03 +08:00
bincxz
237b4404dc fix: sync encoding before first data chunk arrives
Move encoding sync from updateStatus("connected") to a new
onSessionAttached callback in attachSessionToTerminal, which fires
right after sessionRef is set but before the data listener is
registered. This ensures the first data chunk is decoded correctly
even if the user changed encoding during connection.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 02:03:27 +08:00
bincxz
1c10076866 fix: revert localhost guard and scope encoding sync to SSH sessions
- Remove hostname==='localhost' check since SSH to localhost is valid
  and local protocol sessions are already filtered by isLocalTerminal
- Restrict updateStatus encoding sync to SSH sessions only, preventing
  stale decoder entries from accumulating for non-SSH session types

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 01:54:24 +08:00
bincxz
eb80b8f60c fix: always sync encoding on connect and hide for localhost sessions
- Remove utf-8 guard from connect-time sync so GB-preseeded hosts that
  get switched to UTF-8 during connect are synced correctly
- Exclude hostname==='localhost' sessions from encoding popover since
  they route through startLocal, not the SSH bridge

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 01:46:47 +08:00
bincxz
f38515d383 fix: sync encoding to backend when session connects
If the user changes encoding while still connecting, sessionRef is null
so the IPC call is skipped. Now updateStatus syncs the encoding to the
backend when status transitions to 'connected' and encoding is non-default.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 01:35:42 +08:00
bincxz
64a1b8de3e fix: exclude Mosh sessions from encoding switcher
Mosh sessions keep host.protocol as 'ssh' but set host.moshEnabled,
so also gate encoding popover on !host?.moshEnabled.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 01:29:36 +08:00
bincxz
c1eb19a739 fix: use stateful iconv decoder and restrict encoding to SSH sessions
- Replace per-chunk iconv.decode() with stateful iconv.getDecoder() to
  handle multibyte characters split across packet boundaries (P1)
- Reset decoders when encoding is switched mid-session
- Gate encoding popover to SSH sessions only, excluding Telnet/Mosh (P2)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 01:23:45 +08:00
bincxz
7342b4a872 feat: add terminal encoding switcher for SSH sessions (#261)
Allow users to switch between UTF-8 and GB18030 encoding mid-session
via a toolbar popover, fixing garbled output when viewing mixed-encoding
logs on remote servers.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 01:17:05 +08:00
陈大猫
db682d7857 Merge pull request #271 from binaricat/fix/issue-258-windows-ssh-agent-check
fix: check Windows SSH Agent before connecting to agent pipe
2026-03-05 01:00:05 +08:00
bincxz
c6491b71c9 fix: only enable agentForward when agent is actually available
ssh2 throws when agentForward=true but no agent path is set. Move the
agentForward assignment after the agent availability check so forwarding
is silently skipped when the agent is unavailable.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 00:56:28 +08:00
bincxz
8667d0d535 fix: check Windows SSH Agent before connecting to agent pipe
On Windows, the agent socket path was set unconditionally to
\\.\pipe\openssh-ssh-agent even when the ssh-agent service is not
running. This caused "Failed to connect to agent" errors and prevented
fallback to keyboard-interactive auth (password prompt).

Now uses the existing checkWindowsSshAgent() to verify the service is
running before setting the agent path, allowing auth to fall through to
keyboard-interactive when no keys or password are configured.

Closes #258

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 00:52:05 +08:00
陈大猫
2bcb081486 Merge pull request #270 from binaricat/feat/issue-260-local-sftp-bookmarks
feat: add bookmark support for local SFTP directories
2026-03-05 00:44:54 +08:00
bincxz
fefda0015e fix: use shared external store for local bookmarks
Replace per-instance useState with useSyncExternalStore backed by a
module-level singleton so all mounted local SFTP panes share the same
bookmark state and writes never overwrite each other.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 00:38:50 +08:00
bincxz
5fc5471685 fix: handle Windows backslash paths in local bookmark labels
Split on both / and \ so the label extracts correctly for paths
like C:\Users\damao\Documents → "Documents".

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 00:37:26 +08:00
bincxz
4601372ce6 feat: add bookmark support for local SFTP directories (#260)
Local SFTP panes now support directory bookmarks, stored in localStorage
since there is no Host object for local connections.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 00:32:40 +08:00
陈大猫
6491ab38bc Merge pull request #269 from binaricat/fix/issue-266-password-only-passphrase
fix: skip SSH key passphrase prompt for password-only connections
2026-03-05 00:23:50 +08:00
bincxz
6476bc95df fix: include agentForwarding in password-only guard
When agent forwarding is enabled, the session uses an SSH agent which
may hold encrypted keys. Don't classify such sessions as password-only
to preserve the encrypted key retry path.

Addresses P2 review feedback on #269.
2026-03-05 00:04:45 +08:00
bincxz
7ef1059f7b fix: preserve encrypted key retry for jump host connections
When jump hosts are configured, the auth error could originate from a
key-based bastion rather than the password-only final target. Skip the
passphrase prompt bypass when jump hosts are present to ensure encrypted
default keys can still be offered for the chain.

Addresses review feedback on #269.
2026-03-04 23:57:54 +08:00
bincxz
fd78fc7baa fix: skip SSH key passphrase prompt for password-only connections
When a host is configured with username+password (no SSH key), the app
incorrectly prompted for local SSH key passphrases because:

1. buildAuthHandler added default ~/.ssh/ keys and ssh-agent as fallback
   methods for password-only connections, causing unnecessary key probing
2. startSSHSessionWrapper unconditionally scanned for encrypted default
   keys on auth failure and showed passphrase modal

Fix by:
- Removing default key/agent fallback from password-only auth handler
- Skipping encrypted key passphrase prompt in retry logic when the user
  explicitly configured password authentication

Fixes #266
2026-03-04 23:48:11 +08:00
陈大猫
5787a6ac6a Merge pull request #268 from binaricat/fix/issue-264-linux-x64-build
fix(ci): build Linux x64 in debian:bullseye container for native modules
2026-03-04 23:44:16 +08:00
bincxz
787760d02c fix(ci): build Linux x64 in debian:bullseye container for native modules
The Linux x64 AppImage was missing the compiled node-pty native module
(pty.node), causing the app to crash on launch. This happened because
the bare ubuntu-latest runner lacked build-essential/python3 needed by
node-gyp to compile native addons.

Move the Linux x64 build into a dedicated job using debian:bullseye
container (matching the ARM64 job) which:
- Installs build-essential, python3 and other native build deps
- Ensures node-pty, ssh2, cpu-features compile correctly
- Pins GLIBC to 2.31 for broader distro compatibility

Fixes #264
2026-03-04 23:37:42 +08:00
陈大猫
1b2c3e30a2 Merge pull request #267 from binaricat/fix/issue-263-rhel-distro-detection
fix: handle quoted ID values in /etc/os-release for RHEL distro detection
2026-03-04 23:32:49 +08:00
bincxz
ae7495baf9 fix: handle quoted ID values in /etc/os-release for distro detection
The regex for parsing the distro ID from /etc/os-release only matched
unquoted values like `ID=ubuntu`, but RHEL uses `ID="rhel"` with
double quotes. The new regex `/^ID="?([\w-]+)"?$/im` handles both
quoted and unquoted forms.

Fixes #263
2026-03-04 23:30:05 +08:00
陈大猫
2bcea8386f Merge pull request #265 from RoryChou-flux/codex/issue-259-sftp-reconnect-pr
fix(sftp): recover stale channel after network reconnect
2026-03-04 23:26:39 +08:00
bincxz
be7d29f45e fix(sftp): address reconnect selection and channel timeout edge cases 2026-03-04 23:18:36 +08:00
bincxz
4a762097ee fix(sftp): avoid sudo channel downgrade during channel recovery 2026-03-04 23:06:56 +08:00
bincxz
c91cf1d2f8 fix(sftp): guard reconnect reload against stale navigation state 2026-03-04 22:57:31 +08:00
bincxz
0a43220057 Merge remote-tracking branch 'origin/main' into fix/sftp-stale-channel-recovery
# Conflicts:
#	components/sftp-modal/hooks/useSftpModalSession.ts
#	electron/bridges/transferBridge.cjs
2026-03-04 22:47:05 +08:00
bincxz
288ea06c04 fix(sftp): add channel recovery to transferBridge stream operations
- Export requireSftpChannel from sftpBridge for cross-module use
- Add channel recovery to uploadWithStreams, downloadWithStreams,
  and startTransfer stat call in transferBridge
- Clean up verbose debug console.logs in cancelTransfer
2026-03-04 22:16:28 +08:00
bincxz
9ca7e39748 chore(sftp): remove dead isFatalUploadError function
The function was exported but never imported anywhere in the codebase.
2026-03-04 22:13:07 +08:00
bincxz
1cbbb61afa fix(sftp): add channel recovery to ensureRemoteDirForSession UTF-8 branch
The mkdirSftp handler delegates to ensureRemoteDirForSession, which
had the same issue as deleteSftp — the UTF-8 branch called
client.mkdir() directly without validating the channel first.
2026-03-04 22:11:33 +08:00
bincxz
cf352502f8 fix(sftp): deep review fixes for channel recovery
- Fix per-client dedup: store _reopeningPromise on client object
  instead of module-level global to prevent cross-session confusion
- Narrow isSessionError patterns: replace overly broad "not found"
  and "closed" with specific "channel closed"/"connection closed",
  add "timed out" for channel open timeout errors
- Prevent channel leak on timeout: close orphaned SFTP channel
  when tryOpenSftpChannel callback fires after timeout
- Auto-reload directory listing after successful reconnect in
  SFTP modal to avoid stale UI state
2026-03-04 22:07:51 +08:00
bincxz
72d270580f fix(sftp): harden channel recovery across all operations
P1 fixes:
- Add requireSftpChannel() to all SFTP operations: readSftp,
  readSftpBinary, writeSftp, writeSftpBinary,
  writeSftpBinaryWithProgress, renameSftp, statSftp, chmodSftp,
  and deleteSftp UTF-8 branch
- Add 10s timeout to tryOpenSftpChannel to prevent hang when
  SSH connection is half-dead

P2 fixes:
- Deduplicate concurrent getSftpChannel calls to avoid redundant
  channel re-opens
- Refactor isFatalUploadError to compose with isSessionError,
  eliminating pattern duplication and drift risk
2026-03-04 22:01:44 +08:00
bincxz
f0cfcbc560 refactor(sftp): consolidate duplicate isSessionError logic
- Add "write after end" and "no response" patterns to the shared
  isSessionError() in errors.ts
- Replace inline duplicate in useSftpModalSession with an import
  of the shared function
- Remove stale isSessionError from useCallback dependency array
2026-03-04 21:53:44 +08:00
rorychou
f8262a64ab fix(sftp): recover stale channel after reconnect 2026-03-04 21:37:31 +08:00
陈大猫
a24e27586a Merge pull request #257 from binaricat/fix/issue-254-sftp-bugs
Some checks failed
build-packages / build-linux-x64 (push) Has been cancelled
build-packages / build-macos (push) Has been cancelled
build-packages / build-windows (push) Has been cancelled
build-packages / build-linux-arm64 (push) Has been cancelled
build-packages / release (push) Has been cancelled
fix: resolve multiple SFTP bugs (#254)
2026-03-04 13:20:14 +08:00
bincxz
ca24d3861c fix: limit depth guard to symlink dirs only, allow deep real dirs
Real directories cannot form cycles, so remove depth limit for them.
Only track and limit symlink-directory nesting (MAX_SYMLINK_DEPTH=32)
to prevent cycles like `loop -> .` while allowing legitimate deep
directory structures to download without error.
2026-03-04 13:07:52 +08:00
bincxz
eb3b99b164 fix: cancel active child transfer directly from cancelTask
Add activeChildTransferIdsRef (Map<parentId, childId>) to track the
currently in-flight child transfer for directory downloads. cancelTask
now cancels both the parent ID and the active child transfer ID,
making folder download cancellation immediate and reliable.
2026-03-04 12:56:43 +08:00
bincxz
681f4cb3df fix: fail on depth exceeded + hide folder download for local sessions
- Throw error when MAX_RECURSION_DEPTH exceeded instead of silently
  returning, so download is marked failed with a clear message (P1)
- Hide folder download context menu item for local sessions where
  handleDownload only supports files (P2)
2026-03-04 12:05:59 +08:00
bincxz
6fae312981 fix: add max depth limit to prevent symlink cycle infinite recursion
SFTP doesn't expose realpath, so raw path strings can't detect cycles
like `loop -> .` that produce unique paths each level. Add a hard
MAX_RECURSION_DEPTH=32 guard alongside the existing visitedPaths set
to reliably prevent unbounded recursion.
2026-03-04 11:56:11 +08:00
bincxz
ed199eae8c fix: prevent symlink cycle recursion + handle undefined stream result
- Add visitedPaths Set to prevent infinite recursion from symlink
  cycles (e.g. symlink to parent directory)
- Handle undefined result from startStreamTransfer (bridge unavailable)
  by rejecting immediately instead of hanging indefinitely
2026-03-04 11:45:08 +08:00
bincxz
e38af76bfd fix: handle child transfer result errors + precise mkdir error handling
- Handle resolved result.error from startStreamTransfer to prevent
  hung Promises on cancellation (P1)
- Only ignore EEXIST from subdirectory mkdirLocal, propagate other
  errors like permission failures (P2)
2026-03-04 11:34:42 +08:00
bincxz
1726917db0 fix: abort in-flight child transfer on cancel + handle symlink dirs
- Cancel active child transfer from onProgress callback immediately
  when parent folder download is cancelled (P1)
- Handle symlink -> directory entries in recursive descent so they
  are treated as directories instead of files (P2)
2026-03-04 11:26:39 +08:00
bincxz
1712762305 fix: address code review feedback
- Revert mkdirLocal to safe original (no silent file deletion)
- Move EEXIST handling to download-overwrite flow only (deleteLocalFile)
- Add cancellation support for recursive folder downloads:
  - Track active child transfer ID for cancellation
  - Check cancelledTransferIdsRef between files
  - Cancel in-flight child transfer when parent is cancelled
2026-03-04 11:17:05 +08:00
bincxz
5d75f1acd4 fix: resolve multiple SFTP bugs (#254)
- Fix new folder input not resetting after deletion (SftpPaneToolbar/View)
- Fix folder download stuck at 95% by replacing simulated progress with real child-file progress tracking (useSftpTransfers)
- Add download menu item for directories in SFTP modal context menu (SftpModalFileList)
- Implement recursive folder download in SFTP modal with real-time progress (useSftpModalTransfers, SFTPModal)
- Fix mkdirLocal EEXIST error when target path is an existing file (localFsBridge)
- Close settings window when main window is minimized to tray (windowManager)

Closes #254
2026-03-04 11:04:34 +08:00
陈大猫
18b77f9a87 fix(ci): build linux-arm64 in Debian Buster container for GLIBC 2.28 compat (#255)
* fix(ci): build linux-arm64 in Debian Buster container for GLIBC 2.28 compat\n\nSplit linux-arm64 out of the build matrix into a dedicated job that\nruns inside a debian:buster container (GLIBC 2.28) on the ARM64 runner.\nThis ensures the compiled node-pty native module is compatible with\nolder distros like UOS/Deepin.\n\nCloses #253

* fix(ci): use archive.debian.org for EOL Buster repos

* fix(ci): switch to debian:bullseye for Python 3.9 + GLIBC 2.31 compat\n\nBuster's Python 3.7 is too old for node-gyp@11 (walrus operator).\nBullseye provides Python 3.9 and GLIBC 2.31 which is still below\nthe critical 2.34 boundary (libpthread merge into libc).
2026-03-04 10:23:35 +08:00
25 changed files with 1228 additions and 498 deletions

View File

@@ -25,12 +25,6 @@ jobs:
- name: windows
os: windows-latest
pack_script: pack:win
- name: linux-x64
os: ubuntu-latest
pack_script: pack:linux-x64
- name: linux-arm64
os: ubuntu-24.04-arm
pack_script: pack:linux-arm64
env:
VITE_SYNC_GITHUB_CLIENT_ID: ${{ secrets.VITE_SYNC_GITHUB_CLIENT_ID }}
VITE_SYNC_GOOGLE_CLIENT_ID: ${{ secrets.VITE_SYNC_GOOGLE_CLIENT_ID }}
@@ -62,13 +56,6 @@ jobs:
echo "Setting version to ${VERSION}"
npm pkg set version="${VERSION}"
# On ARM64 runners, electron-builder's post-build @electron/rebuild incorrectly
# tries to restore native modules to x64, but the ARM g++ doesn't support -m64.
# Setting npm_config_arch=arm64 ensures node-gyp uses the correct host architecture.
- name: Set native module arch for ARM64
if: matrix.name == 'linux-arm64'
run: echo "npm_config_arch=arm64" >> "$GITHUB_ENV"
- name: Build package
env:
CSC_IDENTITY_AUTO_DISCOVERY: ${{ matrix.name == 'macos' && 'false' || '' }}
@@ -89,10 +76,117 @@ jobs:
release/*.tar.gz
if-no-files-found: ignore
# Dedicated job for Linux x64 — builds inside Debian Bullseye (GLIBC 2.31)
# to ensure native modules (node-pty, ssh2) compile correctly and to
# maintain GLIBC compatibility with older distros.
build-linux-x64:
name: build-linux-x64
runs-on: ubuntu-latest
container:
image: debian:bullseye
env:
VITE_SYNC_GITHUB_CLIENT_ID: ${{ secrets.VITE_SYNC_GITHUB_CLIENT_ID }}
VITE_SYNC_GOOGLE_CLIENT_ID: ${{ secrets.VITE_SYNC_GOOGLE_CLIENT_ID }}
VITE_SYNC_GOOGLE_CLIENT_SECRET: ${{ secrets.VITE_SYNC_GOOGLE_CLIENT_SECRET }}
VITE_SYNC_ONEDRIVE_CLIENT_ID: ${{ secrets.VITE_SYNC_ONEDRIVE_CLIENT_ID }}
steps:
- name: Install build dependencies
run: |
apt-get update
apt-get install -y curl build-essential python3 git libfuse2 file rpm
curl -fsSL https://deb.nodesource.com/setup_20.x | bash -
apt-get install -y nodejs
- name: Checkout
uses: actions/checkout@v4
- name: Install deps
run: npm ci
- name: Set version
shell: bash
run: |
if [[ "$GITHUB_REF" == refs/tags/v* ]]; then
VERSION="${GITHUB_REF_NAME#v}"
else
VERSION="${GITHUB_SHA:0:7}"
fi
echo "Setting version to ${VERSION}"
npm pkg set version="${VERSION}"
- name: Build package
env:
ELECTRON_BUILDER_PUBLISH: "never"
run: npm run pack:linux-x64
- name: Upload artifacts
uses: actions/upload-artifact@v4
with:
name: netcatty-linux-x64
path: |
release/*.AppImage
release/*.deb
release/*.rpm
if-no-files-found: ignore
# Dedicated job for Linux ARM64 — builds inside Debian Bullseye (GLIBC 2.31)
# to ensure compatibility with older distros like UOS/Deepin (GLIBC 2.28).
# Key: GLIBC < 2.34 avoids the libpthread-merge symbol requirement.
build-linux-arm64:
name: build-linux-arm64
runs-on: ubuntu-24.04-arm
container:
image: debian:bullseye
env:
VITE_SYNC_GITHUB_CLIENT_ID: ${{ secrets.VITE_SYNC_GITHUB_CLIENT_ID }}
VITE_SYNC_GOOGLE_CLIENT_ID: ${{ secrets.VITE_SYNC_GOOGLE_CLIENT_ID }}
VITE_SYNC_GOOGLE_CLIENT_SECRET: ${{ secrets.VITE_SYNC_GOOGLE_CLIENT_SECRET }}
VITE_SYNC_ONEDRIVE_CLIENT_ID: ${{ secrets.VITE_SYNC_ONEDRIVE_CLIENT_ID }}
steps:
- name: Install build dependencies
run: |
apt-get update
apt-get install -y curl build-essential python3 git libfuse2 file rpm
curl -fsSL https://deb.nodesource.com/setup_20.x | bash -
apt-get install -y nodejs
- name: Checkout
uses: actions/checkout@v4
- name: Install deps
run: npm ci
- name: Set version
shell: bash
run: |
if [[ "$GITHUB_REF" == refs/tags/v* ]]; then
VERSION="${GITHUB_REF_NAME#v}"
else
VERSION="${GITHUB_SHA:0:7}"
fi
echo "Setting version to ${VERSION}"
npm pkg set version="${VERSION}"
- name: Build package
env:
npm_config_arch: arm64
ELECTRON_BUILDER_PUBLISH: "never"
run: npm run pack:linux-arm64
- name: Upload artifacts
uses: actions/upload-artifact@v4
with:
name: netcatty-linux-arm64
path: |
release/*.AppImage
release/*.deb
release/*.rpm
if-no-files-found: ignore
release:
name: release
runs-on: ubuntu-latest
needs: build
needs: [build, build-linux-x64, build-linux-arm64]
if: startsWith(github.ref, 'refs/tags/') || (github.event_name == 'workflow_dispatch' && inputs.publish_release)
permissions:
contents: write

View File

@@ -926,6 +926,9 @@ const en: Messages = {
'terminal.composeBar.broadcasting': 'Broadcasting to all sessions',
'terminal.toolbar.focus': 'Focus',
'terminal.toolbar.focusMode': 'Focus Mode',
'terminal.toolbar.encoding': 'Terminal Encoding',
'terminal.toolbar.encoding.utf8': 'UTF-8',
'terminal.toolbar.encoding.gb18030': 'GB18030',
'terminal.toolbar.closeSession': 'Close session',
'terminal.toolbar.hostHighlight.title': 'Host Keyword Highlighting',
'terminal.toolbar.hostHighlight.noRules': 'No custom highlight rules defined for this host',

View File

@@ -608,6 +608,9 @@ const zhCN: Messages = {
'terminal.composeBar.broadcasting': '正在广播到所有会话',
'terminal.toolbar.focus': '聚焦',
'terminal.toolbar.focusMode': '聚焦模式',
'terminal.toolbar.encoding': '终端编码',
'terminal.toolbar.encoding.utf8': 'UTF-8',
'terminal.toolbar.encoding.gb18030': 'GB18030',
'terminal.toolbar.closeSession': '关闭会话',
'terminal.toolbar.hostHighlight.title': '主机关键字高亮',
'terminal.toolbar.hostHighlight.noRules': '此主机未定义自定义高亮规则',

View File

@@ -4,31 +4,16 @@ export const isSessionError = (err: unknown): boolean => {
return (
msg.includes("session not found") ||
msg.includes("sftp session") ||
msg.includes("not found") ||
msg.includes("closed") ||
msg.includes("connection reset")
);
};
/**
* Check if an error message indicates a fatal error that should stop the entire upload.
* This includes session errors AND target directory deletion errors.
*/
export const isFatalUploadError = (errorMessage: string): boolean => {
const msg = errorMessage.toLowerCase();
return (
// Session-related errors
msg.includes("session not found") ||
msg.includes("sftp session") ||
msg.includes("connection") ||
msg.includes("disconnected") ||
// Target directory was deleted during upload
msg.includes("no such file") ||
msg.includes("enoent") ||
msg.includes("does not exist") ||
msg.includes("write stream error") ||
// Directory was removed
msg.includes("directory not found") ||
msg.includes("not a directory")
msg.includes("session lost") ||
msg.includes("channel not ready") ||
msg.includes("readdir is not a function") ||
msg.includes("channel closed") ||
msg.includes("connection closed") ||
msg.includes("connection reset") ||
msg.includes("write after end") ||
msg.includes("no response") ||
msg.includes("not connected") ||
msg.includes("client disconnected") ||
msg.includes("timed out")
);
};

View File

@@ -132,6 +132,7 @@ export const useSftpTransfers = ({
sourceEncoding: SftpFilenameEncoding,
targetEncoding: SftpFilenameEncoding,
rootTaskId: string, // The original top-level task ID for cancellation checking
onStreamProgress?: (transferred: number, total: number, speed: number) => void,
): Promise<void> => {
// Check if task or root task was cancelled before starting
if (cancelledTasksRef.current.has(task.id) || cancelledTasksRef.current.has(rootTaskId)) {
@@ -158,6 +159,9 @@ export const useSftpTransfers = ({
total: number,
speed: number,
) => {
// Bubble up streaming progress to parent (for directory transfers)
onStreamProgress?.(transferred, total, speed);
setTransfers((prev) =>
prev.map((t) => {
if (t.id !== task.id) return t;
@@ -254,6 +258,7 @@ export const useSftpTransfers = ({
sourceEncoding: SftpFilenameEncoding,
targetEncoding: SftpFilenameEncoding,
rootTaskId: string, // The original top-level task ID for cancellation checking
onChildProgress?: (completedBytes: number, currentFileTransferred: number, currentFileTotal: number, speed: number) => void,
) => {
// Check if task or root task was cancelled before starting
if (cancelledTasksRef.current.has(task.id) || cancelledTasksRef.current.has(rootTaskId)) {
@@ -275,6 +280,9 @@ export const useSftpTransfers = ({
throw new Error("No source connection");
}
// Track bytes completed so far in this directory (including subdirectories)
let completedBytesInDir = 0;
for (const file of files) {
if (file.name === "..") continue;
@@ -295,6 +303,13 @@ export const useSftpTransfers = ({
};
if (file.type === "directory") {
// For subdirectories, create a nested progress tracker
let subDirCompletedBytes = 0;
const onSubDirChildProgress = (subCompleted: number, currentTransferred: number, currentTotal: number, speed: number) => {
subDirCompletedBytes = subCompleted;
// Report to parent: our completed + subdirectory's (completed + in-progress)
onChildProgress?.(completedBytesInDir + subCompleted, currentTransferred, currentTotal, speed);
};
await transferDirectory(
childTask,
sourceSftpId,
@@ -304,8 +319,14 @@ export const useSftpTransfers = ({
sourceEncoding,
targetEncoding,
rootTaskId,
onSubDirChildProgress,
);
completedBytesInDir += subDirCompletedBytes;
} else {
// For files, report streaming progress
const onFileStreamProgress = (transferred: number, total: number, speed: number) => {
onChildProgress?.(completedBytesInDir, transferred, total, speed);
};
await transferFile(
childTask,
sourceSftpId,
@@ -315,7 +336,12 @@ export const useSftpTransfers = ({
sourceEncoding,
targetEncoding,
rootTaskId,
onFileStreamProgress,
);
// After file completes, add its bytes to completed total
const childSize = typeof file.size === 'string' ? parseInt(file.size, 10) || 0 : (file.size || 0);
completedBytesInDir += childSize;
onChildProgress?.(completedBytesInDir, 0, 0, 0);
}
}
};
@@ -398,7 +424,7 @@ export const useSftpTransfers = ({
}
let useSimulatedProgress = false;
if (!hasStreamingTransfer || task.isDirectory) {
if (!hasStreamingTransfer && !task.isDirectory) {
useSimulatedProgress = true;
startProgressSimulation(task.id, estimatedSize);
}
@@ -486,6 +512,24 @@ export const useSftpTransfers = ({
}
if (task.isDirectory) {
// Track real progress for directory transfers:
// completedBytes = sum of all finished child files
// + currentFileTransferred = in-progress bytes of the currently transferring file
const onChildProgress = (completedBytes: number, currentFileTransferred: number, currentFileTotal: number, speed: number) => {
const totalProgress = completedBytes + currentFileTransferred;
setTransfers((prev) =>
prev.map((t) => {
if (t.id !== task.id || t.status === "cancelled") return t;
const newTotal = Math.max(t.totalBytes, totalProgress, completedBytes + currentFileTotal);
return {
...t,
transferredBytes: Math.max(t.transferredBytes, totalProgress),
totalBytes: newTotal,
speed: Number.isFinite(speed) && speed > 0 ? speed : t.speed,
};
}),
);
};
await transferDirectory(
task,
sourceSftpId,
@@ -495,6 +539,7 @@ export const useSftpTransfers = ({
sourceEncoding,
targetEncoding,
task.id, // rootTaskId - this is the top-level task
onChildProgress,
);
} else {
await transferFile(
@@ -595,14 +640,14 @@ export const useSftpTransfers = ({
async (
sourceFiles: { name: string; isDirectory: boolean }[],
sourceSide: "left" | "right",
targetSide: "left" | "right",
options?: {
sourcePane?: SftpPane;
sourcePath?: string;
sourceConnectionId?: string;
onTransferComplete?: (result: TransferResult) => void | Promise<void>;
},
) => {
targetSide: "left" | "right",
options?: {
sourcePane?: SftpPane;
sourcePath?: string;
sourceConnectionId?: string;
onTransferComplete?: (result: TransferResult) => void | Promise<void>;
},
) => {
const sourcePane = options?.sourcePane ?? getActivePane(sourceSide);
const targetPane = getActivePane(targetSide);
@@ -638,11 +683,11 @@ export const useSftpTransfers = ({
const stat = await netcattyBridge.get()?.statLocal?.(fullPath);
if (stat) fileSize = stat.size;
} else if (sourceSftpId) {
const stat = await netcattyBridge.get()?.statSftp?.(
sourceSftpId,
fullPath,
sourceEncoding,
);
const stat = await netcattyBridge.get()?.statSftp?.(
sourceSftpId,
fullPath,
sourceEncoding,
);
if (stat) fileSize = stat.size;
}
} catch {

View File

@@ -78,6 +78,12 @@ export const useTerminalBackend = () => {
bridge?.closeSession?.(sessionId);
}, []);
const setSessionEncoding = useCallback(async (sessionId: string, encoding: string) => {
const bridge = netcattyBridge.get();
if (!bridge?.setSessionEncoding) return { ok: false, encoding };
return bridge.setSessionEncoding(sessionId, encoding);
}, []);
const onSessionData = useCallback((sessionId: string, cb: (data: string) => void) => {
const bridge = netcattyBridge.get();
if (!bridge?.onSessionData) throw new Error("onSessionData unavailable");
@@ -148,6 +154,7 @@ export const useTerminalBackend = () => {
writeToSession,
resizeSession,
closeSession,
setSessionEncoding,
onSessionData,
onSessionExit,
onChainProgress,

View File

@@ -403,6 +403,8 @@ const SFTPModal: React.FC<SFTPModalProps> = ({
setLoading,
t,
useCompressedUpload: sftpUseCompressedUpload,
listSftp: listSftpWithEncoding,
deleteLocalFile,
});
const hasEverOpenedRef = useRef(false);

View File

@@ -266,7 +266,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
snippetsRef.current = snippets;
const terminalBackend = useTerminalBackend();
const { resizeSession } = terminalBackend;
const { resizeSession, setSessionEncoding } = terminalBackend;
@@ -297,6 +297,12 @@ const TerminalComponent: React.FC<TerminalProps> = ({
const dragCounterRef = useRef(0);
const [pendingUploadEntries, setPendingUploadEntries] = useState<DropEntry[]>([]);
const [isComposeBarOpen, setIsComposeBarOpen] = useState(false);
const [terminalEncoding, setTerminalEncoding] = useState<'utf-8' | 'gb18030'>(() => {
if (host?.charset && /^gb/i.test(String(host.charset).trim())) return 'gb18030';
return 'utf-8';
});
const terminalEncodingRef = useRef(terminalEncoding);
terminalEncodingRef.current = terminalEncoding;
const terminalSearch = useTerminalSearch({ searchAddonRef, termRef });
const {
@@ -428,6 +434,13 @@ const TerminalComponent: React.FC<TerminalProps> = ({
setProgressValue,
setChainProgress,
t,
onSessionAttached: (id: string) => {
// Sync terminal encoding to SSH backend before first data arrives
const isSSH = host.protocol !== 'local' && host.protocol !== 'serial' && host.protocol !== 'telnet' && host.protocol !== 'mosh' && !host.moshEnabled && !host.id?.startsWith('local-') && !host.id?.startsWith('serial-') && host.hostname !== 'localhost';
if (isSSH) {
setSessionEncoding(id, terminalEncodingRef.current);
}
},
onSessionExit,
onTerminalDataCapture,
onOsDetected,
@@ -909,6 +922,13 @@ const TerminalComponent: React.FC<TerminalProps> = ({
termRef.current?.writeln("\r\n[No active SSH session]");
};
const handleSetTerminalEncoding = (encoding: 'utf-8' | 'gb18030') => {
setTerminalEncoding(encoding);
if (sessionRef.current) {
setSessionEncoding(sessionRef.current, encoding);
}
};
const handleOpenSFTP = async () => {
// If SFTP is already open, toggle it off
if (showSFTP) {
@@ -1113,6 +1133,8 @@ const TerminalComponent: React.FC<TerminalProps> = ({
onToggleSearch={handleToggleSearch}
isComposeBarOpen={inWorkspace ? isWorkspaceComposeBarOpen : isComposeBarOpen}
onToggleComposeBar={inWorkspace ? onToggleComposeBar : () => setIsComposeBarOpen(prev => !prev)}
terminalEncoding={terminalEncoding}
onSetTerminalEncoding={handleSetTerminalEncoding}
/>
);

View File

@@ -329,18 +329,26 @@ export const SftpModalFileList: React.FC<SftpModalFileListProps> = ({
) : (
<>
{isNavigableDirectory && (
<ContextMenuItem
onClick={() =>
handleNavigate(
currentPath === "/"
? `/${file.name}`
: `${currentPath}/${file.name}`,
)
}
>
<FolderOpen size={14} className="mr-2" />
{t("sftp.context.open")}
</ContextMenuItem>
<>
<ContextMenuItem
onClick={() =>
handleNavigate(
currentPath === "/"
? `/${file.name}`
: `${currentPath}/${file.name}`,
)
}
>
<FolderOpen size={14} className="mr-2" />
{t("sftp.context.open")}
</ContextMenuItem>
{!isLocalSession && (
<ContextMenuItem onClick={() => handleDownload(file)}>
<Download size={14} className="mr-2" />
{t("sftp.context.download")}
</ContextMenuItem>
)}
</>
)}
{isDownloadableFile && (
<>

View File

@@ -1,6 +1,7 @@
import React, { useCallback, useEffect, useLayoutEffect, useRef, useState } from "react";
import type { Host, RemoteFile } from "../../../types";
import { logger } from "../../../lib/logger";
import { isSessionError } from "../../../application/state/sftp/errors";
import { toast } from "../../ui/toast";
interface UseSftpModalSessionParams {
@@ -78,11 +79,12 @@ export const useSftpModalSession = ({
getHomeDir,
onClearSelection,
}: UseSftpModalSessionParams): UseSftpModalSessionResult => {
const [currentPath, setCurrentPath] = useState("/");
const [currentPath, setCurrentPathState] = useState("/");
const [files, setFiles] = useState<RemoteFile[]>([]);
const [loading, setLoading] = useState(false);
const [reconnecting, setReconnecting] = useState(false);
const [sessionVersion, setSessionVersion] = useState(0);
const currentPathRef = useRef(currentPath);
const sftpIdRef = useRef<string | null>(null);
const closingPromiseRef = useRef<Promise<void> | null>(null);
const initializedRef = useRef(false);
@@ -98,6 +100,10 @@ export const useSftpModalSession = ({
Map<string, { files: RemoteFile[]; timestamp: number }>
>(new Map());
const loadSeqRef = useRef(0);
const setCurrentPath = useCallback((path: string) => {
currentPathRef.current = path;
setCurrentPathState(path);
}, []);
const bumpSessionVersion = useCallback(() => {
setSessionVersion((prev) => prev + 1);
}, []);
@@ -187,20 +193,7 @@ export const useSftpModalSession = ({
await currentClosePromise;
}, [bumpSessionVersion, closeSftp, isLocalSession]);
const isSessionError = useCallback((err: unknown): boolean => {
if (!(err instanceof Error)) return false;
const msg = err.message.toLowerCase();
return (
msg.includes("session not found") ||
msg.includes("sftp session") ||
msg.includes("not found") ||
msg.includes("closed") ||
msg.includes("connection reset") ||
msg.includes("write after end") ||
msg.includes("no response") ||
msg.includes("client disconnected")
);
}, []);
// Use shared session-error classifier from errors.ts
const handleSessionError = useCallback(async () => {
if (reconnectingRef.current) return;
@@ -212,9 +205,30 @@ export const useSftpModalSession = ({
try {
reconnectAttemptsRef.current += 1;
await closeSftpSession();
await ensureSftp();
const newSftpId = await ensureSftp();
reconnectingRef.current = false;
setReconnecting(false);
// Auto-reload current directory after successful reconnect
try {
const reloadPath = currentPathRef.current;
const reloadRequestId = loadSeqRef.current;
const list = await listSftp(newSftpId, reloadPath);
if (
reloadRequestId !== loadSeqRef.current ||
currentPathRef.current !== reloadPath
) {
return;
}
onClearSelection();
setFiles(list);
dirCacheRef.current.set(`${host.id}::${reloadPath}`, {
files: list,
timestamp: Date.now(),
});
} catch {
// Reload failed — UI still shows old data, user can manually refresh
}
return;
} catch (err) {
logger.warn(
@@ -230,7 +244,7 @@ export const useSftpModalSession = ({
await new Promise((resolve) => setTimeout(resolve, 1000));
}
}
}, [closeSftpSession, ensureSftp, t]);
}, [closeSftpSession, ensureSftp, listSftp, host.id, onClearSelection, t]);
const loadFiles = useCallback(
async (path: string, options?: { force?: boolean }) => {
@@ -283,7 +297,7 @@ export const useSftpModalSession = ({
}
}
},
[ensureSftp, host.id, isLocalSession, listLocalDir, listSftp, t, isSessionError, handleSessionError, files.length, onClearSelection],
[ensureSftp, host.id, isLocalSession, listLocalDir, listSftp, t, handleSessionError, files.length, onClearSelection],
);
useLayoutEffect(() => {
@@ -401,6 +415,7 @@ export const useSftpModalSession = ({
loadFiles,
onClearSelection,
open,
setCurrentPath,
t,
]);

View File

@@ -40,6 +40,8 @@ interface UseSftpModalTransfersParams {
loadFiles: (path: string, options?: { force?: boolean }) => Promise<void>;
readLocalFile: (path: string) => Promise<ArrayBuffer>;
readSftp: (sftpId: string, path: string) => Promise<string>;
listSftp?: (sftpId: string, path: string) => Promise<RemoteFile[]>;
deleteLocalFile?: (path: string) => Promise<void>;
writeLocalFile: (path: string, data: ArrayBuffer) => Promise<void>;
writeSftpBinaryWithProgress: (
sftpId: string,
@@ -113,6 +115,8 @@ export const useSftpModalTransfers = ({
setLoading,
t,
useCompressedUpload = false,
listSftp,
deleteLocalFile,
}: UseSftpModalTransfersParams): UseSftpModalTransfersResult => {
const [uploading, setUploading] = useState(false);
const [uploadTasks, setUploadTasks] = useState<UploadTask[]>([]);
@@ -127,6 +131,9 @@ export const useSftpModalTransfers = ({
// Track cancelled transfer IDs to detect cancellation in bridge wrapper
const cancelledTransferIdsRef = useRef<Set<string>>(new Set());
// Track active child transfer IDs for directory downloads (parentId -> childId)
const activeChildTransferIdsRef = useRef<Map<string, string>>(new Map());
// Create upload bridge that adapts the modal's functions to the service interface
const createUploadBridge = useMemo((): UploadBridge => {
return {
@@ -157,7 +164,7 @@ export const useSftpModalTransfers = ({
onComplete || (() => { }),
onError || (() => { })
);
// Check if this transfer was cancelled
const wasCancelled = cancelledTransferIdsRef.current.has(taskId);
if (wasCancelled) {
@@ -320,8 +327,8 @@ export const useSftpModalTransfers = ({
const [folderName, phase] = newName.split('|');
const phaseLabel = phase === 'compressing' ? t('sftp.upload.phase.compressing')
: phase === 'extracting' ? t('sftp.upload.phase.extracting')
: phase === 'uploading' ? t('sftp.upload.phase.uploading')
: t('sftp.upload.phase.compressed');
: phase === 'uploading' ? t('sftp.upload.phase.uploading')
: t('sftp.upload.phase.compressed');
displayName = `${folderName} (${phaseLabel})`;
}
setUploadTasks(prev =>
@@ -410,12 +417,236 @@ export const useSftpModalTransfers = ({
return;
}
// For remote SFTP files, use streaming download with save dialog
// For remote SFTP files/directories, use streaming download with save dialog
if (!showSaveDialog || !startStreamTransfer) {
toast.error(t("sftp.error.downloadFailed"), "SFTP");
return;
}
// Check if this is a directory download
const isDirectory = file.type === 'directory' || (file.type === 'symlink' && file.linkTarget === 'directory');
if (isDirectory) {
// For directories, download recursively
if (!listSftp) {
toast.error(t("sftp.error.downloadFailed"), "SFTP");
return;
}
// Show save dialog to get target path (the saved "file" becomes the folder path)
const targetPath = await showSaveDialog(file.name);
if (!targetPath) return;
const sftpId = await ensureSftp();
const transferId = `download-dir-${Date.now()}-${Math.random().toString(36).slice(2)}`;
// Track the currently active child transfer ID for cancellation
let activeChildTransferId: string | null = null;
const setActiveChild = (childId: string | null) => {
activeChildTransferId = childId;
if (childId) {
activeChildTransferIdsRef.current.set(transferId, childId);
} else {
activeChildTransferIdsRef.current.delete(transferId);
}
};
// Create download task for progress display
const downloadTask: TransferTask = {
id: transferId,
fileName: file.name,
status: "downloading",
progress: 0,
totalBytes: 0,
transferredBytes: 0,
speed: 0,
startTime: Date.now(),
direction: "download",
isDirectory: true,
};
setUploadTasks(prev => [...prev, downloadTask]);
try {
// Safely create target directory.
// showSaveDialog "Replace" may leave a file (not directory) at the path,
// so we remove it first — ONLY in this explicit overwrite context.
try {
await createUploadBridge.mkdirLocal(targetPath);
} catch (mkdirErr: unknown) {
const isEEXIST = mkdirErr instanceof Error && mkdirErr.message.includes('EEXIST');
if (isEEXIST && deleteLocalFile) {
// Path exists as a file (from save dialog replace), remove it and retry
await deleteLocalFile(targetPath);
await createUploadBridge.mkdirLocal(targetPath);
} else {
throw mkdirErr;
}
}
// Recursively download directory contents
let completedBytes = 0;
// Track visited remote paths to prevent symlink cycles
const visitedPaths = new Set<string>();
// Max symlink-directory nesting depth to prevent cycles (only applies to symlinks)
const MAX_SYMLINK_DEPTH = 32;
const downloadDir = async (remotePath: string, localPath: string, symlinkDepth = 0): Promise<void> => {
// Prevent revisiting the same path
if (visitedPaths.has(remotePath)) return;
visitedPaths.add(remotePath);
// Check if transfer was cancelled
if (cancelledTransferIdsRef.current.has(transferId)) {
throw new Error('Transfer cancelled');
}
const entries = await listSftp(sftpId, remotePath);
for (const entry of entries) {
if (entry.name === '..' || entry.name === '.') continue;
// Check cancellation between files
if (cancelledTransferIdsRef.current.has(transferId)) {
// Cancel the active child transfer if any
if (activeChildTransferId && cancelTransfer) {
try { await cancelTransfer(activeChildTransferId); } catch { /* ignore */ }
}
throw new Error('Transfer cancelled');
}
const remoteEntryPath = joinPath(remotePath, entry.name);
const localEntryPath = `${localPath}/${entry.name}`;
const isRealDir = entry.type === 'directory';
const isSymlinkDir = entry.type === 'symlink' && entry.linkTarget === 'directory';
if (isRealDir || isSymlinkDir) {
// Only symlink directories can form cycles; enforce depth limit for them
if (isSymlinkDir && symlinkDepth >= MAX_SYMLINK_DEPTH) {
throw new Error('Maximum symlink directory depth exceeded (possible symlink cycle)');
}
try {
await createUploadBridge.mkdirLocal(localEntryPath);
} catch (mkdirErr: unknown) {
// Only ignore EEXIST (directory already exists), propagate other errors
const isEEXIST = mkdirErr instanceof Error && mkdirErr.message.includes('EEXIST');
if (!isEEXIST) throw mkdirErr;
}
await downloadDir(remoteEntryPath, localEntryPath, isSymlinkDir ? symlinkDepth + 1 : symlinkDepth);
} else {
// Download individual file
const childTransferId = `download-${Date.now()}-${Math.random().toString(36).slice(2)}`;
activeChildTransferId = childTransferId;
setActiveChild(childTransferId);
const entrySize = typeof entry.size === 'number' ? entry.size : parseInt(String(entry.size), 10) || 0;
await new Promise<void>((resolve, reject) => {
startStreamTransfer(
{
transferId: childTransferId,
sourcePath: remoteEntryPath,
targetPath: localEntryPath,
sourceType: 'sftp',
targetType: 'local',
sourceSftpId: sftpId,
totalBytes: entrySize,
},
// onProgress - update parent task
(transferred, total, speed) => {
if (cancelledTransferIdsRef.current.has(transferId)) {
// Actively cancel the in-flight child transfer
if (cancelTransfer) {
cancelTransfer(childTransferId).catch(() => { /* ignore */ });
}
return;
}
const totalProgress = completedBytes + transferred;
setUploadTasks(prev =>
prev.map(task =>
task.id === transferId
? {
...task,
transferredBytes: Math.max(task.transferredBytes, totalProgress),
totalBytes: Math.max(task.totalBytes, totalProgress, completedBytes + total),
progress: (() => {
const effectiveTotal = Math.max(task.totalBytes, completedBytes + total);
if (effectiveTotal <= 0) return task.progress;
const percent = (totalProgress / effectiveTotal) * 100;
return Math.max(task.progress, Math.min(percent, 99));
})(),
speed: Number.isFinite(speed) && speed > 0 ? speed : 0,
}
: task
)
);
},
// onComplete
() => {
completedBytes += entrySize;
setActiveChild(null);
resolve();
},
// onError
(error) => {
setActiveChild(null);
reject(new Error(error));
}
).then((result) => {
// Handle resolved result with error (e.g. cancellation)
if (result === undefined) {
setActiveChild(null);
reject(new Error('Stream transfer unavailable'));
} else if (result?.error) {
setActiveChild(null);
reject(new Error(result.error));
}
}).catch(reject);
});
}
}
};
await downloadDir(fullPath, targetPath);
// Mark as completed
setUploadTasks(prev =>
prev.map(task =>
task.id === transferId
? {
...task,
status: "completed" as const,
progress: 100,
transferredBytes: completedBytes,
totalBytes: completedBytes,
speed: 0,
}
: task
)
);
toast.success(`${t("sftp.context.download")}: ${file.name}`, "SFTP");
} catch (e) {
const errorMsg = e instanceof Error ? e.message : t("sftp.error.downloadFailed");
const isCancelError = errorMsg.includes('cancelled') || errorMsg.includes('canceled')
|| cancelledTransferIdsRef.current.has(transferId);
setUploadTasks(prev =>
prev.map(task =>
task.id === transferId
? {
...task,
status: isCancelError ? "cancelled" as const : "failed" as const,
speed: 0,
error: isCancelError ? undefined : errorMsg,
}
: task
)
);
if (!isCancelError) {
toast.error(errorMsg, "SFTP");
}
} finally {
cancelledTransferIdsRef.current.delete(transferId);
}
return;
}
// Show save dialog to get target path
const targetPath = await showSaveDialog(file.name);
if (!targetPath) {
@@ -461,20 +692,20 @@ export const useSftpModalTransfers = ({
prev.map(task =>
task.id === transferId
? {
...task,
transferredBytes: Math.max(
task.transferredBytes,
Math.min(transferred, total > 0 ? total : transferred)
),
totalBytes: total > 0 ? total : task.totalBytes,
progress: (() => {
const effectiveTotal = total > 0 ? total : task.totalBytes;
if (effectiveTotal <= 0) return task.progress;
const percent = (Math.max(task.transferredBytes, transferred) / effectiveTotal) * 100;
return Math.max(task.progress, Math.min(percent, 100));
})(),
speed: Number.isFinite(speed) && speed > 0 ? speed : 0,
}
...task,
transferredBytes: Math.max(
task.transferredBytes,
Math.min(transferred, total > 0 ? total : transferred)
),
totalBytes: total > 0 ? total : task.totalBytes,
progress: (() => {
const effectiveTotal = total > 0 ? total : task.totalBytes;
if (effectiveTotal <= 0) return task.progress;
const percent = (Math.max(task.transferredBytes, transferred) / effectiveTotal) * 100;
return Math.max(task.progress, Math.min(percent, 100));
})(),
speed: Number.isFinite(speed) && speed > 0 ? speed : 0,
}
: task
)
);
@@ -485,12 +716,12 @@ export const useSftpModalTransfers = ({
prev.map(task =>
task.id === transferId
? {
...task,
status: "completed" as const,
progress: 100,
transferredBytes: task.totalBytes > 0 ? task.totalBytes : task.transferredBytes,
speed: 0,
}
...task,
status: "completed" as const,
progress: 100,
transferredBytes: task.totalBytes > 0 ? task.totalBytes : task.transferredBytes,
speed: 0,
}
: task
)
);
@@ -569,7 +800,7 @@ export const useSftpModalTransfers = ({
setLoading(false);
}
},
[currentPath, ensureSftp, isLocalSession, joinPath, readLocalFile, setLoading, showSaveDialog, startStreamTransfer, t],
[currentPath, ensureSftp, isLocalSession, joinPath, readLocalFile, setLoading, showSaveDialog, startStreamTransfer, t, listSftp, createUploadBridge, deleteLocalFile, cancelledTransferIdsRef, cancelTransfer],
);
@@ -786,13 +1017,27 @@ export const useSftpModalTransfers = ({
if (!task) return;
if (task.direction === "download") {
// For download tasks, cancel only this specific transfer
// For download tasks, cancel the specific transfer
// Add to cancelled set so recursive downloads can check
cancelledTransferIdsRef.current.add(taskId);
if (cancelTransfer) {
try {
// Cancel the parent task ID (works for single-file downloads)
await cancelTransfer(taskId);
} catch {
// Ignore cancellation errors
}
// Also cancel the active child transfer for directory downloads
const activeChildId = activeChildTransferIdsRef.current.get(taskId);
if (activeChildId) {
try {
await cancelTransfer(activeChildId);
} catch {
// Ignore cancellation errors
}
activeChildTransferIdsRef.current.delete(taskId);
}
}
// Mark task as cancelled
setUploadTasks(prev =>

View File

@@ -40,6 +40,7 @@ interface SftpPaneToolbarProps {
setFileNameError: (value: string | null) => void;
setShowNewFileDialog: (open: boolean) => void;
setShowNewFolderDialog: (open: boolean) => void;
setNewFolderName: (value: string) => void;
// Bookmark props
bookmarks: SftpBookmark[];
isCurrentPathBookmarked: boolean;
@@ -79,6 +80,7 @@ export const SftpPaneToolbar: React.FC<SftpPaneToolbarProps> = ({
setFileNameError,
setShowNewFileDialog,
setShowNewFolderDialog,
setNewFolderName,
bookmarks,
isCurrentPathBookmarked,
onToggleBookmark,
@@ -167,8 +169,7 @@ export const SftpPaneToolbar: React.FC<SftpPaneToolbarProps> = ({
)}
{/* Bookmark button with dropdown */}
{!pane.connection?.isLocal && (
<Popover>
<Popover>
<PopoverTrigger asChild>
<Button
variant="ghost"
@@ -235,7 +236,6 @@ export const SftpPaneToolbar: React.FC<SftpPaneToolbarProps> = ({
)}
</PopoverContent>
</Popover>
)}
<div className="ml-auto flex items-center gap-0.5">
{!pane.connection?.isLocal && (
@@ -278,7 +278,10 @@ export const SftpPaneToolbar: React.FC<SftpPaneToolbarProps> = ({
variant="ghost"
size="icon"
className="h-6 w-6"
onClick={() => setShowNewFolderDialog(true)}
onClick={() => {
setNewFolderName("");
setShowNewFolderDialog(true);
}}
title={t("sftp.newFolder")}
>
<FolderPlus size={14} />

View File

@@ -25,6 +25,7 @@ import { useSftpPaneSorting } from "./hooks/useSftpPaneSorting";
import { useSftpPaneVirtualList } from "./hooks/useSftpPaneVirtualList";
import { useSftpDialogActionHandler } from "./hooks/useSftpDialogAction";
import { useSftpBookmarks } from "./hooks/useSftpBookmarks";
import { useLocalSftpBookmarks } from "./hooks/useLocalSftpBookmarks";
interface SftpPaneWrapperProps {
side: "left" | "right";
@@ -98,16 +99,20 @@ const SftpPaneViewInner: React.FC<SftpPaneViewProps> = ({
(updated: Host) => updateHosts(hosts.map((h) => (h.id === updated.id ? updated : h))),
[hosts, updateHosts],
);
const remoteBookmarks = useSftpBookmarks({
host: currentHost,
currentPath: pane.connection?.currentPath,
onUpdateHost,
});
const localBookmarks = useLocalSftpBookmarks({
currentPath: pane.connection?.currentPath,
});
const {
bookmarks,
isCurrentPathBookmarked,
toggleBookmark,
deleteBookmark,
} = useSftpBookmarks({
host: currentHost,
currentPath: pane.connection?.currentPath,
onUpdateHost,
});
} = pane.connection?.isLocal ? localBookmarks : remoteBookmarks;
const { filteredFiles, sortedDisplayFiles } = useSftpPaneFiles({
files: pane.files,
@@ -226,7 +231,10 @@ const SftpPaneViewInner: React.FC<SftpPaneViewProps> = ({
() => ({
onRename: (fileName: string) => openRenameDialog(fileName),
onDelete: (fileNames: string[]) => openDeleteConfirm(fileNames),
onNewFolder: () => setShowNewFolderDialog(true),
onNewFolder: () => {
setNewFolderName("");
setShowNewFolderDialog(true);
},
onNewFile: () => {
const defaultName = getNextUntitledName(pane.files.map(f => f.name));
setNewFileName(defaultName);
@@ -241,6 +249,7 @@ const SftpPaneViewInner: React.FC<SftpPaneViewProps> = ({
pane.files,
setFileNameError,
setNewFileName,
setNewFolderName,
setShowNewFileDialog,
setShowNewFolderDialog,
],
@@ -318,6 +327,7 @@ const SftpPaneViewInner: React.FC<SftpPaneViewProps> = ({
setFileNameError={setFileNameError}
setShowNewFileDialog={setShowNewFileDialog}
setShowNewFolderDialog={setShowNewFolderDialog}
setNewFolderName={setNewFolderName}
bookmarks={bookmarks}
isCurrentPathBookmarked={isCurrentPathBookmarked}
onToggleBookmark={toggleBookmark}

View File

@@ -0,0 +1,73 @@
import { useCallback, useMemo, useSyncExternalStore } from "react";
import type { SftpBookmark } from "../../../domain/models";
import { localStorageAdapter } from "../../../infrastructure/persistence/localStorageAdapter";
import { STORAGE_KEY_SFTP_LOCAL_BOOKMARKS } from "../../../infrastructure/config/storageKeys";
// ── Shared external store so every hook instance sees the same bookmarks ──
type Listener = () => void;
const listeners = new Set<Listener>();
let snapshot: SftpBookmark[] =
localStorageAdapter.read<SftpBookmark[]>(STORAGE_KEY_SFTP_LOCAL_BOOKMARKS) ?? [];
function subscribe(listener: Listener) {
listeners.add(listener);
return () => { listeners.delete(listener); };
}
function getSnapshot() {
return snapshot;
}
function setBookmarks(next: SftpBookmark[] | ((prev: SftpBookmark[]) => SftpBookmark[])) {
snapshot = typeof next === "function" ? next(snapshot) : next;
localStorageAdapter.write(STORAGE_KEY_SFTP_LOCAL_BOOKMARKS, snapshot);
for (const l of listeners) l();
}
// ── Hook ──
interface UseLocalSftpBookmarksParams {
currentPath: string | undefined;
}
export const useLocalSftpBookmarks = ({
currentPath,
}: UseLocalSftpBookmarksParams) => {
const bookmarks = useSyncExternalStore(subscribe, getSnapshot, getSnapshot);
const isCurrentPathBookmarked = useMemo(
() => !!currentPath && bookmarks.some((b) => b.path === currentPath),
[currentPath, bookmarks],
);
const toggleBookmark = useCallback(() => {
if (!currentPath) return;
if (isCurrentPathBookmarked) {
setBookmarks((prev) => prev.filter((b) => b.path !== currentPath));
} else {
const isRoot = currentPath === "/" || /^[A-Za-z]:\\?$/.test(currentPath);
const label = isRoot
? currentPath
: currentPath.split(/[\\/]/).filter(Boolean).pop() || currentPath;
const newBookmark: SftpBookmark = {
id: `bm-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`,
path: currentPath,
label,
};
setBookmarks((prev) => [...prev, newBookmark]);
}
}, [currentPath, isCurrentPathBookmarked]);
const deleteBookmark = useCallback((id: string) => {
setBookmarks((prev) => prev.filter((b) => b.id !== id));
}, []);
return {
bookmarks,
isCurrentPathBookmarked,
toggleBookmark,
deleteBookmark,
};
};

View File

@@ -2,12 +2,13 @@
* Terminal Toolbar
* Displays SFTP, Scripts, Theme, Highlight, Search buttons and close button in terminal status bar
*/
import { FolderInput, X, Zap, Palette, Search, TextCursorInput } from 'lucide-react';
import { Check, FolderInput, Languages, X, Zap, Palette, Search, TextCursorInput } from 'lucide-react';
import React, { useState } from 'react';
import { useI18n } from '../../application/i18n/I18nProvider';
import { Snippet, Host } from '../../types';
import { Button } from '../ui/button';
import { Popover, PopoverContent, PopoverTrigger } from '../ui/popover';
import { Popover, PopoverClose, PopoverContent, PopoverTrigger } from '../ui/popover';
import { cn } from '../../lib/utils';
import { ScrollArea } from '../ui/scroll-area';
import ThemeCustomizeModal from './ThemeCustomizeModal';
import HostKeywordHighlightPopover from './HostKeywordHighlightPopover';
@@ -35,6 +36,9 @@ export interface TerminalToolbarProps {
// Compose bar
isComposeBarOpen?: boolean;
onToggleComposeBar?: () => void;
// Terminal encoding
terminalEncoding?: 'utf-8' | 'gb18030';
onSetTerminalEncoding?: (encoding: 'utf-8' | 'gb18030') => void;
}
export const TerminalToolbar: React.FC<TerminalToolbarProps> = ({
@@ -58,6 +62,8 @@ export const TerminalToolbar: React.FC<TerminalToolbarProps> = ({
onToggleSearch,
isComposeBarOpen,
onToggleComposeBar,
terminalEncoding,
onSetTerminalEncoding,
}) => {
const { t } = useI18n();
const [themeModalOpen, setThemeModalOpen] = useState(false);
@@ -66,6 +72,7 @@ export const TerminalToolbar: React.FC<TerminalToolbarProps> = ({
const isLocalTerminal = host?.protocol === 'local' || host?.id?.startsWith('local-');
const isSerialTerminal = host?.protocol === 'serial' || host?.id?.startsWith('serial-');
const isSSHSession = !isLocalTerminal && !isSerialTerminal && host?.protocol !== 'telnet' && host?.protocol !== 'mosh' && !host?.moshEnabled && host?.hostname !== 'localhost';
const hidesSftp = isLocalTerminal || isSerialTerminal;
const currentThemeId = host?.theme || defaultThemeId;
@@ -118,6 +125,44 @@ export const TerminalToolbar: React.FC<TerminalToolbarProps> = ({
</Button>
)}
{isSSHSession && onSetTerminalEncoding && (
<Popover>
<PopoverTrigger asChild>
<Button
variant="secondary"
size="icon"
className={buttonBase}
title={t("terminal.toolbar.encoding")}
aria-label={t("terminal.toolbar.encoding")}
>
<Languages size={12} />
</Button>
</PopoverTrigger>
<PopoverContent className="w-36 p-1" align="start">
{(["utf-8", "gb18030"] as const).map((enc) => (
<PopoverClose asChild key={enc}>
<button
className={cn(
"w-full flex items-center gap-2 px-2 py-1.5 text-xs rounded-sm hover:bg-secondary transition-colors",
terminalEncoding === enc && "font-medium"
)}
onClick={() => onSetTerminalEncoding(enc)}
>
<Check
size={12}
className={cn(
"shrink-0",
terminalEncoding === enc ? "opacity-100" : "opacity-0"
)}
/>
{t(`terminal.toolbar.encoding.${enc === "utf-8" ? "utf8" : enc}`)}
</button>
</PopoverClose>
))}
</PopoverContent>
</Popover>
)}
<Popover open={isScriptsOpen} onOpenChange={setIsScriptsOpen}>
<PopoverTrigger asChild>
<Button

View File

@@ -91,6 +91,7 @@ export type TerminalSessionStartersContext = {
setChainProgress: Dispatch<SetStateAction<ChainProgressState>>;
t?: (key: string) => string;
onSessionAttached?: (sessionId: string) => void;
onSessionExit?: (sessionId: string) => void;
onTerminalDataCapture?: (sessionId: string, data: string) => void;
onOsDetected?: (hostId: string, distro: string) => void;
@@ -128,6 +129,7 @@ const attachSessionToTerminal = (
},
) => {
ctx.sessionRef.current = id;
ctx.onSessionAttached?.(id);
ctx.disposeDataRef.current = ctx.terminalBackend.onSessionData(id, (chunk) => {
let data = chunk;
@@ -188,9 +190,9 @@ const runDistroDetection = async (
timeout: 8000,
});
const data = `${res.stdout || ""}\n${res.stderr || ""}`;
const idMatch = data.match(/ID=([\\w\\-]+)/i);
const idMatch = data.match(/^ID="?([\w-]+)"?$/im);
const distro = idMatch
? idMatch[1].replace(/"/g, "")
? idMatch[1]
: (data.split(/\s+/)[0] || "").toLowerCase();
if (distro) ctx.onOsDetected?.(ctx.host.id, distro);
} catch (err) {

View File

@@ -55,10 +55,10 @@ async function listLocalDir(event, payload) {
const fullPath = path.join(dirPath, entry.name);
// fs.promises.stat follows symlinks, so we get the target's stats
const stat = await fs.promises.stat(fullPath);
let type;
let linkTarget = null;
if (entry.isSymbolicLink()) {
// This is a symlink - mark it as such and record the target type
type = "symlink";
@@ -69,10 +69,10 @@ async function listLocalDir(event, payload) {
} else {
type = "file";
}
// Check for Windows hidden attribute
const hidden = isWindows ? await isWindowsHiddenFile(fullPath) : false;
result[i] = {
name: entry.name,
type,
@@ -201,7 +201,7 @@ async function getSystemInfo() {
async function readKnownHosts() {
const homeDir = os.homedir();
const knownHostsPaths = [];
if (process.platform === "win32") {
knownHostsPaths.push(path.join(homeDir, ".ssh", "known_hosts"));
knownHostsPaths.push(path.join(process.env.PROGRAMDATA || "C:\\ProgramData", "ssh", "known_hosts"));
@@ -212,9 +212,9 @@ async function readKnownHosts() {
knownHostsPaths.push(path.join(homeDir, ".ssh", "known_hosts"));
knownHostsPaths.push("/etc/ssh/ssh_known_hosts");
}
let combinedContent = "";
for (const knownHostsPath of knownHostsPaths) {
try {
if (fs.existsSync(knownHostsPath)) {
@@ -227,7 +227,7 @@ async function readKnownHosts() {
console.warn(`Failed to read known_hosts from ${knownHostsPath}:`, err.message);
}
}
return combinedContent || null;
}

View File

@@ -127,7 +127,102 @@ const encodePathForSession = (sftpId, inputPath, requestedEncoding) => {
return encodePath(inputPath, encoding);
};
const getSftpChannel = (client) => client?.sftp || client?.client?.sftp;
const hasSftpChannelApi = (value) =>
!!value &&
typeof value.readdir === "function" &&
typeof value.stat === "function" &&
typeof value.mkdir === "function" &&
typeof value.unlink === "function";
const SFTP_CHANNEL_OPEN_TIMEOUT_MS = 10_000;
const tryOpenSftpChannel = (client) =>
new Promise((resolve, reject) => {
const sshClient = client?.client;
if (!sshClient || typeof sshClient.sftp !== "function") {
resolve(null);
return;
}
let settled = false;
const timer = setTimeout(() => {
settled = true;
reject(new Error("SFTP channel open timed out"));
}, SFTP_CHANNEL_OPEN_TIMEOUT_MS);
try {
sshClient.sftp((err, sftp) => {
clearTimeout(timer);
if (settled) {
// Timeout already fired — close the orphaned channel to prevent leaks
try { sftp?.end?.(); } catch { }
return;
}
if (err) return reject(err);
resolve(sftp || null);
});
} catch (err) {
clearTimeout(timer);
if (settled) return;
settled = true;
reject(err);
}
});
const getSftpChannel = async (client) => {
if (!client) return null;
if (hasSftpChannelApi(client.sftp)) {
return client.sftp;
}
// sudo sessions must keep using the sudo-bootstrapped SFTP wrapper.
// Reopening with sshClient.sftp() would silently downgrade permissions.
if (client.__netcattySudoMode) {
console.warn("[SFTP] Sudo SFTP channel is unavailable; automatic recovery is disabled for sudo sessions. Please reconnect.");
return null;
}
// Do not treat ssh2's "client.sftp" method as a channel object.
// Re-open a fresh channel when the cached channel is stale.
if (!client.client || typeof client.client.sftp !== "function") {
return null;
}
// Deduplicate per-client: avoid concurrent channel re-open attempts
if (client._reopeningPromise) {
try {
return await client._reopeningPromise;
} catch {
return null;
}
}
client._reopeningPromise = (async () => {
try {
const reopened = await tryOpenSftpChannel(client);
if (hasSftpChannelApi(reopened)) {
client.sftp = reopened;
return reopened;
}
} catch (err) {
console.warn("[SFTP] Failed to recover SFTP channel", err?.message || String(err));
}
return null;
})();
try {
return await client._reopeningPromise;
} finally {
client._reopeningPromise = null;
}
};
const requireSftpChannel = async (client) => {
const sftp = await getSftpChannel(client);
if (!sftp) {
throw new Error("SFTP session lost. Please reconnect.");
}
return sftp;
};
const statAsync = (sftp, targetPath) =>
new Promise((resolve, reject) => {
@@ -241,13 +336,13 @@ const ensureRemoteDirForSession = async (sftpId, dirPath, requestedEncoding) =>
const encoding = resolveEncodingForRequest(sftpId, requestedEncoding);
if (encoding === "utf-8") {
await requireSftpChannel(client);
const encodedPath = encodePath(dirPath, encoding);
await client.mkdir(encodedPath, true);
return true;
}
const sftp = getSftpChannel(client);
if (!sftp) throw new Error("SFTP channel not ready");
const sftp = await requireSftpChannel(client);
const normalizedPath = await normalizeRemotePathString(client, dirPath);
await ensureRemoteDirInternal(sftp, normalizedPath, encoding);
@@ -891,10 +986,7 @@ async function listSftp(event, payload) {
const pathEncoding = resolveEncodingForRequest(payload.sftpId, requestedEncoding);
const encodedPath = encodePath(basePath, pathEncoding);
const sftp = getSftpChannel(client);
if (!sftp) {
throw new Error("SFTP channel not ready");
}
const sftp = await requireSftpChannel(client);
let list;
try {
@@ -1015,6 +1107,7 @@ async function readSftp(event, payload) {
const client = sftpClients.get(payload.sftpId);
if (!client) throw new Error("SFTP session not found");
await requireSftpChannel(client);
const encoding = resolveEncodingForRequest(payload.sftpId, payload.encoding);
const encodedPath = encodePath(payload.path, encoding);
const buffer = await client.get(encodedPath);
@@ -1028,6 +1121,7 @@ async function readSftpBinary(event, payload) {
const client = sftpClients.get(payload.sftpId);
if (!client) throw new Error("SFTP session not found");
await requireSftpChannel(client);
const encoding = resolveEncodingForRequest(payload.sftpId, payload.encoding);
const encodedPath = encodePath(payload.path, encoding);
const buffer = await client.get(encodedPath);
@@ -1042,6 +1136,7 @@ async function writeSftp(event, payload) {
const client = sftpClients.get(payload.sftpId);
if (!client) throw new Error("SFTP session not found");
await requireSftpChannel(client);
const encoding = resolveEncodingForRequest(payload.sftpId, payload.encoding);
const encodedPath = encodePath(payload.path, encoding);
await client.put(Buffer.from(payload.content, "utf-8"), encodedPath);
@@ -1055,6 +1150,7 @@ async function writeSftpBinary(event, payload) {
const client = sftpClients.get(payload.sftpId);
if (!client) throw new Error("SFTP session not found");
await requireSftpChannel(client);
const encoding = resolveEncodingForRequest(payload.sftpId, payload.encoding);
const encodedPath = encodePath(payload.path, encoding);
await client.put(Buffer.from(payload.content), encodedPath);
@@ -1071,6 +1167,7 @@ async function writeSftpBinaryWithProgress(event, payload) {
if (!client) throw new Error("SFTP session not found");
const { sftpId, path: remotePath, content, transferId } = payload;
await requireSftpChannel(client);
const encoding = resolveEncodingForRequest(payload.sftpId, payload.encoding);
const encodedPath = encodePath(remotePath, encoding);
@@ -1305,6 +1402,7 @@ async function deleteSftp(event, payload) {
const encoding = resolveEncodingForRequest(payload.sftpId, payload.encoding);
if (encoding === "utf-8") {
await requireSftpChannel(client);
const encodedPath = encodePath(payload.path, encoding);
const stat = await client.stat(encodedPath);
if (stat.isDirectory) {
@@ -1342,8 +1440,7 @@ async function deleteSftp(event, payload) {
return true;
}
const sftp = getSftpChannel(client);
if (!sftp) throw new Error("SFTP channel not ready");
const sftp = await requireSftpChannel(client);
const normalizedPath = await normalizeRemotePathString(client, payload.path);
await removeRemotePathInternal(sftp, normalizedPath, encoding);
return true;
@@ -1356,6 +1453,7 @@ async function renameSftp(event, payload) {
const client = sftpClients.get(payload.sftpId);
if (!client) throw new Error("SFTP session not found");
await requireSftpChannel(client);
const encoding = resolveEncodingForRequest(payload.sftpId, payload.encoding);
const encodedOldPath = encodePath(payload.oldPath, encoding);
const encodedNewPath = encodePath(payload.newPath, encoding);
@@ -1370,6 +1468,7 @@ async function statSftp(event, payload) {
const client = sftpClients.get(payload.sftpId);
if (!client) throw new Error("SFTP session not found");
await requireSftpChannel(client);
const encoding = resolveEncodingForRequest(payload.sftpId, payload.encoding);
const encodedPath = encodePath(payload.path, encoding);
const stat = await client.stat(encodedPath);
@@ -1389,6 +1488,7 @@ async function chmodSftp(event, payload) {
const client = sftpClients.get(payload.sftpId);
if (!client) throw new Error("SFTP session not found");
await requireSftpChannel(client);
const encoding = resolveEncodingForRequest(payload.sftpId, payload.encoding);
const encodedPath = encodePath(payload.path, encoding);
await client.chmod(encodedPath, parseInt(payload.mode, 8));
@@ -1426,6 +1526,7 @@ module.exports = {
init,
registerHandlers,
getSftpClients,
requireSftpChannel,
encodePathForSession,
ensureRemoteDirForSession,
openSftp,

View File

@@ -1,38 +1,38 @@
/**
* SSH Authentication Helper - Shared authentication logic for SSH connections
* Used by sshBridge, sftpBridge, and portForwardingBridge
*/
const fs = require("node:fs");
const path = require("node:path");
const os = require("node:os");
const keyboardInteractiveHandler = require("./keyboardInteractiveHandler.cjs");
/**
* SSH Authentication Helper - Shared authentication logic for SSH connections
* Used by sshBridge, sftpBridge, and portForwardingBridge
*/
const fs = require("node:fs");
const path = require("node:path");
const os = require("node:os");
const keyboardInteractiveHandler = require("./keyboardInteractiveHandler.cjs");
const passphraseHandler = require("./passphraseHandler.cjs");
// Default SSH key names in priority order
const DEFAULT_KEY_NAMES = ["id_ed25519", "id_ecdsa", "id_rsa"];
/**
* Check if an SSH private key is encrypted (requires passphrase)
* @param {string} keyContent - The content of the private key file
* @returns {boolean} - True if the key is encrypted
*/
function isKeyEncrypted(keyContent) {
if (!keyContent || typeof keyContent !== "string") return false;
// Default SSH key names in priority order
const DEFAULT_KEY_NAMES = ["id_ed25519", "id_ecdsa", "id_rsa"];
/**
* Check if an SSH private key is encrypted (requires passphrase)
* @param {string} keyContent - The content of the private key file
* @returns {boolean} - True if the key is encrypted
*/
function isKeyEncrypted(keyContent) {
if (!keyContent || typeof keyContent !== "string") return false;
// Check for PKCS#8 encrypted format (-----BEGIN ENCRYPTED PRIVATE KEY-----)
if (keyContent.includes("-----BEGIN ENCRYPTED PRIVATE KEY-----")) {
return true;
}
// Check for legacy PEM format encryption (e.g., RSA PRIVATE KEY with encryption)
if (keyContent.includes("Proc-Type:") && keyContent.includes("ENCRYPTED")) {
return true;
}
// Check for DEK-Info header (legacy PEM encryption indicator)
if (keyContent.includes("DEK-Info:")) return true;
if (keyContent.includes("DEK-Info:")) return true;
// Check for OpenSSH format keys
if (keyContent.includes("-----BEGIN OPENSSH PRIVATE KEY-----")) {
try {
@@ -43,7 +43,7 @@ const passphraseHandler = require("./passphraseHandler.cjs");
if (base64Match) {
const base64Content = base64Match[1].replace(/\s/g, "");
const keyBuffer = Buffer.from(base64Content, "base64");
// OpenSSH key format: "openssh-key-v1\0" followed by cipher name
// If ciphername is "none", the key is not encrypted
const authMagic = "openssh-key-v1\0";
@@ -61,132 +61,132 @@ const passphraseHandler = require("./passphraseHandler.cjs");
return true;
}
}
return false;
}
/**
* Find default SSH private key from user's ~/.ssh directory
* Skips encrypted keys that require a passphrase
* @returns {Promise<{ privateKey: string, keyPath: string, keyName: string } | null>}
*/
async function findDefaultPrivateKey() {
const sshDir = path.join(os.homedir(), ".ssh");
for (const name of DEFAULT_KEY_NAMES) {
const keyPath = path.join(sshDir, name);
try {
await fs.promises.access(keyPath, fs.constants.F_OK);
const privateKey = await fs.promises.readFile(keyPath, "utf8");
if (isKeyEncrypted(privateKey)) {
continue;
}
return { privateKey, keyPath, keyName: name };
} catch {
continue;
}
}
return null;
}
/**
* Find ALL default SSH private keys from user's ~/.ssh directory
* @param {Object} [options]
* @param {boolean} [options.includeEncrypted=false] - If true, include encrypted keys with isEncrypted flag
* @returns {Promise<Array<{ privateKey: string, keyPath: string, keyName: string, isEncrypted?: boolean }>>}
*/
return false;
}
/**
* Find default SSH private key from user's ~/.ssh directory
* Skips encrypted keys that require a passphrase
* @returns {Promise<{ privateKey: string, keyPath: string, keyName: string } | null>}
*/
async function findDefaultPrivateKey() {
const sshDir = path.join(os.homedir(), ".ssh");
for (const name of DEFAULT_KEY_NAMES) {
const keyPath = path.join(sshDir, name);
try {
await fs.promises.access(keyPath, fs.constants.F_OK);
const privateKey = await fs.promises.readFile(keyPath, "utf8");
if (isKeyEncrypted(privateKey)) {
continue;
}
return { privateKey, keyPath, keyName: name };
} catch {
continue;
}
}
return null;
}
/**
* Find ALL default SSH private keys from user's ~/.ssh directory
* @param {Object} [options]
* @param {boolean} [options.includeEncrypted=false] - If true, include encrypted keys with isEncrypted flag
* @returns {Promise<Array<{ privateKey: string, keyPath: string, keyName: string, isEncrypted?: boolean }>>}
*/
async function findAllDefaultPrivateKeys(options = {}) {
const { includeEncrypted = false } = options;
const sshDir = path.join(os.homedir(), ".ssh");
const sshDir = path.join(os.homedir(), ".ssh");
const promises = DEFAULT_KEY_NAMES.map(async (name) => {
const keyPath = path.join(sshDir, name);
try {
await fs.promises.access(keyPath, fs.constants.F_OK);
const privateKey = await fs.promises.readFile(keyPath, "utf8");
const encrypted = isKeyEncrypted(privateKey);
if (encrypted && !includeEncrypted) {
return null;
}
return {
privateKey,
keyPath,
keyName: name,
...(includeEncrypted ? { isEncrypted: encrypted } : {})
};
} catch {
return null;
}
});
const promises = DEFAULT_KEY_NAMES.map(async (name) => {
const keyPath = path.join(sshDir, name);
try {
await fs.promises.access(keyPath, fs.constants.F_OK);
const privateKey = await fs.promises.readFile(keyPath, "utf8");
const encrypted = isKeyEncrypted(privateKey);
if (encrypted && !includeEncrypted) {
return null;
}
return {
privateKey,
keyPath,
keyName: name,
...(includeEncrypted ? { isEncrypted: encrypted } : {})
};
} catch {
return null;
}
});
const results = await Promise.all(promises);
return results.filter(Boolean);
}
/**
* Get ssh-agent socket path based on platform
* @returns {string|null}
*/
function getSshAgentSocket() {
if (process.platform === "win32") {
return "\\\\.\\pipe\\openssh-ssh-agent";
}
return process.env.SSH_AUTH_SOCK || null;
}
/**
* Build authentication handler with default key fallback support
* @param {Object} options
* @param {string} [options.privateKey] - Explicitly configured private key
* @param {string} [options.password] - Password for authentication
* @param {string} [options.passphrase] - Passphrase for encrypted private key
* @param {Object} [options.agent] - SSH agent (NetcattyAgent or socket path)
* @param {string} options.username - SSH username
* @param {string} [options.logPrefix] - Log prefix for debugging
* @returns {{ authHandler: Function|Array, privateKey: string|null, agent: string|Object|null, usedDefaultKeys: boolean }}
* @param {Array} [options.unlockedEncryptedKeys] - Array of unlocked encrypted keys with passphrases
*/
function buildAuthHandler(options) {
const results = await Promise.all(promises);
return results.filter(Boolean);
}
/**
* Get ssh-agent socket path based on platform
* @returns {string|null}
*/
function getSshAgentSocket() {
if (process.platform === "win32") {
return "\\\\.\\pipe\\openssh-ssh-agent";
}
return process.env.SSH_AUTH_SOCK || null;
}
/**
* Build authentication handler with default key fallback support
* @param {Object} options
* @param {string} [options.privateKey] - Explicitly configured private key
* @param {string} [options.password] - Password for authentication
* @param {string} [options.passphrase] - Passphrase for encrypted private key
* @param {Object} [options.agent] - SSH agent (NetcattyAgent or socket path)
* @param {string} options.username - SSH username
* @param {string} [options.logPrefix] - Log prefix for debugging
* @returns {{ authHandler: Function|Array, privateKey: string|null, agent: string|Object|null, usedDefaultKeys: boolean }}
* @param {Array} [options.unlockedEncryptedKeys] - Array of unlocked encrypted keys with passphrases
*/
function buildAuthHandler(options) {
const { privateKey, password, passphrase, agent, username, logPrefix = "[SSH]", unlockedEncryptedKeys = [], defaultKeys = [] } = options;
// Determine what type of explicit auth the user configured
const hasExplicitKey = !!privateKey;
const hasExplicitPassword = !!password;
const hasExplicitAgent = !!agent;
const hasExplicitAuth = hasExplicitKey || hasExplicitPassword || hasExplicitAgent;
// Determine if this is a password-only or key-only connection
const isPasswordOnly = hasExplicitPassword && !hasExplicitKey && !hasExplicitAgent;
const isKeyOnly = hasExplicitKey && !hasExplicitAgent;
const sshAgentSocket = getSshAgentSocket();
const sshAgentSocket = getSshAgentSocket();
// Only use system ssh-agent BEFORE user's auth when:
// - User explicitly configured agent, OR
// - No explicit auth is configured (pure fallback mode)
// When user configured key/password, system agent should only be used AFTER as fallback
const useAgentFirst = hasExplicitAgent || !hasExplicitAuth;
// Determine effective agent
const effectiveAgent = agent || (useAgentFirst ? sshAgentSocket : null);
// Determine effective privateKey (user-provided takes priority)
const effectivePrivateKey = privateKey || (!hasExplicitAuth && defaultKeys.length > 0 ? defaultKeys[0].privateKey : null);
// Determine fallback keys (keys to try after user's primary auth fails)
// - If user provided a key: all default keys are fallbacks
// - If no explicit auth: first default key is primary, rest are fallbacks
// - If password-only or agent-only: all default keys are fallbacks (tried after primary)
const fallbackKeys = hasExplicitKey
? defaultKeys
: !hasExplicitAuth
? defaultKeys.slice(1)
const fallbackKeys = hasExplicitKey
? defaultKeys
: !hasExplicitAuth
? defaultKeys.slice(1)
: defaultKeys;
// Check if we need dynamic handler (have fallback options)
const hasFallbackOptions = fallbackKeys.length > 0 ||
(!hasExplicitAgent && sshAgentSocket) ||
const hasFallbackOptions = fallbackKeys.length > 0 ||
(!hasExplicitAgent && sshAgentSocket) ||
(isPasswordOnly && defaultKeys.length > 0);
// If only simple auth methods and no fallback keys needed, use array-based handler
if (hasExplicitAuth && !hasFallbackOptions) {
const authMethods = [];
@@ -194,15 +194,15 @@ async function findAllDefaultPrivateKeys(options = {}) {
if (privateKey) authMethods.push("publickey");
if (password) authMethods.push("password");
authMethods.push("keyboard-interactive");
return {
authHandler: authMethods,
privateKey: effectivePrivateKey,
agent: effectiveAgent,
usedDefaultKeys: false,
};
}
}
// Build comprehensive authMethods array with all auth options
// Order depends on what user explicitly configured:
// - Password-only: password -> agent -> default keys -> keyboard-interactive
@@ -210,144 +210,132 @@ async function findAllDefaultPrivateKeys(options = {}) {
// - Agent configured: agent -> user key -> password -> default keys -> keyboard-interactive
// - No explicit auth: agent -> default keys -> keyboard-interactive
const authMethods = [];
if (isPasswordOnly) {
// Password-only: password first, then fallbacks
// Password-only: respect user's explicit choice, no key/agent fallback
authMethods.push({ type: "password", id: "password" });
// Add agent and default keys AFTER password as fallback
if (sshAgentSocket) {
authMethods.push({ type: "agent", id: "agent" });
}
for (const keyInfo of defaultKeys) {
authMethods.push({
type: "publickey",
key: keyInfo.privateKey,
id: `publickey-default-${keyInfo.keyName}`
});
}
} else if (isKeyOnly) {
// Key-only: user key first, then password (if any), then agent/default keys as fallback
// 1. User-provided key first
authMethods.push({
type: "publickey",
key: privateKey,
authMethods.push({
type: "publickey",
key: privateKey,
passphrase: passphrase,
id: "publickey-user"
id: "publickey-user"
});
// 2. Password (if configured alongside key)
if (password) {
authMethods.push({ type: "password", id: "password" });
}
// 3. System agent as fallback (AFTER user's key)
if (sshAgentSocket) {
authMethods.push({ type: "agent", id: "agent" });
}
// 4. Default keys as fallback
for (const keyInfo of fallbackKeys) {
authMethods.push({
type: "publickey",
key: keyInfo.privateKey,
id: `publickey-default-${keyInfo.keyName}`
authMethods.push({
type: "publickey",
key: keyInfo.privateKey,
id: `publickey-default-${keyInfo.keyName}`
});
}
} else {
// Agent configured or no explicit auth: agent -> user key -> password -> default keys
// 1. Agent (user-provided or system)
if (effectiveAgent) {
authMethods.push({ type: "agent", id: "agent" });
}
// 2. User-provided key
if (privateKey) {
authMethods.push({
type: "publickey",
key: privateKey,
authMethods.push({
type: "publickey",
key: privateKey,
passphrase: passphrase,
id: "publickey-user"
id: "publickey-user"
});
}
// 3. Password (if configured)
if (password) {
authMethods.push({ type: "password", id: "password" });
}
// 4. Default keys as fallback
for (const keyInfo of fallbackKeys) {
authMethods.push({
type: "publickey",
key: keyInfo.privateKey,
id: `publickey-default-${keyInfo.keyName}`
authMethods.push({
type: "publickey",
key: keyInfo.privateKey,
id: `publickey-default-${keyInfo.keyName}`
});
}
// 5. If no user key provided, add first default key at the beginning (after agent)
if (!privateKey && defaultKeys.length > 0) {
const insertIndex = effectiveAgent ? 1 : 0;
authMethods.splice(insertIndex, 0, {
type: "publickey",
key: defaultKeys[0].privateKey,
id: `publickey-default-${defaultKeys[0].keyName}`
authMethods.splice(insertIndex, 0, {
type: "publickey",
key: defaultKeys[0].privateKey,
id: `publickey-default-${defaultKeys[0].keyName}`
});
}
}
// Add unlocked encrypted default keys (user provided passphrases for these)
for (const keyInfo of unlockedEncryptedKeys) {
authMethods.push({
type: "publickey",
key: keyInfo.privateKey,
authMethods.push({
type: "publickey",
key: keyInfo.privateKey,
passphrase: keyInfo.passphrase,
id: `publickey-encrypted-${keyInfo.keyName}`
id: `publickey-encrypted-${keyInfo.keyName}`
});
}
// Keyboard-interactive as last resort
authMethods.push({ type: "keyboard-interactive", id: "keyboard-interactive" });
console.log(`${logPrefix} Auth methods configured`, {
isPasswordOnly,
hasUserKey: !!privateKey,
hasPassword: !!password,
hasAgent: !!effectiveAgent,
methodCount: authMethods.length,
methods: authMethods.map(m => m.id),
});
// Use dynamic authHandler to try all keys
let authIndex = 0;
const attemptedMethodIds = new Set();
const authHandler = (methodsLeft, partialSuccess, callback) => {
const availableMethods = methodsLeft || ["publickey", "password", "keyboard-interactive", "agent"];
while (authIndex < authMethods.length) {
const method = authMethods[authIndex];
authIndex++;
if (attemptedMethodIds.has(method.id)) continue;
attemptedMethodIds.add(method.id);
console.log(`${logPrefix} Auth methods configured`, {
isPasswordOnly,
hasUserKey: !!privateKey,
hasPassword: !!password,
hasAgent: !!effectiveAgent,
methodCount: authMethods.length,
methods: authMethods.map(m => m.id),
});
// Use dynamic authHandler to try all keys
let authIndex = 0;
const attemptedMethodIds = new Set();
const authHandler = (methodsLeft, partialSuccess, callback) => {
const availableMethods = methodsLeft || ["publickey", "password", "keyboard-interactive", "agent"];
while (authIndex < authMethods.length) {
const method = authMethods[authIndex];
authIndex++;
if (attemptedMethodIds.has(method.id)) continue;
attemptedMethodIds.add(method.id);
if (method.type === "agent" && (availableMethods.includes("publickey") || availableMethods.includes("agent"))) {
console.log(`${logPrefix} Trying agent auth`);
return callback("agent");
} else if (method.type === "publickey" && availableMethods.includes("publickey")) {
console.log(`${logPrefix} Trying publickey auth:`, method.id);
const pubkeyAuth = {
type: "publickey",
username,
key: method.key,
};
if (method.passphrase) {
pubkeyAuth.passphrase = method.passphrase;
}
return callback(pubkeyAuth);
console.log(`${logPrefix} Trying publickey auth:`, method.id);
const pubkeyAuth = {
type: "publickey",
username,
key: method.key,
};
if (method.passphrase) {
pubkeyAuth.passphrase = method.passphrase;
}
return callback(pubkeyAuth);
} else if (method.type === "password" && availableMethods.includes("password")) {
console.log(`${logPrefix} Trying password auth`);
return callback({
@@ -355,107 +343,107 @@ async function findAllDefaultPrivateKeys(options = {}) {
username,
password,
});
} else if (method.type === "keyboard-interactive" && availableMethods.includes("keyboard-interactive")) {
return callback("keyboard-interactive");
}
}
return callback(false);
};
} else if (method.type === "keyboard-interactive" && availableMethods.includes("keyboard-interactive")) {
return callback("keyboard-interactive");
}
}
return callback(false);
};
// Determine the agent to return - if authMethods includes agent, we need to provide the socket
// even if effectiveAgent is null (for fallback scenarios)
const hasAgentInMethods = authMethods.some(m => m.type === "agent");
const returnAgent = effectiveAgent || (hasAgentInMethods ? sshAgentSocket : null);
return {
authHandler,
return {
authHandler,
privateKey: effectivePrivateKey,
agent: returnAgent,
usedDefaultKeys: true,
};
}
/**
* Create a keyboard-interactive event handler
* @param {Object} options
* @param {Object} options.sender - Electron webContents sender
* @param {string} options.sessionId - Session/connection ID
* @param {string} options.hostname - Host being connected to
* @param {string} [options.password] - Saved password for fill button
* @param {string} [options.logPrefix] - Log prefix for debugging
* @returns {Function} - Event handler for 'keyboard-interactive' event
*/
function createKeyboardInteractiveHandler(options) {
const { sender, sessionId, hostname, password, logPrefix = "[SSH]" } = options;
return (name, instructions, instructionsLang, prompts, finish) => {
console.log(`${logPrefix} ${hostname} keyboard-interactive auth requested`, {
name,
instructions,
promptCount: prompts?.length || 0,
});
// If there are no prompts, just call finish with empty array
if (!prompts || prompts.length === 0) {
console.log(`${logPrefix} No prompts, finishing keyboard-interactive`);
finish([]);
return;
}
// Forward prompts to user via IPC
const requestId = keyboardInteractiveHandler.generateRequestId('ssh');
keyboardInteractiveHandler.storeRequest(requestId, (userResponses) => {
console.log(`${logPrefix} Received user responses, finishing keyboard-interactive`);
finish(userResponses);
}, sender.id, sessionId);
const promptsData = prompts.map((p) => ({
prompt: p.prompt,
echo: p.echo,
}));
console.log(`${logPrefix} Showing modal for ${promptsData.length} prompts`);
safeSend(sender, "netcatty:keyboard-interactive", {
requestId,
sessionId,
name: name || hostname,
instructions: instructions || "",
prompts: promptsData,
usedDefaultKeys: true,
};
}
/**
* Create a keyboard-interactive event handler
* @param {Object} options
* @param {Object} options.sender - Electron webContents sender
* @param {string} options.sessionId - Session/connection ID
* @param {string} options.hostname - Host being connected to
* @param {string} [options.password] - Saved password for fill button
* @param {string} [options.logPrefix] - Log prefix for debugging
* @returns {Function} - Event handler for 'keyboard-interactive' event
*/
function createKeyboardInteractiveHandler(options) {
const { sender, sessionId, hostname, password, logPrefix = "[SSH]" } = options;
return (name, instructions, instructionsLang, prompts, finish) => {
console.log(`${logPrefix} ${hostname} keyboard-interactive auth requested`, {
name,
instructions,
promptCount: prompts?.length || 0,
});
// If there are no prompts, just call finish with empty array
if (!prompts || prompts.length === 0) {
console.log(`${logPrefix} No prompts, finishing keyboard-interactive`);
finish([]);
return;
}
// Forward prompts to user via IPC
const requestId = keyboardInteractiveHandler.generateRequestId('ssh');
keyboardInteractiveHandler.storeRequest(requestId, (userResponses) => {
console.log(`${logPrefix} Received user responses, finishing keyboard-interactive`);
finish(userResponses);
}, sender.id, sessionId);
const promptsData = prompts.map((p) => ({
prompt: p.prompt,
echo: p.echo,
}));
console.log(`${logPrefix} Showing modal for ${promptsData.length} prompts`);
safeSend(sender, "netcatty:keyboard-interactive", {
requestId,
sessionId,
name: name || hostname,
instructions: instructions || "",
prompts: promptsData,
hostname: hostname,
savedPassword: password || null,
});
};
}
/**
* Send message to renderer safely
*/
function safeSend(sender, channel, payload) {
try {
if (!sender || sender.isDestroyed()) return;
sender.send(channel, payload);
} catch {
// Ignore destroyed webContents during shutdown.
}
}
/**
* Apply auth configuration to connection options
* Convenience function that combines buildAuthHandler results with connOpts
* @param {Object} connOpts - SSH connection options to modify
* @param {Object} authConfig - Auth configuration from buildAuthHandler
*/
function applyAuthToConnOpts(connOpts, authConfig) {
connOpts.authHandler = authConfig.authHandler;
if (authConfig.privateKey) {
connOpts.privateKey = authConfig.privateKey;
}
if (authConfig.agent) {
connOpts.agent = authConfig.agent;
}
}
savedPassword: password || null,
});
};
}
/**
* Send message to renderer safely
*/
function safeSend(sender, channel, payload) {
try {
if (!sender || sender.isDestroyed()) return;
sender.send(channel, payload);
} catch {
// Ignore destroyed webContents during shutdown.
}
}
/**
* Apply auth configuration to connection options
* Convenience function that combines buildAuthHandler results with connOpts
* @param {Object} connOpts - SSH connection options to modify
* @param {Object} authConfig - Auth configuration from buildAuthHandler
*/
function applyAuthToConnOpts(connOpts, authConfig) {
connOpts.authHandler = authConfig.authHandler;
if (authConfig.privateKey) {
connOpts.privateKey = authConfig.privateKey;
}
if (authConfig.agent) {
connOpts.agent = authConfig.agent;
}
}
/**
* Request passphrases for encrypted default keys
* Shows a modal for each encrypted key and collects passphrases
@@ -466,16 +454,16 @@ async function findAllDefaultPrivateKeys(options = {}) {
async function requestPassphrasesForEncryptedKeys(sender, hostname) {
const allKeys = await findAllDefaultPrivateKeys({ includeEncrypted: true });
const encryptedKeys = allKeys.filter(k => k.isEncrypted);
if (encryptedKeys.length === 0) {
return { keys: [], cancelled: false };
}
console.log(`[SSHAuth] Found ${encryptedKeys.length} encrypted default key(s), requesting passphrases`);
const unlockedKeys = [];
let wasCancelled = false;
for (const keyInfo of encryptedKeys) {
const result = await passphraseHandler.requestPassphrase(
sender,
@@ -483,27 +471,27 @@ async function requestPassphrasesForEncryptedKeys(sender, hostname) {
keyInfo.keyName,
hostname
);
// Handle different response types
if (!result) {
// Timeout or error - continue with next key
console.log(`[SSHAuth] No response for ${keyInfo.keyName}, continuing...`);
continue;
}
if (result.cancelled) {
// User clicked Cancel - stop the entire flow
console.log(`[SSHAuth] User cancelled passphrase flow at ${keyInfo.keyName}`);
wasCancelled = true;
break;
}
if (result.skipped) {
// User clicked Skip - continue with next key
console.log(`[SSHAuth] User skipped passphrase for ${keyInfo.keyName}`);
continue;
}
if (result.passphrase) {
// User provided passphrase
unlockedKeys.push({
@@ -514,19 +502,19 @@ async function requestPassphrasesForEncryptedKeys(sender, hostname) {
});
}
}
return { keys: unlockedKeys, cancelled: wasCancelled };
}
module.exports = {
DEFAULT_KEY_NAMES,
isKeyEncrypted,
findDefaultPrivateKey,
findAllDefaultPrivateKeys,
getSshAgentSocket,
buildAuthHandler,
createKeyboardInteractiveHandler,
applyAuthToConnOpts,
safeSend,
module.exports = {
DEFAULT_KEY_NAMES,
isKeyEncrypted,
findDefaultPrivateKey,
findAllDefaultPrivateKeys,
getSshAgentSocket,
buildAuthHandler,
createKeyboardInteractiveHandler,
applyAuthToConnOpts,
safeSend,
requestPassphrasesForEncryptedKeys,
};
};

View File

@@ -227,6 +227,31 @@ let electronModule = null;
// Cache persists until auth failure, then cleared to retry all methods
const authMethodCache = new Map();
// Per-session terminal encoding (default: utf-8)
const sessionEncodings = new Map();
// Per-session stateful iconv decoders (keyed by sessionId, value: { stdout, stderr })
const sessionDecoders = new Map();
const iconv = require("iconv-lite");
function getSessionDecoder(sessionId, stream) {
let decoders = sessionDecoders.get(sessionId);
if (!decoders) {
decoders = { stdout: null, stderr: null };
sessionDecoders.set(sessionId, decoders);
}
if (!decoders[stream]) {
const enc = sessionEncodings.get(sessionId) || "utf-8";
decoders[stream] = iconv.getDecoder(enc);
}
return decoders[stream];
}
function resetSessionDecoders(sessionId) {
const enc = sessionEncodings.get(sessionId) || "utf-8";
const decoders = { stdout: iconv.getDecoder(enc), stderr: iconv.getDecoder(enc) };
sessionDecoders.set(sessionId, decoders);
}
function getAuthCacheKey(username, hostname, port) {
return `${username}@${hostname}:${port || 22}`;
}
@@ -567,9 +592,14 @@ async function startSSHSession(event, options) {
// If no primary auth method configured, try ssh-agent first, then ALL default keys
if (!connectOpts.privateKey && !connectOpts.password && !connectOpts.agent) {
// First, try to use ssh-agent if available (this is what regular SSH does)
const sshAgentSocket = process.platform === "win32"
? "\\\\.\\pipe\\openssh-ssh-agent"
: process.env.SSH_AUTH_SOCK;
let sshAgentSocket;
if (process.platform === "win32") {
const agentStatus = await checkWindowsSshAgent();
log("Windows SSH Agent check", agentStatus);
sshAgentSocket = agentStatus.running ? "\\\\.\\pipe\\openssh-ssh-agent" : null;
} else {
sshAgentSocket = process.env.SSH_AUTH_SOCK;
}
if (sshAgentSocket) {
log("No auth method configured, trying ssh-agent first", { agentSocket: sshAgentSocket });
@@ -596,14 +626,23 @@ async function startSSHSession(event, options) {
// Agent forwarding
if (options.agentForwarding) {
connectOpts.agentForward = true;
if (!connectOpts.agent) {
if (process.platform === "win32") {
connectOpts.agent = "\\\\.\\pipe\\openssh-ssh-agent";
const agentStatus = await checkWindowsSshAgent();
log("Windows SSH Agent check (agentForwarding)", agentStatus);
if (agentStatus.running) {
connectOpts.agent = "\\\\.\\pipe\\openssh-ssh-agent";
}
} else {
connectOpts.agent = process.env.SSH_AUTH_SOCK;
}
}
// Only enable forwarding when an agent is actually available
if (connectOpts.agent) {
connectOpts.agentForward = true;
} else {
log("Agent forwarding requested but no agent available, skipping");
}
}
// Build authentication handler with fallback support
@@ -962,11 +1001,13 @@ async function startSSHSession(event, options) {
};
stream.on("data", (data) => {
bufferData(data.toString("utf8"));
const decoder = getSessionDecoder(sessionId, "stdout");
bufferData(decoder.write(data));
});
stream.stderr?.on("data", (data) => {
bufferData(data.toString("utf8"));
const decoder = getSessionDecoder(sessionId, "stderr");
bufferData(decoder.write(data));
});
stream.on("close", () => {
@@ -978,12 +1019,19 @@ async function startSSHSession(event, options) {
const contents = event.sender;
safeSend(contents, "netcatty:exit", { sessionId, exitCode: 0 });
sessions.delete(sessionId);
sessionEncodings.delete(sessionId);
sessionDecoders.delete(sessionId);
conn.end();
for (const c of chainConnections) {
try { c.end(); } catch { }
}
});
// Pre-seed encoding from host charset if it's a GB variant
if (options.charset && /^gb/i.test(String(options.charset).trim())) {
sessionEncodings.set(sessionId, "gb18030");
}
// Run startup command if specified
if (options.startupCommand) {
setTimeout(() => {
@@ -1325,7 +1373,9 @@ async function startSSHSessionWrapper(event, options) {
if (isAuthError) {
// Check if there are encrypted default keys we haven't tried yet
// Only offer retry if no unlocked keys were provided in this attempt
if (!options._unlockedEncryptedKeys || options._unlockedEncryptedKeys.length === 0) {
const hasJumpHosts = options.jumpHosts && options.jumpHosts.length > 0;
const isPasswordOnly = !hasJumpHosts && !options.agentForwarding && !!options.password && !options.privateKey && !options.certificate;
if (!isPasswordOnly && (!options._unlockedEncryptedKeys || options._unlockedEncryptedKeys.length === 0)) {
const allKeysWithEncrypted = await findAllDefaultPrivateKeysFromHelper({ includeEncrypted: true });
const encryptedKeys = allKeysWithEncrypted.filter(k => k.isEncrypted);
@@ -1786,6 +1836,24 @@ async function getServerStats(event, payload) {
});
}
/**
* Set terminal encoding for an active SSH session
*/
async function setSessionEncoding(_event, { sessionId, encoding }) {
const session = sessions?.get(sessionId);
if (!session || !session.stream) {
return { ok: false, encoding: encoding || "utf-8" };
}
const enc = String(encoding || "utf-8").toLowerCase();
if (!iconv.encodingExists(enc)) {
return { ok: false, encoding: enc };
}
sessionEncodings.set(sessionId, enc);
// Reset stateful decoders so new data uses the updated encoding
resetSessionDecoders(sessionId);
return { ok: true, encoding: enc };
}
/**
* Register IPC handlers for SSH operations
*/
@@ -1795,6 +1863,7 @@ function registerHandlers(ipcMain) {
ipcMain.handle("netcatty:ssh:pwd", getSessionPwd);
ipcMain.handle("netcatty:ssh:stats", getServerStats);
ipcMain.handle("netcatty:key:generate", generateKeyPair);
ipcMain.handle("netcatty:ssh:setEncoding", setSessionEncoding);
ipcMain.handle("netcatty:ssh:check-agent", async () => {
return await checkWindowsSshAgent();
});

View File

@@ -6,7 +6,7 @@
const fs = require("node:fs");
const path = require("node:path");
const os = require("node:os");
const { encodePathForSession, ensureRemoteDirForSession } = require("./sftpBridge.cjs");
const { encodePathForSession, ensureRemoteDirForSession, requireSftpChannel } = require("./sftpBridge.cjs");
// ── Transfer performance tuning ──────────────────────────────────────────────
// ssh2's fastPut/fastGet send multiple SFTP read/write requests in parallel,
@@ -52,6 +52,7 @@ async function openIsolatedSftpChannel(client) {
* Falls back to sequential stream piping if fastPut is unavailable.
*/
async function uploadFile(localPath, remotePath, client, fileSize, transfer, sendProgress) {
await requireSftpChannel(client);
const sftp = client.sftp;
if (!sftp) throw new Error("SFTP client not ready");
@@ -159,6 +160,7 @@ async function uploadFile(localPath, remotePath, client, fileSize, transfer, sen
* Falls back to sequential stream piping if fastGet is unavailable.
*/
async function downloadFile(remotePath, localPath, client, fileSize, transfer, sendProgress) {
await requireSftpChannel(client);
const sftp = client.sftp;
if (!sftp) throw new Error("SFTP client not ready");
@@ -404,6 +406,7 @@ async function startTransfer(event, payload, onProgress) {
} else if (sourceType === 'sftp') {
const client = sftpClients.get(sourceSftpId);
if (!client) throw new Error("Source SFTP session not found");
await requireSftpChannel(client);
const encodedSourcePath = encodePathForSession(sourceSftpId, sourcePath, sourceEncoding);
const stat = await client.stat(encodedSourcePath);
fileSize = stat.size;

View File

@@ -519,15 +519,15 @@ function attachOAuthLoadingOverlay(win) {
`;
win.webContents.on("did-start-loading", () => {
win.webContents.executeJavaScript(injectOverlayScript, true).catch(() => {});
win.webContents.executeJavaScript(injectOverlayScript, true).catch(() => { });
});
win.webContents.on("did-stop-loading", () => {
win.webContents.executeJavaScript(removeOverlayScript, true).catch(() => {});
win.webContents.executeJavaScript(removeOverlayScript, true).catch(() => { });
});
win.webContents.on("did-fail-load", () => {
win.webContents.executeJavaScript(removeOverlayScript, true).catch(() => {});
win.webContents.executeJavaScript(removeOverlayScript, true).catch(() => { });
});
}
@@ -626,10 +626,10 @@ function setupDeferredShow(win, { timeoutMs = 3000, waitForRendererReady = true
async function createWindow(electronModule, options) {
const { BrowserWindow, nativeTheme, app, screen, shell } = electronModule;
const { preload, devServerUrl, isDev, appIcon, isMac, onRegisterBridge, electronDir } = options;
// Store app reference for window state persistence
electronApp = app;
const osTheme = nativeTheme?.shouldUseDarkColors ? "dark" : "light";
const effectiveTheme = currentTheme === "dark" || currentTheme === "light" ? currentTheme : osTheme;
const frontendBackground = resolveFrontendBackgroundColor(electronDir || __dirname, effectiveTheme);
@@ -775,6 +775,7 @@ async function createWindow(electronModule, options) {
if (saveStateTimer) clearTimeout(saveStateTimer);
const state = getWindowBoundsState(win, lastNormalBounds);
if (state) saveWindowStateSync(state);
closeSettingsWindow();
return;
}
@@ -871,7 +872,7 @@ async function createWindow(electronModule, options) {
// Production mode - load via custom protocol.
await win.loadURL("app://netcatty/index.html");
onRegisterBridge?.(win);
return win;
}
@@ -882,13 +883,13 @@ async function createWindow(electronModule, options) {
async function openSettingsWindow(electronModule, options) {
const { BrowserWindow, shell } = electronModule;
const { preload, devServerUrl, isDev, appIcon, isMac, electronDir } = options;
// If settings window already exists, just focus it
if (settingsWindow && !settingsWindow.isDestroyed()) {
settingsWindow.focus();
return settingsWindow;
}
const osTheme = electronModule?.nativeTheme?.shouldUseDarkColors ? "dark" : "light";
const effectiveTheme = currentTheme === "dark" || currentTheme === "light" ? currentTheme : osTheme;
const frontendBackground = resolveFrontendBackgroundColor(electronDir || __dirname, effectiveTheme);
@@ -1001,7 +1002,7 @@ async function openSettingsWindow(electronModule, options) {
// Load the settings page
const settingsPath = '/#/settings';
if (isDev) {
try {
const baseUrl = getDevRendererBaseUrl(devServerUrl);
@@ -1014,7 +1015,7 @@ async function openSettingsWindow(electronModule, options) {
// Production mode - load via custom protocol.
await win.loadURL("app://netcatty/index.html#/settings");
return win;
}
@@ -1188,19 +1189,19 @@ function buildAppMenu(Menu, app, isMac, language = currentLanguage) {
const template = [
...(isMac
? [
{
label: app.name,
submenu: [
{ role: "about" },
{ type: "separator" },
{ role: "hide" },
{ role: "hideOthers" },
{ role: "unhide" },
{ type: "separator" },
{ role: "quit" },
],
},
]
{
label: app.name,
submenu: [
{ role: "about" },
{ type: "separator" },
{ role: "hide" },
{ role: "hideOthers" },
{ role: "unhide" },
{ type: "separator" },
{ role: "quit" },
],
},
]
: []),
{
label: tMenu(language, "edit"),
@@ -1239,7 +1240,7 @@ function buildAppMenu(Menu, app, isMac, language = currentLanguage) {
],
},
];
return Menu.buildFromTemplate(template);
}

View File

@@ -376,6 +376,8 @@ const api = {
closeSession: (sessionId) => {
ipcRenderer.send("netcatty:close", { sessionId });
},
setSessionEncoding: (sessionId, encoding) =>
ipcRenderer.invoke("netcatty:ssh:setEncoding", { sessionId, encoding }),
onSessionData: (sessionId, cb) => {
if (!dataListeners.has(sessionId)) dataListeners.set(sessionId, new Set());
dataListeners.get(sessionId).add(cb);

1
global.d.ts vendored
View File

@@ -229,6 +229,7 @@ declare global {
}>;
};
}>;
setSessionEncoding?(sessionId: string, encoding: string): Promise<{ ok: boolean; encoding: string }>;
writeToSession(sessionId: string, data: string): void;
resizeSession(sessionId: string, cols: number, rows: number): void;
closeSession(sessionId: string): void;

View File

@@ -41,6 +41,9 @@ export const STORAGE_KEY_UPDATE_DISMISSED_VERSION = 'netcatty_update_dismissed_v
// SFTP File Opener Associations
export const STORAGE_KEY_SFTP_FILE_ASSOCIATIONS = 'netcatty_sftp_file_associations_v1';
// SFTP Local Bookmarks
export const STORAGE_KEY_SFTP_LOCAL_BOOKMARKS = 'netcatty_sftp_local_bookmarks_v1';
// SFTP Settings
export const STORAGE_KEY_SFTP_DOUBLE_CLICK_BEHAVIOR = 'netcatty_sftp_double_click_behavior_v1';
export const STORAGE_KEY_SFTP_AUTO_SYNC = 'netcatty_sftp_auto_sync_v1';