Compare commits

...

52 Commits

Author SHA1 Message Date
陈大猫
892c6da44d fix: cloud sync 401 Unauthorized on first app launch (#287)
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
* fix: cloud sync 401 Unauthorized on first app launch

Root cause: CloudSyncManager.initProviderDecryption() runs before the
Electron bridge (window.netcatty) is available. decryptField() silently
returns encrypted ciphertext as-is (no-op fallback), so tokens remain
encrypted. When checkRemoteVersion() fires, the adapter sends encrypted
ciphertext as the Bearer token → 401 Unauthorized.

Fix: Add a decryptionEffective flag to detect when decryption was a
no-op. In getConnectedAdapter(), retry decryption for the requested
provider if startup decryption failed due to bridge unavailability.

* fix: track actual decryption success instead of bridge function existence

The preload script sets up bridge functions before the main process
registers IPC handlers. Checking function existence is unreliable —
the function exists but the actual IPC call throws. Now we track
whether any decryption threw an error and only mark decryptionEffective
when decryption actually succeeds.

* fix: use per-provider decryption state instead of global flag

Address P1 review: with a single global decryptionEffective flag,
the first provider's successful retry would prevent retries for
other providers. Changed to providerDecrypted record so each
provider independently tracks its decryption status.

* fix: evict stale adapter after successful deferred decryption

Address P1 review: after deferred decryption succeeds, the old adapter
(built with encrypted ciphertext) was still cached. isAuthenticated
returns true for it because the ciphertext is a non-empty string, so
it kept being reused and returning 401. Now the stale adapter is signed
out and evicted, forcing a fresh one with decrypted credentials.
2026-03-08 01:09:05 +08:00
陈大猫
0ff6273882 fix: enable Windows PTY compatibility for local terminals (#286)
* fix: enable Windows PTY compatibility for local terminals

* fix: detect localhost local terminal sessions

* fix: improve Windows local shell defaults

* fix: align detected local shell with launcher

* fix: limit windows pty handling to local terminals

* fix: skip pwsh app execution alias shims
2026-03-08 00:20:20 +08:00
陈大猫
92556d824e fix: normalize persisted redhat distro alias (#285) 2026-03-07 11:48:49 +08:00
midas
f3676734a7 feat(sftp): show download progress for "Open With" temp file downloads (#283)
* feat(sftp): show download progress for "Open With" temp file downloads

When opening remote files via "Open With" or double-click, the download
to a temp directory now displays real-time progress (bar, speed, ETA) in
the transfer overlay instead of silently blocking until completion.

Reuses the existing transferBridge infrastructure (fastGet with throttled
IPC progress events) and the SftpTransferItem UI. Cancellation is handled
gracefully — the task transitions to "cancelled" status, the partial temp
file is cleaned up, and the file is not opened in the external application.
The original downloadSftpToTemp path is preserved as a fallback for
contexts without a transfer queue.

* fix(sftp): harden temp download transfer state

---------

Co-authored-by: midasgao <midasgao@distinctclinic.com>
Co-authored-by: bincxz <16399091+binaricat@users.noreply.github.com>
2026-03-07 10:14:30 +08:00
陈大猫
3d1db751ca Remove legacy macOS quarantine workaround (#284) 2026-03-06 17:08:52 +08:00
陈大猫
35f531bb55 Fix SFTP folder copy into symlinked directories (#282)
* Fix SFTP directory copy into symlinked folders

* Honor SFTP directory drop targets

* Limit SFTP drop targeting to symlink directories

* Bind SFTP drops to the visible target pane

* Revert "Bind SFTP drops to the visible target pane"

This reverts commit d1bad223ffafd89d15217add8fbe4a24dda60433.

* Revert "Limit SFTP drop targeting to symlink directories"

This reverts commit edc67ed4a21c0c510854b5479592f4451d9b4cb7.

* Revert "Honor SFTP directory drop targets"

This reverts commit fed0d7bdd0f28fa6d4e9335f3964467b62921d7c.

* Stabilize SFTP directory transfer progress

* Enable compressed uploads in SFTP view

* Fix directory transfer cancellation and total growth

* Keep prescan cancellation in transfer cleanup

* Sync compressed uploads and persistent cancellation

* Tighten SFTP cancellation cleanup

* Handle Windows SFTP directory paths
2026-03-06 17:07:18 +08:00
陈大猫
71ff9953bd Fix issue #278 identity refresh and session log autosave (#281)
* Fix issue #278 identity refresh and session log autosave

* Sync session log settings across windows
2026-03-06 15:12:38 +08:00
bincxz
72635eeaeb fix(ci): upgrade Node.js from 20 to 22 for @electron/rebuild compat
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
@electron/rebuild@4.0.3 requires Node >= 22.12.0
2026-03-06 02:34:24 +08:00
bincxz
ec17abb507 Merge pull request: feat: enable macOS code signing and notarization
- Enable hardenedRuntime and notarize in electron-builder config
- Remove FixQuarantine.app workaround and DMG background image
- Pass signing and notarization secrets in CI build step
2026-03-06 02:07:10 +08:00
bincxz
fe7f760a47 chore: remove DMG background image 2026-03-06 02:06:50 +08:00
bincxz
ab70a406c9 feat: enable macOS code signing and notarization
- Enable hardenedRuntime and notarize in electron-builder config
- Remove FixQuarantine.app workaround from DMG (no longer needed
  with proper code signing)
- Pass signing and notarization secrets in CI build step
- Shrink DMG window to fit the simpler two-icon layout
2026-03-06 01:48:49 +08:00
bincxz
7e73da5557 Merge pull request #277 from binaricat/fix/issue-264-linux-x64-revert-container
fix(ci): revert Linux x64 build to ubuntu-latest without container

Closes #264
2026-03-06 01:45:47 +08:00
bincxz
97474acb89 fix(ci): revert Linux x64 build to ubuntu-latest without container
The debian:bullseye container introduced in v1.0.39 broke native module
packaging — node-pty's .node binary was missing from app.asar.unpacked,
causing 'No such file or directory' on ArchLinux and other x64 distros.

Revert to the v1.0.38 approach: build x64 directly on ubuntu-latest
with setup-node. ARM64 keeps the Debian container for GLIBC compat.

Closes #264
2026-03-06 01:44:08 +08:00
陈大猫
f59c83be2a fix: await provider token decryption before creating sync adapters (#276)
* fix: await provider token decryption before creating sync adapters

On cold start, initProviderDecryption() runs async in the constructor
but getConnectedAdapter() could be called before it finished, causing
adapter creation with still-encrypted tokens to fail silently.

Store the decryption promise and await it in getConnectedAdapter() so
tokens are guaranteed to be decrypted before use.

* fix: auto-recover sync providers stuck in error status

When syncAllProviders runs, providers with status 'error' that still
have tokens/config are now reset to 'connected' and their cached
adapter is invalidated, allowing a fresh retry with current (decrypted)
tokens. This prevents the permanent 'not configured' state that
previously required opening Settings to clear.
2026-03-06 01:38:18 +08:00
陈大猫
cba1803230 fix: install Linux icons in standard hicolor sizes (#274)\n\nGenerate 16x16 through 512x512 icon PNGs in build/icons/ so\nelectron-builder installs them to the correct hicolor directories\ninstead of only 1024x1024.\n\nUpdate .gitignore to track build/icons/ while keeping other\nbuild artifacts ignored.\n\nCloses #274 (#275) 2026-03-06 01:10:22 +08:00
陈大猫
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
51 changed files with 1455 additions and 612 deletions

View File

@@ -25,9 +25,6 @@ jobs:
- name: windows
os: windows-latest
pack_script: pack:win
- name: linux-x64
os: ubuntu-latest
pack_script: pack:linux-x64
env:
VITE_SYNC_GITHUB_CLIENT_ID: ${{ secrets.VITE_SYNC_GITHUB_CLIENT_ID }}
VITE_SYNC_GOOGLE_CLIENT_ID: ${{ secrets.VITE_SYNC_GOOGLE_CLIENT_ID }}
@@ -40,7 +37,7 @@ jobs:
- name: Setup Node
uses: actions/setup-node@v4
with:
node-version: 20
node-version: 22
cache: npm
- name: Install deps
@@ -61,8 +58,13 @@ jobs:
- name: Build package
env:
CSC_IDENTITY_AUTO_DISCOVERY: ${{ matrix.name == 'macos' && 'false' || '' }}
ELECTRON_BUILDER_PUBLISH: "never"
# macOS code signing & notarization (ignored on other platforms)
CSC_LINK: ${{ secrets.MAC_CSC_LINK }}
CSC_KEY_PASSWORD: ${{ secrets.MAC_CSC_KEY_PASSWORD }}
APPLE_ID: ${{ secrets.APPLE_ID }}
APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLE_APP_SPECIFIC_PASSWORD }}
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
run: npm run ${{ matrix.pack_script }}
- name: Upload artifacts
@@ -79,6 +81,57 @@ jobs:
release/*.tar.gz
if-no-files-found: ignore
# Linux x64 — builds directly on ubuntu-latest (no container).
# v1.0.39 used a debian:bullseye container which broke native module
# packaging (node-pty .node file missing from asar.unpacked). Reverted
# to the v1.0.38 approach. See #264.
build-linux-x64:
name: build-linux-x64
runs-on: ubuntu-latest
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: Checkout
uses: actions/checkout@v4
- name: Setup Node
uses: actions/setup-node@v4
with:
node-version: 22
cache: npm
- 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.
@@ -97,7 +150,7 @@ jobs:
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 -
curl -fsSL https://deb.nodesource.com/setup_22.x | bash -
apt-get install -y nodejs
- name: Checkout
@@ -136,7 +189,7 @@ jobs:
release:
name: release
runs-on: ubuntu-latest
needs: [build, build-linux-arm64]
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

3
.gitignore vendored
View File

@@ -17,7 +17,8 @@ dist-ssr
*.tsbuildinfo
coverage
/.vite
/build
/build/*
!/build/icons
/electron/native/**/build
/release
/out

70
App.tsx
View File

@@ -440,16 +440,40 @@ function App({ settings }: { settings: SettingsState }) {
return;
}
const { username, hostname: localHost } = systemInfoRef.current;
if (host.protocol === 'serial') {
const portName = host.hostname.split('/').pop() || host.hostname;
const sessionId = connectToHost(host);
addConnectionLog({
sessionId,
hostId: host.id,
hostLabel: host.label || `Serial: ${portName}`,
hostname: host.hostname,
username,
protocol: 'serial',
startTime: Date.now(),
localUsername: username,
localHostname: localHost,
saved: false,
});
return;
}
const protocol = host.moshEnabled ? 'mosh' : (host.protocol || 'ssh');
const resolvedAuth = resolveHostAuth({ host, keys, identities });
const sessionId = connectToHost(host);
addConnectionLog({
sessionId,
hostId: host.id,
hostLabel: host.label,
hostname: host.hostname,
username: host.username,
localHostname: "",
username: resolvedAuth.username || 'root',
protocol: protocol as 'ssh' | 'telnet' | 'local' | 'mosh',
startTime: Date.now(),
localUsername: username,
localHostname: localHost,
saved: false,
});
connectToHost(host);
};
const bridge = netcattyBridge.get();
@@ -461,7 +485,7 @@ function App({ settings }: { settings: SettingsState }) {
unsubscribeJump?.();
unsubscribeConnect?.();
};
}, [addConnectionLog, connectToHost, hosts, sessions, setActiveTabId, setWorkspaceFocusedSession, t]);
}, [addConnectionLog, connectToHost, hosts, identities, keys, sessions, setActiveTabId, setWorkspaceFocusedSession, t]);
// Keyboard-interactive authentication (2FA/MFA) event listener
useEffect(() => {
@@ -895,7 +919,9 @@ function App({ settings }: { settings: SettingsState }) {
// Wrapper to create local terminal with logging
const handleCreateLocalTerminal = useCallback(() => {
const { username, hostname } = systemInfoRef.current;
const sessionId = createLocalTerminal();
addConnectionLog({
sessionId,
hostId: '',
hostLabel: 'Local Terminal',
hostname: 'localhost',
@@ -906,7 +932,6 @@ function App({ settings }: { settings: SettingsState }) {
localHostname: hostname,
saved: false,
});
createLocalTerminal();
}, [addConnectionLog, createLocalTerminal]);
// Wrapper to connect to host with logging
@@ -916,7 +941,9 @@ function App({ settings }: { settings: SettingsState }) {
// Handle serial hosts separately
if (host.protocol === 'serial') {
const portName = host.hostname.split('/').pop() || host.hostname;
const sessionId = connectToHost(host);
addConnectionLog({
sessionId,
hostId: host.id,
hostLabel: host.label || `Serial: ${portName}`,
hostname: host.hostname,
@@ -927,13 +954,14 @@ function App({ settings }: { settings: SettingsState }) {
localHostname: localHost,
saved: false,
});
connectToHost(host);
return;
}
const protocol = host.moshEnabled ? 'mosh' : (host.protocol || 'ssh');
const resolvedAuth = resolveHostAuth({ host, keys, identities });
const sessionId = connectToHost(host);
addConnectionLog({
sessionId,
hostId: host.id,
hostLabel: host.label,
hostname: host.hostname,
@@ -944,14 +972,15 @@ function App({ settings }: { settings: SettingsState }) {
localHostname: localHost,
saved: false,
});
connectToHost(host);
}, [addConnectionLog, connectToHost, identities, keys]);
// Wrapper to create serial session with logging
const handleConnectSerial = useCallback((config: SerialConfig) => {
const { username, hostname } = systemInfoRef.current;
const portName = config.path.split('/').pop() || config.path;
const sessionId = createSerialSession(config);
addConnectionLog({
sessionId,
hostId: '',
hostLabel: `Serial: ${portName}`,
hostname: config.path,
@@ -962,32 +991,23 @@ function App({ settings }: { settings: SettingsState }) {
localHostname: hostname,
saved: false,
});
createSerialSession(config);
}, [addConnectionLog, createSerialSession]);
// Handle terminal data capture when session exits
const handleTerminalDataCapture = useCallback((sessionId: string, data: string) => {
if (IS_DEV) console.log('[handleTerminalDataCapture] Called', { sessionId, dataLength: data.length });
// Find the connection log for this session
const session = sessions.find(s => s.id === sessionId);
if (IS_DEV) console.log('[handleTerminalDataCapture] Session', session);
if (!session) {
if (IS_DEV) console.log('[handleTerminalDataCapture] No session found');
return;
}
if (IS_DEV) console.log('[handleTerminalDataCapture] All logs:', connectionLogs.map(l => ({ id: l.id, sessionId: l.sessionId, hostname: l.hostname, endTime: l.endTime, hasTerminalData: !!l.terminalData })));
if (IS_DEV) console.log('[handleTerminalDataCapture] Looking for logs with hostname:', session.hostname);
if (IS_DEV) console.log('[handleTerminalDataCapture] All logs:', connectionLogs.map(l => ({ id: l.id, hostname: l.hostname, endTime: l.endTime, hasTerminalData: !!l.terminalData })));
// Find the most recent log matching this session's hostname and doesn't have terminalData yet
// For local terminal, hostname is 'localhost'
// Sort by startTime descending to find the most recent matching log
// Prefer the persisted sessionId because the session may already have been
// removed from state by the time the terminal unmount cleanup runs.
const matchingLog = connectionLogs
.filter(log =>
log.hostname === session.hostname &&
!log.endTime &&
!log.terminalData
)
.filter((log) => {
if (log.endTime || log.terminalData) return false;
if (log.sessionId) return log.sessionId === sessionId;
return !!session && log.hostname === session.hostname;
})
.sort((a, b) => b.startTime - a.startTime)[0];
if (IS_DEV) console.log('[handleTerminalDataCapture] Matching log', matchingLog);

View File

@@ -215,11 +215,7 @@ Netcatty は接続したホストの OS を検出し、ホスト一覧でアイ
または [GitHub Releases](https://github.com/binaricat/Netcatty/releases) ですべてのリリースを参照してください。
> **⚠️ macOS ユーザーへ:** アプリはコード署名されていないため、macOS Gatekeeper によってブロックされます。ダウンロード後、以下のコマンドを実行して隔離属性を削除してください
> ```bash
> xattr -cr /Applications/Netcatty.app
> ```
> または、アプリを右クリック → 開く → ダイアログで「開く」をクリックしてください。
> **macOS ユーザーへ:** 現在のリリースはコード署名と notarization が行われている想定です。Gatekeeper の警告が出る場合は、GitHub Releases から最新版の公式ビルドを取得しているか確認してください
### 前提条件
- Node.js 18+ と npm

View File

@@ -214,11 +214,7 @@ Download the latest release for your platform from [GitHub Releases](https://git
Or browse all releases at [GitHub Releases](https://github.com/binaricat/Netcatty/releases).
> **⚠️ macOS Users:** Since the app is not code-signed, macOS Gatekeeper will block it. After downloading, run this command to remove the quarantine attribute:
> ```bash
> xattr -cr /Applications/Netcatty.app
> ```
> Or right-click the app → Open → Click "Open" in the dialog.
> **macOS Users:** Current releases are expected to be code-signed and notarized. If Gatekeeper still warns, make sure you downloaded the latest official build from GitHub Releases.
### Prerequisites
- Node.js 18+ and npm

View File

@@ -214,11 +214,7 @@ Netcatty 会自动识别并在主机列表中展示对应的系统图标:
或在 [GitHub Releases](https://github.com/binaricat/Netcatty/releases) 浏览所有版本。
> **⚠️ macOS 用户注意:** 由于应用未经代码签名macOS Gatekeeper 会阻止运行。下载后,请在终端运行以下命令移除隔离属性:
> ```bash
> xattr -cr /Applications/Netcatty.app
> ```
> 或者右键点击应用 → 打开 → 在弹出的对话框中点击"打开"。
> **macOS 用户注意:** 当前发布版本应已完成代码签名和公证。如果 Gatekeeper 仍然提示风险,请确认您下载的是 GitHub Releases 中的最新官方构建。
### 前置条件
- Node.js 18+ 和 npm

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

@@ -52,4 +52,5 @@ export interface FileWatchErrorEvent {
export interface SftpStateOptions {
onFileWatchSynced?: (event: FileWatchSyncedEvent) => void;
onFileWatchError?: (event: FileWatchErrorEvent) => void;
useCompressedUpload?: boolean;
}

View File

@@ -20,8 +20,10 @@ interface UseSftpExternalOperationsParams {
getActivePane: (side: "left" | "right") => SftpPane | null;
refresh: (side: "left" | "right") => Promise<void>;
sftpSessionsRef: React.MutableRefObject<Map<string, string>>;
useCompressedUpload?: boolean;
addExternalUpload?: (task: TransferTask) => void;
updateExternalUpload?: (taskId: string, updates: Partial<TransferTask>) => void;
isTransferCancelled?: (taskId: string) => boolean;
dismissExternalUpload?: (taskId: string) => void;
}
@@ -47,7 +49,16 @@ interface SftpExternalOperationsResult {
export const useSftpExternalOperations = (
params: UseSftpExternalOperationsParams
): SftpExternalOperationsResult => {
const { getActivePane, refresh, sftpSessionsRef, addExternalUpload, updateExternalUpload, dismissExternalUpload } = params;
const {
getActivePane,
refresh,
sftpSessionsRef,
useCompressedUpload = false,
addExternalUpload,
updateExternalUpload,
isTransferCancelled,
dismissExternalUpload,
} = params;
// Upload controller for cancellation support
const uploadControllerRef = useRef<UploadController | null>(null);
@@ -173,14 +184,113 @@ export const useSftpExternalOperations = (
throw new Error("SFTP session not found");
}
console.log("[SFTP] Downloading file to temp", { sftpId, remotePath, fileName });
const localTempPath = await bridge.downloadSftpToTemp(
sftpId,
remotePath,
fileName,
pane.filenameEncoding,
);
console.log("[SFTP] File downloaded to temp", { localTempPath });
let localTempPath: string;
let wasCancelled = false;
let externalTransferId: string | undefined;
const isLocalTempDownloadCancelled = () =>
!!externalTransferId && !!isTransferCancelled?.(externalTransferId);
const cleanupTempDownload = async (filePath: string) => {
if (!bridge.deleteTempFile) return;
try {
await bridge.deleteTempFile(filePath);
} catch (err) {
console.warn("[SFTP] Failed to delete cancelled temp download:", err);
}
};
if (bridge.downloadSftpToTempWithProgress && addExternalUpload && updateExternalUpload) {
externalTransferId = `download-temp-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
addExternalUpload({
id: externalTransferId,
fileName,
sourcePath: remotePath,
targetPath: "(temp)",
sourceConnectionId: pane.connection.id,
targetConnectionId: "local",
direction: "download",
status: "transferring" as TransferStatus,
totalBytes: 0,
transferredBytes: 0,
speed: 0,
startTime: Date.now(),
isDirectory: false,
retryable: false,
});
try {
const result = await bridge.downloadSftpToTempWithProgress(
sftpId,
remotePath,
fileName,
pane.filenameEncoding,
externalTransferId,
(transferred, total, speed) => {
updateExternalUpload(externalTransferId, {
transferredBytes: transferred,
totalBytes: total,
speed,
});
},
undefined,
(error) => {
updateExternalUpload(externalTransferId, {
status: "failed" as TransferStatus,
endTime: Date.now(),
error,
speed: 0,
});
},
() => {
updateExternalUpload(externalTransferId, {
status: "cancelled" as TransferStatus,
endTime: Date.now(),
speed: 0,
});
},
);
wasCancelled = result.cancelled;
localTempPath = result.localPath;
} catch (err) {
updateExternalUpload(externalTransferId, {
status: "failed" as TransferStatus,
endTime: Date.now(),
error: err instanceof Error ? err.message : String(err),
speed: 0,
});
throw err;
}
if (wasCancelled) {
if (localTempPath && bridge.deleteTempFile) {
bridge.deleteTempFile(localTempPath).catch(() => {});
}
return { localTempPath: "" };
}
if (isLocalTempDownloadCancelled()) {
await cleanupTempDownload(localTempPath);
return { localTempPath: "" };
}
updateExternalUpload(externalTransferId, {
status: "completed" as TransferStatus,
endTime: Date.now(),
speed: 0,
});
} else {
localTempPath = await bridge.downloadSftpToTemp(
sftpId,
remotePath,
fileName,
pane.filenameEncoding,
);
}
if (isLocalTempDownloadCancelled()) {
await cleanupTempDownload(localTempPath);
return { localTempPath: "" };
}
if (bridge.registerTempFile) {
try {
@@ -190,15 +300,23 @@ export const useSftpExternalOperations = (
}
}
console.log("[SFTP] Opening with application", { localTempPath, appPath });
await bridge.openWithApplication(localTempPath, appPath);
console.log("[SFTP] Application launched");
try {
await bridge.openWithApplication(localTempPath, appPath);
} catch (err) {
if (externalTransferId) {
updateExternalUpload(externalTransferId, {
status: "failed" as TransferStatus,
endTime: Date.now(),
error: err instanceof Error ? err.message : String(err),
speed: 0,
});
}
throw err;
}
let watchId: string | undefined;
console.log("[SFTP] Auto-sync enabled check", { enableWatch: options?.enableWatch, hasStartFileWatch: !!bridge.startFileWatch });
if (options?.enableWatch && bridge.startFileWatch) {
try {
console.log("[SFTP] Starting file watch", { localTempPath, remotePath, sftpId });
const result = await bridge.startFileWatch(
localTempPath,
remotePath,
@@ -206,17 +324,14 @@ export const useSftpExternalOperations = (
pane.filenameEncoding,
);
watchId = result.watchId;
console.log("[SFTP] File watch started successfully", { watchId, localTempPath, remotePath });
} catch (err) {
console.warn("[SFTP] Failed to start file watch:", err);
}
} else {
console.log("[SFTP] File watching not enabled or not available");
}
return { localTempPath, watchId };
},
[getActivePane, sftpSessionsRef],
[getActivePane, sftpSessionsRef, addExternalUpload, updateExternalUpload, isTransferCancelled],
);
// Create upload callbacks that translate to TransferTask updates
@@ -402,6 +517,7 @@ export const useSftpExternalOperations = (
bridge: createUploadBridge,
joinPath,
callbacks,
useCompressedUpload,
},
controller
);
@@ -415,7 +531,14 @@ export const useSftpExternalOperations = (
uploadControllerRef.current = null;
}
},
[getActivePane, refresh, sftpSessionsRef, createUploadCallbacks, createUploadBridge],
[
getActivePane,
refresh,
sftpSessionsRef,
createUploadCallbacks,
createUploadBridge,
useCompressedUpload,
],
);
const cancelExternalUpload = useCallback(async () => {

View File

@@ -39,6 +39,7 @@ interface UseSftpTransfersResult {
addExternalUpload: (task: TransferTask) => void;
updateExternalUpload: (taskId: string, updates: Partial<TransferTask>) => void;
cancelTransfer: (transferId: string) => Promise<void>;
isTransferCancelled: (transferId: string) => boolean;
retryTransfer: (transferId: string) => Promise<void>;
clearCompletedTransfers: () => void;
dismissTransfer: (transferId: string) => void;
@@ -123,6 +124,73 @@ export const useSftpTransfers = ({
}
}, []);
const clearCancelledTask = useCallback((taskId: string) => {
cancelledTasksRef.current.delete(taskId);
}, []);
const isTransferCancelledError = useCallback(
(error: unknown): boolean =>
error instanceof Error && error.message === "Transfer cancelled",
[],
);
const getEntrySize = useCallback((entry: SftpFileEntry): number => {
if (typeof entry.size === "string") {
const parsed = parseInt(entry.size, 10);
return Number.isFinite(parsed) && parsed > 0 ? parsed : 0;
}
return typeof entry.size === "number" && entry.size > 0 ? entry.size : 0;
}, []);
const estimateDirectoryBytes = useCallback(
async (
sourcePath: string,
sourceSftpId: string | null,
sourceIsLocal: boolean,
sourceEncoding: SftpFilenameEncoding,
rootTaskId: string,
): Promise<number> => {
if (cancelledTasksRef.current.has(rootTaskId)) {
throw new Error("Transfer cancelled");
}
const files = sourceIsLocal
? await listLocalFiles(sourcePath)
: sourceSftpId
? await listRemoteFiles(sourceSftpId, sourcePath, sourceEncoding)
: null;
if (!files) {
throw new Error("No source connection");
}
let totalBytes = 0;
for (const file of files) {
if (file.name === "..") continue;
if (cancelledTasksRef.current.has(rootTaskId)) {
throw new Error("Transfer cancelled");
}
if (file.type === "directory") {
totalBytes += await estimateDirectoryBytes(
joinPath(sourcePath, file.name),
sourceSftpId,
sourceIsLocal,
sourceEncoding,
rootTaskId,
);
} else {
totalBytes += getEntrySize(file);
}
}
return totalBytes;
},
[getEntrySize, listLocalFiles, listRemoteFiles],
);
const transferFile = async (
task: TransferTask,
sourceSftpId: string | null,
@@ -367,7 +435,27 @@ export const useSftpTransfers = ({
: targetPane.filenameEncoding || "auto";
let actualFileSize = task.totalBytes;
if (!task.isDirectory && actualFileSize === 0) {
let prescanCancelled = false;
if (task.isDirectory) {
try {
const sourceSftpId = sourcePane.connection?.isLocal
? null
: sftpSessionsRef.current.get(sourcePane.connection!.id);
actualFileSize = await estimateDirectoryBytes(
task.sourcePath,
sourceSftpId,
sourcePane.connection!.isLocal,
sourceEncoding,
task.id,
);
} catch (err) {
if (isTransferCancelledError(err)) {
prescanCancelled = true;
}
// Fall back to the existing estimate below if size discovery fails.
}
} else if (actualFileSize === 0) {
try {
const sourceSftpId = sourcePane.connection?.isLocal
? null
@@ -398,13 +486,6 @@ export const useSftpTransfers = ({
const hasStreamingTransfer = !!netcattyBridge.get()?.startStreamTransfer;
updateTask({
status: "transferring",
totalBytes: estimatedSize,
transferredBytes: 0,
startTime: Date.now(),
});
const sourceSftpId = sourcePane.connection?.isLocal
? null
: sftpSessionsRef.current.get(sourcePane.connection!.id);
@@ -424,12 +505,24 @@ export const useSftpTransfers = ({
}
let useSimulatedProgress = false;
if (!hasStreamingTransfer && !task.isDirectory) {
useSimulatedProgress = true;
startProgressSimulation(task.id, estimatedSize);
}
try {
if (prescanCancelled) {
throw new Error("Transfer cancelled");
}
updateTask({
status: "transferring",
totalBytes: estimatedSize,
transferredBytes: 0,
startTime: Date.now(),
});
if (!hasStreamingTransfer && !task.isDirectory) {
useSimulatedProgress = true;
startProgressSimulation(task.id, estimatedSize);
}
if (!task.skipConflictCheck && !task.isDirectory && targetPane.connection) {
let targetExists = false;
let existingStat: { size: number; mtime: number } | null = null;
@@ -520,10 +613,17 @@ export const useSftpTransfers = ({
setTransfers((prev) =>
prev.map((t) => {
if (t.id !== task.id || t.status === "cancelled") return t;
const newTotal = Math.max(t.totalBytes, totalProgress, completedBytes + currentFileTotal);
const newTotal = Math.max(
t.totalBytes,
totalProgress,
completedBytes + currentFileTotal,
);
return {
...t,
transferredBytes: Math.max(t.transferredBytes, totalProgress),
transferredBytes: Math.max(
t.transferredBytes,
Math.min(totalProgress, newTotal),
),
totalBytes: newTotal,
speed: Number.isFinite(speed) && speed > 0 ? speed : t.speed,
};
@@ -610,6 +710,7 @@ export const useSftpTransfers = ({
completionHandlersRef.current.delete(task.id);
}
}
clearCancelledTask(task.id);
return "cancelled";
}
@@ -768,10 +869,6 @@ export const useSftpTransfers = ({
}
}
// Clean up cancelled task ID after a delay to ensure all async ops see it
setTimeout(() => {
cancelledTasksRef.current.delete(transferId);
}, 5000);
},
[stopProgressSimulation],
);
@@ -779,7 +876,18 @@ export const useSftpTransfers = ({
const retryTransfer = useCallback(
async (transferId: string) => {
const task = transfers.find((t) => t.id === transferId);
if (!task) return;
if (!task || task.retryable === false) return;
const retriedTask: TransferTask = {
...task,
id: crypto.randomUUID(),
status: "pending" as TransferStatus,
error: undefined,
transferredBytes: 0,
speed: 0,
startTime: Date.now(),
endTime: undefined,
};
const sourceSide = task.sourceConnectionId.startsWith("left") ? "left" : "right";
const targetSide = task.targetConnectionId.startsWith("left") ? "left" : "right";
@@ -787,14 +895,20 @@ export const useSftpTransfers = ({
const targetPane = getActivePane(targetSide as "left" | "right");
if (sourcePane?.connection && targetPane?.connection) {
const completionHandler = completionHandlersRef.current.get(transferId);
if (completionHandler) {
completionHandlersRef.current.set(retriedTask.id, completionHandler);
completionHandlersRef.current.delete(transferId);
}
setTransfers((prev) =>
prev.map((t) =>
t.id === transferId
? { ...t, status: "pending" as TransferStatus, error: undefined }
? retriedTask
: t,
),
);
await processTransfer(task, sourcePane, targetPane, targetSide);
await processTransfer(retriedTask, sourcePane, targetPane, targetSide);
}
},
// eslint-disable-next-line react-hooks/exhaustive-deps -- processTransfer is defined inline
@@ -811,6 +925,10 @@ export const useSftpTransfers = ({
setTransfers((prev) => prev.filter((t) => t.id !== transferId));
}, []);
const isTransferCancelled = useCallback((transferId: string) => {
return cancelledTasksRef.current.has(transferId);
}, []);
const addExternalUpload = useCallback((task: TransferTask) => {
// Filter out any pending scanning tasks before adding the new task.
// This ensures that even if dismissExternalUpload's state update hasn't been applied yet
@@ -940,6 +1058,7 @@ export const useSftpTransfers = ({
addExternalUpload,
updateExternalUpload,
cancelTransfer,
isTransferCancelled,
retryTransfer,
clearCompletedTransfers,
dismissTransfer,

View File

@@ -49,9 +49,10 @@ export const useSessionState = () => {
username: 'local',
status: 'connecting',
};
setSessions(prev => [...prev, newSession]);
setActiveTabId(sessionId);
}, [setActiveTabId]);
setSessions(prev => [...prev, newSession]);
setActiveTabId(sessionId);
return sessionId;
}, [setActiveTabId]);
const createSerialSession = useCallback((config: SerialConfig) => {
const sessionId = crypto.randomUUID();
@@ -69,6 +70,7 @@ export const useSessionState = () => {
};
setSessions(prev => [...prev, newSession]);
setActiveTabId(sessionId);
return sessionId;
}, [setActiveTabId]);
const connectToHost = useCallback((host: Host) => {
@@ -100,7 +102,7 @@ export const useSessionState = () => {
};
setSessions(prev => [...prev, newSession]);
setActiveTabId(sessionId);
return;
return sessionId;
}
const newSession: TerminalSession = {
@@ -115,9 +117,10 @@ export const useSessionState = () => {
port: host.port,
moshEnabled: host.moshEnabled,
};
setSessions(prev => [...prev, newSession]);
setActiveTabId(newSession.id);
}, [setActiveTabId]);
setSessions(prev => [...prev, newSession]);
setActiveTabId(newSession.id);
return newSession.id;
}, [setActiveTabId]);
const updateSessionStatus = useCallback((sessionId: string, status: TerminalSession['status']) => {
setSessions(prev => prev.map(s => s.id === sessionId ? { ...s, status } : s));

View File

@@ -404,6 +404,18 @@ export const useSettingsState = () => {
if (key === STORAGE_KEY_EDITOR_WORD_WRAP && typeof value === 'boolean') {
setEditorWordWrapState((prev) => (prev === value ? prev : value));
}
if (key === STORAGE_KEY_SESSION_LOGS_ENABLED && typeof value === 'boolean') {
setSessionLogsEnabled((prev) => (prev === value ? prev : value));
}
if (key === STORAGE_KEY_SESSION_LOGS_DIR && typeof value === 'string') {
setSessionLogsDir((prev) => (prev === value ? prev : value));
}
if (
key === STORAGE_KEY_SESSION_LOGS_FORMAT &&
(value === 'txt' || value === 'raw' || value === 'html')
) {
setSessionLogsFormat((prev) => (prev === value ? prev : value));
}
if (key === STORAGE_KEY_HOTKEY_SCHEME && (value === 'disabled' || value === 'mac' || value === 'pc')) {
setHotkeyScheme(value);
}
@@ -560,6 +572,25 @@ export const useSettingsState = () => {
setEditorWordWrapState(newValue);
}
}
if (e.key === STORAGE_KEY_SESSION_LOGS_ENABLED && e.newValue !== null) {
const newValue = e.newValue === 'true';
if (newValue !== sessionLogsEnabled) {
setSessionLogsEnabled(newValue);
}
}
if (e.key === STORAGE_KEY_SESSION_LOGS_DIR && e.newValue !== null) {
if (e.newValue !== sessionLogsDir) {
setSessionLogsDir(e.newValue);
}
}
if (e.key === STORAGE_KEY_SESSION_LOGS_FORMAT && e.newValue) {
if (
(e.newValue === 'txt' || e.newValue === 'raw' || e.newValue === 'html') &&
e.newValue !== sessionLogsFormat
) {
setSessionLogsFormat(e.newValue);
}
}
// Sync SFTP compressed upload setting from other windows
if (e.key === STORAGE_KEY_SFTP_USE_COMPRESSED_UPLOAD && e.newValue !== null) {
const newValue = e.newValue === 'true' || e.newValue === 'enabled';
@@ -571,7 +602,7 @@ export const useSettingsState = () => {
window.addEventListener('storage', handleStorageChange);
return () => window.removeEventListener('storage', handleStorageChange);
}, [theme, lightUiThemeId, darkUiThemeId, accentMode, customAccent, customCSS, uiFontFamilyId, hotkeyScheme, uiLanguage, terminalThemeId, terminalFontFamilyId, terminalFontSize, sftpDoubleClickBehavior, sftpAutoSync, sftpShowHiddenFiles, sftpUseCompressedUpload, editorWordWrap, mergeIncomingTerminalSettings]);
}, [theme, lightUiThemeId, darkUiThemeId, accentMode, customAccent, customCSS, uiFontFamilyId, hotkeyScheme, uiLanguage, terminalThemeId, terminalFontFamilyId, terminalFontSize, sftpDoubleClickBehavior, sftpAutoSync, sftpShowHiddenFiles, sftpUseCompressedUpload, editorWordWrap, sessionLogsEnabled, sessionLogsDir, sessionLogsFormat, mergeIncomingTerminalSettings]);
useEffect(() => {
localStorageAdapter.writeString(STORAGE_KEY_TERM_THEME, terminalThemeId);

View File

@@ -213,6 +213,7 @@ export const useSftpState = (
addExternalUpload,
updateExternalUpload,
cancelTransfer,
isTransferCancelled,
retryTransfer,
clearCompletedTransfers,
dismissTransfer,
@@ -238,8 +239,10 @@ export const useSftpState = (
getActivePane,
refresh,
sftpSessionsRef,
useCompressedUpload: options?.useCompressedUpload,
addExternalUpload,
updateExternalUpload,
isTransferCancelled,
dismissExternalUpload: dismissTransfer,
});

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,

BIN
build/icons/128x128.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.5 KiB

BIN
build/icons/16x16.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 645 B

BIN
build/icons/256x256.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.4 KiB

BIN
build/icons/32x32.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

BIN
build/icons/48x48.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

BIN
build/icons/512x512.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

BIN
build/icons/64x64.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 KiB

View File

@@ -58,6 +58,7 @@ const SftpViewInner: React.FC<SftpViewProps> = ({ hosts, keys, identities, updat
sftpDoubleClickBehavior,
sftpAutoSync,
sftpShowHiddenFiles,
sftpUseCompressedUpload,
hotkeyScheme,
keyBindings,
editorWordWrap,
@@ -77,7 +78,12 @@ const SftpViewInner: React.FC<SftpViewProps> = ({ hosts, keys, identities, updat
},
}), [t]);
const sftp = useSftpState(hosts, keys, identities, fileWatchHandlers);
const sftpOptions = useMemo(() => ({
...fileWatchHandlers,
useCompressedUpload: sftpUseCompressedUpload,
}), [fileWatchHandlers, sftpUseCompressedUpload]);
const sftp = useSftpState(hosts, keys, identities, sftpOptions);
// Get stream transfer functions for optimized downloads
const { showSaveDialog, startStreamTransfer } = useSftpBackend();

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

@@ -2543,6 +2543,7 @@ const vaultViewAreEqual = (
const isEqual =
prev.hosts === next.hosts &&
prev.keys === next.keys &&
prev.identities === next.identities &&
prev.snippets === next.snippets &&
prev.snippetPackages === next.snippetPackages &&
prev.customGroups === next.customGroups &&

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

@@ -169,8 +169,7 @@ export const SftpPaneToolbar: React.FC<SftpPaneToolbarProps> = ({
)}
{/* Bookmark button with dropdown */}
{!pane.connection?.isLocal && (
<Popover>
<Popover>
<PopoverTrigger asChild>
<Button
variant="ghost"
@@ -237,7 +236,6 @@ export const SftpPaneToolbar: React.FC<SftpPaneToolbarProps> = ({
)}
</PopoverContent>
</Popover>
)}
<div className="ml-auto flex items-center gap-0.5">
{!pane.connection?.isLocal && (

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,

View File

@@ -121,7 +121,7 @@ const SftpTransferItemInner: React.FC<SftpTransferItemProps> = ({ task, onCancel
</div>
<div className="flex items-center gap-1 shrink-0">
{task.status === 'failed' && (
{task.status === 'failed' && task.retryable !== false && (
<Button variant="ghost" size="icon" className="h-7 w-7" onClick={onRetry} title="Retry">
<RefreshCw size={12} />
</Button>

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

@@ -19,6 +19,7 @@ import {
} from "../../../infrastructure/config/xtermPerformance";
import { logger } from "../../../lib/logger";
import { isMacPlatform, normalizeLineEndings, wrapBracketedPaste } from "../../../lib/utils";
import { netcattyBridge } from "../../../infrastructure/services/netcattyBridge";
import type {
Host,
KeyBinding,
@@ -119,6 +120,12 @@ export const createXTermRuntime = (ctx: CreateXTermRuntimeContext): XTermRuntime
const settings = ctx.terminalSettingsRef.current;
const rendererType = settings?.rendererType ?? "auto";
const bridge = netcattyBridge.get();
const isLocalTerminalHost = ctx.host.protocol === "local";
const windowsPty =
platform === "win32" && isLocalTerminalHost
? bridge?.getWindowsPtyInfo?.() ?? { backend: "conpty" as const }
: undefined;
const performanceConfig = resolveXTermPerformanceConfig({
platform,
@@ -157,6 +164,7 @@ export const createXTermRuntime = (ctx: CreateXTermRuntimeContext): XTermRuntime
const term = new XTerm({
...performanceConfig.options,
...(windowsPty ? { windowsPty } : {}),
// Override ignoreBracketedPasteMode if user explicitly disables bracketed paste
ignoreBracketedPasteMode: settings?.disableBracketedPaste ?? performanceConfig.options.ignoreBracketedPasteMode,
fontSize: effectiveFontSize,

View File

@@ -12,7 +12,7 @@ export const normalizeDistroId = (value?: string) => {
if (v.includes('alpine')) return 'alpine';
if (v.includes('amzn') || v.includes('amazon') || v.includes('aws')) return 'amazon';
if (v.includes('opensuse') || v.includes('suse') || v.includes('sles')) return 'opensuse';
if (v.includes('red hat') || v.includes('rhel')) return 'redhat';
if (v.includes('red hat') || v.includes('redhat') || v.includes('rhel')) return 'redhat';
if (v.includes('oracle')) return 'oracle';
if (v.includes('kali')) return 'kali';
return '';

View File

@@ -612,6 +612,7 @@ export interface TransferTask {
childTasks?: string[]; // For directory transfers
parentTaskId?: string;
skipConflictCheck?: boolean; // Skip conflict check for replace operations
retryable?: boolean; // False for task types that cannot be safely replayed through generic retry
}
export interface FileConflict {

View File

@@ -1,6 +1,3 @@
/* global __dirname */
const path = require('path');
/**
* @type {import('electron-builder').Configuration}
*/
@@ -37,8 +34,8 @@ module.exports = {
}
],
category: 'public.app-category.developer-tools',
hardenedRuntime: false,
gatekeeperAssess: false,
hardenedRuntime: true,
notarize: true,
entitlements: 'electron/entitlements.mac.plist',
entitlementsInherit: 'electron/entitlements.mac.plist',
extendInfo: {
@@ -49,24 +46,15 @@ module.exports = {
},
dmg: {
title: '${productName}',
background: 'public/dmg-background.jpg',
iconSize: 100,
iconTextSize: 12,
window: {
width: 672,
height: 500
width: 540,
height: 380
},
contents: [
{ x: 150, y: 158 },
{ x: 550, y: 158, type: 'link', path: '/Applications' },
{
x: 350,
y: 330,
type: 'file',
// Use absolute path resolved at build time
path: path.resolve(__dirname, 'scripts/FixQuarantine.app'),
name: '已损坏修复.app'
}
{ x: 140, y: 158 },
{ x: 400, y: 158, type: 'link', path: '/Applications' }
]
},
win: {

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) => {
@@ -167,9 +262,20 @@ const normalizeRemotePathString = async (client, inputPath) => {
return inputPath;
};
const isWindowsRemotePath = (dirPath) => /^[A-Za-z]:[\\/]/.test(dirPath) || /^[A-Za-z]:$/.test(dirPath);
const normalizeRemoteDirPath = (dirPath) => {
if (isWindowsRemotePath(dirPath)) {
const normalized = dirPath.replace(/\//g, "\\").replace(/\\+/g, "\\");
if (/^[A-Za-z]:$/.test(normalized)) return `${normalized}\\`;
return normalized;
}
return path.posix.normalize(dirPath);
};
const ensureRemoteDirInternal = async (sftp, dirPath, encoding) => {
if (!dirPath || dirPath === ".") return;
const normalized = path.posix.normalize(dirPath);
const normalized = normalizeRemoteDirPath(dirPath);
if (!normalized || normalized === ".") return;
// Optimization: Check if the full path already exists to avoid O(N) round trips
@@ -184,12 +290,22 @@ const ensureRemoteDirInternal = async (sftp, dirPath, encoding) => {
// If path doesn't exist or other error, proceed to recursive check
}
const isWindowsPath = isWindowsRemotePath(normalized);
const isAbsolute = normalized.startsWith("/");
const parts = normalized.split("/").filter(Boolean);
let current = isAbsolute ? "/" : "";
const parts = isWindowsPath
? normalized.slice(2).replace(/^[\\]+/, "").split(/[\\]+/).filter(Boolean)
: normalized.split("/").filter(Boolean);
let current = isWindowsPath
? `${normalized.slice(0, 2)}\\`
: (isAbsolute ? "/" : "");
for (const part of parts) {
current = current === "/" ? `/${part}` : (current ? `${current}/${part}` : part);
if (isWindowsPath) {
const base = current.replace(/[\\]+$/, "");
current = `${base}\\${part}`;
} else {
current = current === "/" ? `/${part}` : (current ? `${current}/${part}` : part);
}
const encodedCurrent = encodePath(current, encoding);
try {
const stats = await statAsync(sftp, encodedCurrent);
@@ -240,15 +356,11 @@ const ensureRemoteDirForSession = async (sftpId, dirPath, requestedEncoding) =>
if (!dirPath || dirPath === ".") return true;
const encoding = resolveEncodingForRequest(sftpId, requestedEncoding);
if (encoding === "utf-8") {
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);
// Always walk the path segment-by-segment. This lets sftp.stat() follow
// symlinked directory segments before deciding whether the next mkdir is
// valid, which avoids recursive mkdir failures on paths like /link/subdir.
const normalizedPath = await normalizeRemotePathString(client, dirPath);
await ensureRemoteDirInternal(sftp, normalizedPath, encoding);
return true;
@@ -891,10 +1003,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 +1124,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 +1138,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 +1153,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 +1167,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 +1184,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 +1419,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 +1457,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 +1470,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 +1485,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 +1505,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 +1543,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

@@ -17,6 +17,7 @@ let electronModule = null;
const DEFAULT_UTF8_LOCALE = "en_US.UTF-8";
const LOGIN_SHELLS = new Set(["bash", "zsh", "fish", "ksh"]);
const POWERSHELL_SHELLS = new Set(["powershell", "powershell.exe", "pwsh", "pwsh.exe"]);
const getLoginShellArgs = (shellPath) => {
if (!shellPath || process.platform === "win32") return [];
@@ -35,15 +36,34 @@ function init(deps) {
/**
* Find executable path on Windows
*/
function isWindowsAppExecutionAlias(filePath) {
if (!filePath || process.platform !== "win32") return false;
const normalizedPath = path.normalize(filePath).toLowerCase();
const windowsAppsDir = path.join(
process.env.LOCALAPPDATA || "",
"Microsoft",
"WindowsApps",
).toLowerCase();
return !!windowsAppsDir && normalizedPath.startsWith(`${windowsAppsDir}${path.sep}`);
}
function findExecutable(name) {
if (process.platform !== "win32") return name;
const { execFileSync } = require("child_process");
try {
const result = execFileSync("where.exe", [name], { encoding: "utf8" });
const firstLine = result.split(/\r?\n/)[0].trim();
if (firstLine && fs.existsSync(firstLine)) {
return firstLine;
const candidates = result
.split(/\r?\n/)
.map((line) => line.trim())
.filter(Boolean);
for (const candidate of candidates) {
if (!fs.existsSync(candidate)) continue;
if (name === "pwsh" && isWindowsAppExecutionAlias(candidate)) continue;
return candidate;
}
} catch (err) {
console.warn(`Could not find ${name} via where.exe:`, err.message);
@@ -51,11 +71,32 @@ function findExecutable(name) {
// Fallback to common locations
const path = require("node:path");
const commonPaths = [
const commonPaths = [];
if (name === "pwsh") {
commonPaths.push(
path.join(process.env.ProgramFiles || "C:\\Program Files", "PowerShell", "7", "pwsh.exe"),
path.join(process.env.ProgramW6432 || "C:\\Program Files", "PowerShell", "7", "pwsh.exe"),
);
}
if (name === "powershell") {
commonPaths.push(
path.join(
process.env.SystemRoot || "C:\\Windows",
"System32",
"WindowsPowerShell",
"v1.0",
"powershell.exe",
),
);
}
commonPaths.push(
path.join(process.env.SystemRoot || "C:\\Windows", "System32", "OpenSSH", `${name}.exe`),
path.join(process.env.ProgramFiles || "C:\\Program Files", "Git", "usr", "bin", `${name}.exe`),
path.join(process.env.ProgramFiles || "C:\\Program Files", "OpenSSH", `${name}.exe`),
];
);
for (const p of commonPaths) {
if (fs.existsSync(p)) return p;
@@ -64,6 +105,39 @@ function findExecutable(name) {
return name;
}
function getDefaultLocalShell() {
if (process.platform !== "win32") {
return process.env.SHELL || "/bin/bash";
}
const pwsh = findExecutable("pwsh");
if (pwsh && pwsh.toLowerCase() !== "pwsh") {
return pwsh;
}
const powershell = findExecutable("powershell");
if (powershell && powershell.toLowerCase() !== "powershell") {
return powershell;
}
return "powershell.exe";
}
function getLocalShellArgs(shellPath) {
if (!shellPath) return [];
if (process.platform !== "win32") {
return getLoginShellArgs(shellPath);
}
const shellName = path.basename(shellPath).toLowerCase();
if (POWERSHELL_SHELLS.has(shellName)) {
return ["-NoLogo"];
}
return [];
}
const isUtf8Locale = (value) => typeof value === "string" && /utf-?8/i.test(value);
const isEmptyLocale = (value) => {
@@ -97,11 +171,9 @@ function startLocalSession(event, payload) {
const sessionId =
payload?.sessionId ||
`${Date.now()}-${Math.random().toString(16).slice(2)}`;
const defaultShell = process.platform === "win32"
? findExecutable("powershell") || "powershell.exe"
: process.env.SHELL || "/bin/bash";
const defaultShell = getDefaultLocalShell();
const shell = payload?.shell || defaultShell;
const shellArgs = getLoginShellArgs(shell);
const shellArgs = getLocalShellArgs(shell);
const env = applyLocaleDefaults({
...process.env,
...(payload?.env || {}),
@@ -129,6 +201,7 @@ function startLocalSession(event, payload) {
}
const proc = pty.spawn(shell, shellArgs, {
name: env.TERM || "xterm-256color",
cols: payload?.cols || 80,
rows: payload?.rows || 24,
env,
@@ -666,10 +739,7 @@ function registerHandlers(ipcMain) {
* Get the default shell for the current platform
*/
function getDefaultShell() {
if (process.platform === "win32") {
return findExecutable("powershell") || "powershell.exe";
}
return process.env.SHELL || "/bin/bash";
return getDefaultLocalShell();
}
/**

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

@@ -641,6 +641,49 @@ const registerBridges = (win) => {
return localPath;
});
// Download SFTP file to temp with progress reporting via transfer events.
// Progress/complete/cancelled events are delivered via the netcatty:transfer:*
// channels (handled by transferBridge.startTransfer), so the IPC return value
// only carries the resolved temp path. Cancellation is NOT an error here —
// the UI already transitions the task to "cancelled" via the dedicated event.
ipcMain.handle("netcatty:sftp:downloadToTempWithProgress", async (event, { sftpId, remotePath, fileName, encoding, transferId }) => {
const localPath = await tempDirBridge.getTempFilePath(fileName);
const cleanupPartialDownload = async () => {
try {
await fs.promises.rm(localPath, { force: true });
} catch (err) {
console.warn(`[Main] Failed to clean temp download after interruption: ${localPath}`, err);
}
};
try {
const payload = {
transferId,
sourcePath: remotePath,
targetPath: localPath,
sourceType: "sftp",
targetType: "local",
sourceSftpId: sftpId,
sourceEncoding: encoding,
totalBytes: 0,
};
const result = await transferBridge.startTransfer(event, payload);
if (result.error) {
await cleanupPartialDownload();
if (result.error === "Transfer cancelled") {
return { localPath, cancelled: true };
}
throw new Error(result.error);
}
return { localPath, cancelled: false };
} catch (err) {
await cleanupPartialDownload();
throw err;
}
});
// Delete a temp file (for cleanup when editors close)
ipcMain.handle("netcatty:deleteTempFile", async (_event, { filePath }) => {
try {

View File

@@ -1,10 +1,12 @@
const { ipcRenderer, contextBridge, webUtils } = require("electron");
const os = require("node:os");
const dataListeners = new Map();
const exitListeners = new Map();
const transferProgressListeners = new Map();
const transferCompleteListeners = new Map();
const transferErrorListeners = new Map();
const transferCancelledListeners = new Map();
const chainProgressListeners = new Map();
const authFailedListeners = new Map();
const languageChangeListeners = new Set();
@@ -13,6 +15,13 @@ const keyboardInteractiveListeners = new Set();
const passphraseListeners = new Set();
const passphraseTimeoutListeners = new Set();
function cleanupTransferListeners(transferId) {
transferProgressListeners.delete(transferId);
transferCompleteListeners.delete(transferId);
transferErrorListeners.delete(transferId);
transferCancelledListeners.delete(transferId);
}
ipcRenderer.on("netcatty:data", (_event, payload) => {
const set = dataListeners.get(payload.sessionId);
if (!set) return;
@@ -143,10 +152,7 @@ ipcRenderer.on("netcatty:transfer:complete", (_event, payload) => {
console.error("Transfer complete callback failed", err);
}
}
// Cleanup listeners
transferProgressListeners.delete(payload.transferId);
transferCompleteListeners.delete(payload.transferId);
transferErrorListeners.delete(payload.transferId);
cleanupTransferListeners(payload.transferId);
});
ipcRenderer.on("netcatty:transfer:error", (_event, payload) => {
@@ -158,17 +164,15 @@ ipcRenderer.on("netcatty:transfer:error", (_event, payload) => {
console.error("Transfer error callback failed", err);
}
}
// Cleanup listeners
transferProgressListeners.delete(payload.transferId);
transferCompleteListeners.delete(payload.transferId);
transferErrorListeners.delete(payload.transferId);
cleanupTransferListeners(payload.transferId);
});
ipcRenderer.on("netcatty:transfer:cancelled", (_event, payload) => {
// Just cleanup listeners, the UI already knows it's cancelled
transferProgressListeners.delete(payload.transferId);
transferCompleteListeners.delete(payload.transferId);
transferErrorListeners.delete(payload.transferId);
const cb = transferCancelledListeners.get(payload.transferId);
if (cb) {
try { cb(); } catch { }
}
cleanupTransferListeners(payload.transferId);
});
// Upload with progress listeners
@@ -320,6 +324,19 @@ ipcRenderer.on("netcatty:trayPanel:setMenuData", (_event, data) => {
});
const api = {
getWindowsPtyInfo: () => {
if (process.platform !== "win32") {
return null;
}
const releaseParts = os.release().split(".");
const buildNumber = Number.parseInt(releaseParts[2] || "", 10);
const hasBuildNumber = Number.isFinite(buildNumber);
const backend =
hasBuildNumber && buildNumber < 18309 ? "winpty" : "conpty";
return hasBuildNumber ? { backend, buildNumber } : { backend };
},
startSSHSession: async (options) => {
const result = await ipcRenderer.invoke("netcatty:start", options);
return result.sessionId;
@@ -376,6 +393,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);
@@ -542,10 +561,7 @@ const api = {
return ipcRenderer.invoke("netcatty:transfer:start", options);
},
cancelTransfer: async (transferId) => {
// Cleanup listeners
transferProgressListeners.delete(transferId);
transferCompleteListeners.delete(transferId);
transferErrorListeners.delete(transferId);
cleanupTransferListeners(transferId);
return ipcRenderer.invoke("netcatty:transfer:cancel", { transferId });
},
// Compressed folder upload
@@ -712,6 +728,18 @@ const api = {
ipcRenderer.invoke("netcatty:openWithApplication", { filePath, appPath }),
downloadSftpToTemp: (sftpId, remotePath, fileName, encoding) =>
ipcRenderer.invoke("netcatty:sftp:downloadToTemp", { sftpId, remotePath, fileName, encoding }),
downloadSftpToTempWithProgress: (sftpId, remotePath, fileName, encoding, transferId, onProgress, onComplete, onError, onCancelled) => {
if (onProgress) transferProgressListeners.set(transferId, onProgress);
if (onComplete) transferCompleteListeners.set(transferId, onComplete);
if (onError) transferErrorListeners.set(transferId, onError);
if (onCancelled) transferCancelledListeners.set(transferId, onCancelled);
return ipcRenderer
.invoke("netcatty:sftp:downloadToTempWithProgress", { sftpId, remotePath, fileName, encoding, transferId })
.catch((err) => {
cleanupTransferListeners(transferId);
throw err;
});
},
// Save dialog for file downloads
showSaveDialog: (defaultPath, filters) =>

18
global.d.ts vendored
View File

@@ -124,9 +124,15 @@ declare global {
error?: string;
}
interface NetcattyWindowsPtyInfo {
backend: 'conpty' | 'winpty';
buildNumber?: number;
}
type PortForwardStatusCallback = (status: 'inactive' | 'connecting' | 'active' | 'error', error?: string) => void;
interface NetcattyBridge {
getWindowsPtyInfo?(): NetcattyWindowsPtyInfo | null;
startSSHSession(options: NetcattySSHOptions): Promise<string>;
startTelnetSession?(options: {
sessionId?: string;
@@ -229,6 +235,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;
@@ -541,6 +548,17 @@ declare global {
selectApplication?(): Promise<{ path: string; name: string } | null>;
openWithApplication?(filePath: string, appPath: string): Promise<boolean>;
downloadSftpToTemp?(sftpId: string, remotePath: string, fileName: string, encoding?: SftpFilenameEncoding): Promise<string>;
downloadSftpToTempWithProgress?(
sftpId: string,
remotePath: string,
fileName: string,
encoding: SftpFilenameEncoding | undefined,
transferId: string,
onProgress?: (transferred: number, total: number, speed: number) => void,
onComplete?: () => void,
onError?: (error: string) => void,
onCancelled?: () => void
): Promise<{ localPath: string; cancelled: boolean }>;
// Save dialog for file downloads
showSaveDialog?(defaultPath: string, filters?: Array<{ name: string; extensions: string[] }>): Promise<string | null>;

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';

View File

@@ -83,6 +83,15 @@ export class CloudSyncManager {
private autoSyncTimer: ReturnType<typeof setInterval> | null = null;
private masterPassword: string | null = null; // In memory only!
private hasStorageListener = false;
// Promise that resolves once startup provider secret decryption finishes.
// Awaited by getConnectedAdapter() to prevent using still-encrypted tokens.
private decryptionReady: Promise<void>;
// Per-provider flag: true once that provider's secrets have been
// successfully decrypted. When false, getConnectedAdapter() will
// retry decryption before using the tokens.
private providerDecrypted: Record<CloudProvider, boolean> = {
github: false, google: false, onedrive: false, webdav: false, s3: false,
};
// Per-provider sequence counters for async decrypt callbacks (startup,
// cross-window storage events). Bumped by any state mutation so stale
// decrypt results are discarded.
@@ -101,7 +110,7 @@ export class CloudSyncManager {
this.stateSnapshot = { ...this.state };
this.setupCrossWindowSync();
// Decrypt provider secrets asynchronously after initial load
this.initProviderDecryption();
this.decryptionReady = this.initProviderDecryption();
}
// ==========================================================================
@@ -201,10 +210,15 @@ export class CloudSyncManager {
// Only apply if no newer update has occurred during the async gap
if (seq === this.providerDecryptSeq[p]) {
this.state.providers[p] = decrypted;
this.providerDecrypted[p] = true;
}
} else {
// No secrets to decrypt — mark as done
this.providerDecrypted[p] = true;
}
} catch {
// Decryption failure is non-fatal; the adapter will fail on use
// Decryption failed — likely the Electron IPC handler is not yet
// registered. getConnectedAdapter() will retry for this provider.
}
}
this.notifyStateChange();
@@ -399,6 +413,35 @@ export class CloudSyncManager {
};
private async getConnectedAdapter(provider: CloudProvider): Promise<CloudAdapter> {
// Ensure startup decryption has finished before reading tokens
await this.decryptionReady;
// If this provider's secrets were not successfully decrypted at
// startup (IPC handler not registered yet), retry now.
if (!this.providerDecrypted[provider]) {
const conn = this.state.providers[provider];
if (conn.tokens || conn.config) {
try {
const seq = ++this.providerDecryptSeq[provider];
const decrypted = await decryptProviderSecrets(conn);
if (seq === this.providerDecryptSeq[provider]) {
this.state.providers[provider] = decrypted;
this.providerDecrypted[provider] = true;
// Evict any adapter cached with the old (encrypted) tokens
// so a fresh one is built from the decrypted credentials below.
const stale = this.adapters.get(provider);
if (stale) {
stale.signOut();
this.adapters.delete(provider);
}
this.notifyStateChange();
}
} catch {
// Still failing — will surface when adapter tries to use tokens
}
}
}
const connection = this.state.providers[provider];
const tokens = connection?.tokens;
const config = connection?.config;
@@ -1210,7 +1253,18 @@ export class CloudSyncManager {
}
const connectedProviders = Object.entries(this.state.providers)
.filter(([_, conn]) => conn.status === 'connected')
.filter(([p, conn]) => {
if (conn.status === 'connected') return true;
// Auto-recover: retry providers stuck in 'error' if tokens/config still exist
if (conn.status === 'error' && (conn.tokens || conn.config)) {
this.state.providers[p as CloudProvider].status = 'connected';
this.state.providers[p as CloudProvider].error = undefined;
// Clear cached adapter so a fresh one is created with current (decrypted) tokens
this.adapters.delete(p as CloudProvider);
return true;
}
return false;
})
.map(([p]) => p as CloudProvider);
if (connectedProviders.length === 0) {

View File

@@ -1,28 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleDevelopmentRegion</key>
<string>en</string>
<key>CFBundleDisplayName</key>
<string>Fix Quarantine</string>
<key>CFBundleExecutable</key>
<string>FixQuarantine</string>
<key>CFBundleIdentifier</key>
<string>com.netcatty.fixquarantine</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
<string>Fix Quarantine</string>
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>1.0.0</string>
<key>CFBundleVersion</key>
<string>1.0.0</string>
<key>CFBundleIconFile</key>
<string>FixQuarantine.icns</string>
<key>LSMinimumSystemVersion</key>
<string>10.13</string>
</dict>
</plist>

View File

@@ -1,17 +0,0 @@
#!/bin/bash
set -e
APP_PATH="/Applications/Netcatty.app"
if [ ! -d "$APP_PATH" ]; then
/usr/bin/osascript <<'EOF'
display alert "Netcatty.app not found" message "Drag Netcatty.app into /Applications, then run this tool again." as critical buttons {"OK"} default button "OK"
EOF
exit 1
fi
/usr/bin/osascript <<'EOF'
do shell script "xattr -dr com.apple.quarantine /Applications/Netcatty.app" with administrator privileges
EOF
open "$APP_PATH"

View File

@@ -1,10 +0,0 @@
# 1) 准备一张 1024x1024 PNG例如放在 public/dmg-fix-icon.png
# 2) 生成 iconset 并转 icns
ICONSET="scripts/fixquarantine.iconset"
mkdir -p "$ICONSET"
for size in 16 32 128 256 512; do
sips -z "$size" "$size" public/dmg-fix-icon.png --out "$ICONSET/icon_${size}x${size}.png" >/dev/null
sips -z "$((size*2))" "$((size*2))" public/dmg-fix-icon.png --out "$ICONSET/icon_${size}x${size}@2x.png" >/dev/null
done
iconutil -c icns "$ICONSET" -o scripts/FixQuarantine.app/Contents/Resources/FixQuarantine.icns
rm -rf $ICONSET