Compare commits

...

129 Commits

Author SHA1 Message Date
陈大猫
35194036cb Merge pull request #502 from crawt/perf/settings-window-prewarm-hide-on-close
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
perf(settings): prewarm settings window and hide on close
2026-03-25 01:24:48 +08:00
陈大猫
6a077a3855 Merge pull request #501 from binaricat/codex/optimize-ai-panel-tab-switch
Optimize AI panel tab switching
2026-03-25 01:19:30 +08:00
bincxz
43f4687bb9 Keep AI panel UI inside side panel layout 2026-03-25 01:13:49 +08:00
bincxz
bbb888ae1e Keep AI state mounted when side panels close 2026-03-25 01:09:36 +08:00
bincxz
c74b78a49d Reconcile AI session state with live sessions 2026-03-25 01:03:34 +08:00
bincxz
a7f42ec93e Avoid dropping unflushed AI sessions during cleanup 2026-03-25 00:57:48 +08:00
panwk
a886d509f8 perf(settings): prewarm settings window and hide on close
Instead of creating a new BrowserWindow on each user click, the settings window is now:
1. Pre-warmed silently 3 s after app startup (showOnLoad: false)
2. Hidden instead of destroyed when the user closes it
3. Instantly shown/focused on subsequent opens
2026-03-25 00:54:32 +08:00
bincxz
d6fea6c328 Preserve AI session state and cleanup across panel unmounts 2026-03-25 00:52:33 +08:00
bincxz
b6169f1735 Optimize AI panel tab switching 2026-03-25 00:46:59 +08:00
陈大猫
c97470a085 Merge pull request #500 from binaricat/codex/preserve-vault-hosts-state
Preserve vault hosts state across section switches
2026-03-25 00:38:10 +08:00
bincxz
98cb9d09df Preserve vault hosts state across vault section switches 2026-03-25 00:37:56 +08:00
陈大猫
9deb39dec2 Merge pull request #499 from binaricat/codex/jump-host-proxy-support
Support proxy config on jump hosts
2026-03-25 00:32:09 +08:00
bincxz
bb45279d4e Track jump-host proxy socket during chain setup 2026-03-25 00:23:55 +08:00
bincxz
6b1d9ee409 Gate jump-proxy checks on usable endpoints 2026-03-25 00:16:16 +08:00
bincxz
c0c0378df0 Ignore incomplete jump-host proxy configs 2026-03-25 00:09:26 +08:00
bincxz
093951150c Only validate first-hop jump proxies 2026-03-25 00:06:00 +08:00
bincxz
a0418039c4 Prefer jump-host proxy over target proxy guards 2026-03-25 00:04:35 +08:00
bincxz
559e71cfcc Block jump-host proxy auth placeholders 2026-03-25 00:02:59 +08:00
bincxz
a0a2567fa5 Validate jump-host proxy credentials early 2026-03-25 00:01:24 +08:00
陈大猫
d080a43ae6 Merge pull request #497 from crawt/feat/electron-v8-cache-lazy-bridges
feat(electron): enable V8 code cache and lazy-load non-critical bridges
2026-03-25 00:00:21 +08:00
bincxz
2c551cf5e8 Sanitize proxy credentials for jump hosts 2026-03-24 23:58:35 +08:00
bincxz
c54aa52191 Support proxy config on jump hosts 2026-03-24 23:56:28 +08:00
陈大猫
b8c838059a Merge pull request #496 from binaricat/codex/port-forward-jump-hosts
Support jump hosts for port forwarding
2026-03-24 23:55:00 +08:00
bincxz
007b4bd389 Treat cancelled port-forward setup as non-error 2026-03-24 23:50:00 +08:00
bincxz
13fd198243 Allow cancelling proxy setup for port forwarding 2026-03-24 23:48:29 +08:00
bincxz
2c562463c4 Respect cancellation during port-forward startup 2026-03-24 23:47:45 +08:00
bincxz
859d4b8156 Fix auto-start auth readiness checks 2026-03-24 23:45:54 +08:00
bincxz
c6e07cf149 Clean up port forwarding auto-start lint 2026-03-24 23:45:26 +08:00
bincxz
0ab18ce186 Fix port forwarding startup and cleanup races 2026-03-24 23:45:02 +08:00
bincxz
f814719b32 Fix jump-host port forwarding edge cases 2026-03-24 23:43:03 +08:00
bincxz
ee6b05892d Support jump hosts for port forwarding 2026-03-24 23:36:13 +08:00
陈大猫
0f98ffd4f7 Merge pull request #494 from binaricat/codex/ai-command-exec-fixes
Fix AI terminal execution completion and tool UI
2026-03-24 23:22:44 +08:00
bincxz
7ca5d0c832 Track pending ACP cancels during startup 2026-03-24 23:04:08 +08:00
bincxz
1a76d34696 Handle ACP startup cancellation and cmd echo 2026-03-24 23:01:41 +08:00
bincxz
0b2d1b613b Tighten prompt fallback matching 2026-03-24 22:59:35 +08:00
bincxz
ded989b374 Harden cmd tool-call echo handling 2026-03-24 22:57:18 +08:00
bincxz
04c6348bc0 Fix cmd wrapper variable expansion 2026-03-24 22:55:42 +08:00
bincxz
54297859e3 Fix AI cancellation and shell wrapper edge cases 2026-03-24 22:54:17 +08:00
panwk
d236adcd48 1.Enable V8 code caching for BrowserWindow instances by setting webPreferences.v8CacheOptions to bypassHeatCheck
2.Reduce eager main-process module loading by replacing several top-level bridge require() calls in main.cjs with lazy module getters
2026-03-24 22:48:15 +08:00
bincxz
4971f18bbe Fix AI terminal execution completion and tool UI 2026-03-24 22:41:40 +08:00
panwk
15687bd56e Merge branch 'main' of https://github.com/crawt/Netcatty 2026-03-24 22:00:14 +08:00
陈大猫
76675ec515 Merge pull request #492 from binaricat/fix/smooth-scroll-default-off-490
fix: default smooth scrolling to off to prevent scroll freeze
2026-03-24 19:51:50 +08:00
bincxz
7c6304c355 fix: default smooth scrolling to off to prevent scroll freeze (#490)
When smooth scrolling is enabled (smoothScrollDuration: 120ms) and
an AI agent produces high-throughput output, the scroll animation
can't keep up with incoming data, causing the viewport to get stuck
mid-buffer. Users can't scroll to the bottom or Ctrl+C to interrupt.

Default to false. Users who prefer smooth scrolling can still enable
it in Settings > Terminal.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 19:50:28 +08:00
陈大猫
8fdcbf87c2 Merge pull request #487 from binaricat/fix/empty-password-crash-482
fix: prevent crash on ECONNRESET from embedded SSH devices
2026-03-24 19:45:57 +08:00
bincxz
0326ba7556 fix: prevent duplicate exit events when conn.close fires before stream.close
ssh2 emits conn.once("close") before stream.on("close") during
transport drops. The conn.close handler was sending exit + deleting
the session, then stream.close would send a second misleading exit.

Now stream.close checks sessions.has() before sending exit, while
still flushing the data buffer unconditionally. This ensures:
- Buffer flush always happens (no data loss)
- Exit event is sent exactly once
- Transport errors are correctly reported

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 19:19:02 +08:00
bincxz
964230a737 fix: always use dynamic authHandler, detect encrypted PPK keys
P1: Change authMethods.length condition from > 1 to >= 1 so the
dynamic authHandler (which includes 'none' probing) is always used,
even when only keyboard-interactive is available. Fixes the
passwordless embedded device case when no keys/agent are discovered.

P1: Add PPK encryption detection to isKeyEncrypted() — check for
"Encryption:" header in PuTTY PPK format. Without this, encrypted
.ppk files were treated as unencrypted and attempted without a
passphrase, failing silently instead of triggering the passphrase
retry flow.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 19:09:37 +08:00
bincxz
5d551ee8e9 fix: address codex P1/P2 — agent none auth, PPK support, FIFO safety
P1: Add "none" to the agent-mode simple array auth path so passwordless
devices work even when agent forwarding is configured.

P1: Extend looksLikePrivateKey() to recognize PuTTY PPK format
("PuTTY-User-Key-File" prefix) so PPK keys in ~/.ssh/ are not
incorrectly filtered out.

P2: Add stat().isFile() check before readFile() in all key discovery
paths to skip FIFOs, sockets, directories, and other non-regular files
that would block readFile() indefinitely.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 18:40:27 +08:00
bincxz
ec4e209972 fix: address codex P1s — transport error in stream close, key content validation
P1: Transport errors on established sessions now surface correctly.
The stream.on("close") handler (which fires before conn close and
after buffer flush) checks session._transportError and sends exit
with exitCode:1 and the error message instead of a misleading
exitCode:0 "closed".

P1: Add looksLikePrivateKey() content validation to all key discovery
functions. Files matching id_* that don't start with "-----BEGIN" or
"openssh-key-v1" are skipped, preventing non-key files from being
passed to ssh2 as privateKey (which would abort connect before
password/agent fallback could run).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 18:00:47 +08:00
bincxz
c141fbc11e fix: defer post-settle exit event to preserve buffered stream data
Codex P2: when a transport error (ECONNRESET) arrives after the session
is established, the error handler was immediately sending netcatty:exit,
causing preload to remove data listeners before the stream close handler
could flush the 8ms data buffer. Users would lose the last chunk of
terminal output.

Now the error handler stores the error message on the session object
(_transportError) instead of sending exit immediately. The close handler
(which fires after stream close + buffer flush) checks for this flag
and sends the exit event with the transport error info.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 17:48:07 +08:00
bincxz
8e61ccac91 fix: address agent review — double exit event, array none auth, label consistency
Medium: Close handler now checks sessions.has(sessionId) before sending
netcatty:exit, preventing a misleading exitCode:0 "closed" event after
the error handler already reported the real transport failure.

Medium: Array-based auth path in buildAuthHandler now includes "none"
as the first method, matching the dynamic handler behavior.

Low: Set lastAttemptedLabel to "none (no credentials)" so the rejection
message is consistent with the initial onAuthAttempt callback.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 17:32:00 +08:00
bincxz
7c5047f22e feat: scan ~/.ssh/ for all id_* keys instead of hardcoded list
Replace the fixed DEFAULT_KEY_NAMES array ("id_ed25519", "id_ecdsa",
"id_rsa") with a directory scan using /^id_[\w-]+$/ regex, matching
Tabby's PrivateKeyLocator behavior. This discovers keys like
id_ed25519_work, id_dsa, or any custom-named key automatically.

Preferred keys (ed25519, ecdsa, rsa) are still tried first, followed
by any additional keys found in alphabetical order.

Applied to both sshBridge.cjs and sshAuthHelper.cjs (all four
key discovery functions + the get-default-keys IPC handler).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 17:25:18 +08:00
bincxz
c10100a314 feat: always try SSH 'none' auth first (matches OpenSSH and Tabby)
Restore unconditional 'none' auth as the first method tried. Per
RFC 4252, the 'none' request is the standard way for clients to
discover which auth methods the server supports. It also enables
passwordless login on embedded devices (#482).

This matches the behavior of OpenSSH (which always sends 'none'
first) and Tabby (which unconditionally adds { type: 'none' } as
the first element of allAuthMethods). Most SSH servers do not count
'none' toward MaxAuthTries per the RFC.

Applied to both the main SSH authHandler and the shared
buildAuthHandler used by SFTP/chain/exec connections.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 17:19:18 +08:00
bincxz
5a294aa306 revert: remove automatic 'none' auth probing (needs separate feature)
Codex review identified P1 issues: automatic 'none' auth before any
other method can exhaust MaxAuthTries on hardened servers, breaking
connections that previously worked. The 'none' auth support for
embedded devices should be a user-facing option, not automatic.

This commit reverts the 'none' auth additions while keeping the
crash prevention fixes (settled guard, conn.destroy, error wrapping).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 17:09:50 +08:00
bincxz
54b3ba2c01 fix: address Codex review — conditional none auth and post-ready error handling
P2: Only try 'none' auth when no explicit credentials (password/key/agent)
are configured. Avoids wasting an auth attempt on servers with low
MaxAuthTries.

P2: Post-settle errors on active sessions now send netcatty:exit to the
renderer instead of being silently swallowed, so transport failures
(keepalive timeout, ECONNRESET) are correctly reported as errors.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 16:55:38 +08:00
bincxz
f25822fdae feat: support SSH 'none' auth for embedded devices with no password
The SSH protocol's 'none' auth method allows login without any
credentials — common on embedded devices (routers, switches) where
root has no password. ssh2 tries this by default, but Netcatty's
custom authHandler and buildAuthHandler overrode the default behavior
and never attempted 'none', making it impossible to connect to these
devices.

Now both authHandlers try 'none' as the first method (before any
other auth) on the initial call (methodsLeft === null). If the server
accepts it, the connection succeeds immediately. If rejected, the
normal auth flow continues with publickey/password/keyboard-interactive.

This is the root cause of #482: the user's embedded device needed
'none' auth, but Netcatty never tried it, then the auth failure +
ECONNRESET combination crashed the app.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 16:44:22 +08:00
bincxz
69f433c161 fix: prevent crash on ECONNRESET from embedded devices with empty password (#482)
When connecting to embedded devices with legacy algorithms and no password,
the SSH connection could crash the app with an uncaught ECONNRESET exception.

Three fixes:
1. Guard against duplicate error handling in conn.on("error") — once the
   promise is settled, late errors (e.g. ECONNRESET after auth failure)
   are logged but no longer re-reject or re-notify the renderer.
2. Destroy the SSH connection on error/timeout to prevent the underlying
   TCP socket from emitting further uncaught errors.
3. Wrap non-auth errors in startSSHSessionWrapper with clean Error objects
   so Electron's ipcMain.handle can serialize them back to the renderer.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 16:39:29 +08:00
陈大猫
6087343203 Merge pull request #489 from binaricat/fix/restore-npm-rebuild-macos-474
fix: restore npmRebuild for macOS/Windows to fix local terminal crash
2026-03-24 16:37:54 +08:00
bincxz
bb63de2658 fix: restore npmRebuild for macOS/Windows to fix posix_spawnp crash (#474)
PR #449 set npmRebuild: false in electron-builder.config.cjs to fix a
Linux architecture mismatch. But this also disabled native module
recompilation for macOS and Windows builds, causing node-pty to ship
with the wrong ABI (Node.js instead of Electron). On macOS, this
manifests as "posix_spawnp failed" when opening a local terminal.

Restore npmRebuild: true. Linux builds are unaffected because they
already run ensure-node-pty-linux.sh before packaging with explicit
npm_config_arch, and the redundant rebuild uses the same arch setting.

User confirmed: 1.0.62 works, 1.0.63 (first release after #449) fails.

Closes #474

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 16:32:54 +08:00
陈大猫
fd938a84e4 Merge pull request #485 from yaotiancheng-ola/feature/macos_stats
feat(terminal): support server stats on macOS
2026-03-24 16:29:15 +08:00
陈大猫
c2e629ad61 Merge pull request #488 from binaricat/fix/sftp-permissions-not-displayed-480
fix: SFTP permissions dialog shows empty (000) instead of actual file permissions
2026-03-24 16:21:08 +08:00
bincxz
4bf61c02a0 fix: pass permissions field from SFTP listing to frontend (#480)
The remote file listing mapper in useSftpDirectoryListing.ts was
dropping the `permissions` field returned by the backend. This caused
the permissions dialog to show all checkboxes unchecked (000) and the
file list to show "--" in the permissions column.

One-line fix: add `permissions: f.permissions` to the mapped object.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 16:17:22 +08:00
陈大猫
4747217929 Merge pull request #486 from binaricat/fix/sftp-filename-tooltip-480
fix(sftp): show full filename tooltip on hover
2026-03-24 16:15:42 +08:00
bincxz
fb3cdd0661 fix(sftp): show full filename tooltip on hover in file list (#480)
Add title attribute to the file name span so truncated names reveal
their full text via native browser tooltip on hover.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 16:02:41 +08:00
陈大猫
11ca8fba87 Merge pull request #484 from binaricat/feat/unified-auth-logs-and-sftp-progress
feat: unified auth logging for SSH and SFTP connections
2026-03-24 15:55:52 +08:00
bincxz
7ffc4b4c7f fix: address Codex round 4 — keyboard-interactive progress for all paths
P2: Wrap keyboard-interactive handlers in SSH chain, SFTP chain, and
SFTP main connections to emit "waiting for user input..." and "user
responded" progress events, matching the SSH main connection behavior.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 15:47:14 +08:00
bincxz
fe27dd8a9d fix: address Codex round 3 — accurate auth logs and clean state
P2: Remove premature onAuthAttempt calls from buildAuthHandler's array
branch — methods are listed before connect(), making logs inaccurate.

P2: Handle "waiting for user input..." and "user responded" as literal
log messages, not as "Trying X..." format, in both SSH and SFTP.

P3: Clear connectionLogs after successful SFTP connect so directory
navigation doesn't replay stale auth transcript in the loading overlay.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 15:40:05 +08:00
bincxz
eca11e9d2a fix: address Codex round 2 — array auth logging, cached overlay, stale listener
P2: Emit onAuthAttempt notifications from buildAuthHandler's array
branch so single-method SFTP connections (e.g. password-only) show
auth method logs in the connection panel.

P3: Show connectionLogs in the cached-files loading overlay so repeat
connections still display auth progress during reconnect.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 15:26:52 +08:00
徐三
779aa31ef8 chore(terminal): clarify server stats scope comment
- update Terminal server-stats comment to reflect Linux/macOS support
- no runtime behavior changes
2026-03-24 15:21:47 +08:00
徐三
2c8670a6c6 fix(terminal): stop server-stats polling on unsupported OS
- add explicit Linux/macOS guard in server-stats hook
- return UNSUPPORTED_OS from ssh bridge when uname is not Linux/Darwin
- fail fast when stats payload cannot be parsed to avoid futile polling
- wire Terminal to pass supported-OS hint to useServerStats
2026-03-24 15:18:12 +08:00
bincxz
a94293d31e fix: address Codex review — scoped progress, local reset, connected event
P2: Guard SFTP progress callback with navSeqRef check to prevent stale
auth logs from leaking into a reused tab after retry/disconnect.

P3: Reset connectionLogs when connecting to local filesystem, avoiding
stale remote auth logs showing in the local pane.

P3: Emit 'connected' progress event when the final SFTP SSH session
is ready, so the log confirms the connection completed.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 15:11:42 +08:00
徐三
04b62f7ba3 feat(terminal): support server stats on macOS via remote OS auto-detection
- auto-detect remote OS in sshBridge using uname -s
- add macOS stats collection path (CPU, memory, swap, processes, disk, network)
- keep existing Linux stats pipeline and parsing logic
- remove Linux-only gating in useServerStats and Terminal display logic
- show server stats whenever connected (not restricted by host.os)
- add CPU hover fallback UI when per-core data is unavailable (e.g. macOS)
- update bridge type docs in global.d.ts to reflect cross-OS stats support
2026-03-24 15:00:33 +08:00
bincxz
45794b7f6f feat: unified auth logging for SSH and SFTP connections
Add detailed authentication method logs to both SSH terminal and SFTP
connection flows, giving users visibility into which methods are tried,
rejected, or require input.

Backend (shared):
- sshAuthHelper buildAuthHandler: track lastAttemptedLabel, log method
  rejections and "all methods exhausted" via onAuthAttempt callback
- sftpBridge: add sendSftpProgress helper, wire onAuthAttempt to both
  chain and main buildAuthHandler calls, emit connecting/authenticating/
  connected/error progress events via new IPC channel

Backend (SSH-specific):
- sshBridge: log method rejections in custom authHandler, log
  keyboard-interactive prompt/response and all-methods-exhausted

IPC/Bridge:
- preload: register netcatty:sftp:connection-progress listener, expose
  onSftpConnectionProgress in bridge API
- global.d.ts: add onSftpConnectionProgress type

Frontend (SFTP):
- types.ts: add connectionLogs to SftpPane
- useSftpConnections: subscribe to progress events during connect,
  convert to human-readable log lines, accumulate in pane state
- SftpPaneFileList: show logs below spinner during connecting, show
  expandable "Show logs" in error view with collapsible log panel

Frontend (SSH):
- createTerminalSessionStarters: format rejected methods with ✗ prefix
  and "all methods exhausted" message

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 14:34:25 +08:00
陈大猫
314072a631 Merge pull request #479 from binaricat/feat/ssh-config-identity-file
feat: support IdentityFile from SSH config import
2026-03-24 14:07:08 +08:00
bincxz
c9f1951e28 fix: address Codex review — quoted paths, stale keys, managed source round-trip
P1: serializeHostsToSshConfig now emits IdentityFile directives so
managed ssh_config sources preserve key paths on sync. Paths with
spaces are automatically quoted.

P2: Unquote IdentityFile paths during import — ssh_config allows
quoted paths for filenames with spaces, but the quotes were stored
literally and caused fs.readFile to fail.

P2: Clear identityFilePaths when applying an identity profile, and
only forward them at connection time when no vault key is selected.
Prevents stale local key paths from triggering unrelated passphrase
prompts after switching to a different credential source.

P1 (SFTP): Forward identityFilePaths for jump hosts in SFTP credentials.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 13:59:36 +08:00
bincxz
7f83b22c95 fix: address Codex review — SFTP jump host identity files and skip handling
P1: Pass identityFilePaths for jump hosts in SFTP credentials so chain
connections can load IdentityFile keys for bastion hosts.

P2: When the passphrase dialog is skipped or times out (not just
cancelled), clear the encrypted key and continue to the next identity
file. Previously skip/timeout fell through and left the encrypted key
in connOpts, causing the same stall this feature is meant to fix.

Applies to all 4 identity file loading paths (SSH chain, SSH main,
SFTP chain, SFTP main).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 13:53:05 +08:00
bincxz
b7082ab198 feat: add native file picker for local key file selection
Replace the manual-only text input with a file picker button that opens
the system file dialog (showOpenDialog with showHiddenFiles enabled so
~/.ssh/ keys are visible). Users can still type a path manually or use
the browse button.

Changes:
- electron/main.cjs: add netcatty:selectFile IPC handler
- electron/preload.cjs: expose selectFile on bridge
- global.d.ts: add selectFile type
- HostDetailsPanel.tsx: add FolderOpen browse button next to path input

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 13:51:08 +08:00
bincxz
9369495e22 feat: add local key file path UI in host editor
Add "Local Key File" option in the host credential type selector.
Users can specify local SSH key file paths (e.g. ~/.ssh/id_ed25519)
as an alternative to selecting a key from the vault. This is the
primary UI for keys imported via SSH config's IdentityFile directive.

UI behavior:
- Credential selector now shows three options: Key, Certificate,
  Local Key File
- Local key file paths are displayed as a list with delete buttons
- Text input with Enter/Add support for adding new paths
- Selecting a vault key clears local key paths (and vice versa)
- Paths are stored as host.identityFilePaths and resolved at
  connection time

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 13:51:08 +08:00
bincxz
e3fdb1f7ff feat: support IdentityFile from SSH config import (#463)
SSH config import now parses the `IdentityFile` directive and stores
the file paths on the host as `identityFilePaths`. At connection time,
the SSH and SFTP bridges resolve these paths, read the key file content,
and use it for authentication — matching the behavior of OpenSSH and
Tabby.

If the key file is encrypted, a passphrase dialog is shown before
connecting. If the user cancels, the key is skipped and auth falls back
to other methods. If the file doesn't exist, a warning is logged and
the next key path is tried.

Changes:
- domain/models.ts: add `identityFilePaths` to Host interface
- domain/vaultImport.ts: parse `IdentityFile`, expand `~`, store paths
- global.d.ts: add `identityFilePaths` to NetcattySSHOptions and
  NetcattyJumpHost types
- createTerminalSessionStarters.ts: pass identityFilePaths for both
  main connection and jump hosts
- useSftpHostCredentials.ts: pass identityFilePaths for SFTP
- sshBridge.cjs: read identity files at connection time for both main
  and chain connections, with encrypted key passphrase prompting
- sftpBridge.cjs: same for SFTP main and chain connections

Closes #463

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 13:51:08 +08:00
陈大猫
b9bc6b95e5 Merge pull request #477 from binaricat/fix/chain-encrypted-key-passphrase-463
fix: prompt passphrase for encrypted keys on jump hosts and SFTP
2026-03-24 13:48:40 +08:00
bincxz
5cbaae8d2f fix: throw auth-level error on SFTP passphrase cancel for password fallback
Address Codex P2: when the passphrase dialog is cancelled, the thrown
error now includes 'authentication' in the message and sets
level='client-authentication'. This allows the SFTP frontend's
isAuthError() check to recognize it and fall back to the password
retry path, preserving the key-first-then-password behavior.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 13:43:05 +08:00
bincxz
915e571c63 fix: use readable host/key label in passphrase dialog
Address Codex P3: the passphrase modal was showing UUIDs or generic
placeholders like "private-key" / "hop-1-key" instead of the host
label or hostname. Now pass the human-readable label/hostname as
keyName so users can identify which key needs the passphrase.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 13:37:06 +08:00
bincxz
86a43655e1 fix: destroy proxy socket when SFTP passphrase is cancelled
Address Codex P2: when using a proxy and an encrypted key, cancelling
the passphrase dialog cleaned up chain connections but leaked the
proxy socket in connectionSocket. Now explicitly destroy it.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 13:29:07 +08:00
bincxz
e47d86874f fix: clean up chain connections when SFTP passphrase is cancelled
Address Codex P2: when the passphrase dialog is cancelled for the
final SFTP host, any already-open proxy/jump-host connections were
leaked because the throw bypassed the cleanup path. Now explicitly
end all chain connections before throwing.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 13:22:39 +08:00
bincxz
369de6fff2 fix: clear encrypted key when passphrase is skipped or times out
Address Codex P1 review: when the passphrase dialog is skipped or
times out, the encrypted key was left in connOpts.privateKey without
a passphrase. buildAuthHandler would still attempt it as publickey-user,
causing the same stall this PR fixes. Now delete connOpts.privateKey
in all non-success paths so auth falls back to password/keyboard-interactive.

Applies to SSH chain, SFTP chain, and SFTP main connection paths.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 13:16:05 +08:00
陈大猫
3aa414ad05 Merge pull request #478 from binaricat/codex/fix-sidebar-snippet-execution-order
fix: restore proper snippet paste semantics for sidebar clicks
2026-03-24 13:13:36 +08:00
bincxz
356c27d0fb fix: send auto-run Enter outside bracketed paste markers
Codex review caught a P1 regression: when a multi-line snippet had
noAutoRun=false, the \r was appended before wrapping in bracketed
paste, causing shells to treat the Enter as pasted text instead of a
submit action. Now the bracketed paste wraps only the command text,
and \r is appended afterward so it is sent as a real keypress.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 12:51:22 +08:00
bincxz
ae94e7e529 fix: register snippet executor only after terminal is connected
Address Codex review feedback: the snippet executor was registered on
mount before the session was ready, causing sidebar snippet clicks to
be silently dropped during the connecting/reconnecting window instead
of falling through to TerminalLayer's raw writeToSession fallback.

Now the executor is only published when status === "connected" and is
cleared back to null on disconnect so the fallback path is used for
sessions that aren't ready.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 12:45:23 +08:00
bincxz
5828503ffc fix: restore proper snippet paste semantics for sidebar clicks 2026-03-24 11:48:02 +08:00
bincxz
1c0f45e410 fix: prompt passphrase for encrypted keys on jump hosts and SFTP (#463)
When an SSH config specifies an encrypted IdentityFile for a jump host
(e.g. `IdentityFile ~/.ssh/id_ed25519` with passphrase protection),
the chain connection passed the encrypted key to ssh2 without a
passphrase. ssh2 failed to parse it and the auth hung until timeout,
with no user-visible prompt.

The same issue existed for SFTP connections using encrypted keys.

Now detect encrypted keys via `isKeyEncrypted()` before connecting and
prompt the user for the passphrase via the existing passphrase dialog.
If the user cancels, a clear error is shown. If skipped, auth falls
back to other methods (password, keyboard-interactive, default keys).

Closes #463

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 11:23:07 +08:00
陈大猫
5c791cebe5 Merge pull request #476 from binaricat/fix/ssh-error-crash-452
fix: prevent SSH connection errors from crashing the entire app
2026-03-24 10:42:23 +08:00
bincxz
0ce6b0f777 fix: expand non-fatal network error coverage in safety net
Add EHOSTDOWN, ENETDOWN, EPROTO, EPERM to the isNonFatalNetworkError
check. Also refactor to switch/case for readability.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 10:40:33 +08:00
bincxz
6fca38a209 fix: prevent SSH connection errors from crashing the entire app (#452)
ssh2 emits multiple error events per failed connection (e.g. ECONNRESET
followed by "Connection lost before handshake"). Several code paths used
`.once("error")` which removed the listener after the first event,
leaving the second error unhandled and crashing the process via the
uncaughtException handler's re-throw.

Root cause: `runDistroDetection` ran unconditionally after connection
attempts (including failures), creating a new SSHClient to the same
unreachable host. Its `execCommand` used `.once("error")`, so the
second ssh2 error event had no listener and became an uncaught exception.

Fixes:
- execCommand: `.once("error")` → `.on("error")` with settled guard and
  explicit `conn.end()` cleanup
- runDistroDetection: move into try block so it only runs after
  successful connections
- portForwardingBridge: same `.once` → `.on` fix
- sftpBridge: add catch-all error listener after cleanup() removes the
  pre-ready listeners
- main.cjs: suppress non-fatal SSH/network errors in uncaughtException
  and unhandledRejection handlers as defense-in-depth (log to crash
  bridge, do not re-throw)

Closes #452

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 10:31:51 +08:00
Leo Pan
52541a6066 将 SSH 已有的 8ms / 16KB PTY 缓冲策略移植到 Local、Telnet、Mosh (#473)
抽出共享 createPtyBuffer helper,减少高吞吐场景下的 IPC 压力

Co-authored-by: panwukan <panwukan@yco.pet>
2026-03-24 09:35:48 +08:00
panwukan
6d35301436 将 SSH 已有的 8ms / 16KB PTY 缓冲策略移植到 Local、Telnet、Mosh
抽出共享 createPtyBuffer helper,减少高吞吐场景下的 IPC 压力
2026-03-24 06:40:12 +08:00
陈大猫
5d29c8d91a fix: support IPv6 addresses in quick connect and fix display formatting (#472)
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: support bare IPv6 addresses in quick connect and fix IPv6 display

- Accept un-bracketed IPv6 addresses (e.g. 2607:f130::4f06) in quick
  connect input. The main regex requires brackets for IPv6+port, but now
  falls back to detecting bare IPv6 (2+ colons, hex-only) when the
  primary pattern fails.
- Add formatHostPort() helper that wraps IPv6 addresses in brackets
  when appending a port, preventing ambiguous displays like
  "2607:f130::4f06:22"
- Apply formatHostPort in QuickConnectWizard, TerminalConnectionDialog,
  and SftpSidePanel
- Fix hop label formatting in sshBridge and sftpBridge for IPv6 jump
  hosts

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

* fix: truncate long hostnames in connection dialog

Add truncate to the host label and protocol subtitle in the connection
dialog so long IPv6 addresses don't overflow into the action buttons.

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

* fix: constrain connection dialog header so truncate works correctly

Add min-w-0/flex-1 to the left side of the header flex container and
shrink-0 to the avatar so long hostnames truncate instead of pushing
into the Show logs / close buttons.

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

* fix: prevent action buttons from being squeezed by long hostname

Add shrink-0 and left margin to the right-side button group so truncated
text doesn't crowd into Show logs / close buttons.

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

* fix: tighten bare IPv6 detection to avoid MAC address false positives

Only accept bare (un-bracketed) hex:colon strings as IPv6 if they
contain '::' (unambiguously IPv6) or have exactly 7 colons (full
8-group notation). This rejects MAC addresses like aa:bb:cc:dd:ee:ff
(5 colons) which would otherwise trigger quick-connect mode.

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

* fix: avoid double-wrapping already-bracketed IPv6 hop labels

Add !startsWith('[') guard so hostnames that are already bracketed
(e.g. from URL-imported hosts) don't produce malformed labels like
[[2607:f130::4f06]]:22.

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

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-23 23:13:58 +08:00
陈大猫
196b1f8dbb feat: add terminal smooth scrolling setting (#471)
- Add smoothScrolling boolean to TerminalSettings (default: true)
- Wire setting to xterm.js smoothScrollDuration (120ms when on, 0 when off)
- Add toggle in terminal settings UI
- Include in sync payload and i18n strings (en, zh-CN)

Inspired by #467 (@crawt).

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-23 22:39:03 +08:00
陈大猫
f1065745bc perf(keyword-highlight): skip cellMap for ASCII lines and share empty result array (#470)
- Use a regex ASCII test to detect lines where string indices equal cell
  columns, skipping the buildStringToCellMap buffer walk entirely. Most
  terminal output is ASCII, so this avoids the majority of cell API calls.
- Share a frozen empty array for non-matching lines instead of allocating
  a new array per scanLine call, reducing GC pressure during scrollback.

Inspired by #466 (@crawt).

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-23 22:24:39 +08:00
陈大猫
c67befa0e9 perf(keyword-highlight): reduce latency with throttled rAF and line cache (#469)
* perf(keyword-highlight): reduce highlight latency with throttled rAF and line cache

Based on #464 by @crawt with fixes for review feedback:

- Split triggerRefresh into immediate (rAF) and debounced (setTimeout) modes
  so onWriteParsed highlights land with fresh content instead of trailing
  by 200ms
- Throttle the immediate path (50ms min interval) to prevent heavy output
  like tail -f from refreshing every frame
- Add per-line match result cache (LRU, bounded by cacheEntries config)
  so repeated or scrolled-back lines skip regex scanning entirely
- Lazily build cellMap only when a regex match is found, avoiding
  unnecessary work on non-matching lines
- Fix buildStringToCellMap to handle empty cells (codepoint 0) which
  translateToString() renders as spaces — keeps the map aligned with
  the string and makes lineText a safe cache key
- Clean up animationFrameId and matchCache on dispose/rule change

Co-Authored-By: Leo Pan <crawt@users.noreply.github.com>
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: guard rAF callback against stale state and add debounce fallback

- Re-check enabled/alternate-buffer inside the rAF callback so a
  pending frame doesn't resurrect decorations after the user disables
  highlighting or enters an alternate-buffer app
- Schedule a debounce timer alongside rAF so background/hidden tabs
  (where Chromium suspends rAF) still get highlight updates

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

* fix: prevent fallback timer from being cleared on rAF-pending path

- Don't clear debounceTimer at the start of immediate mode — in hidden
  tabs rAF stays pending indefinitely, so repeated onWriteParsed calls
  were clearing the only timer that could actually fire
- Cancel debounceTimer inside the rAF callback instead, so foreground
  tabs don't get a redundant second refreshViewport() 200ms later
- Only arm a new fallback timer if one isn't already pending

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

* fix: clear stale rAF in fallback timer and add alternate buffer guard

- Cancel the pending rAF and clear animationFrameId in the fallback
  timer callback so hidden-tab refreshes don't leave animationFrameId
  stuck, which would block all future immediate refreshes
- Add enabled/alternate-buffer re-check in the fallback callback,
  matching the guard already present in the rAF callback

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

* fix: extract executeRefresh to ensure all timer paths clear stale rAF

A debounced-path timer (from scroll/resize) could fire without clearing
a stale animationFrameId left by an earlier immediate-path rAF that
never executed (hidden tab). This left the immediate path permanently
blocked.

Extract executeRefresh() with rAF cleanup + state guards, used by all
three callback sites (rAF, immediate fallback, debounced timer).

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

---------

Co-authored-by: Leo Pan <crawt@users.noreply.github.com>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-23 22:17:01 +08:00
陈大猫
cea83d6cb1 Revert "Mod:perf(keyword-highlight): reduce highlight latency and redundant regex scanning (#464)" (#468)
This reverts commit 293ee46b26.
2026-03-23 21:46:04 +08:00
Leo Pan
293ee46b26 Mod:perf(keyword-highlight): reduce highlight latency and redundant regex scanning (#464)
* perf(keyword-highlight): reduce highlight latency and redundant regex scanning

- Split triggerRefresh into two modes: "immediate" (rAF, for new output
  and rule changes) and "debounced" (setTimeout, for scroll/resize),
  eliminating the fixed 200ms delay after each write that caused visible
  highlight lag on commands like `ls`.
- Add per-line match result cache (LRU, bounded by cacheEntries config)
  so repeated or scrolled-back lines skip regex scanning entirely.
- Lazily build the string-to-cell column map only when a regex match is
  actually found, avoiding unnecessary work on non-matching lines.
- Clean up animationFrameId and matchCache on dispose/rule change to
  prevent leaks and stale results.

* fix: include cell layout in highlight cache key to prevent misplaced decorations

Two IBufferLines can produce identical translateToString() output but
differ in cell layout (e.g. empty cells vs real space characters after
tab stops). Using lineText alone as the cache key could return cached
x/width ranges computed from a different cell layout, producing
misplaced or truncated highlights.

Build the cellMap eagerly and include it in the cache key so lines with
different cell structures get separate cache entries. Pass the pre-built
cellMap into scanLine to avoid redundant work.

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

---------

Co-authored-by: panwk <panwukan@suangoo.com>
Co-authored-by: bincxz <16399091+binaricat@users.noreply.github.com>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-23 21:43:29 +08:00
陈大猫
a6af1dffed fix: resolve SSH chain connection hang and improve connection progress (#465)
* fix: resolve SSH chain connection hang and improve connection progress

- Fix Promise never settling when conn 'close' fires before 'ready'
  during chain connections, which caused "reply was never sent" error
- Replace fake timed progress animation with real backend events
- Send granular connection progress for all SSH connections (not just
  chain), including: connecting, key exchange, auth attempts, forwarding,
  shell opening
- Surface auth method attempts (SSH agent, key names, password) in
  progress logs so users can diagnose authentication failures
- Include error details in progress events for better error visibility

Closes #463

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

* fix: scope progress events by sessionId, prevent duplicate errors, hide chain UI for direct SSH

- Add sessionId to chain progress payload so events are scoped per session (P1)
- Set settled=true in error/timeout handlers to prevent close handler from
  emitting a second misleading 'closed unexpectedly' error (P2)
- Only show chain progress UI when total > 1 so direct SSH connections
  don't render as 'Chain 1/1' (P3)

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

* fix: mark shell-open failure as settled before closing connection

The conn.shell() error branch calls conn.end() which triggers the close
handler, but settled was not set yet, causing a duplicate 'closed
unexpectedly' error to overwrite the real shell-open failure message.

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

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-23 21:28:44 +08:00
陈大猫
0a3e61af4b Merge pull request #462 from binaricat/fix/snippet-execution-order
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: normalize line endings and bracket-paste multi-line snippets
2026-03-23 17:51:06 +08:00
bincxz
9e4a79acd7 fix: remove unconditional bracket paste from sidebar, fix broadcast
- TerminalLayer: remove bracket paste wrapping since we can't check
  term.modes.bracketedPasteMode here — keep only normalizeLineEndings
- createXTermRuntime: broadcast un-wrapped data before applying
  bracket paste, so target sessions don't receive literal escape
  sequences meant for the source terminal's paste mode state

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-23 17:44:49 +08:00
bincxz
a62353bb41 fix: respect bracketedPasteMode and disableBracketedPaste for snippets
Only wrap multi-line snippets in bracket paste sequences when:
- createXTermRuntime: term.modes.bracketedPasteMode is active AND
  disableBracketedPaste setting is false (matches paste handler)
- TerminalLayer: disableBracketedPaste setting is false (no access
  to term.modes, but respects user opt-out)

Prevents sending literal ^[[200~ escape sequences to shells that
don't support or have disabled bracketed paste mode.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-23 17:39:48 +08:00
bincxz
d2ab27ab92 fix: normalize line endings and bracket-paste multi-line snippets
Snippet execution via sidebar click was missing normalizeLineEndings()
and bracket paste wrapping that the paste handler and shortkey handler
already apply. On Windows ConPTY/PowerShell, sending raw multi-line
input without bracket paste can cause out-of-order line execution
because the shell processes lines individually and asynchronously.

- Add normalizeLineEndings() to sidebar snippet click handler
- Wrap multi-line snippets in bracketed paste sequences (\e[200~...\e[201~)
  so the shell treats them as a single atomic paste
- Apply same fix to shortkey snippet handler for consistency
- Fix broadcast payload to use the processed data

Fixes #455

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-23 17:33:36 +08:00
陈大猫
65f62983b6 Merge pull request #461 from binaricat/fix/sftp-home-dir
fix: detect actual home directory for SFTP auto-open
2026-03-23 17:21:16 +08:00
bincxz
56d3109d23 fix: abort timed-out exec channel, treat realpath '/' as ambiguous
- Close/destroy the SSH exec stream when the 5s timeout fires to
  avoid leaking session slots (MaxSessions).
- Treat SFTP realpath('.') returning '/' as non-authoritative so
  non-root users fall through to the candidate probe chain instead
  of incorrectly opening at root.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-23 17:15:13 +08:00
bincxz
34ab6c0e98 fix: add 5s timeout to SSH echo ~ home dir probe
Prevent indefinite blocking when the remote shell init hangs or a
forced command never exits. Falls through to SFTP realpath after
timeout.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-23 17:07:32 +08:00
bincxz
3db9b0aa26 fix: restore listSftp fallback when statSftp is unavailable
Preserve the original fallback behavior for bridges that don't expose
statSftp — probe candidate directories via listSftp instead.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-23 17:03:06 +08:00
陈大猫
fe49ea74e2 Merge pull request #460 from binaricat/fix/update-metadata-verify
ci: verify and recover update metadata after artifact merge
2026-03-23 16:59:38 +08:00
bincxz
be91740582 fix: add actions:read permission for artifact recovery in release job
gh run download requires actions:read scope. Without it, the recovery
step would fail silently when trying to re-download individual artifacts.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-23 16:56:27 +08:00
bincxz
ad15d8ceb5 fix: detect actual home directory for SFTP instead of hardcoding /home
Query the remote server for the real home directory using two methods:
1. SSH exec `echo ~` — works for any user regardless of home path
2. SFTP realpath('.') — fallback, SFTP cwd is typically home dir

Falls back to the previous hardcoded /home/{username} candidates if
both methods fail. This fixes SFTP auto-open sidebar not navigating
to the correct directory for users with non-standard home paths
(e.g. /usr/home, /export/home, custom paths).

Fixes #458

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-23 16:54:36 +08:00
bincxz
c37fe8f9e0 ci: verify and recover update metadata after artifact merge
download-artifact@v4 merge-multiple can silently drop files when
multiple artifacts contain same-named files (builder-debug.yml).
This caused latest-mac.yml to be missing from v1.0.64 release.

Add a verification step that checks all platform update yml files
exist after merge. If any are missing, re-downloads individual
artifacts to recover them. Fails the release if recovery fails.

Fixes #456

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-23 16:44:52 +08:00
陈大猫
b0924c14b1 Merge pull request #454 from binaricat/feat/crash-logs
feat: crash log capture and viewer in Settings
2026-03-23 15:56:12 +08:00
bincxz
774c25086e fix: truncate crash log env info with tooltip on overflow
Replace flex-wrap layout with single-line truncate + title tooltip
for the environment metadata row, preventing awkward wrapping when
the settings window is narrow.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-23 15:45:45 +08:00
bincxz
05c0d43bc4 feat: enrich crash logs with error metadata and process details
- Extract error properties (code, errno, syscall, hostname, port,
  signal, level) into errorMeta field for system-level diagnostics.
- Add extra field for structured context (e.g. render-process-gone
  reason and exitCode as separate fields, not just a string).
- Add process PID for correlating with OS-level logs.
- Accept optional extra parameter in captureError() for callers to
  attach structured context data.
- Display errorMeta and extra as tagged badges in the crash log viewer.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-23 15:41:45 +08:00
bincxz
baac8670d3 feat: enrich crash log entries with environment diagnostics
Add electronVersion, osVersion, memoryUsage (RSS/heap in MB),
activeSessionCount, and process uptime to each crash log entry.
Display these fields inline in the Settings crash log viewer.

These extra fields help diagnose issues like #452 where knowing
the session count and memory state at crash time is critical.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-23 15:34:02 +08:00
bincxz
c84bf497f2 fix: address codex review round 6 — stream line counting, tail-read logs
- listLogs: stream-count newlines instead of reading entire file content
  just to compute entryCount.
- readLog: read only the last 256KB of large files and parse the tail,
  avoiding O(file_size) memory/CPU for crash-loop scenarios.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-23 15:23:14 +08:00
bincxz
ac5f708eba fix: address codex review round 5 — filter benign rejections and clean exits
- Skip EPIPE/ERR_STREAM_DESTROYED in unhandledRejection handler to
  avoid false positives in crash logs.
- Skip render-process-gone events with reason 'clean-exit' since
  those are normal shutdowns, not crashes.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-23 15:12:46 +08:00
bincxz
ecba2560c9 fix: address codex review round 4 — skip benign errors, check openPath result
- Move EPIPE/ERR_STREAM_DESTROYED check before captureError so benign
  stream teardown errors don't pollute crash logs.
- Check shell.openPath return value (error string) instead of always
  returning success: true.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-23 15:03:27 +08:00
bincxz
ff638c64cd fix: address codex review round 3 — dedupe logs, reload after clear
- Mark re-thrown unhandledRejection errors so uncaughtException handler
  skips duplicate logging.
- Reload crash log list after clearing instead of blindly emptying,
  so partial delete failures still show remaining files.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-23 14:54:23 +08:00
bincxz
3db6465340 fix: address codex review round 2 — early require, stale request guard
- Move crashLogBridge require before process error handlers so it is
  available if a bridge import throws during startup.
- Add request ID ref to handleExpandCrashLog to discard out-of-order
  results when the user clicks different log files in quick succession.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-23 14:21:50 +08:00
bincxz
2b4f8d33c9 fix: address codex review — re-throw unhandled rejections, early crash capture
- P1: Re-throw in unhandledRejection handler to preserve default fatal
  semantics instead of silently swallowing rejections.
- P2: Fall back to require('electron').app.getPath('userData') in
  ensureLogDir() so crash logs work even before init() is called,
  catching early startup failures.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-23 14:14:04 +08:00
bincxz
bc6c0a2ef6 feat: add crash log capture and viewer in Settings > System
Capture main-process errors (uncaughtException, unhandledRejection,
render-process-gone) to JSONL log files in userData/crash-logs/ with
30-day auto-rotation. Users can view, expand, and clear crash logs
from Settings > System to help diagnose issues like #452.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-23 14:05:56 +08:00
陈大猫
9cccc943ff Merge pull request #451 from tces1/patch-1 2026-03-23 12:31:30 +08:00
Eric Chan
cecda50ce2 Add 'meslolgs nf' to local fonts list
Fixes an issue on macOS where MesloLGS NF was incorrectly filtered out of the terminal font list
2026-03-23 12:28:30 +08:00
bincxz
c136006108 fix: prevent x64 build from producing arm64 packages with wrong native modules
Some checks failed
build-packages / release (push) Has been cancelled
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
The linux target config specified arch: ['x64', 'arm64'] for each format,
causing the x64 build job to also produce arm64 packages. These packages
contained x86-64 native modules (node-pty, serialport) since the x64 job
only rebuilds for x64. When artifacts were merged in the release job,
the incorrect arm64 deb from the x64 build could overwrite the correct
one from the arm64 build.

Remove arch from linux target config so the CLI flags (--x64/--arm64)
control which architecture is built per job.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-23 10:25:12 +08:00
陈大猫
ba073219e5 Merge pull request #450 from binaricat/fix/linux-native-module-arch-verification
ci(linux): enhance native module arch verification
2026-03-23 09:43:41 +08:00
li88iioo
034e5ea3bc ci(linux): enhance artifact verification and architecture handling
- Added environment variables for npm configuration to specify architecture in CI jobs for both x64 and arm64 builds.
- Implemented verification steps for downloaded Linux deb artifacts, ensuring both amd64 and arm64 versions are checked for integrity.
- Updated the `ensure-node-pty-linux.sh` script to resolve and verify serialport prebuilds, ensuring compatibility with the specified architecture.
- Enhanced the `verify-linux-deb-artifact.sh` script to allow optional deb file input and improved error handling for missing artifacts.

These changes improve the reliability of the build process and ensure that the correct native modules are used for each architecture.
2026-03-23 09:40:56 +08:00
66 changed files with 4570 additions and 1263 deletions

View File

@@ -93,6 +93,8 @@ jobs:
name: build-linux-x64
runs-on: ubuntu-22.04
env:
npm_config_arch: x64
npm_config_target_arch: x64
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 }}
@@ -159,6 +161,8 @@ jobs:
container:
image: debian:bullseye
env:
npm_config_arch: arm64
npm_config_target_arch: arm64
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 }}
@@ -226,6 +230,7 @@ jobs:
if: startsWith(github.ref, 'refs/tags/') || (github.event_name == 'workflow_dispatch' && inputs.publish_release)
permissions:
contents: write
actions: read
steps:
- name: Checkout
uses: actions/checkout@v4
@@ -239,6 +244,54 @@ jobs:
- name: List artifacts
run: ls -la artifacts/
- name: Verify update metadata files
run: |
missing=0
for f in latest-mac.yml latest.yml latest-linux.yml latest-linux-arm64.yml; do
if [ ! -f "artifacts/$f" ]; then
echo "::warning::Missing $f in merged artifacts, attempting recovery..."
missing=1
fi
done
if [ "$missing" = "1" ]; then
echo "Re-downloading individual artifacts to recover missing files..."
for name in netcatty-macos netcatty-windows netcatty-linux-x64 netcatty-linux-arm64; do
tmpdir="/tmp/artifact-${name}"
gh run download ${{ github.run_id }} --name "${name}" --dir "${tmpdir}" 2>/dev/null || true
if [ -d "${tmpdir}" ]; then
for yml in "${tmpdir}"/latest*.yml; do
[ -f "$yml" ] && cp -v "$yml" artifacts/
done
fi
done
echo "After recovery:"
ls -la artifacts/*.yml
fi
# Final check — fail if any update yml is still missing
for f in latest-mac.yml latest.yml latest-linux.yml latest-linux-arm64.yml; do
if [ ! -f "artifacts/$f" ]; then
echo "::error::$f is still missing after recovery attempt"
exit 1
fi
done
echo "All update metadata files present."
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Verify downloaded Linux amd64 deb artifact
run: |
deb_file="$(find artifacts -maxdepth 1 -type f -name '*-linux-amd64.deb' -print | sort | head -n 1)"
test -n "${deb_file}"
bash scripts/verify-linux-deb-artifact.sh amd64 "${deb_file}"
- name: Verify downloaded Linux arm64 deb artifact metadata
env:
VERIFY_LOAD: "0"
run: |
deb_file="$(find artifacts -maxdepth 1 -type f -name '*-linux-arm64.deb' -print | sort | head -n 1)"
test -n "${deb_file}"
bash scripts/verify-linux-deb-artifact.sh arm64 "${deb_file}"
- name: Generate Release Body
run: node .github/scripts/generate-release-note.js
env:

14
App.tsx
View File

@@ -381,16 +381,11 @@ function App({ settings }: { settings: SettingsState }) {
}
}, [updateState.autoDownloadStatus, updateState.latestRelease?.version, t, installUpdate, openReleasePage]);
// Memoize keys for port forwarding to prevent unnecessary re-renders
const portForwardingKeys = useMemo(
() => keys.map((k) => ({ id: k.id, privateKey: k.privateKey, passphrase: k.passphrase, })),
[keys]
);
// Auto-start port forwarding rules on app launch
usePortForwardingAutoStart({
hosts,
keys: portForwardingKeys,
keys,
identities,
});
// Sync tray menu data + handle tray actions
@@ -452,9 +447,8 @@ function App({ settings }: { settings: SettingsState }) {
return;
}
const keysForPf = keys.map((k) => ({ id: k.id, privateKey: k.privateKey, passphrase: k.passphrase }));
if (start) {
void startTunnel(rule, host, keysForPf, (status, error) => {
void startTunnel(rule, host, hosts, keys, identities, (status, error) => {
if (status === "error" && error) toast.error(error);
}, rule.autoStart);
} else {
@@ -466,7 +460,7 @@ function App({ settings }: { settings: SettingsState }) {
unsubscribeFocus?.();
unsubscribeToggle?.();
};
}, [hosts, keys, portForwardingRules, sessions, setActiveTabId, setWorkspaceFocusedSession, startTunnel, stopTunnel, t]);
}, [hosts, identities, keys, portForwardingRules, sessions, setActiveTabId, setWorkspaceFocusedSession, startTunnel, stopTunnel, t]);
// Tray panel actions (from main process)
useEffect(() => {

View File

@@ -99,6 +99,21 @@ const en: Messages = {
'settings.system.credentials.unavailableHint': 'Credentials encrypted on another user profile or machine cannot be decrypted here. Re-enter and save credentials on this device.',
'settings.system.credentials.portabilityHint': 'Cloud Sync is portable because it uses your master key encryption. Local safeStorage encryption is device/user scoped.',
// Settings > System > Crash Logs
'settings.system.crashLogs.title': 'Crash Logs',
'settings.system.crashLogs.description': 'View error logs from the main process to help diagnose unexpected behavior.',
'settings.system.crashLogs.noLogs': 'No crash logs found.',
'settings.system.crashLogs.entries': '{count} entries',
'settings.system.crashLogs.clear': 'Clear all logs',
'settings.system.crashLogs.cleared': 'Cleared {count} log file(s).',
'settings.system.crashLogs.source': 'Source',
'settings.system.crashLogs.time': 'Time',
'settings.system.crashLogs.message': 'Message',
'settings.system.crashLogs.stack': 'Stack Trace',
'settings.system.crashLogs.hint': 'Crash logs are retained for 30 days and automatically rotated.',
'settings.system.crashLogs.collapse': 'Collapse',
'settings.system.crashLogs.expand': 'Show details',
// Settings > System > Software Update
'settings.update.title': 'Software Update',
'settings.update.currentVersion': 'Current version',
@@ -296,6 +311,9 @@ const en: Messages = {
'settings.terminal.behavior.scrollOnPaste': 'Scroll on paste',
'settings.terminal.behavior.scrollOnPaste.desc':
'Scroll terminal to bottom when pasting text',
'settings.terminal.behavior.smoothScrolling': 'Smooth scrolling',
'settings.terminal.behavior.smoothScrolling.desc':
'Animate terminal viewport scrolling instead of jumping instantly',
'settings.terminal.behavior.linkModifier': 'Link modifier key',
'settings.terminal.behavior.linkModifier.desc': 'Hold this key to click on links in terminal',
'settings.terminal.behavior.linkModifier.none': 'None (click directly)',
@@ -876,9 +894,12 @@ const en: Messages = {
'hostDetails.password.save': 'Save password',
'hostDetails.identity.suggestions': 'Identities',
'hostDetails.identity.missing': 'Identity not found',
'hostDetails.credential.keyCertificate': 'Key, Certificate',
'hostDetails.credential.keyCertificate': 'Key, Certificate, Local Key File',
'hostDetails.credential.key': 'Key',
'hostDetails.credential.certificate': 'Certificate',
'hostDetails.credential.localKeyFile': 'Local Key File',
'hostDetails.credential.localKeyFilePlaceholder': '~/.ssh/id_ed25519',
'hostDetails.credential.browseKeyFile': 'Browse...',
'hostDetails.credential.missing': 'Credential not found',
'hostDetails.keys.search': 'Search keys...',
'hostDetails.keys.empty': 'No keys available',

View File

@@ -83,6 +83,21 @@ const zhCN: Messages = {
'settings.system.credentials.unavailableHint': '在其他用户或机器上加密的凭据无法在此处解密。请在当前设备重新输入并保存凭据。',
'settings.system.credentials.portabilityHint': '云同步可跨设备,因为使用主密钥加密;本地 safeStorage 加密仅绑定当前系统用户/设备。',
// Settings > System > Crash Logs
'settings.system.crashLogs.title': '崩溃日志',
'settings.system.crashLogs.description': '查看主进程错误日志,帮助诊断异常行为。',
'settings.system.crashLogs.noLogs': '未找到崩溃日志。',
'settings.system.crashLogs.entries': '{count} 条记录',
'settings.system.crashLogs.clear': '清除所有日志',
'settings.system.crashLogs.cleared': '已清除 {count} 个日志文件。',
'settings.system.crashLogs.source': '来源',
'settings.system.crashLogs.time': '时间',
'settings.system.crashLogs.message': '消息',
'settings.system.crashLogs.stack': '堆栈跟踪',
'settings.system.crashLogs.hint': '崩溃日志保留 30 天,超期自动清理。',
'settings.system.crashLogs.collapse': '收起',
'settings.system.crashLogs.expand': '查看详情',
// Settings > System > Software Update
'settings.update.title': '软件更新',
'settings.update.currentVersion': '当前版本',
@@ -568,9 +583,12 @@ const zhCN: Messages = {
'hostDetails.password.save': '保存密码',
'hostDetails.identity.suggestions': '身份',
'hostDetails.identity.missing': '身份不存在',
'hostDetails.credential.keyCertificate': '密钥 / 证书',
'hostDetails.credential.keyCertificate': '密钥 / 证书 / 本地密钥',
'hostDetails.credential.key': '密钥',
'hostDetails.credential.certificate': '证书',
'hostDetails.credential.localKeyFile': '本地密钥文件',
'hostDetails.credential.localKeyFilePlaceholder': '~/.ssh/id_ed25519',
'hostDetails.credential.browseKeyFile': '浏览…',
'hostDetails.credential.missing': '凭据不存在',
'hostDetails.keys.search': '搜索密钥…',
'hostDetails.keys.empty': '暂无密钥',
@@ -1204,6 +1222,8 @@ const zhCN: Messages = {
'settings.terminal.behavior.scrollOnKeyPress.desc': '按键(例如 Enter时将终端滚动到底部',
'settings.terminal.behavior.scrollOnPaste': '粘贴时自动滚动',
'settings.terminal.behavior.scrollOnPaste.desc': '粘贴文本时将终端滚动到底部',
'settings.terminal.behavior.smoothScrolling': '平滑滚动',
'settings.terminal.behavior.smoothScrolling.desc': '滚动终端视口时使用平滑动画',
'settings.terminal.behavior.linkModifier': '链接修饰键',
'settings.terminal.behavior.linkModifier.desc': '按住此键再点击终端中的链接',
'settings.terminal.behavior.linkModifier.none': '无(直接点击)',

View File

@@ -7,6 +7,7 @@ export interface SftpPane {
loading: boolean;
reconnecting: boolean;
error: string | null;
connectionLogs: string[];
selectedFiles: Set<string>;
filter: string;
filenameEncoding: SftpFilenameEncoding;
@@ -33,6 +34,7 @@ export const createEmptyPane = (
loading: false,
reconnecting: false,
error: null,
connectionLogs: [],
selectedFiles: new Set(),
filter: "",
filenameEncoding: "auto",

View File

@@ -159,6 +159,7 @@ export const useSftpConnections = ({
loading: true,
reconnecting: false,
error: null,
connectionLogs: [],
filenameEncoding, // Reset encoding for new connection
}));
@@ -213,13 +214,57 @@ export const useSftpConnections = ({
loading: true,
reconnecting: prev.reconnecting,
error: null,
connectionLogs: [],
files: prev.reconnecting ? prev.files : (sharedHostCache?.files ?? []),
filenameEncoding, // Reset encoding for new connection
}));
// Subscribe to SFTP connection progress events for auth logging
const sftpSessionId = `sftp-${connectionId}`;
let unsubSftpProgress: (() => void) | undefined;
const bridge = netcattyBridge.get();
if (bridge?.onSftpConnectionProgress) {
unsubSftpProgress = bridge.onSftpConnectionProgress((sid, label, status, detail) => {
if (sid !== sftpSessionId) return;
let logLine: string;
switch (status) {
case 'connecting':
logLine = `Connecting to ${label}...`;
break;
case 'authenticating':
logLine = `${label} - Key exchange complete`;
break;
case 'auth-attempt':
if (detail?.endsWith('rejected')) {
logLine = `${label} - ✗ ${detail}`;
} else if (detail === 'all methods exhausted') {
logLine = `${label} - ✗ All authentication methods exhausted`;
} else if (detail === 'waiting for user input...' || detail === 'user responded') {
logLine = `${label} - ${detail}`;
} else {
logLine = `${label} - Trying ${detail}...`;
}
break;
case 'connected':
logLine = `${label} - Connected`;
break;
case 'error':
logLine = `${label} - Error${detail ? `: ${detail}` : ''}`;
break;
default:
logLine = `${label} - ${status}${detail ? `: ${detail}` : ''}`;
}
// Only update if this is still the active request (avoids stale logs leaking)
if (navSeqRef.current[side] !== connectRequestId) return;
updateTab(side, activeTabId, (prev) => ({
...prev,
connectionLogs: [...prev.connectionLogs, logLine],
}));
});
}
try {
const credentials = getHostCredentials(host);
const bridge = netcattyBridge.get();
const openSftp = bridge?.openSftp;
if (!openSftp) throw new Error("SFTP bridge unavailable");
@@ -278,8 +323,24 @@ export const useSftpConnections = ({
let homeDir = sharedHostCache?.homeDir ?? startPath;
if (!sharedHostCache) {
const statSftp = netcattyBridge.get()?.statSftp;
if (statSftp) {
// Detect home directory: SSH exec `echo ~` → SFTP realpath('.') → hardcoded fallback
const bridge = netcattyBridge.get();
let detected = false;
if (bridge?.getSftpHomeDir) {
try {
const result = await bridge.getSftpHomeDir(sftpId);
if (result?.success && result.homeDir) {
startPath = result.homeDir;
homeDir = result.homeDir;
detected = true;
}
} catch {
// Fall through to hardcoded candidates
}
}
if (!detected) {
const candidates: string[] = [];
if (credentials.username === "root") {
candidates.push("/root");
@@ -289,63 +350,33 @@ export const useSftpConnections = ({
} else {
candidates.push("/root");
}
for (const candidate of candidates) {
try {
const stat = await statSftp(sftpId, candidate, filenameEncoding);
if (stat?.type === "directory") {
startPath = candidate;
homeDir = candidate;
break;
}
} catch {
// Ignore missing/permission errors
}
}
} else {
if (credentials.username === "root") {
try {
const rootFiles = await netcattyBridge.get()?.listSftp(sftpId, "/root", filenameEncoding);
if (rootFiles) {
startPath = "/root";
homeDir = "/root";
}
} catch {
// Fallback path not available
}
} else if (credentials.username) {
try {
const homeFiles = await netcattyBridge.get()?.listSftp(
sftpId,
`/home/${credentials.username}`,
filenameEncoding,
);
if (homeFiles) {
startPath = `/home/${credentials.username}`;
homeDir = startPath;
}
} catch {
// Fall through to /root check
}
if (startPath === "/") {
const statSftp = bridge?.statSftp;
if (statSftp) {
for (const candidate of candidates) {
try {
const rootFiles = await netcattyBridge.get()?.listSftp(sftpId, "/root", filenameEncoding);
if (rootFiles) {
startPath = "/root";
homeDir = "/root";
const stat = await statSftp(sftpId, candidate, filenameEncoding);
if (stat?.type === "directory") {
startPath = candidate;
homeDir = candidate;
break;
}
} catch {
// Fallback path not available
// Ignore missing/permission errors
}
}
} else {
try {
const rootFiles = await netcattyBridge.get()?.listSftp(sftpId, "/root", filenameEncoding);
if (rootFiles) {
startPath = "/root";
homeDir = "/root";
// Fallback: probe candidates via listSftp when statSftp is unavailable
for (const candidate of candidates) {
try {
const files = await bridge?.listSftp(sftpId, candidate, filenameEncoding);
if (files) {
startPath = candidate;
homeDir = candidate;
break;
}
} catch {
// Ignore missing/permission errors
}
} catch {
// Fallback path not available
}
}
}
@@ -421,6 +452,7 @@ export const useSftpConnections = ({
files,
loading: false,
reconnecting: false,
connectionLogs: [], // Clear after successful connect to avoid replay during navigation
}));
} catch (err) {
if (navSeqRef.current[side] !== connectRequestId) return;
@@ -438,6 +470,8 @@ export const useSftpConnections = ({
loading: false,
reconnecting: false,
}));
} finally {
unsubSftpProgress?.();
}
}
},

View File

@@ -49,6 +49,7 @@ export const useSftpDirectoryListing = () => {
sizeFormatted: formatFileSize(size),
lastModified,
lastModifiedFormatted: formatDate(lastModified),
permissions: f.permissions,
linkTarget: f.linkTarget as "file" | "directory" | null | undefined,
};
});

View File

@@ -1,5 +1,6 @@
import { useCallback } from "react";
import type { Host, Identity, SSHKey } from "../../../domain/models";
import { isEncryptedCredentialPlaceholder, sanitizeCredentialValue } from "../../../domain/credentials";
import { resolveHostAuth } from "../../../domain/sshAuth";
interface UseSftpHostCredentialsParams {
@@ -24,22 +25,32 @@ export const useSftpHostCredentials = ({
host: host.proxyConfig.host,
port: host.proxyConfig.port,
username: host.proxyConfig.username,
password: host.proxyConfig.password,
password: sanitizeCredentialValue(host.proxyConfig.password),
}
: undefined;
let jumpHosts: NetcattyJumpHost[] | undefined;
if (host.hostChain?.hostIds && host.hostChain.hostIds.length > 0) {
jumpHosts = host.hostChain.hostIds
.map((hostId) => hosts.find((h) => h.id === hostId))
.filter((h): h is Host => !!h)
.map((jumpHost) => {
.map((jumpHost, index) => {
const jumpAuth = resolveHostAuth({
host: jumpHost,
keys,
identities,
});
const jumpKey = jumpAuth.key;
const hasConfiguredJumpProxyEndpoint =
index === 0 &&
!!(jumpHost.proxyConfig?.host && jumpHost.proxyConfig?.port);
if (
hasConfiguredJumpProxyEndpoint &&
jumpHost.proxyConfig?.username &&
isEncryptedCredentialPlaceholder(jumpHost.proxyConfig.password) &&
!sanitizeCredentialValue(jumpHost.proxyConfig.password)
) {
throw new Error(`Proxy credentials for jump host "${jumpHost.label || jumpHost.hostname}" cannot be decrypted on this device. Open host settings and re-enter the proxy password.`);
}
return {
hostname: jumpHost.hostname,
port: jumpHost.port || 22,
@@ -52,9 +63,23 @@ export const useSftpHostCredentials = ({
keyId: jumpAuth.keyId,
keySource: jumpKey?.source,
label: jumpHost.label,
proxy: jumpHost.proxyConfig?.host && jumpHost.proxyConfig?.port
? {
type: jumpHost.proxyConfig.type,
host: jumpHost.proxyConfig.host,
port: jumpHost.proxyConfig.port,
username: jumpHost.proxyConfig.username,
password: sanitizeCredentialValue(jumpHost.proxyConfig.password),
}
: undefined,
identityFilePaths: jumpHost.identityFilePaths,
};
});
}
const usesTargetProxyForFirstHop = !!proxyConfig && !jumpHosts?.[0]?.proxy;
if (usesTargetProxyForFirstHop && host.proxyConfig?.username && isEncryptedCredentialPlaceholder(host.proxyConfig.password) && !proxyConfig?.password) {
throw new Error("Proxy credentials cannot be decrypted on this device. Open host settings and re-enter the proxy password.");
}
return {
hostname: host.hostname,
@@ -70,6 +95,7 @@ export const useSftpHostCredentials = ({
proxy: proxyConfig,
jumpHosts: jumpHosts && jumpHosts.length > 0 ? jumpHosts : undefined,
sudo: host.sftpSudo,
identityFilePaths: host.identityFilePaths,
};
},
[hosts, identities, keys],

View File

@@ -12,6 +12,7 @@ import {
STORAGE_KEY_AI_COMMAND_TIMEOUT,
STORAGE_KEY_AI_MAX_ITERATIONS,
STORAGE_KEY_AI_SESSIONS,
STORAGE_KEY_AI_ACTIVE_SESSION_MAP,
STORAGE_KEY_AI_AGENT_MODEL_MAP,
STORAGE_KEY_AI_WEB_SEARCH,
} from '../../infrastructure/config/storageKeys';
@@ -32,6 +33,12 @@ function getAIBridge() {
return (window as unknown as { netcatty?: Record<string, (...args: unknown[]) => unknown> }).netcatty;
}
const AI_STATE_CHANGED_EVENT = 'netcatty:ai-state-changed';
function emitAIStateChanged(key: string) {
window.dispatchEvent(new CustomEvent<{ key: string }>(AI_STATE_CHANGED_EVENT, { detail: { key } }));
}
function cleanupAcpSessions(sessionIds: string[]) {
const bridge = getAIBridge();
if (!bridge?.aiAcpCleanup || sessionIds.length === 0) return;
@@ -40,6 +47,48 @@ function cleanupAcpSessions(sessionIds: string[]) {
}
}
export function cleanupOrphanedAISessions(activeTargetIds: Set<string>) {
const currentSessions = latestAISessionsSnapshot
?? localStorageAdapter.read<AISession[]>(STORAGE_KEY_AI_SESSIONS)
?? [];
const removedSessionIds = currentSessions
.filter((session) => session.scope.targetId && !activeTargetIds.has(session.scope.targetId))
.map((session) => session.id);
if (removedSessionIds.length === 0) return;
cleanupAcpSessions(removedSessionIds);
const removedSessionIdSet = new Set(removedSessionIds);
const nextSessions = currentSessions.filter((session) => {
if (!session.scope.targetId) return true;
return activeTargetIds.has(session.scope.targetId);
});
setLatestAISessionsSnapshot(nextSessions);
localStorageAdapter.write(STORAGE_KEY_AI_SESSIONS, pruneSessionsForStorage(nextSessions));
emitAIStateChanged(STORAGE_KEY_AI_SESSIONS);
const activeSessionIdMap = latestAIActiveSessionMapSnapshot
?? localStorageAdapter.read<Record<string, string | null>>(STORAGE_KEY_AI_ACTIVE_SESSION_MAP)
?? {};
let activeSessionMapChanged = false;
const nextActiveSessionIdMap = { ...activeSessionIdMap };
for (const [scopeKey, sessionId] of Object.entries(activeSessionIdMap)) {
if (sessionId && removedSessionIdSet.has(sessionId)) {
nextActiveSessionIdMap[scopeKey] = null;
activeSessionMapChanged = true;
}
}
if (activeSessionMapChanged) {
setLatestAIActiveSessionMapSnapshot(nextActiveSessionIdMap);
localStorageAdapter.write(STORAGE_KEY_AI_ACTIVE_SESSION_MAP, nextActiveSessionIdMap);
emitAIStateChanged(STORAGE_KEY_AI_ACTIVE_SESSION_MAP);
}
}
/** Maximum number of sessions to keep in localStorage. */
const MAX_STORED_SESSIONS = 50;
@@ -66,6 +115,17 @@ function pruneSessionsForStorage(sessions: AISession[]): AISession[] {
});
}
let latestAISessionsSnapshot: AISession[] | null = null;
let latestAIActiveSessionMapSnapshot: Record<string, string | null> | null = null;
function setLatestAISessionsSnapshot(sessions: AISession[]) {
latestAISessionsSnapshot = sessions;
}
function setLatestAIActiveSessionMapSnapshot(activeSessionIdMap: Record<string, string | null>) {
latestAIActiveSessionMapSnapshot = activeSessionIdMap;
}
export function useAIState() {
// ── Provider Config ──
const [providers, setProvidersRaw] = useState<ProviderConfig[]>(() =>
@@ -117,7 +177,9 @@ export function useAIState() {
sessionsRef.current = sessions;
}, [sessions]);
// Per-scope active session: keyed by `${scopeType}:${scopeTargetId}`
const [activeSessionIdMap, setActiveSessionIdMapRaw] = useState<Record<string, string | null>>({});
const [activeSessionIdMap, setActiveSessionIdMapRaw] = useState<Record<string, string | null>>(() =>
localStorageAdapter.read<Record<string, string | null>>(STORAGE_KEY_AI_ACTIVE_SESSION_MAP) ?? {}
);
// Per-agent model selection: remembers last selected model per agent
const [agentModelMap, setAgentModelMapRaw] = useState<Record<string, string>>(() =>
@@ -129,8 +191,43 @@ export function useAIState() {
localStorageAdapter.read<WebSearchConfig>(STORAGE_KEY_AI_WEB_SEARCH) ?? null
);
useEffect(() => {
setLatestAISessionsSnapshot(sessions);
}, [sessions]);
useEffect(() => {
setLatestAIActiveSessionMapSnapshot(activeSessionIdMap);
}, [activeSessionIdMap]);
useEffect(() => {
const validSessionIds = new Set(sessions.map((session) => session.id));
let changed = false;
const nextActiveSessionIdMap: Record<string, string | null> = {};
for (const [scopeKey, sessionId] of Object.entries(activeSessionIdMap)) {
const nextSessionId = sessionId && validSessionIds.has(sessionId) ? sessionId : null;
nextActiveSessionIdMap[scopeKey] = nextSessionId;
if (nextSessionId !== sessionId) {
changed = true;
}
}
if (!changed) return;
setLatestAIActiveSessionMapSnapshot(nextActiveSessionIdMap);
localStorageAdapter.write(STORAGE_KEY_AI_ACTIVE_SESSION_MAP, nextActiveSessionIdMap);
setActiveSessionIdMapRaw(nextActiveSessionIdMap);
emitAIStateChanged(STORAGE_KEY_AI_ACTIVE_SESSION_MAP);
}, [sessions, activeSessionIdMap]);
const setActiveSessionId = useCallback((scopeKey: string, id: string | null) => {
setActiveSessionIdMapRaw(prev => ({ ...prev, [scopeKey]: id }));
setActiveSessionIdMapRaw(prev => {
const next = { ...prev, [scopeKey]: id };
setLatestAIActiveSessionMapSnapshot(next);
localStorageAdapter.write(STORAGE_KEY_AI_ACTIVE_SESSION_MAP, next);
emitAIStateChanged(STORAGE_KEY_AI_ACTIVE_SESSION_MAP);
return next;
});
}, []);
const setAgentModel = useCallback((agentId: string, modelId: string) => {
@@ -303,9 +400,22 @@ export function useAIState() {
setHostPermissionsRaw(perms ?? []);
break;
}
case STORAGE_KEY_AI_SESSIONS: {
const nextSessions = localStorageAdapter.read<AISession[]>(STORAGE_KEY_AI_SESSIONS) ?? [];
setLatestAISessionsSnapshot(nextSessions);
setSessionsRaw(nextSessions);
break;
}
case STORAGE_KEY_AI_AGENT_MODEL_MAP:
setAgentModelMapRaw(localStorageAdapter.read<Record<string, string>>(STORAGE_KEY_AI_AGENT_MODEL_MAP) ?? {});
break;
case STORAGE_KEY_AI_ACTIVE_SESSION_MAP: {
const nextActiveSessionIdMap =
localStorageAdapter.read<Record<string, string | null>>(STORAGE_KEY_AI_ACTIVE_SESSION_MAP) ?? {};
setLatestAIActiveSessionMapSnapshot(nextActiveSessionIdMap);
setActiveSessionIdMapRaw(nextActiveSessionIdMap);
break;
}
case STORAGE_KEY_AI_WEB_SEARCH:
setWebSearchConfigRaw(localStorageAdapter.read<WebSearchConfig>(STORAGE_KEY_AI_WEB_SEARCH) ?? null);
break;
@@ -315,7 +425,33 @@ export function useAIState() {
}
};
window.addEventListener('storage', handleStorage);
return () => window.removeEventListener('storage', handleStorage);
const handleLocalStateChanged = (event: Event) => {
const key = (event as CustomEvent<{ key?: string }>).detail?.key;
if (!key) return;
switch (key) {
case STORAGE_KEY_AI_SESSIONS:
setSessionsRaw(
latestAISessionsSnapshot
?? localStorageAdapter.read<AISession[]>(STORAGE_KEY_AI_SESSIONS)
?? [],
);
return;
case STORAGE_KEY_AI_ACTIVE_SESSION_MAP:
setActiveSessionIdMapRaw(
latestAIActiveSessionMapSnapshot
?? localStorageAdapter.read<Record<string, string | null>>(STORAGE_KEY_AI_ACTIVE_SESSION_MAP)
?? {},
);
return;
default:
handleStorage({ key } as StorageEvent);
}
};
window.addEventListener(AI_STATE_CHANGED_EVENT, handleLocalStateChanged);
return () => {
window.removeEventListener('storage', handleStorage);
window.removeEventListener(AI_STATE_CHANGED_EVENT, handleLocalStateChanged);
};
}, []);
// ── Sync initial safety settings to MCP Server on mount ──
@@ -375,6 +511,7 @@ export function useAIState() {
};
setSessionsRaw(prev => {
const next = [session, ...prev];
setLatestAISessionsSnapshot(next);
persistSessions(next);
return next;
});
@@ -391,12 +528,19 @@ export function useAIState() {
}
setSessionsRaw(prev => {
const next = prev.filter(s => s.id !== sessionId);
setLatestAISessionsSnapshot(next);
persistSessions(next);
return next;
});
if (scopeKey) {
setActiveSessionIdMapRaw(prev => {
if (prev[scopeKey] === sessionId) return { ...prev, [scopeKey]: null };
if (prev[scopeKey] === sessionId) {
const next = { ...prev, [scopeKey]: null };
setLatestAIActiveSessionMapSnapshot(next);
localStorageAdapter.write(STORAGE_KEY_AI_ACTIVE_SESSION_MAP, next);
emitAIStateChanged(STORAGE_KEY_AI_ACTIVE_SESSION_MAP);
return next;
}
return prev;
});
}
@@ -415,12 +559,19 @@ export function useAIState() {
const next = prev.filter(s => {
return !(s.scope.type === scopeType && s.scope.targetId === targetId);
});
setLatestAISessionsSnapshot(next);
persistSessions(next);
return next;
});
const scopeKey = `${scopeType}:${targetId}`;
setActiveSessionIdMapRaw(prev => {
if (prev[scopeKey] != null) return { ...prev, [scopeKey]: null };
if (prev[scopeKey] != null) {
const next = { ...prev, [scopeKey]: null };
setLatestAIActiveSessionMapSnapshot(next);
localStorageAdapter.write(STORAGE_KEY_AI_ACTIVE_SESSION_MAP, next);
emitAIStateChanged(STORAGE_KEY_AI_ACTIVE_SESSION_MAP);
return next;
}
return prev;
});
}, [persistSessions]);
@@ -428,6 +579,7 @@ export function useAIState() {
const updateSessionTitle = useCallback((sessionId: string, title: string) => {
setSessionsRaw(prev => {
const next = prev.map(s => s.id === sessionId ? { ...s, title, updatedAt: Date.now() } : s);
setLatestAISessionsSnapshot(next);
persistSessions(next);
return next;
});
@@ -440,6 +592,7 @@ export function useAIState() {
? { ...s, externalSessionId, updatedAt: Date.now() }
: s
));
setLatestAISessionsSnapshot(next);
debouncedPersistSessions();
return next;
});
@@ -463,6 +616,7 @@ export function useAIState() {
}
return { ...s, messages: msgs, updatedAt: Date.now() };
});
setLatestAISessionsSnapshot(next);
debouncedPersistSessions();
return next;
});
@@ -476,6 +630,7 @@ export function useAIState() {
msgs[msgs.length - 1] = updater(msgs[msgs.length - 1]);
return { ...s, messages: msgs, updatedAt: Date.now() };
});
setLatestAISessionsSnapshot(next);
debouncedPersistSessions();
return next;
});
@@ -491,6 +646,7 @@ export function useAIState() {
msgs[idx] = updater(msgs[idx]);
return { ...s, messages: msgs, updatedAt: Date.now() };
});
setLatestAISessionsSnapshot(next);
debouncedPersistSessions();
return next;
});
@@ -503,29 +659,21 @@ export function useAIState() {
}
setSessionsRaw(prev => {
const next = prev.map(s => s.id === sessionId ? { ...s, messages: [], updatedAt: Date.now() } : s);
setLatestAISessionsSnapshot(next);
persistSessions(next);
return next;
});
}, [persistSessions]);
const cleanupOrphanedSessions = useCallback((activeTargetIds: Set<string>) => {
const removedSessionIds = sessionsRef.current
.filter(s => s.scope.targetId && !activeTargetIds.has(s.scope.targetId))
.map(s => s.id);
cleanupAcpSessions(removedSessionIds);
setSessionsRaw(prev => {
const next = prev.filter(s => {
// Keep sessions without a targetId (global scope)
if (!s.scope.targetId) return true;
// Keep sessions whose target still exists
return activeTargetIds.has(s.scope.targetId);
});
if (next.length !== prev.length) {
persistSessions(next);
}
return next;
});
}, [persistSessions]);
cleanupOrphanedAISessions(activeTargetIds);
setSessionsRaw(latestAISessionsSnapshot ?? localStorageAdapter.read<AISession[]>(STORAGE_KEY_AI_SESSIONS) ?? []);
setActiveSessionIdMapRaw(
latestAIActiveSessionMapSnapshot
?? localStorageAdapter.read<Record<string, string | null>>(STORAGE_KEY_AI_ACTIVE_SESSION_MAP)
?? {},
);
}, []);
// ── Provider CRUD helpers ──
const addProvider = useCallback((provider: ProviderConfig) => {

View File

@@ -3,8 +3,8 @@
* This should be used at the App level to ensure auto-start happens
* when the application starts, not when the user navigates to the port forwarding page.
*/
import { useEffect, useRef } from "react";
import { Host, PortForwardingRule } from "../../domain/models";
import { useCallback, useEffect, useRef } from "react";
import { Host, Identity, PortForwardingRule, SSHKey } from "../../domain/models";
import { STORAGE_KEY_PORT_FORWARDING } from "../../infrastructure/config/storageKeys";
import { localStorageAdapter } from "../../infrastructure/persistence/localStorageAdapter";
import {
@@ -17,7 +17,8 @@ import { logger } from "../../lib/logger";
export interface UsePortForwardingAutoStartOptions {
hosts: Host[];
keys: { id: string; privateKey: string; passphrase: string }[];
keys: SSHKey[];
identities: Identity[];
}
/**
@@ -27,10 +28,37 @@ export interface UsePortForwardingAutoStartOptions {
export const usePortForwardingAutoStart = ({
hosts,
keys,
identities,
}: UsePortForwardingAutoStartOptions): void => {
const autoStartExecutedRef = useRef(false);
const hostsRef = useRef<Host[]>(hosts);
const keysRef = useRef<{ id: string; privateKey: string; passphrase: string }[]>(keys);
const keysRef = useRef<SSHKey[]>(keys);
const identitiesRef = useRef<Identity[]>(identities);
const isHostAuthReady = useCallback((host: Host, seen = new Set<string>()): boolean => {
if (!host || seen.has(host.id)) return true;
seen.add(host.id);
if (host.identityId) {
const identity = identitiesRef.current.find((candidate) => candidate.id === host.identityId);
if (!identity) return false;
if (identity.keyId && !keysRef.current.some((key) => key.id === identity.keyId)) {
return false;
}
}
if (host.identityFileId && !keysRef.current.some((key) => key.id === host.identityFileId)) {
return false;
}
const chainIds = host.hostChain?.hostIds || [];
for (const chainId of chainIds) {
const chainHost = hostsRef.current.find((candidate) => candidate.id === chainId);
if (!chainHost) return false;
if (!isHostAuthReady(chainHost, seen)) return false;
}
return true;
}, []);
// Keep refs in sync
useEffect(() => {
@@ -41,6 +69,10 @@ export const usePortForwardingAutoStart = ({
keysRef.current = keys;
}, [keys]);
useEffect(() => {
identitiesRef.current = identities;
}, [identities]);
// Set up the reconnect callback
useEffect(() => {
const handleReconnect = async (
@@ -62,7 +94,7 @@ export const usePortForwardingAutoStart = ({
return { success: false, error: "Host not found" };
}
return startPortForward(rule, host, keysRef.current, onStatusChange, true);
return startPortForward(rule, host, hostsRef.current, keysRef.current, identitiesRef.current, onStatusChange, true);
};
setReconnectCallback(handleReconnect);
@@ -76,6 +108,17 @@ export const usePortForwardingAutoStart = ({
if (autoStartExecutedRef.current) return;
if (hosts.length === 0) return;
const storedRules = localStorageAdapter.read<PortForwardingRule[]>(
STORAGE_KEY_PORT_FORWARDING,
) ?? [];
const pendingAutoStartRules = storedRules.filter((rule) => rule.autoStart && rule.hostId);
if (pendingAutoStartRules.some((rule) => {
const host = hosts.find((candidate) => candidate.id === rule.hostId);
return !host || !isHostAuthReady(host);
})) {
return;
}
// Mark as executed immediately to prevent duplicate runs
// (React StrictMode or dependency changes could cause re-runs)
autoStartExecutedRef.current = true;
@@ -108,7 +151,9 @@ export const usePortForwardingAutoStart = ({
void startPortForward(
rule,
host,
hosts,
keys,
identities,
(status, error) => {
// Update the rule status in storage
const currentRules = localStorageAdapter.read<PortForwardingRule[]>(
@@ -135,5 +180,5 @@ export const usePortForwardingAutoStart = ({
};
void runAutoStart();
}, [hosts, keys]);
}, [hosts, identities, isHostAuthReady, keys]);
};

View File

@@ -1,5 +1,5 @@
import { useCallback, useEffect, useMemo, useState } from "react";
import { Host, PortForwardingRule } from "../../domain/models";
import { Host, Identity, PortForwardingRule, SSHKey } from "../../domain/models";
import {
STORAGE_KEY_PF_PREFER_FORM_MODE,
STORAGE_KEY_PF_VIEW_MODE,
@@ -63,7 +63,9 @@ export interface UsePortForwardingStateResult {
startTunnel: (
rule: PortForwardingRule,
host: Host,
keys: { id: string; privateKey: string; passphrase: string }[],
hosts: Host[],
keys: SSHKey[],
identities: Identity[],
onStatusChange?: (status: PortForwardingRule["status"], error?: string) => void,
enableReconnect?: boolean,
) => Promise<{ success: boolean; error?: string }>;
@@ -377,14 +379,16 @@ export const usePortForwardingState = (): UsePortForwardingStateResult => {
async (
rule: PortForwardingRule,
host: Host,
keys: { id: string; privateKey: string; passphrase: string }[],
hosts: Host[],
keys: SSHKey[],
identities: Identity[],
onStatusChange?: (
status: PortForwardingRule["status"],
error?: string,
) => void,
enableReconnect = false,
) => {
return startPortForward(rule, host, keys, (status, error) => {
return startPortForward(rule, host, hosts, keys, identities, (status, error) => {
setRuleStatus(rule.id, status, error);
onStatusChange?.(status, error ?? undefined);
}, enableReconnect);

View File

@@ -38,7 +38,6 @@ import { DEFAULT_FONT_SIZE } from '../../infrastructure/config/fonts';
import { DARK_UI_THEMES, LIGHT_UI_THEMES, UiThemeTokens, getUiThemeById } from '../../infrastructure/config/uiThemes';
import { UI_FONTS, DEFAULT_UI_FONT_ID } from '../../infrastructure/config/uiFonts';
import { uiFontStore, useUIFontsLoaded } from './uiFontStore';
import { useAvailableFonts } from './fontStore';
import { localStorageAdapter } from '../../infrastructure/persistence/localStorageAdapter';
import { netcattyBridge } from '../../infrastructure/services/netcattyBridge';
@@ -155,7 +154,6 @@ const applyThemeTokens = (
};
export const useSettingsState = () => {
const availableFonts = useAvailableFonts();
const uiFontsLoaded = useUIFontsLoaded();
const [theme, setTheme] = useState<'dark' | 'light' | 'system'>(() => {
const stored = readStoredString(STORAGE_KEY_THEME);
@@ -287,6 +285,10 @@ export const useSettingsState = () => {
const localTerminalSettingsVersionRef = useRef(0);
const broadcastedLocalTerminalSettingsVersionRef = useRef(0);
// Fix 1: Mount guard — skip redundant IPC broadcasts & localStorage writes on initial mount.
// Set to true by the LAST useEffect declaration; all persist effects see false on first render.
const persistMountedRef = useRef(false);
const setTerminalSettings = useCallback((nextValue: SetStateAction<TerminalSettings>) => {
setTerminalSettingsState((prev) => {
const candidate = typeof nextValue === 'function'
@@ -334,6 +336,17 @@ export const useSettingsState = () => {
const storedAccent = readStoredString(STORAGE_KEY_COLOR);
const nextAccent = storedAccent && isValidHslToken(storedAccent) ? storedAccent.trim() : customAccent;
// Fix 2: Skip expensive DOM operations if nothing actually changed
if (
nextTheme === theme &&
nextLightId === lightUiThemeId &&
nextDarkId === darkUiThemeId &&
nextAccentMode === accentMode &&
nextAccent === customAccent
) {
return;
}
setTheme(nextTheme);
setLightUiThemeId(nextLightId);
setDarkUiThemeId(nextDarkId);
@@ -414,12 +427,11 @@ export const useSettingsState = () => {
localStorageAdapter.writeString(STORAGE_KEY_UI_THEME_DARK, darkUiThemeId);
localStorageAdapter.writeString(STORAGE_KEY_ACCENT_MODE, accentMode);
localStorageAdapter.writeString(STORAGE_KEY_COLOR, customAccent);
// Notify other windows
// Fix 1: Skip IPC broadcast on initial mount (values already match localStorage)
if (!persistMountedRef.current) return;
// Fix 3: Send a single IPC instead of 5 — the receiver calls syncAppearanceFromStorage()
// which re-reads ALL appearance values from localStorage.
notifySettingsChanged(STORAGE_KEY_THEME, theme);
notifySettingsChanged(STORAGE_KEY_UI_THEME_LIGHT, lightUiThemeId);
notifySettingsChanged(STORAGE_KEY_UI_THEME_DARK, darkUiThemeId);
notifySettingsChanged(STORAGE_KEY_ACCENT_MODE, accentMode);
notifySettingsChanged(STORAGE_KEY_COLOR, customAccent);
}, [theme, resolvedTheme, lightUiThemeId, darkUiThemeId, accentMode, customAccent, notifySettingsChanged]);
// Listen for OS color scheme changes to keep systemPreference in sync
@@ -437,7 +449,10 @@ export const useSettingsState = () => {
localStorageAdapter.writeString(STORAGE_KEY_UI_LANGUAGE, uiLanguage);
document.documentElement.lang = uiLanguage;
netcattyBridge.get()?.setLanguage?.(uiLanguage);
notifySettingsChanged(STORAGE_KEY_UI_LANGUAGE, uiLanguage);
// Fix 1: Skip IPC broadcast on initial mount
if (persistMountedRef.current) {
notifySettingsChanged(STORAGE_KEY_UI_LANGUAGE, uiLanguage);
}
}, [uiLanguage, notifySettingsChanged]);
// Apply and persist UI font family
@@ -446,7 +461,10 @@ export const useSettingsState = () => {
const font = uiFontStore.getFontById(uiFontFamilyId);
document.documentElement.style.setProperty('--font-sans', font.family);
localStorageAdapter.writeString(STORAGE_KEY_UI_FONT_FAMILY, uiFontFamilyId);
notifySettingsChanged(STORAGE_KEY_UI_FONT_FAMILY, uiFontFamilyId);
// Fix 1: Skip IPC broadcast on initial mount
if (persistMountedRef.current) {
notifySettingsChanged(STORAGE_KEY_UI_FONT_FAMILY, uiFontFamilyId);
}
}, [uiFontFamilyId, uiFontsLoaded, notifySettingsChanged]);
// Listen for settings changes from other windows via IPC
@@ -567,53 +585,76 @@ export const useSettingsState = () => {
};
}, []);
// Fix 4: Keep a ref snapshot of current settings so the storage event handler
// can compare without capturing 25+ state variables in its closure / dep array.
// This avoids constant listener detach/reattach on every state change.
const settingsSnapshotRef = useRef({
theme, lightUiThemeId, darkUiThemeId, accentMode, customAccent,
customCSS, uiFontFamilyId, hotkeyScheme, uiLanguage,
terminalThemeId, terminalFontFamilyId, terminalFontSize,
sftpDoubleClickBehavior, sftpAutoSync, sftpShowHiddenFiles,
sftpUseCompressedUpload, sftpAutoOpenSidebar,
editorWordWrap, sessionLogsEnabled, sessionLogsDir, sessionLogsFormat,
globalHotkeyEnabled, autoUpdateEnabled,
});
settingsSnapshotRef.current = {
theme, lightUiThemeId, darkUiThemeId, accentMode, customAccent,
customCSS, uiFontFamilyId, hotkeyScheme, uiLanguage,
terminalThemeId, terminalFontFamilyId, terminalFontSize,
sftpDoubleClickBehavior, sftpAutoSync, sftpShowHiddenFiles,
sftpUseCompressedUpload, sftpAutoOpenSidebar,
editorWordWrap, sessionLogsEnabled, sessionLogsDir, sessionLogsFormat,
globalHotkeyEnabled, autoUpdateEnabled,
};
// Listen for storage changes from other windows (cross-window sync)
useEffect(() => {
const handleStorageChange = (e: StorageEvent) => {
const s = settingsSnapshotRef.current;
if (e.key === STORAGE_KEY_THEME && e.newValue) {
if (isValidTheme(e.newValue) && e.newValue !== theme) {
if (isValidTheme(e.newValue) && e.newValue !== s.theme) {
setTheme(e.newValue);
}
}
if (e.key === STORAGE_KEY_UI_THEME_LIGHT && e.newValue) {
if (isValidUiThemeId('light', e.newValue) && e.newValue !== lightUiThemeId) {
if (isValidUiThemeId('light', e.newValue) && e.newValue !== s.lightUiThemeId) {
setLightUiThemeId(e.newValue);
}
}
if (e.key === STORAGE_KEY_UI_THEME_DARK && e.newValue) {
if (isValidUiThemeId('dark', e.newValue) && e.newValue !== darkUiThemeId) {
if (isValidUiThemeId('dark', e.newValue) && e.newValue !== s.darkUiThemeId) {
setDarkUiThemeId(e.newValue);
}
}
if (e.key === STORAGE_KEY_ACCENT_MODE && e.newValue) {
if ((e.newValue === 'theme' || e.newValue === 'custom') && e.newValue !== accentMode) {
if ((e.newValue === 'theme' || e.newValue === 'custom') && e.newValue !== s.accentMode) {
setAccentMode(e.newValue);
}
}
if (e.key === STORAGE_KEY_COLOR && e.newValue) {
if (isValidHslToken(e.newValue) && e.newValue !== customAccent) {
if (isValidHslToken(e.newValue) && e.newValue !== s.customAccent) {
setCustomAccent(e.newValue.trim());
}
}
if (e.key === STORAGE_KEY_CUSTOM_CSS && e.newValue !== null) {
if (e.newValue !== customCSS) {
if (e.newValue !== s.customCSS) {
setCustomCSS(e.newValue);
}
}
if (e.key === STORAGE_KEY_UI_FONT_FAMILY && e.newValue) {
if (isValidUiFontId(e.newValue) && e.newValue !== uiFontFamilyId) {
if (isValidUiFontId(e.newValue) && e.newValue !== s.uiFontFamilyId) {
setUiFontFamilyId(e.newValue);
}
}
if (e.key === STORAGE_KEY_HOTKEY_SCHEME && e.newValue) {
const newScheme = e.newValue as HotkeyScheme;
if (newScheme !== hotkeyScheme) {
if (newScheme !== s.hotkeyScheme) {
setHotkeyScheme(newScheme);
}
}
if (e.key === STORAGE_KEY_UI_LANGUAGE && e.newValue) {
const next = resolveSupportedLocale(e.newValue);
if (next !== uiLanguage) {
if (next !== s.uiLanguage) {
setUiLanguage(next as UILanguage);
}
}
@@ -636,64 +677,64 @@ export const useSettingsState = () => {
}
// Sync terminal theme from other windows
if (e.key === STORAGE_KEY_TERM_THEME && e.newValue) {
if (e.newValue !== terminalThemeId) {
if (e.newValue !== s.terminalThemeId) {
setTerminalThemeId(e.newValue);
}
}
// Sync terminal font family from other windows
if (e.key === STORAGE_KEY_TERM_FONT_FAMILY && e.newValue) {
if (e.newValue !== terminalFontFamilyId) {
if (e.newValue !== s.terminalFontFamilyId) {
setTerminalFontFamilyId(e.newValue);
}
}
// Sync terminal font size from other windows
if (e.key === STORAGE_KEY_TERM_FONT_SIZE && e.newValue) {
const newSize = parseInt(e.newValue, 10);
if (!isNaN(newSize) && newSize !== terminalFontSize) {
if (!isNaN(newSize) && newSize !== s.terminalFontSize) {
setTerminalFontSize(newSize);
}
}
// Sync SFTP double-click behavior from other windows
if (e.key === STORAGE_KEY_SFTP_DOUBLE_CLICK_BEHAVIOR && e.newValue) {
if ((e.newValue === 'open' || e.newValue === 'transfer') && e.newValue !== sftpDoubleClickBehavior) {
if ((e.newValue === 'open' || e.newValue === 'transfer') && e.newValue !== s.sftpDoubleClickBehavior) {
setSftpDoubleClickBehavior(e.newValue);
}
}
// Sync SFTP auto-sync setting from other windows
if (e.key === STORAGE_KEY_SFTP_AUTO_SYNC && e.newValue !== null) {
const newValue = e.newValue === 'true';
if (newValue !== sftpAutoSync) {
if (newValue !== s.sftpAutoSync) {
setSftpAutoSync(newValue);
}
}
// Sync SFTP show hidden files setting from other windows
if (e.key === STORAGE_KEY_SFTP_SHOW_HIDDEN_FILES && e.newValue !== null) {
const newValue = e.newValue === 'true';
if (newValue !== sftpShowHiddenFiles) {
if (newValue !== s.sftpShowHiddenFiles) {
setSftpShowHiddenFiles(newValue);
}
}
if (e.key === STORAGE_KEY_EDITOR_WORD_WRAP && e.newValue !== null) {
const newValue = e.newValue === 'true';
if (newValue !== editorWordWrap) {
if (newValue !== s.editorWordWrap) {
setEditorWordWrapState(newValue);
}
}
if (e.key === STORAGE_KEY_SESSION_LOGS_ENABLED && e.newValue !== null) {
const newValue = e.newValue === 'true';
if (newValue !== sessionLogsEnabled) {
if (newValue !== s.sessionLogsEnabled) {
setSessionLogsEnabled(newValue);
}
}
if (e.key === STORAGE_KEY_SESSION_LOGS_DIR && e.newValue !== null) {
if (e.newValue !== sessionLogsDir) {
if (e.newValue !== s.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
e.newValue !== s.sessionLogsFormat
) {
setSessionLogsFormat(e.newValue);
}
@@ -701,28 +742,28 @@ export const useSettingsState = () => {
// 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';
if (newValue !== sftpUseCompressedUpload) {
if (newValue !== s.sftpUseCompressedUpload) {
setSftpUseCompressedUpload(newValue);
}
}
// Sync SFTP auto-open sidebar setting from other windows
if (e.key === STORAGE_KEY_SFTP_AUTO_OPEN_SIDEBAR && e.newValue !== null) {
const newValue = e.newValue === 'true';
if (newValue !== sftpAutoOpenSidebar) {
if (newValue !== s.sftpAutoOpenSidebar) {
setSftpAutoOpenSidebar(newValue);
}
}
// Sync global hotkey enabled setting from other windows
if (e.key === STORAGE_KEY_GLOBAL_HOTKEY_ENABLED && e.newValue !== null) {
const newValue = e.newValue === 'true';
if (newValue !== globalHotkeyEnabled) {
if (newValue !== s.globalHotkeyEnabled) {
setGlobalHotkeyEnabled(newValue);
}
}
// Sync auto-update enabled setting from other windows
if (e.key === STORAGE_KEY_AUTO_UPDATE_ENABLED && e.newValue !== null) {
const newValue = e.newValue === 'true';
if (newValue !== autoUpdateEnabled) {
if (newValue !== s.autoUpdateEnabled) {
setAutoUpdateEnabled(newValue);
}
}
@@ -730,25 +771,29 @@ 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, sftpAutoOpenSidebar, editorWordWrap, sessionLogsEnabled, sessionLogsDir, sessionLogsFormat, globalHotkeyEnabled, autoUpdateEnabled, mergeIncomingTerminalSettings]);
}, [mergeIncomingTerminalSettings]); // Fix 4: stable deps only — state comparisons use settingsSnapshotRef
useEffect(() => {
localStorageAdapter.writeString(STORAGE_KEY_TERM_THEME, terminalThemeId);
if (!persistMountedRef.current) return;
notifySettingsChanged(STORAGE_KEY_TERM_THEME, terminalThemeId);
}, [terminalThemeId, notifySettingsChanged]);
useEffect(() => {
localStorageAdapter.writeString(STORAGE_KEY_TERM_FONT_FAMILY, terminalFontFamilyId);
if (!persistMountedRef.current) return;
notifySettingsChanged(STORAGE_KEY_TERM_FONT_FAMILY, terminalFontFamilyId);
}, [terminalFontFamilyId, notifySettingsChanged]);
useEffect(() => {
localStorageAdapter.writeNumber(STORAGE_KEY_TERM_FONT_SIZE, terminalFontSize);
if (!persistMountedRef.current) return;
notifySettingsChanged(STORAGE_KEY_TERM_FONT_SIZE, terminalFontSize);
}, [terminalFontSize, notifySettingsChanged]);
useEffect(() => {
localStorageAdapter.write(STORAGE_KEY_TERM_SETTINGS, terminalSettings);
if (!persistMountedRef.current) return;
const currentSignature = serializeTerminalSettings(terminalSettings);
const hasPendingUnbroadcastLocalChanges =
localTerminalSettingsVersionRef.current !== broadcastedLocalTerminalSettingsVersionRef.current;
@@ -763,11 +808,13 @@ export const useSettingsState = () => {
useEffect(() => {
localStorageAdapter.writeString(STORAGE_KEY_HOTKEY_SCHEME, hotkeyScheme);
if (!persistMountedRef.current) return;
notifySettingsChanged(STORAGE_KEY_HOTKEY_SCHEME, hotkeyScheme);
}, [hotkeyScheme, notifySettingsChanged]);
useEffect(() => {
localStorageAdapter.write(STORAGE_KEY_CUSTOM_KEY_BINDINGS, customKeyBindings);
if (!persistMountedRef.current) return;
notifySettingsChanged(STORAGE_KEY_CUSTOM_KEY_BINDINGS, customKeyBindings);
}, [customKeyBindings, notifySettingsChanged]);
@@ -778,10 +825,7 @@ export const useSettingsState = () => {
// Apply and persist custom CSS
useEffect(() => {
localStorageAdapter.writeString(STORAGE_KEY_CUSTOM_CSS, customCSS);
notifySettingsChanged(STORAGE_KEY_CUSTOM_CSS, customCSS);
// Apply custom CSS to document
// Always apply CSS to document (needed on mount)
let styleEl = document.getElementById('netcatty-custom-css') as HTMLStyleElement | null;
if (!styleEl) {
styleEl = document.createElement('style');
@@ -789,59 +833,69 @@ export const useSettingsState = () => {
document.head.appendChild(styleEl);
}
styleEl.textContent = customCSS;
localStorageAdapter.writeString(STORAGE_KEY_CUSTOM_CSS, customCSS);
// Skip IPC on initial mount
if (!persistMountedRef.current) return;
notifySettingsChanged(STORAGE_KEY_CUSTOM_CSS, customCSS);
}, [customCSS, notifySettingsChanged]);
// Persist SFTP double-click behavior
useEffect(() => {
localStorageAdapter.writeString(STORAGE_KEY_SFTP_DOUBLE_CLICK_BEHAVIOR, sftpDoubleClickBehavior);
if (!persistMountedRef.current) return;
notifySettingsChanged(STORAGE_KEY_SFTP_DOUBLE_CLICK_BEHAVIOR, sftpDoubleClickBehavior);
}, [sftpDoubleClickBehavior, notifySettingsChanged]);
// Persist SFTP auto-sync setting
useEffect(() => {
localStorageAdapter.writeString(STORAGE_KEY_SFTP_AUTO_SYNC, sftpAutoSync ? 'true' : 'false');
if (!persistMountedRef.current) return;
notifySettingsChanged(STORAGE_KEY_SFTP_AUTO_SYNC, sftpAutoSync);
}, [sftpAutoSync, notifySettingsChanged]);
// Persist SFTP show hidden files setting
useEffect(() => {
localStorageAdapter.writeString(STORAGE_KEY_SFTP_SHOW_HIDDEN_FILES, sftpShowHiddenFiles ? 'true' : 'false');
if (!persistMountedRef.current) return;
notifySettingsChanged(STORAGE_KEY_SFTP_SHOW_HIDDEN_FILES, sftpShowHiddenFiles);
}, [sftpShowHiddenFiles, notifySettingsChanged]);
// Persist SFTP compressed upload setting
useEffect(() => {
localStorageAdapter.writeString(STORAGE_KEY_SFTP_USE_COMPRESSED_UPLOAD, sftpUseCompressedUpload ? 'true' : 'false');
if (!persistMountedRef.current) return;
notifySettingsChanged(STORAGE_KEY_SFTP_USE_COMPRESSED_UPLOAD, sftpUseCompressedUpload);
}, [sftpUseCompressedUpload, notifySettingsChanged]);
// Persist SFTP auto-open sidebar setting
useEffect(() => {
localStorageAdapter.writeString(STORAGE_KEY_SFTP_AUTO_OPEN_SIDEBAR, sftpAutoOpenSidebar ? 'true' : 'false');
if (!persistMountedRef.current) return;
notifySettingsChanged(STORAGE_KEY_SFTP_AUTO_OPEN_SIDEBAR, sftpAutoOpenSidebar);
}, [sftpAutoOpenSidebar, notifySettingsChanged]);
// Persist Session Logs settings
useEffect(() => {
localStorageAdapter.writeString(STORAGE_KEY_SESSION_LOGS_ENABLED, sessionLogsEnabled ? 'true' : 'false');
if (!persistMountedRef.current) return;
notifySettingsChanged(STORAGE_KEY_SESSION_LOGS_ENABLED, sessionLogsEnabled);
}, [sessionLogsEnabled, notifySettingsChanged]);
useEffect(() => {
localStorageAdapter.writeString(STORAGE_KEY_SESSION_LOGS_DIR, sessionLogsDir);
if (!persistMountedRef.current) return;
notifySettingsChanged(STORAGE_KEY_SESSION_LOGS_DIR, sessionLogsDir);
}, [sessionLogsDir, notifySettingsChanged]);
useEffect(() => {
localStorageAdapter.writeString(STORAGE_KEY_SESSION_LOGS_FORMAT, sessionLogsFormat);
if (!persistMountedRef.current) return;
notifySettingsChanged(STORAGE_KEY_SESSION_LOGS_FORMAT, sessionLogsFormat);
}, [sessionLogsFormat, notifySettingsChanged]);
// Persist and sync toggle window hotkey setting
useEffect(() => {
localStorageAdapter.writeString(STORAGE_KEY_TOGGLE_WINDOW_HOTKEY, toggleWindowHotkey);
notifySettingsChanged(STORAGE_KEY_TOGGLE_WINDOW_HOTKEY, toggleWindowHotkey);
// Register/unregister the global hotkey in main process
// Register/unregister the global hotkey in main process (needed on mount)
const bridge = netcattyBridge.get();
if (bridge?.registerGlobalHotkey) {
if (toggleWindowHotkey && globalHotkeyEnabled) {
@@ -865,25 +919,32 @@ export const useSettingsState = () => {
});
}
}
localStorageAdapter.writeString(STORAGE_KEY_TOGGLE_WINDOW_HOTKEY, toggleWindowHotkey);
// Skip IPC on initial mount
if (!persistMountedRef.current) return;
notifySettingsChanged(STORAGE_KEY_TOGGLE_WINDOW_HOTKEY, toggleWindowHotkey);
}, [toggleWindowHotkey, globalHotkeyEnabled, notifySettingsChanged]);
// Persist global hotkey enabled setting
useEffect(() => {
localStorageAdapter.writeString(STORAGE_KEY_GLOBAL_HOTKEY_ENABLED, globalHotkeyEnabled ? 'true' : 'false');
if (!persistMountedRef.current) return;
notifySettingsChanged(STORAGE_KEY_GLOBAL_HOTKEY_ENABLED, globalHotkeyEnabled);
}, [globalHotkeyEnabled, notifySettingsChanged]);
// Persist and sync close to tray setting
useEffect(() => {
localStorageAdapter.writeString(STORAGE_KEY_CLOSE_TO_TRAY, closeToTray ? 'true' : 'false');
notifySettingsChanged(STORAGE_KEY_CLOSE_TO_TRAY, closeToTray);
// Update main process tray behavior
// Update main process tray behavior (needed on mount)
const bridge = netcattyBridge.get();
if (bridge?.setCloseToTray) {
bridge.setCloseToTray(closeToTray).catch((err) => {
console.warn('[SystemTray] Failed to set close-to-tray:', err);
});
}
localStorageAdapter.writeString(STORAGE_KEY_CLOSE_TO_TRAY, closeToTray ? 'true' : 'false');
// Skip IPC on initial mount
if (!persistMountedRef.current) return;
notifySettingsChanged(STORAGE_KEY_CLOSE_TO_TRAY, closeToTray);
}, [closeToTray, notifySettingsChanged]);
// Hydrate auto-update state from the main-process preference file on mount.
@@ -904,16 +965,11 @@ export const useSettingsState = () => {
}, []);
// Persist auto-update enabled setting.
// Skip IPC on initial mount to avoid overwriting the main-process preference
// file when localStorage has been cleared (where the default is true).
const autoUpdateMountedRef = useRef(false);
// Initial mount still writes localStorage, but skips cross-window/main-process IPC.
useEffect(() => {
localStorageAdapter.writeString(STORAGE_KEY_AUTO_UPDATE_ENABLED, autoUpdateEnabled ? 'true' : 'false');
if (!persistMountedRef.current) return;
notifySettingsChanged(STORAGE_KEY_AUTO_UPDATE_ENABLED, autoUpdateEnabled);
if (!autoUpdateMountedRef.current) {
autoUpdateMountedRef.current = true;
return; // Skip IPC on initial mount
}
// Notify main process on user-initiated changes
const bridge = netcattyBridge.get();
bridge?.setAutoUpdate?.(autoUpdateEnabled).catch((err: unknown) => {
@@ -921,6 +977,13 @@ export const useSettingsState = () => {
});
}, [autoUpdateEnabled, notifySettingsChanged]);
// Fix 1: Mark all persist effects as mounted.
// This MUST be declared AFTER all persist useEffects so that React runs it last
// during the initial mount cycle (effects fire in declaration order).
useEffect(() => {
persistMountedRef.current = true;
}, []);
// Get merged key bindings (defaults + custom overrides)
const keyBindings = useMemo((): KeyBinding[] => {
return DEFAULT_KEY_BINDINGS.map(binding => {
@@ -983,11 +1046,6 @@ export const useSettingsState = () => {
[terminalThemeId, customThemes]
);
const currentTerminalFont = useMemo(
() => availableFonts.find(f => f.id === terminalFontFamilyId) || availableFonts[0],
[terminalFontFamilyId, availableFonts]
);
const updateTerminalSetting = useCallback(<K extends keyof TerminalSettings>(
key: K,
value: TerminalSettings[K]
@@ -1018,7 +1076,6 @@ export const useSettingsState = () => {
currentTerminalTheme,
terminalFontFamilyId,
setTerminalFontFamilyId,
currentTerminalFont,
terminalFontSize,
setTerminalFontSize,
terminalSettings,
@@ -1052,7 +1109,6 @@ export const useSettingsState = () => {
localStorageAdapter.writeString(STORAGE_KEY_EDITOR_WORD_WRAP, String(enabled));
notifySettingsChanged(STORAGE_KEY_EDITOR_WORD_WRAP, enabled);
}, [notifySettingsChanged]),
availableFonts,
// Session Logs
sessionLogsEnabled,
setSessionLogsEnabled,

View File

@@ -96,7 +96,7 @@ export const useTerminalBackend = () => {
return bridge.onSessionExit(sessionId, cb);
}, []);
const onChainProgress = useCallback((cb: (hop: number, total: number, label: string, status: string) => void) => {
const onChainProgress = useCallback((cb: (sessionId: string, hop: number, total: number, label: string, status: string, error?: string) => void) => {
const bridge = netcattyBridge.get();
return bridge?.onChainProgress?.(cb);
}, []);

View File

@@ -420,7 +420,9 @@ const AIChatSidePanelInner: React.FC<AIChatSidePanelProps> = ({
/** Ensure a session exists for the current scope and return its ID. */
const ensureSession = useCallback((): string => {
if (activeSessionId) return activeSessionId;
if (activeSessionId && sessionsRef.current.some((session) => session.id === activeSessionId)) {
return activeSessionId;
}
const scope: AISessionScope = { type: scopeType, targetId: scopeTargetId, hostIds: scopeHostIds };
const session = createSession(scope, currentAgentId);
setActiveSessionId(session.id);
@@ -543,6 +545,10 @@ const AIChatSidePanelInner: React.FC<AIChatSidePanelProps> = ({
}));
// Clear pending approvals for this session (so tool execute functions don't hang)
clearAllPendingApprovals(activeSessionId);
// Cancel in-flight command executions (Catty Agent + ACP Agent)
const bridge = getNetcattyBridge();
bridge?.aiCattyCancelExec?.(activeSessionId);
bridge?.aiAcpCancel?.('', activeSessionId);
}, [activeSessionId, setStreamingForScope, updateLastMessage, abortControllersRef]);
const handleSelectSession = useCallback(

View File

@@ -20,6 +20,9 @@ import {
Tag,
TerminalSquare,
User,
FileKey,
FolderOpen,
Trash2,
Variable,
Wifi,
X,
@@ -69,7 +72,7 @@ import {
ProxyPanel,
} from "./host-details";
type CredentialType = "sshid" | "key" | "certificate" | null;
type CredentialType = "sshid" | "key" | "certificate" | "localKeyFile" | null;
type SubPanel =
| "none"
| "create-group"
@@ -147,6 +150,9 @@ const HostDetailsPanel: React.FC<HostDetailsPanelProps> = ({
// Password visibility state
const [showPassword, setShowPassword] = useState(false);
// Local key file path input state
const [newKeyFilePath, setNewKeyFilePath] = useState("");
// New group creation state
const [newGroupName, setNewGroupName] = useState("");
const [newGroupParent, setNewGroupParent] = useState("");
@@ -469,6 +475,7 @@ const HostDetailsPanel: React.FC<HostDetailsPanelProps> = ({
authMethod: identity.authMethod,
password: undefined,
identityFileId: undefined,
identityFilePaths: undefined,
}));
setSelectedCredentialType(null);
setCredentialPopoverOpen(false);
@@ -969,6 +976,31 @@ const HostDetailsPanel: React.FC<HostDetailsPanelProps> = ({
</div>
)}
{/* Local key file paths display */}
{!selectedIdentity && !form.identityFileId && form.identityFilePaths && form.identityFilePaths.length > 0 && (
<div className="space-y-1.5">
{form.identityFilePaths.map((keyPath, idx) => (
<div key={idx} className="flex items-center gap-2 p-2 rounded-md bg-secondary/50 border border-border/60">
<FileKey size={14} className="text-primary shrink-0" />
<span className="text-xs flex-1 truncate font-mono" title={keyPath}>
{keyPath}
</span>
<Button
variant="ghost"
size="icon"
className="h-6 w-6 shrink-0"
onClick={() => {
const paths = form.identityFilePaths?.filter((_, i) => i !== idx) || [];
update("identityFilePaths", paths.length > 0 ? paths : undefined);
}}
>
<Trash2 size={12} />
</Button>
</div>
))}
</div>
)}
{/* Selected credential display */}
{!selectedIdentity && form.identityFileId && (
<div className="flex items-center gap-2 p-2 rounded-md bg-secondary/50 border border-border/60">
@@ -1046,6 +1078,20 @@ const HostDetailsPanel: React.FC<HostDetailsPanelProps> = ({
{t("hostDetails.credential.certificate")}
</span>
</button>
<button
type="button"
className="w-full flex items-center gap-3 px-3 py-2.5 rounded-md hover:bg-secondary/80 transition-colors text-left"
onClick={() => {
setSelectedCredentialType("localKeyFile");
setCredentialPopoverOpen(false);
}}
>
<FileKey size={16} className="text-muted-foreground" />
<span className="text-sm font-medium">
{t("hostDetails.credential.localKeyFile")}
</span>
</button>
</div>
</PopoverContent>
</Popover>
@@ -1067,6 +1113,7 @@ const HostDetailsPanel: React.FC<HostDetailsPanelProps> = ({
onValueChange={(val) => {
update("identityFileId", val);
update("authMethod", "key");
update("identityFilePaths", undefined);
setSelectedCredentialType(null);
}}
placeholder={t("hostDetails.keys.search")}
@@ -1102,6 +1149,7 @@ const HostDetailsPanel: React.FC<HostDetailsPanelProps> = ({
onValueChange={(val) => {
update("identityFileId", val);
update("authMethod", "certificate");
update("identityFilePaths", undefined);
setSelectedCredentialType(null);
}}
placeholder={t("hostDetails.certs.search")}
@@ -1121,6 +1169,67 @@ const HostDetailsPanel: React.FC<HostDetailsPanelProps> = ({
</Button>
</div>
)}
{/* Local key file path input - appears after selecting "Local Key File" type */}
{!selectedIdentity &&
selectedCredentialType === "localKeyFile" &&
!form.identityFileId && (
<div className="space-y-1.5">
<div className="flex items-center gap-1">
<input
type="text"
className="flex-1 h-8 px-2 text-xs font-mono bg-background border border-border/60 rounded-md focus:outline-none focus:ring-1 focus:ring-ring"
placeholder={t("hostDetails.credential.localKeyFilePlaceholder")}
value={newKeyFilePath}
onChange={(e) => setNewKeyFilePath(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter" && newKeyFilePath.trim()) {
e.preventDefault();
const paths = [...(form.identityFilePaths || []), newKeyFilePath.trim()];
update("identityFilePaths", paths);
update("identityFileId", undefined);
update("authMethod", "key");
setNewKeyFilePath("");
}
}}
/>
<Button
variant="secondary"
size="icon"
className="h-8 w-8 shrink-0"
title={t("hostDetails.credential.browseKeyFile")}
onClick={async () => {
const bridge = (window as unknown as { netcatty?: NetcattyBridge }).netcatty;
if (!bridge?.selectFile) return;
const filePath = await bridge.selectFile(
"Select SSH Private Key",
undefined,
[{ name: "All Files", extensions: ["*"] }]
);
if (filePath) {
const paths = [...(form.identityFilePaths || []), filePath];
update("identityFilePaths", paths);
update("identityFileId", undefined);
update("authMethod", "key");
}
}}
>
<FolderOpen size={14} />
</Button>
<Button
variant="ghost"
size="icon"
className="h-8 w-8 shrink-0"
onClick={() => {
setSelectedCredentialType(null);
setNewKeyFilePath("");
}}
>
<X size={14} />
</Button>
</div>
</div>
)}
</div>
</Card>

View File

@@ -130,7 +130,9 @@ const PortForwarding: React.FC<PortForwardingProps> = ({
const result = await startTunnel(
rule,
_host,
keys.map((k) => ({ id: k.id, privateKey: k.privateKey, passphrase: k.passphrase })),
hosts,
keys,
identities,
(status, error) => {
// Show toast on error (only once)
if (status === "error" && error && !errorShown) {
@@ -159,7 +161,7 @@ const PortForwarding: React.FC<PortForwardingProps> = ({
});
}
},
[hosts, keys, setRuleStatus, startTunnel, t],
[hosts, identities, keys, setRuleStatus, startTunnel, t],
);
// Stop a port forwarding tunnel

View File

@@ -13,6 +13,7 @@ import {
import React, { useMemo, useState } from "react";
import { useI18n } from "../application/i18n/I18nProvider";
import type { QuickConnectTarget } from "../domain/quickConnect";
import { formatHostPort } from "../domain/host";
import { cn } from "../lib/utils";
import { Host, SSHKey } from "../types";
import { Button } from "./ui/button";
@@ -531,11 +532,11 @@ const QuickConnectWizard: React.FC<QuickConnectWizardProps> = ({
case "protocol":
return target.hostname;
case "username":
return `${protocol.toUpperCase()} ${target.hostname}:${port}`;
return `${protocol.toUpperCase()} ${formatHostPort(target.hostname, port)}`;
case "knownhost":
return `${protocol.toUpperCase()} ${effectiveUsername}@${target.hostname}:${port}`;
return `${protocol.toUpperCase()} ${effectiveUsername}@${formatHostPort(target.hostname, port)}`;
case "auth":
return `${protocol.toUpperCase()} ${target.hostname}:${port}`;
return `${protocol.toUpperCase()} ${formatHostPort(target.hostname, port)}`;
}
};

View File

@@ -5,6 +5,7 @@
import { AppWindow, Cloud, FileType, HardDrive, Keyboard, Palette, Sparkles, TerminalSquare, X } from "lucide-react";
import React, { useCallback, useEffect, useMemo, useState } from "react";
import { useSettingsState } from "../application/state/useSettingsState";
import { useAvailableFonts } from "../application/state/fontStore";
import { usePortForwardingState } from "../application/state/usePortForwardingState";
import { useVaultState } from "../application/state/useVaultState";
import { useWindowControls } from "../application/state/useWindowControls";
@@ -19,7 +20,6 @@ import SettingsTerminalTab from "./settings/tabs/SettingsTerminalTab";
import SettingsSystemTab from "./settings/tabs/SettingsSystemTab";
const SettingsAITab = React.lazy(() => import("./settings/tabs/SettingsAITab"));
import { Tabs, TabsList, TabsTrigger } from "./ui/tabs";
import type { TerminalFont } from "../infrastructure/config/fonts";
const isMac = typeof navigator !== "undefined" && /Mac|iPhone|iPad/.test(navigator.platform);
@@ -45,12 +45,63 @@ class AITabErrorBoundary extends React.Component<
}
}
type SettingsState = ReturnType<typeof useSettingsState> & {
availableFonts: TerminalFont[];
};
type SettingsState = ReturnType<typeof useSettingsState>;
const SettingsSyncTab = React.lazy(() => import("./settings/tabs/SettingsSyncTab"));
const SettingsTerminalTabContainer: React.FC<{ settings: SettingsState }> = ({ settings }) => {
const availableFonts = useAvailableFonts();
return (
<SettingsTerminalTab
terminalThemeId={settings.terminalThemeId}
setTerminalThemeId={settings.setTerminalThemeId}
terminalFontFamilyId={settings.terminalFontFamilyId}
setTerminalFontFamilyId={settings.setTerminalFontFamilyId}
terminalFontSize={settings.terminalFontSize}
setTerminalFontSize={settings.setTerminalFontSize}
terminalSettings={settings.terminalSettings}
updateTerminalSetting={settings.updateTerminalSetting}
availableFonts={availableFonts}
/>
);
};
const SettingsAITabContainer: React.FC = () => {
const aiState = useAIState();
return (
<AITabErrorBoundary>
<React.Suspense fallback={<div className="flex-1 px-6 py-5 text-sm text-muted-foreground">Loading AI settings...</div>}>
<SettingsAITab
providers={aiState.providers}
addProvider={aiState.addProvider}
updateProvider={aiState.updateProvider}
removeProvider={aiState.removeProvider}
activeProviderId={aiState.activeProviderId}
setActiveProviderId={aiState.setActiveProviderId}
activeModelId={aiState.activeModelId}
setActiveModelId={aiState.setActiveModelId}
globalPermissionMode={aiState.globalPermissionMode}
setGlobalPermissionMode={aiState.setGlobalPermissionMode}
externalAgents={aiState.externalAgents}
setExternalAgents={aiState.setExternalAgents}
defaultAgentId={aiState.defaultAgentId}
setDefaultAgentId={aiState.setDefaultAgentId}
commandBlocklist={aiState.commandBlocklist}
setCommandBlocklist={aiState.setCommandBlocklist}
commandTimeout={aiState.commandTimeout}
setCommandTimeout={aiState.setCommandTimeout}
maxIterations={aiState.maxIterations}
setMaxIterations={aiState.setMaxIterations}
webSearchConfig={aiState.webSearchConfig}
setWebSearchConfig={aiState.setWebSearchConfig}
/>
</React.Suspense>
</AITabErrorBoundary>
);
};
const SettingsSyncTabWithVault: React.FC<{ onSettingsApplied?: () => void }> = ({ onSettingsApplied }) => {
const {
hosts,
@@ -99,7 +150,6 @@ const SettingsPageContent: React.FC<{ settings: SettingsState }> = ({ settings }
const { t } = useI18n();
const { notifyRendererReady, closeSettingsWindow } = useWindowControls();
const { updateState, checkNow, installUpdate, openReleasePage } = useUpdateCheck({ autoUpdateEnabled: settings.autoUpdateEnabled });
const aiState = useAIState();
const [activeTab, setActiveTab] = useState("application");
const [mountedTabs, setMountedTabs] = useState(() => new Set(["application"]));
@@ -231,17 +281,7 @@ const SettingsPageContent: React.FC<{ settings: SettingsState }> = ({ settings }
)}
{mountedTabs.has("terminal") && (
<SettingsTerminalTab
terminalThemeId={settings.terminalThemeId}
setTerminalThemeId={settings.setTerminalThemeId}
terminalFontFamilyId={settings.terminalFontFamilyId}
setTerminalFontFamilyId={settings.setTerminalFontFamilyId}
terminalFontSize={settings.terminalFontSize}
setTerminalFontSize={settings.setTerminalFontSize}
terminalSettings={settings.terminalSettings}
updateTerminalSetting={settings.updateTerminalSetting}
availableFonts={settings.availableFonts}
/>
<SettingsTerminalTabContainer settings={settings} />
)}
{mountedTabs.has("shortcuts") && (
@@ -261,34 +301,7 @@ const SettingsPageContent: React.FC<{ settings: SettingsState }> = ({ settings }
)}
{mountedTabs.has("ai") && (
<AITabErrorBoundary>
<React.Suspense fallback={null}>
<SettingsAITab
providers={aiState.providers}
addProvider={aiState.addProvider}
updateProvider={aiState.updateProvider}
removeProvider={aiState.removeProvider}
activeProviderId={aiState.activeProviderId}
setActiveProviderId={aiState.setActiveProviderId}
activeModelId={aiState.activeModelId}
setActiveModelId={aiState.setActiveModelId}
globalPermissionMode={aiState.globalPermissionMode}
setGlobalPermissionMode={aiState.setGlobalPermissionMode}
externalAgents={aiState.externalAgents}
setExternalAgents={aiState.setExternalAgents}
defaultAgentId={aiState.defaultAgentId}
setDefaultAgentId={aiState.setDefaultAgentId}
commandBlocklist={aiState.commandBlocklist}
setCommandBlocklist={aiState.setCommandBlocklist}
commandTimeout={aiState.commandTimeout}
setCommandTimeout={aiState.setCommandTimeout}
maxIterations={aiState.maxIterations}
setMaxIterations={aiState.setMaxIterations}
webSearchConfig={aiState.webSearchConfig}
setWebSearchConfig={aiState.setWebSearchConfig}
/>
</React.Suspense>
</AITabErrorBoundary>
<SettingsAITabContainer />
)}
{mountedTabs.has("sync") && (

View File

@@ -11,6 +11,7 @@
*/
import React, { memo, useCallback, useEffect, useMemo, useRef } from "react";
import { formatHostPort } from "../domain/host";
import { useI18n } from "../application/i18n/I18nProvider";
import { useSftpState } from "../application/state/useSftpState";
import { useSftpBackend } from "../application/state/useSftpBackend";
@@ -518,7 +519,7 @@ const SftpSidePanelInner: React.FC<SftpSidePanelProps> = ({
/>
<div
className="min-w-0 flex-1 max-w-[calc(100%-1.75rem)] text-[11px] leading-5 truncate"
title={`${displayHost.label} · ${(displayHost.username || "root")}@${displayHost.hostname}:${displayHost.port || 22}`}
title={`${displayHost.label} · ${(displayHost.username || "root")}@${formatHostPort(displayHost.hostname, displayHost.port || 22)}`}
>
<span className="font-medium">
{displayHost.label}

View File

@@ -8,7 +8,7 @@ import React, { memo, useCallback, useEffect, useMemo, useRef, useState } from "
// flushSync removed - no longer needed
import { useI18n } from "../application/i18n/I18nProvider";
import { logger } from "../lib/logger";
import { cn } from "../lib/utils";
import { cn, normalizeLineEndings, wrapBracketedPaste } from "../lib/utils";
import {
Host,
Identity,
@@ -157,6 +157,10 @@ interface TerminalProps {
onToggleComposeBar?: () => void;
isWorkspaceComposeBarOpen?: boolean;
onBroadcastInput?: (data: string, sourceSessionId: string) => void;
onSnippetExecutorChange?: (
sessionId: string,
executor: ((command: string, noAutoRun?: boolean) => void) | null,
) => void;
// Session log configuration for real-time streaming
sessionLog?: { enabled: boolean; directory: string; format: string };
}
@@ -216,6 +220,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
onToggleComposeBar,
isWorkspaceComposeBarOpen,
onBroadcastInput,
onSnippetExecutorChange,
sessionLog,
}) => {
// Timeout for connection - increased to 120s to allow time for keyboard-interactive (2FA) authentication
@@ -346,12 +351,12 @@ const TerminalComponent: React.FC<TerminalProps> = ({
const isLocalConnection = host.protocol === "local";
const isSerialConnection = host.protocol === "serial";
// Server stats (CPU, Memory, Disk) for Linux servers
// Server stats (CPU, Memory, Disk) — only for Linux/macOS
const { stats: serverStats } = useServerStats({
sessionId,
enabled: terminalSettings?.showServerStats ?? true,
refreshInterval: terminalSettings?.serverStatsRefreshInterval ?? 5,
isLinux: host.os === 'linux',
isSupportedOs: host.os === 'linux' || host.os === 'macos',
isConnected: status === 'connected',
});
@@ -635,28 +640,6 @@ const TerminalComponent: React.FC<TerminalProps> = ({
// Local terminal and serial connections don't need timeout/progress UI
if (isLocalConnection || isSerialConnection) return;
// Only show SSH-specific scripted logs for SSH connections
const isSSH = host.protocol !== "telnet";
let stepTimer: ReturnType<typeof setInterval> | undefined;
if (isSSH) {
const scripted = [
"Resolving host and keys...",
"Negotiating ciphers...",
"Exchanging keys...",
"Authenticating user...",
"Waiting for server greeting...",
];
let idx = 0;
stepTimer = setInterval(() => {
setProgressLogs((prev) => {
if (idx >= scripted.length) return prev;
const next = scripted[idx++];
return prev.includes(next) ? prev : [...prev, next];
});
}, 900);
}
setTimeLeft(CONNECTION_TIMEOUT / 1000);
const countdown = setInterval(() => {
setTimeLeft((prev) => (prev > 0 ? prev - 1 : 0));
@@ -679,7 +662,6 @@ const TerminalComponent: React.FC<TerminalProps> = ({
}, 200);
return () => {
if (stepTimer) clearInterval(stepTimer);
clearInterval(countdown);
clearTimeout(timeout);
clearInterval(prog);
@@ -787,6 +769,10 @@ const TerminalComponent: React.FC<TerminalProps> = ({
terminalSettings.drawBoldInBrightColors;
termRef.current.options.minimumContrastRatio =
terminalSettings.minimumContrastRatio;
termRef.current.options.smoothScrollDuration =
terminalSettings.smoothScrolling
? XTERM_PERFORMANCE_CONFIG.rendering.smoothScrollDuration
: 0;
termRef.current.options.scrollOnUserInput =
shouldEnableNativeUserInputAutoScroll(terminalSettings);
termRef.current.options.altClickMovesCursor = !terminalSettings.altAsMeta;
@@ -1061,11 +1047,43 @@ const TerminalComponent: React.FC<TerminalProps> = ({
const scrollOnPasteRef = useRef(terminalSettings?.scrollOnPaste ?? true);
scrollOnPasteRef.current = terminalSettings?.scrollOnPaste ?? true;
const scrollToBottomAfterProgrammaticInput = (data: string) => {
const scrollToBottomAfterProgrammaticInput = useCallback((data: string) => {
if (termRef.current && shouldScrollOnTerminalInput(terminalSettingsRef.current, data)) {
termRef.current.scrollToBottom();
}
};
}, []);
const executeSnippetCommand = useCallback((command: string, noAutoRun?: boolean) => {
const term = termRef.current;
const id = sessionRef.current;
if (!term || !id) return;
let data = normalizeLineEndings(command);
const isMultiLine = data.includes('\n');
// Wrap in bracketed paste BEFORE appending \r so the Enter is sent
// outside the paste markers — otherwise shells treat it as pasted text
// instead of a submit action.
if (isMultiLine && term.modes.bracketedPasteMode && !disableBracketedPasteRef.current) {
data = wrapBracketedPaste(data);
}
if (!noAutoRun) data = `${data}\r`;
terminalBackend.writeToSession(id, data);
scrollToBottomAfterProgrammaticInput(data);
term.focus();
}, [scrollToBottomAfterProgrammaticInput, terminalBackend]);
// Only register the snippet executor once the terminal session is ready.
// Before that, TerminalLayer falls back to raw writeToSession which is the
// correct path for sessions that are still connecting.
useEffect(() => {
if (status !== "connected") {
onSnippetExecutorChange?.(sessionId, null);
return;
}
onSnippetExecutorChange?.(sessionId, executeSnippetCommand);
return () => onSnippetExecutorChange?.(sessionId, null);
}, [executeSnippetCommand, onSnippetExecutorChange, sessionId, status]);
const terminalContextActions = useTerminalContextActions({
termRef,
@@ -1375,8 +1393,8 @@ const TerminalComponent: React.FC<TerminalProps> = ({
)}
/>
</div>
{/* Server Stats Display - Linux only */}
{host.os === 'linux' && terminalSettings?.showServerStats && status === 'connected' && serverStats.lastUpdated && (
{/* Server Stats Display */}
{terminalSettings?.showServerStats && status === 'connected' && serverStats.lastUpdated && (
<div className="flex items-center gap-2.5 ml-2 text-[10px] opacity-80 flex-nowrap overflow-hidden min-w-0">
{/* CPU with HoverCard for per-core details */}
<HoverCard openDelay={200} closeDelay={100}>
@@ -1423,6 +1441,24 @@ const TerminalComponent: React.FC<TerminalProps> = ({
</div>
))}
</div>
) : serverStats.cpu !== null ? (
<div className="flex flex-col gap-1.5 min-w-[160px]">
<div className="w-full h-2 bg-muted rounded-full overflow-hidden">
<div
className={cn(
"h-full rounded-full transition-all",
serverStats.cpu >= 90 ? "bg-red-500" : serverStats.cpu >= 70 ? "bg-amber-500" : "bg-emerald-500"
)}
style={{ width: `${serverStats.cpu}%` }}
/>
</div>
<div className={cn(
"text-center text-[11px] font-medium",
serverStats.cpu >= 90 ? "text-red-400" : serverStats.cpu >= 70 ? "text-amber-400" : "text-emerald-400"
)}>
{serverStats.cpu}% · {serverStats.cpuCores ?? '?'} cores
</div>
</div>
) : (
<div className="text-muted-foreground">{t("terminal.serverStats.noData")}</div>
)}

View File

@@ -1,5 +1,5 @@
import { Circle, FolderTree, LayoutGrid, MessageSquare, PanelLeft, PanelRight, Palette, Server, X, Zap } from 'lucide-react';
import React, { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react';
import React, { createContext, memo, useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react';
import { useActiveTabId } from '../application/state/activeTabStore';
import { useTerminalBackend } from '../application/state/useTerminalBackend';
import { collectSessionIds } from '../domain/workspace';
@@ -16,7 +16,7 @@ import {
resolveHostTerminalFontSize,
resolveHostTerminalThemeId,
} from '../domain/terminalAppearance';
import { cn } from '../lib/utils';
import { cn, normalizeLineEndings } from '../lib/utils';
import { detectLocalOs } from '../lib/localShell';
import { useStoredString } from '../application/state/useStoredString';
import { buildCacheKey } from '../application/state/sftp/sharedRemoteHostCache';
@@ -28,7 +28,7 @@ import { SftpSidePanel } from './SftpSidePanel';
import { ScriptsSidePanel } from './ScriptsSidePanel';
import { ThemeSidePanel } from './terminal/ThemeSidePanel';
import { AIChatSidePanel } from './AIChatSidePanel';
import { useAIState } from '../application/state/useAIState';
import { cleanupOrphanedAISessions, useAIState } from '../application/state/useAIState';
import { TerminalComposeBar } from './terminal/TerminalComposeBar';
import { TERMINAL_THEMES } from '../infrastructure/config/terminalThemes';
import { useCustomThemes } from '../application/state/customThemeStore';
@@ -65,6 +65,8 @@ type PendingSftpUpload = {
entries: DropEntry[];
};
type SnippetExecutor = (command: string, noAutoRun?: boolean) => void;
const filterTabsMap = <T,>(source: Map<string, T>, validIds: Set<string>): Map<string, T> => {
let changed = false;
const next = new Map<string, T>();
@@ -90,6 +92,18 @@ type AITerminalSessionInfo = {
connected: boolean;
};
type AIPanelContext = {
scopeType: 'terminal' | 'workspace';
scopeTargetId?: string;
scopeHostIds: string[];
scopeLabel: string;
terminalSessions: AITerminalSessionInfo[];
};
type AIStateValue = ReturnType<typeof useAIState>;
const AIStateContext = createContext<AIStateValue | null>(null);
const buildAITerminalSessionInfo = (
session: TerminalSession | undefined,
host: Host | undefined,
@@ -110,6 +124,98 @@ const buildAITerminalSessionInfo = (
};
};
interface AIChatPanelsHostProps {
mountedTabIds: string[];
activeTabId: string | null;
activeSidePanelTab: SidePanelTab | null;
contextsByTabId: Map<string, AIPanelContext>;
resolveExecutorContext: (scope: {
type: 'terminal' | 'workspace';
targetId?: string;
label?: string;
}) => ExecutorContext;
}
const AIStateProviderInner: React.FC<{ children: React.ReactNode }> = ({ children }) => {
const aiState = useAIState();
return (
<AIStateContext.Provider value={aiState}>
{children}
</AIStateContext.Provider>
);
};
const AIStateProvider = memo(AIStateProviderInner);
AIStateProvider.displayName = 'AIStateProvider';
const AIChatPanelsHostInner: React.FC<AIChatPanelsHostProps> = ({
mountedTabIds,
activeTabId,
activeSidePanelTab,
contextsByTabId,
resolveExecutorContext,
}) => {
const aiState = useContext(AIStateContext);
if (!aiState) {
throw new Error('AIChatPanelsHost must be rendered inside AIStateProvider');
}
return (
<>
{mountedTabIds.map((tabId) => {
const context = contextsByTabId.get(tabId);
if (!context) return null;
const isVisible = activeTabId === tabId && activeSidePanelTab === 'ai';
return (
<div
key={tabId}
className={cn("absolute inset-0 z-10", !isVisible && "hidden")}
>
<AIChatSidePanel
sessions={aiState.sessions}
activeSessionIdMap={aiState.activeSessionIdMap}
setActiveSessionId={aiState.setActiveSessionId}
createSession={aiState.createSession}
deleteSession={aiState.deleteSession}
updateSessionTitle={aiState.updateSessionTitle}
updateSessionExternalSessionId={aiState.updateSessionExternalSessionId}
addMessageToSession={aiState.addMessageToSession}
updateLastMessage={aiState.updateLastMessage}
updateMessageById={aiState.updateMessageById}
providers={aiState.providers}
activeProviderId={aiState.activeProviderId}
activeModelId={aiState.activeModelId}
defaultAgentId={aiState.defaultAgentId}
externalAgents={aiState.externalAgents}
setExternalAgents={aiState.setExternalAgents}
agentModelMap={aiState.agentModelMap}
setAgentModel={aiState.setAgentModel}
globalPermissionMode={aiState.globalPermissionMode}
setGlobalPermissionMode={aiState.setGlobalPermissionMode}
commandBlocklist={aiState.commandBlocklist}
maxIterations={aiState.maxIterations}
webSearchConfig={aiState.webSearchConfig}
scopeType={context.scopeType}
scopeTargetId={context.scopeTargetId}
scopeHostIds={context.scopeHostIds}
scopeLabel={context.scopeLabel}
terminalSessions={context.terminalSessions}
resolveExecutorContext={resolveExecutorContext}
isVisible={isVisible}
/>
</div>
);
})}
</>
);
};
const AIChatPanelsHost = memo(AIChatPanelsHostInner);
AIChatPanelsHost.displayName = 'AIChatPanelsHost';
interface TerminalLayerProps {
hosts: Host[];
keys: SSHKey[];
@@ -306,6 +412,15 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
// Terminal backend for broadcast writes
const terminalBackend = useTerminalBackend();
const snippetExecutorsRef = useRef<Map<string, SnippetExecutor>>(new Map());
const handleSnippetExecutorChange = useCallback((sessionId: string, executor: SnippetExecutor | null) => {
if (executor) {
snippetExecutorsRef.current.set(sessionId, executor);
return;
}
snippetExecutorsRef.current.delete(sessionId);
}, []);
const [workspaceArea, setWorkspaceArea] = useState<{ width: number; height: number }>({ width: 0, height: 0 });
const workspaceOuterRef = useRef<HTMLDivElement>(null);
@@ -633,6 +748,10 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
setSftpPendingUploadsForTab(prev => filterTabsMap(prev, validTerminalTabIds));
}, [validTerminalTabIds]);
useEffect(() => {
cleanupOrphanedAISessions(validTerminalTabIds);
}, [validTerminalTabIds]);
const computeWorkspaceRects = useCallback((workspace?: Workspace, size?: { width: number; height: number }): Record<string, WorkspaceRect> => {
if (!workspace) return {} as Record<string, WorkspaceRect>;
const wTotal = size?.width || 1;
@@ -876,6 +995,13 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
() => Array.from(sftpHostForTab.keys()),
[sftpHostForTab],
);
const mountedAiTabIds = useMemo(
() =>
Array.from(sidePanelOpenTabs.entries())
.filter(([, panel]) => panel === 'ai')
.map(([tabId]) => tabId),
[sidePanelOpenTabs],
);
// Get the focused terminal's current working directory
const getTerminalCwd = useCallback(async (): Promise<string | null> => {
@@ -982,8 +1108,15 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
const handleSnippetClickForFocusedSession = useCallback((command: string, noAutoRun?: boolean) => {
const sessionId = activeWorkspace?.focusedSessionId ?? activeSession?.id;
if (!sessionId) return;
const payload = noAutoRun ? command : `${command}\r`;
terminalBackend.writeToSession(sessionId, payload);
const executor = snippetExecutorsRef.current.get(sessionId);
if (executor) {
executor(command, noAutoRun);
return;
}
let data = normalizeLineEndings(command);
if (!noAutoRun) data = `${data}\r`;
terminalBackend.writeToSession(sessionId, data);
// Re-focus the terminal so the user can interact immediately
const pane = document.querySelector(`[data-session-id="${sessionId}"]`);
const textarea = pane?.querySelector('textarea.xterm-helper-textarea') as HTMLTextAreaElement | null;
@@ -1058,38 +1191,66 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
const focusedFontFamilyOverridden = hasHostFontFamilyOverride(focusedHost);
const focusedFontSizeOverridden = hasHostFontSizeOverride(focusedHost);
// AI Chat state
const aiState = useAIState();
const { cleanupOrphanedSessions } = aiState;
useEffect(() => {
const activeIds = new Set<string>();
for (const s of sessions) activeIds.add(s.id);
for (const w of workspaces) activeIds.add(w.id);
cleanupOrphanedSessions(activeIds);
}, [sessions, workspaces, cleanupOrphanedSessions]);
// Keep MCP/ACP approval IPC listener alive for the entire terminal lifecycle.
// Must live here (TerminalLayer), NOT in AIChatSidePanel (unmounts on tab switch)
// or ChatMessageList (unmounts on panel hide).
// Must live here (TerminalLayer), not inside the AI panel subtree, so closing
// or hiding the panel never tears down approval handling mid-execution.
useEffect(() => {
return setupMcpApprovalBridge();
}, []);
// Build terminal session context for the AI chat panel
const aiTerminalSessions = useMemo(() => {
// Build per-tab AI contexts so hidden panels can stay mounted without
// recomputing scope resolution from scratch on every tab switch.
const aiContextsByTabId = useMemo(() => {
const localOs = detectLocalOs(navigator.userAgent || navigator.platform);
const sessionIds = activeWorkspace?.root
? collectSessionIds(activeWorkspace.root)
: activeSession ? [activeSession.id] : [];
const sessionById = new Map(sessions.map((session) => [session.id, session]));
const workspaceById = new Map(workspaces.map((workspace) => [workspace.id, workspace]));
const tabIds = new Set<string>(mountedAiTabIds);
if (activeTabId) tabIds.add(activeTabId);
const result = sessionIds.map(sid => {
const s = sessions.find(s => s.id === sid);
const host = s?.hostId ? hosts.find(h => h.id === s.hostId) : undefined;
return buildAITerminalSessionInfo(s, host, localOs);
});
return result;
}, [sessions, hosts, activeWorkspace, activeSession]);
const contexts = new Map<string, AIPanelContext>();
for (const tabId of tabIds) {
const workspace = workspaceById.get(tabId);
if (workspace) {
const sessionIds = collectSessionIds(workspace.root);
contexts.set(tabId, {
scopeType: 'workspace',
scopeTargetId: workspace.id,
scopeHostIds: sessionIds
.map((sessionId) => sessionById.get(sessionId)?.hostId)
.filter((hostId): hostId is string => !!hostId),
scopeLabel: workspace.title,
terminalSessions: sessionIds.map((sessionId) =>
buildAITerminalSessionInfo(
sessionById.get(sessionId),
sessionHostsMap.get(sessionId),
localOs,
),
),
});
continue;
}
const session = sessionById.get(tabId);
if (!session) continue;
contexts.set(tabId, {
scopeType: 'terminal',
scopeTargetId: session.id,
scopeHostIds: session.hostId ? [session.hostId] : [],
scopeLabel: session.hostLabel ?? '',
terminalSessions: [
buildAITerminalSessionInfo(
session,
sessionHostsMap.get(session.id),
localOs,
),
],
});
}
return contexts;
}, [sessions, workspaces, mountedAiTabIds, activeTabId, sessionHostsMap]);
const resolveAIExecutorContext = useCallback((scope: {
type: 'terminal' | 'workspace';
@@ -1302,14 +1463,15 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
};
return (
<div
ref={workspaceOuterRef}
className="absolute inset-0 bg-background flex flex-col"
style={{ display: isTerminalLayerVisible ? 'flex' : 'none', zIndex: isTerminalLayerVisible ? 10 : 0 }}
>
<div className={cn("flex-1 flex min-h-0 relative", sidePanelPosition === 'right' && "flex-row-reverse")}>
<AIStateProvider>
<div
ref={workspaceOuterRef}
className="absolute inset-0 bg-background flex flex-col"
style={{ display: isTerminalLayerVisible ? 'flex' : 'none', zIndex: isTerminalLayerVisible ? 10 : 0 }}
>
<div className={cn("flex-1 flex min-h-0 relative", sidePanelPosition === 'right' && "flex-row-reverse")}>
{/* Side panel with tab header + content (SFTP / Scripts / Theme) */}
{(isSidePanelOpenForCurrentTab || mountedSftpTabIds.length > 0) && (
{(isSidePanelOpenForCurrentTab || mountedSftpTabIds.length > 0 || mountedAiTabIds.length > 0) && (
<>
<div
style={{ width: isSidePanelOpenForCurrentTab ? sidePanelWidth : 0 }}
@@ -1487,48 +1649,14 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
</div>
)}
{/* AI Chat sub-panel */}
{activeSidePanelTab === 'ai' && (
<div className="absolute inset-0 z-10">
<AIChatSidePanel
sessions={aiState.sessions}
activeSessionIdMap={aiState.activeSessionIdMap}
setActiveSessionId={aiState.setActiveSessionId}
createSession={aiState.createSession}
deleteSession={aiState.deleteSession}
updateSessionTitle={aiState.updateSessionTitle}
updateSessionExternalSessionId={aiState.updateSessionExternalSessionId}
addMessageToSession={aiState.addMessageToSession}
updateLastMessage={aiState.updateLastMessage}
updateMessageById={aiState.updateMessageById}
providers={aiState.providers}
activeProviderId={aiState.activeProviderId}
activeModelId={aiState.activeModelId}
defaultAgentId={aiState.defaultAgentId}
externalAgents={aiState.externalAgents}
setExternalAgents={aiState.setExternalAgents}
agentModelMap={aiState.agentModelMap}
setAgentModel={aiState.setAgentModel}
globalPermissionMode={aiState.globalPermissionMode}
setGlobalPermissionMode={aiState.setGlobalPermissionMode}
commandBlocklist={aiState.commandBlocklist}
maxIterations={aiState.maxIterations}
webSearchConfig={aiState.webSearchConfig}
scopeType={activeWorkspace ? 'workspace' : 'terminal'}
scopeTargetId={activeWorkspace?.id ?? activeSession?.id}
scopeHostIds={activeWorkspace?.root
? collectSessionIds(activeWorkspace.root).map(sid => {
const s = sessions.find(s => s.id === sid);
return s?.hostId;
}).filter((id): id is string => !!id)
: activeSession?.hostId ? [activeSession.hostId] : []
}
scopeLabel={activeWorkspace?.title ?? activeSession?.hostLabel ?? ''}
terminalSessions={aiTerminalSessions}
resolveExecutorContext={resolveAIExecutorContext}
/>
</div>
)}
<AIChatPanelsHost
mountedTabIds={mountedAiTabIds}
activeTabId={activeTabId}
activeSidePanelTab={activeSidePanelTab}
contextsByTabId={aiContextsByTabId}
resolveExecutorContext={resolveAIExecutorContext}
/>
</div>
</div>
</div>
@@ -1678,6 +1806,7 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
onToggleComposeBar={inActiveWorkspace ? handleToggleWorkspaceComposeBar : undefined}
isWorkspaceComposeBarOpen={inActiveWorkspace ? isComposeBarOpen : undefined}
onBroadcastInput={inActiveWorkspace && activeWorkspace && isBroadcastEnabled?.(activeWorkspace.id) ? handleBroadcastInput : undefined}
onSnippetExecutorChange={handleSnippetExecutorChange}
sessionLog={sessionLogsEnabled && sessionLogsDir ? { enabled: true, directory: sessionLogsDir, format: sessionLogsFormat || 'txt' } : undefined}
/>
</div>
@@ -1739,25 +1868,26 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
</div>
{/* Global compose bar for workspace mode */}
{activeWorkspace && isComposeBarOpen && (
<TerminalComposeBar
onSend={handleComposeSend}
onClose={() => {
setIsComposeBarOpen(false);
// Refocus the terminal pane (matching solo-session behavior)
if (focusedSessionId) {
requestAnimationFrame(() => {
const pane = document.querySelector(`[data-session-id="${focusedSessionId}"]`);
const textarea = pane?.querySelector('textarea.xterm-helper-textarea') as HTMLTextAreaElement | null;
textarea?.focus();
});
}
}}
isBroadcastEnabled={isBroadcastEnabled?.(activeWorkspace.id)}
themeColors={composeBarThemeColors}
/>
)}
</div>
{activeWorkspace && isComposeBarOpen && (
<TerminalComposeBar
onSend={handleComposeSend}
onClose={() => {
setIsComposeBarOpen(false);
// Refocus the terminal pane (matching solo-session behavior)
if (focusedSessionId) {
requestAnimationFrame(() => {
const pane = document.querySelector(`[data-session-id="${focusedSessionId}"]`);
const textarea = pane?.querySelector('textarea.xterm-helper-textarea') as HTMLTextAreaElement | null;
textarea?.focus();
});
}
}}
isBroadcastEnabled={isBroadcastEnabled?.(activeWorkspace.id)}
themeColors={composeBarThemeColors}
/>
)}
</div>
</AIStateProvider>
);
};

View File

@@ -116,7 +116,7 @@ const TrayPanelContent: React.FC = () => {
onTrayPanelMenuData,
} = useTrayPanelBackend();
const { hosts, keys } = useVaultState();
const { hosts, keys, identities } = useVaultState();
useSessionState();
const { rules: portForwardingRules, startTunnel, stopTunnel } = usePortForwardingState();
const activeTabId = useActiveTabId();
@@ -151,11 +151,6 @@ const TrayPanelContent: React.FC = () => {
return () => unsubscribe?.();
}, [onTrayPanelRefresh]);
const keysForPf = useMemo(
() => keys.map((k) => ({ id: k.id, privateKey: k.privateKey, passphrase: k.passphrase })),
[keys],
);
const handleClose = useCallback(() => {
void hideTrayPanel();
}, [hideTrayPanel]);
@@ -339,7 +334,7 @@ const TrayPanelContent: React.FC = () => {
if (isActive) {
void stopTunnel(rule.id);
} else {
void startTunnel(rule, host, keysForPf, (status, error) => {
void startTunnel(rule, host, hosts, keys, identities, (status, error) => {
if (status === "error" && error) toast.error(error);
}, rule.autoStart);
}

View File

@@ -1213,6 +1213,8 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
return new Set(managedSources.map(s => s.groupName));
}, [managedSources]);
const isHostsSectionActive = currentSection === "hosts";
const moveHostToGroup = (hostId: string, groupPath: string | null) => {
const targetGroup = groupPath || "";
// Find the most specific (deepest) managed source that matches the target group
@@ -1440,463 +1442,454 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
{/* Main Area */}
<div className="flex-1 flex flex-col min-h-0 relative">
{currentSection === "hosts" && (
<header className="border-b border-border/50 bg-secondary/80 backdrop-blur app-drag">
<div className="h-14 px-4 py-2 flex items-center gap-3">
<div className="relative flex-1 app-no-drag">
<Search
size={14}
className="absolute left-3 top-1/2 -translate-y-1/2 text-muted-foreground"
/>
<Input
placeholder={t("vault.hosts.search.placeholder")}
className={cn(
"pl-9 h-10 bg-secondary border-border/60 text-sm",
isSearchQuickConnect &&
"border-primary/50 ring-1 ring-primary/20",
)}
value={search}
onChange={(e) => setSearch(e.target.value)}
onKeyDown={handleSearchKeyDown}
/>
{isSearchQuickConnect && (
<div className="absolute right-3 top-1/2 -translate-y-1/2">
<Zap size={14} className="text-primary" />
</div>
)}
</div>
<Button
variant={isSearchQuickConnect ? "default" : "secondary"}
<header
className={cn(
"border-b border-border/50 bg-secondary/80 backdrop-blur app-drag",
!isHostsSectionActive && "hidden",
)}
>
<div className="h-14 px-4 py-2 flex items-center gap-3">
<div className="relative flex-1 app-no-drag">
<Search
size={14}
className="absolute left-3 top-1/2 -translate-y-1/2 text-muted-foreground"
/>
<Input
placeholder={t("vault.hosts.search.placeholder")}
className={cn(
"h-10 px-4 app-no-drag",
!isSearchQuickConnect &&
currentSection === "hosts" &&
"bg-foreground/5 text-foreground hover:bg-foreground/10 border-border/40",
"pl-9 h-10 bg-secondary border-border/60 text-sm",
isSearchQuickConnect &&
"border-primary/50 ring-1 ring-primary/20",
)}
onClick={handleConnectClick}
>
{t("vault.hosts.connect")}
</Button>
{/* View mode, tag filter, and sort controls */}
<div className="flex items-center gap-1 app-no-drag">
<Dropdown>
<DropdownTrigger asChild>
<Button variant="ghost" size="icon" className="h-10 w-10 app-no-drag">
{viewMode === "grid" ? (
<LayoutGrid size={16} />
) : viewMode === "list" ? (
<List size={16} />
) : (
<Network size={16} />
)}
<ChevronDown size={10} className="ml-0.5" />
</Button>
</DropdownTrigger>
<DropdownContent className="w-32" align="end">
<Button
variant={viewMode === "grid" ? "secondary" : "ghost"}
className="w-full justify-start gap-2 h-9"
onClick={() => setViewMode("grid")}
>
<LayoutGrid size={14} /> {t("vault.view.grid")}
</Button>
<Button
variant={viewMode === "list" ? "secondary" : "ghost"}
className="w-full justify-start gap-2 h-9"
onClick={() => setViewMode("list")}
>
<List size={14} /> {t("vault.view.list")}
</Button>
<Button
variant={viewMode === "tree" ? "secondary" : "ghost"}
className="w-full justify-start gap-2 h-9"
onClick={() => setViewMode("tree")}
>
<Network size={14} /> {t("vault.view.tree")}
</Button>
</DropdownContent>
</Dropdown>
<TagFilterDropdown
allTags={allTags}
selectedTags={selectedTags}
onChange={setSelectedTags}
onEditTag={handleEditTag}
onDeleteTag={handleDeleteTag}
className="h-10 w-10"
/>
<SortDropdown
value={sortMode}
onChange={setSortMode}
className="h-10 w-10"
/>
<Button
variant={isMultiSelectMode ? "secondary" : "ghost"}
size="icon"
className="h-10 w-10"
onClick={() => {
if (isMultiSelectMode) {
clearHostSelection();
} else {
setIsMultiSelectMode(true);
}
}}
title={t("vault.hosts.multiSelect")}
>
<CheckSquare size={16} />
</Button>
</div>
{/* New Host split button */}
<div className="flex items-center app-no-drag">
<Dropdown>
<div className="flex items-center rounded-md bg-primary text-primary-foreground">
<Button
size="sm"
className="h-10 px-3 rounded-r-none bg-transparent hover:bg-white/10 shadow-none app-no-drag"
onClick={handleNewHost}
>
<Plus size={14} className="mr-2" /> {t("vault.hosts.newHost")}
</Button>
<DropdownTrigger asChild>
<Button
size="sm"
className="h-10 px-2 rounded-l-none bg-transparent hover:bg-white/10 border-l border-primary-foreground/20 shadow-none app-no-drag"
>
<ChevronDown size={14} />
</Button>
</DropdownTrigger>
</div>
<DropdownContent className="w-44" align="end" alignToParent>
<Button
variant="ghost"
className="w-full justify-start gap-2"
onClick={() => {
setTargetParentPath(selectedGroupPath);
setNewFolderName("");
setIsNewFolderOpen(true);
}}
>
<FolderTree size={14} /> {t("vault.hosts.newGroup")}
</Button>
<Button
variant="ghost"
className="w-full justify-start gap-2"
onClick={() => {
setIsImportOpen(true);
}}
>
<Upload size={14} /> {t("vault.hosts.import")}
</Button>
<Button
variant="ghost"
className="w-full justify-start gap-2"
onClick={handleExportHosts}
>
<Download size={14} /> {t("vault.hosts.export")}
</Button>
</DropdownContent>
</Dropdown>
</div>
value={search}
onChange={(e) => setSearch(e.target.value)}
onKeyDown={handleSearchKeyDown}
/>
{isSearchQuickConnect && (
<div className="absolute right-3 top-1/2 -translate-y-1/2">
<Zap size={14} className="text-primary" />
</div>
)}
</div>
<Button
variant={isSearchQuickConnect ? "default" : "secondary"}
className={cn(
"h-10 px-4 app-no-drag",
!isSearchQuickConnect &&
"bg-foreground/5 text-foreground hover:bg-foreground/10 border-border/40",
)}
onClick={handleConnectClick}
>
{t("vault.hosts.connect")}
</Button>
{/* View mode, tag filter, and sort controls */}
<div className="flex items-center gap-1 app-no-drag">
<Dropdown>
<DropdownTrigger asChild>
<Button variant="ghost" size="icon" className="h-10 w-10 app-no-drag">
{viewMode === "grid" ? (
<LayoutGrid size={16} />
) : viewMode === "list" ? (
<List size={16} />
) : (
<Network size={16} />
)}
<ChevronDown size={10} className="ml-0.5" />
</Button>
</DropdownTrigger>
<DropdownContent className="w-32" align="end">
<Button
variant={viewMode === "grid" ? "secondary" : "ghost"}
className="w-full justify-start gap-2 h-9"
onClick={() => setViewMode("grid")}
>
<LayoutGrid size={14} /> {t("vault.view.grid")}
</Button>
<Button
variant={viewMode === "list" ? "secondary" : "ghost"}
className="w-full justify-start gap-2 h-9"
onClick={() => setViewMode("list")}
>
<List size={14} /> {t("vault.view.list")}
</Button>
<Button
variant={viewMode === "tree" ? "secondary" : "ghost"}
className="w-full justify-start gap-2 h-9"
onClick={() => setViewMode("tree")}
>
<Network size={14} /> {t("vault.view.tree")}
</Button>
</DropdownContent>
</Dropdown>
<TagFilterDropdown
allTags={allTags}
selectedTags={selectedTags}
onChange={setSelectedTags}
onEditTag={handleEditTag}
onDeleteTag={handleDeleteTag}
className="h-10 w-10"
/>
<SortDropdown
value={sortMode}
onChange={setSortMode}
className="h-10 w-10"
/>
<Button
size="sm"
variant="secondary"
className={cn(
"h-10 px-3 app-no-drag",
currentSection === "hosts" &&
"bg-foreground/5 text-foreground hover:bg-foreground/10 border-border/40",
)}
onClick={onCreateLocalTerminal}
variant={isMultiSelectMode ? "secondary" : "ghost"}
size="icon"
className="h-10 w-10"
onClick={() => {
if (isMultiSelectMode) {
clearHostSelection();
} else {
setIsMultiSelectMode(true);
}
}}
title={t("vault.hosts.multiSelect")}
>
<TerminalSquare size={14} className="mr-2" /> {t("common.terminal")}
</Button>
<Button
size="sm"
variant="secondary"
className={cn(
"h-10 px-3 app-no-drag",
currentSection === "hosts" &&
"bg-foreground/5 text-foreground hover:bg-foreground/10 border-border/40",
)}
onClick={() => setIsSerialModalOpen(true)}
>
<Usb size={14} className="mr-2" /> {t("serial.button")}
<CheckSquare size={16} />
</Button>
</div>
</header>
)}
{/* New Host split button */}
<div className="flex items-center app-no-drag">
<Dropdown>
<div className="flex items-center rounded-md bg-primary text-primary-foreground">
<Button
size="sm"
className="h-10 px-3 rounded-r-none bg-transparent hover:bg-white/10 shadow-none app-no-drag"
onClick={handleNewHost}
>
<Plus size={14} className="mr-2" /> {t("vault.hosts.newHost")}
</Button>
<DropdownTrigger asChild>
<Button
size="sm"
className="h-10 px-2 rounded-l-none bg-transparent hover:bg-white/10 border-l border-primary-foreground/20 shadow-none app-no-drag"
>
<ChevronDown size={14} />
</Button>
</DropdownTrigger>
</div>
<DropdownContent className="w-44" align="end" alignToParent>
<Button
variant="ghost"
className="w-full justify-start gap-2"
onClick={() => {
setTargetParentPath(selectedGroupPath);
setNewFolderName("");
setIsNewFolderOpen(true);
}}
>
<FolderTree size={14} /> {t("vault.hosts.newGroup")}
</Button>
<Button
variant="ghost"
className="w-full justify-start gap-2"
onClick={() => {
setIsImportOpen(true);
}}
>
<Upload size={14} /> {t("vault.hosts.import")}
</Button>
<Button
variant="ghost"
className="w-full justify-start gap-2"
onClick={handleExportHosts}
>
<Download size={14} /> {t("vault.hosts.export")}
</Button>
</DropdownContent>
</Dropdown>
</div>
<Button
size="sm"
variant="secondary"
className="h-10 px-3 app-no-drag bg-foreground/5 text-foreground hover:bg-foreground/10 border-border/40"
onClick={onCreateLocalTerminal}
>
<TerminalSquare size={14} className="mr-2" /> {t("common.terminal")}
</Button>
<Button
size="sm"
variant="secondary"
className="h-10 px-3 app-no-drag bg-foreground/5 text-foreground hover:bg-foreground/10 border-border/40"
onClick={() => setIsSerialModalOpen(true)}
>
<Usb size={14} className="mr-2" /> {t("serial.button")}
</Button>
</div>
</header>
{currentSection !== "port" &&
currentSection !== "keys" &&
currentSection !== "knownhosts" &&
currentSection !== "snippets" &&
currentSection !== "logs" && (
<div className="flex-1 overflow-auto px-4 py-4 space-y-6">
{currentSection === "hosts" && (
<>
<section className="space-y-2">
{viewMode !== "tree" && (
<div className="flex items-center gap-2 text-sm font-semibold">
<button
className="text-primary hover:underline"
onClick={() => setSelectedGroupPath(null)}
>
{t("vault.hosts.allHosts")}
</button>
{selectedGroupPath &&
selectedGroupPath
.split("/")
.filter(Boolean)
.map((part, idx, arr) => {
const crumbPath = arr.slice(0, idx + 1).join("/");
const isLast = idx === arr.length - 1;
return (
<span
key={crumbPath}
className="flex items-center gap-2"
>
<span className="text-muted-foreground"></span>
<button
className={cn(
isLast
? "text-foreground font-semibold"
: "text-primary hover:underline",
)}
onClick={() =>
setSelectedGroupPath(crumbPath)
}
>
{part}
</button>
</span>
);
})}
</div>
)}
{viewMode !== "tree" && displayedGroups.length > 0 && (
<>
<div className="flex items-center justify-between">
<h3 className="text-sm font-semibold text-muted-foreground">
{t("vault.groups.title")}
</h3>
<div className="text-xs text-muted-foreground">
{t("vault.groups.total", { count: displayedGroups.length })}
</div>
</div>
</>
)}
{viewMode !== "tree" && (
<div
className={cn(
displayedGroups.length === 0 ? "hidden" : "",
viewMode === "grid"
? "grid gap-3 grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4"
: "flex flex-col gap-0",
)}
onDragOver={(e) => {
e.preventDefault();
}}
onDrop={(e) => {
e.preventDefault();
e.stopPropagation();
const hostId = e.dataTransfer.getData("host-id");
const groupPath = e.dataTransfer.getData("group-path");
if (hostId) moveHostToGroup(hostId, selectedGroupPath);
if (groupPath && selectedGroupPath !== null)
moveGroup(groupPath, selectedGroupPath);
}}
{/* Keep hosts mounted so switching sections does not reset scroll or remount the list. */}
<div
className={cn(
"flex-1 overflow-auto px-4 py-4 space-y-6",
!isHostsSectionActive && "hidden",
)}
>
<section className="space-y-2">
{viewMode !== "tree" && (
<div className="flex items-center gap-2 text-sm font-semibold">
<button
className="text-primary hover:underline"
onClick={() => setSelectedGroupPath(null)}
>
{displayedGroups.map((node) => (
<ContextMenu key={node.path}>
<ContextMenuTrigger asChild>
<div
className={cn(
"group cursor-pointer",
viewMode === "grid"
? "soft-card elevate rounded-xl h-[68px] px-3 py-2"
: "h-14 px-3 py-2 hover:bg-secondary/60 rounded-lg transition-colors",
)}
draggable
onDragStart={(e) =>
e.dataTransfer.setData("group-path", node.path)
}
onDoubleClick={() =>
setSelectedGroupPath(node.path)
}
onClick={() => setSelectedGroupPath(node.path)}
onDragOver={(e) => {
e.preventDefault();
e.stopPropagation();
}}
onDrop={(e) => {
e.preventDefault();
e.stopPropagation();
const hostId =
e.dataTransfer.getData("host-id");
const groupPath =
e.dataTransfer.getData("group-path");
if (hostId) moveHostToGroup(hostId, node.path);
if (groupPath) moveGroup(groupPath, node.path);
}}
{t("vault.hosts.allHosts")}
</button>
{selectedGroupPath &&
selectedGroupPath
.split("/")
.filter(Boolean)
.map((part, idx, arr) => {
const crumbPath = arr.slice(0, idx + 1).join("/");
const isLast = idx === arr.length - 1;
return (
<span
key={crumbPath}
className="flex items-center gap-2"
>
<div className="flex items-center gap-3 h-full">
<div className="h-11 w-11 rounded-xl bg-primary/15 text-primary flex items-center justify-center">
<FolderTree size={20} />
<span className="text-muted-foreground"></span>
<button
className={cn(
isLast
? "text-foreground font-semibold"
: "text-primary hover:underline",
)}
onClick={() =>
setSelectedGroupPath(crumbPath)
}
>
{part}
</button>
</span>
);
})}
</div>
)}
{viewMode !== "tree" && displayedGroups.length > 0 && (
<div className="flex items-center justify-between">
<h3 className="text-sm font-semibold text-muted-foreground">
{t("vault.groups.title")}
</h3>
<div className="text-xs text-muted-foreground">
{t("vault.groups.total", { count: displayedGroups.length })}
</div>
</div>
)}
{viewMode !== "tree" && (
<div
className={cn(
displayedGroups.length === 0 ? "hidden" : "",
viewMode === "grid"
? "grid gap-3 grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4"
: "flex flex-col gap-0",
)}
onDragOver={(e) => {
e.preventDefault();
}}
onDrop={(e) => {
e.preventDefault();
e.stopPropagation();
const hostId = e.dataTransfer.getData("host-id");
const groupPath = e.dataTransfer.getData("group-path");
if (hostId) moveHostToGroup(hostId, selectedGroupPath);
if (groupPath && selectedGroupPath !== null)
moveGroup(groupPath, selectedGroupPath);
}}
>
{displayedGroups.map((node) => (
<ContextMenu key={node.path}>
<ContextMenuTrigger asChild>
<div
className={cn(
"group cursor-pointer",
viewMode === "grid"
? "soft-card elevate rounded-xl h-[68px] px-3 py-2"
: "h-14 px-3 py-2 hover:bg-secondary/60 rounded-lg transition-colors",
)}
draggable
onDragStart={(e) =>
e.dataTransfer.setData("group-path", node.path)
}
onDoubleClick={() =>
setSelectedGroupPath(node.path)
}
onClick={() => setSelectedGroupPath(node.path)}
onDragOver={(e) => {
e.preventDefault();
e.stopPropagation();
}}
onDrop={(e) => {
e.preventDefault();
e.stopPropagation();
const hostId =
e.dataTransfer.getData("host-id");
const groupPath =
e.dataTransfer.getData("group-path");
if (hostId) moveHostToGroup(hostId, node.path);
if (groupPath) moveGroup(groupPath, node.path);
}}
>
<div className="flex items-center gap-3 h-full">
<div className="h-11 w-11 rounded-xl bg-primary/15 text-primary flex items-center justify-center">
<FolderTree size={20} />
</div>
<div className="flex-1 min-w-0">
<div className="text-sm font-semibold truncate flex items-center gap-2">
{node.name}
{managedGroupPaths.has(node.path) && (
<span className="inline-flex items-center gap-1 text-[10px] font-medium px-1.5 py-0.5 rounded bg-primary/15 text-primary shrink-0">
<FileSymlink size={10} />
Managed
</span>
)}
</div>
<div className="flex-1 min-w-0">
<div className="text-sm font-semibold truncate flex items-center gap-2">
{node.name}
{managedGroupPaths.has(node.path) && (
<span className="inline-flex items-center gap-1 text-[10px] font-medium px-1.5 py-0.5 rounded bg-primary/15 text-primary shrink-0">
<FileSymlink size={10} />
Managed
</span>
)}
</div>
<div className="text-[11px] text-muted-foreground">
{t("vault.groups.hostsCount", { count: node.totalHostCount ?? node.hosts.length })}
</div>
<div className="text-[11px] text-muted-foreground">
{t("vault.groups.hostsCount", { count: node.totalHostCount ?? node.hosts.length })}
</div>
</div>
</div>
</ContextMenuTrigger>
<ContextMenuContent>
<ContextMenuItem
onClick={() => {
setTargetParentPath(node.path);
setNewFolderName("");
setIsNewFolderOpen(true);
}}
>
<FolderPlus className="mr-2 h-4 w-4" /> {t("vault.groups.newSubgroup")}
</ContextMenuItem>
<ContextMenuItem
onClick={() => {
setRenameTargetPath(node.path);
setRenameGroupName(node.name);
setRenameGroupError(null);
setIsRenameGroupOpen(true);
}}
>
<Edit2 className="mr-2 h-4 w-4" /> {t("vault.groups.rename")}
</ContextMenuItem>
<ContextMenuItem
className="text-destructive"
onClick={() => {
setDeleteTargetPath(node.path);
setIsDeleteGroupOpen(true);
}}
>
<Trash2 className="mr-2 h-4 w-4" /> {t("vault.groups.delete")}
</ContextMenuItem>
</ContextMenuContent>
</ContextMenu>
))}
</div>
)}
</section>
</div>
</ContextMenuTrigger>
<ContextMenuContent>
<ContextMenuItem
onClick={() => {
setTargetParentPath(node.path);
setNewFolderName("");
setIsNewFolderOpen(true);
}}
>
<FolderPlus className="mr-2 h-4 w-4" /> {t("vault.groups.newSubgroup")}
</ContextMenuItem>
<ContextMenuItem
onClick={() => {
setRenameTargetPath(node.path);
setRenameGroupName(node.name);
setRenameGroupError(null);
setIsRenameGroupOpen(true);
}}
>
<Edit2 className="mr-2 h-4 w-4" /> {t("vault.groups.rename")}
</ContextMenuItem>
<ContextMenuItem
className="text-destructive"
onClick={() => {
setDeleteTargetPath(node.path);
setIsDeleteGroupOpen(true);
}}
>
<Trash2 className="mr-2 h-4 w-4" /> {t("vault.groups.delete")}
</ContextMenuItem>
</ContextMenuContent>
</ContextMenu>
))}
</div>
)}
</section>
<section className="space-y-2">
<div className="flex items-center justify-between">
<h3 className="text-sm font-semibold text-muted-foreground">
{t("vault.nav.hosts")}
</h3>
<div className="flex items-center gap-2 text-xs text-muted-foreground">
<span>
{t("vault.hosts.header.entries", { count: viewMode === "tree" ? treeViewHosts.length : displayedHosts.length })}
</span>
<div className="bg-secondary/80 border border-border/70 rounded-md px-2 py-1 text-[11px]">
{t("vault.hosts.header.live", { count: sessions.length })}
</div>
<section className="space-y-2">
<div className="flex items-center justify-between">
<h3 className="text-sm font-semibold text-muted-foreground">
{t("vault.nav.hosts")}
</h3>
<div className="flex items-center gap-2 text-xs text-muted-foreground">
<span>
{t("vault.hosts.header.entries", { count: viewMode === "tree" ? treeViewHosts.length : displayedHosts.length })}
</span>
<div className="bg-secondary/80 border border-border/70 rounded-md px-2 py-1 text-[11px]">
{t("vault.hosts.header.live", { count: sessions.length })}
</div>
</div>
</div>
{isMultiSelectMode && (
<div className="flex items-center gap-2 p-2 bg-secondary/60 rounded-lg border border-border/40">
<span className="text-sm text-muted-foreground">
{t("vault.hosts.selected", { count: selectedHostIds.size })}
</span>
<div className="flex-1" />
<Button
variant="ghost"
size="sm"
onClick={() => {
const allIds = new Set(displayedHosts.map(h => h.id));
setSelectedHostIds(allIds);
}}
>
{t("vault.hosts.selectAll")}
</Button>
<Button
variant="ghost"
size="sm"
onClick={clearHostSelection}
>
{t("vault.hosts.deselectAll")}
</Button>
<Button
variant="destructive"
size="sm"
disabled={selectedHostIds.size === 0}
onClick={deleteSelectedHosts}
>
<Trash2 size={14} className="mr-1" />
{t("vault.hosts.deleteSelected", { count: selectedHostIds.size })}
</Button>
<Button
variant="ghost"
size="icon"
className="h-8 w-8"
onClick={clearHostSelection}
>
<X size={14} />
</Button>
</div>
)}
{isMultiSelectMode && (
<div className="flex items-center gap-2 p-2 bg-secondary/60 rounded-lg border border-border/40">
<span className="text-sm text-muted-foreground">
{t("vault.hosts.selected", { count: selectedHostIds.size })}
</span>
<div className="flex-1" />
<Button
variant="ghost"
size="sm"
onClick={() => {
const allIds = new Set(displayedHosts.map(h => h.id));
setSelectedHostIds(allIds);
}}
>
{t("vault.hosts.selectAll")}
</Button>
<Button
variant="ghost"
size="sm"
onClick={clearHostSelection}
>
{t("vault.hosts.deselectAll")}
</Button>
<Button
variant="destructive"
size="sm"
disabled={selectedHostIds.size === 0}
onClick={deleteSelectedHosts}
>
<Trash2 size={14} className="mr-1" />
{t("vault.hosts.deleteSelected", { count: selectedHostIds.size })}
</Button>
<Button
variant="ghost"
size="icon"
className="h-8 w-8"
onClick={clearHostSelection}
>
<X size={14} />
</Button>
</div>
)}
{viewMode === "tree" ? (
<HostTreeView
groupTree={treeViewGroupTree}
hosts={treeViewHosts} // Use filtered and sorted hosts for tree view
sortMode={sortMode}
expandedPaths={treeExpandedState.expandedPaths}
onTogglePath={treeExpandedState.togglePath}
onExpandAll={treeExpandedState.expandAll}
onCollapseAll={treeExpandedState.collapseAll}
onConnect={handleHostConnect}
onEditHost={handleEditHost}
onDuplicateHost={handleDuplicateHost}
onDeleteHost={(host) => onDeleteHost(host.id)}
onCopyCredentials={handleCopyCredentials}
onNewHost={(groupPath) => {
setEditingHost(null);
setNewHostGroupPath(groupPath || null);
setIsHostPanelOpen(true);
}}
onNewGroup={(parentPath) => {
setTargetParentPath(parentPath || null);
setNewFolderName("");
setIsNewFolderOpen(true);
}}
onEditGroup={(groupPath) => {
setRenameTargetPath(groupPath);
const groupName = groupPath.split('/').pop() || '';
setRenameGroupName(groupName);
setRenameGroupError(null);
setIsRenameGroupOpen(true);
}}
onDeleteGroup={(groupPath) => {
setDeleteTargetPath(groupPath);
setIsDeleteGroupOpen(true);
}}
moveHostToGroup={moveHostToGroup}
moveGroup={moveGroup}
managedGroupPaths={managedGroupPaths}
onUnmanageGroup={handleUnmanageGroup}
isMultiSelectMode={isMultiSelectMode}
selectedHostIds={selectedHostIds}
toggleHostSelection={toggleHostSelection}
/>
) : sortMode === "group" && groupedDisplayHosts ? (
<div className="space-y-6">
{viewMode === "tree" ? (
<HostTreeView
groupTree={treeViewGroupTree}
hosts={treeViewHosts} // Use filtered and sorted hosts for tree view
sortMode={sortMode}
expandedPaths={treeExpandedState.expandedPaths}
onTogglePath={treeExpandedState.togglePath}
onExpandAll={treeExpandedState.expandAll}
onCollapseAll={treeExpandedState.collapseAll}
onConnect={handleHostConnect}
onEditHost={handleEditHost}
onDuplicateHost={handleDuplicateHost}
onDeleteHost={(host) => onDeleteHost(host.id)}
onCopyCredentials={handleCopyCredentials}
onNewHost={(groupPath) => {
setEditingHost(null);
setNewHostGroupPath(groupPath || null);
setIsHostPanelOpen(true);
}}
onNewGroup={(parentPath) => {
setTargetParentPath(parentPath || null);
setNewFolderName("");
setIsNewFolderOpen(true);
}}
onEditGroup={(groupPath) => {
setRenameTargetPath(groupPath);
const groupName = groupPath.split('/').pop() || '';
setRenameGroupName(groupName);
setRenameGroupError(null);
setIsRenameGroupOpen(true);
}}
onDeleteGroup={(groupPath) => {
setDeleteTargetPath(groupPath);
setIsDeleteGroupOpen(true);
}}
moveHostToGroup={moveHostToGroup}
moveGroup={moveGroup}
managedGroupPaths={managedGroupPaths}
onUnmanageGroup={handleUnmanageGroup}
isMultiSelectMode={isMultiSelectMode}
selectedHostIds={selectedHostIds}
toggleHostSelection={toggleHostSelection}
/>
) : sortMode === "group" && groupedDisplayHosts ? (
<div className="space-y-6">
{groupedDisplayHosts.map((group) => (
<div key={group.name || "__ungrouped__"}>
<div className="flex items-center gap-2 mb-3 pb-2 border-b border-border/40">
@@ -2045,16 +2038,16 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
</p>
</div>
)}
</div>
) : (
<div
className={cn(
viewMode === "grid"
? "grid gap-3 grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4"
: "flex flex-col gap-0",
)}
>
{displayedHosts.map((host) => {
</div>
) : (
<div
className={cn(
viewMode === "grid"
? "grid gap-3 grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4"
: "flex flex-col gap-0",
)}
>
{displayedHosts.map((host) => {
const safeHost = sanitizeHost(host);
const effectiveDistro = getEffectiveHostDistro(safeHost);
const distroBadge = {
@@ -2167,27 +2160,24 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
</ContextMenuContent>
</ContextMenu>
);
})}
{displayedHosts.length === 0 && (
<div className="col-span-full flex flex-col items-center justify-center py-24 text-muted-foreground">
<div className="h-16 w-16 rounded-2xl bg-secondary/80 flex items-center justify-center mb-4">
<LayoutGrid size={32} className="opacity-60" />
</div>
<h3 className="text-lg font-semibold text-foreground mb-2">
{t('vault.hosts.empty.title')}
</h3>
<p className="text-sm text-center max-w-sm">
{t('vault.hosts.empty.desc')}
</p>
})}
{displayedHosts.length === 0 && (
<div className="col-span-full flex flex-col items-center justify-center py-24 text-muted-foreground">
<div className="h-16 w-16 rounded-2xl bg-secondary/80 flex items-center justify-center mb-4">
<LayoutGrid size={32} className="opacity-60" />
</div>
)}
</div>
)}
</section>
</>
)}
</div>
)}
<h3 className="text-lg font-semibold text-foreground mb-2">
{t('vault.hosts.empty.title')}
</h3>
<p className="text-sm text-center max-w-sm">
{t('vault.hosts.empty.desc')}
</p>
</div>
)}
</div>
)}
</section>
</div>
{currentSection === "snippets" && (
<SnippetsManager

View File

@@ -75,17 +75,18 @@ export const ToolCall = ({
: approvalStatus === 'denied'
? 'border-red-500/20 bg-red-500/[0.03]'
: 'border-border/25 bg-muted/10';
const statusIconClass = 'shrink-0';
const statusIcon = approvalStatus === 'pending' ? (
<ShieldAlert size={12} className="text-yellow-500/70 shrink-0" />
<ShieldAlert size={12} className={cn('text-yellow-500/70', statusIconClass)} />
) : isLoading ? (
<Loader2 size={12} className="animate-spin text-blue-400/70" />
<Loader2 size={12} className={cn('animate-spin text-blue-400/70', statusIconClass)} />
) : isInterrupted ? (
<Slash size={12} className="text-muted-foreground/55" />
<Slash size={12} className={cn('text-muted-foreground/55', statusIconClass)} />
) : isError ? (
<XCircle size={12} className="text-red-400/70" />
<XCircle size={12} className={cn('text-red-400/70', statusIconClass)} />
) : result !== undefined ? (
<CheckCircle2 size={12} className="text-green-400/70" />
<CheckCircle2 size={12} className={cn('text-green-400/70', statusIconClass)} />
) : null;
return (
@@ -105,7 +106,13 @@ export const ToolCall = ({
? <ChevronDown size={12} className="text-muted-foreground/40 shrink-0" />
: <ChevronRight size={12} className="text-muted-foreground/40 shrink-0" />
}
<span className="font-mono text-muted-foreground/70 truncate">{name}</span>
{name === 'terminal_execute' && args?.command ? (
<span className="font-mono text-muted-foreground/70 truncate" title={String(args.command)}>
<span className="text-muted-foreground/40">$ </span>{String(args.command)}
</span>
) : (
<span className="font-mono text-muted-foreground/70 truncate">{name}</span>
)}
<span className="flex-1" />
{/* Approval badge for resolved approvals */}
{approvalStatus === 'approved' && (

View File

@@ -144,12 +144,14 @@ const ChatMessageList: React.FC<ChatMessageListProps> = ({ messages, isStreaming
.flatMap((m) => m.toolResults?.map((tr) => tr.toolCallId) ?? []),
);
// Build a map from toolCallId → toolName for display
// Build maps from toolCallId → toolName / toolArgs for display
const toolCallNames = new Map<string, string>();
const toolCallArgs = new Map<string, Record<string, unknown>>();
for (const m of visibleMessages) {
if (m.role === 'assistant' && m.toolCalls) {
for (const tc of m.toolCalls) {
toolCallNames.set(tc.id, tc.name);
if (tc.arguments) toolCallArgs.set(tc.id, tc.arguments);
}
}
}
@@ -178,6 +180,7 @@ const ChatMessageList: React.FC<ChatMessageListProps> = ({ messages, isStreaming
<ToolCall
key={tr.toolCallId}
name={toolCallNames.get(tr.toolCallId) || tr.toolCallId}
args={toolCallArgs.get(tr.toolCallId)}
result={tr.content}
isError={tr.isError}
/>

View File

@@ -1,7 +1,7 @@
/**
* Settings System Tab - System information, temp file management, session logs, and global hotkey
*/
import { Download, ExternalLink, FileText, FolderOpen, HardDrive, Keyboard, RefreshCw, RotateCcw, Trash2 } from "lucide-react";
import { AlertTriangle, ChevronDown, ChevronRight, Download, ExternalLink, FileText, FolderOpen, HardDrive, Keyboard, RefreshCw, RotateCcw, Trash2 } from "lucide-react";
import React, { useCallback, useEffect, useState } from "react";
import { useI18n } from "../../../application/i18n/I18nProvider";
import { getCredentialProtectionAvailability } from "../../../infrastructure/services/credentialProtection";
@@ -13,6 +13,31 @@ import { Button } from "../../ui/button";
import { Toggle, Select, SettingRow } from "../settings-ui";
import { cn } from "../../../lib/utils";
interface CrashLogFile {
fileName: string;
date: string;
size: number;
entryCount: number;
}
interface CrashLogEntry {
timestamp: string;
source: string;
message: string;
stack?: string;
errorMeta?: Record<string, unknown>;
extra?: Record<string, unknown>;
pid?: number;
platform?: string;
arch?: string;
version?: string;
electronVersion?: string;
osVersion?: string;
memoryMB?: { rss: number; heapUsed: number; heapTotal: number };
activeSessionCount?: number;
uptimeSeconds?: number;
}
interface TempDirInfo {
path: string;
fileCount: number;
@@ -98,6 +123,12 @@ const SettingsSystemTab: React.FC<SettingsSystemTabProps> = ({
const [hotkeyError, setHotkeyError] = useState<string | null>(null);
const [credentialsAvailable, setCredentialsAvailable] = useState<boolean | null>(null);
const [isCheckingCredentials, setIsCheckingCredentials] = useState(false);
const [crashLogs, setCrashLogs] = useState<CrashLogFile[]>([]);
const [isLoadingCrashLogs, setIsLoadingCrashLogs] = useState(false);
const [expandedLog, setExpandedLog] = useState<string | null>(null);
const [logEntries, setLogEntries] = useState<CrashLogEntry[]>([]);
const [isClearingCrashLogs, setIsClearingCrashLogs] = useState(false);
const [crashLogClearResult, setCrashLogClearResult] = useState<{ deletedCount: number } | null>(null);
const [appVersion, setAppVersion] = useState('');
@@ -144,6 +175,73 @@ const SettingsSystemTab: React.FC<SettingsSystemTabProps> = ({
void loadCredentialProtectionStatus();
}, [loadCredentialProtectionStatus]);
const loadCrashLogs = useCallback(async () => {
const bridge = netcattyBridge.get();
if (!bridge?.getCrashLogs) return;
setIsLoadingCrashLogs(true);
try {
const logs = await bridge.getCrashLogs();
setCrashLogs(logs);
} catch (err) {
console.error("[SettingsSystemTab] Failed to load crash logs:", err);
} finally {
setIsLoadingCrashLogs(false);
}
}, []);
useEffect(() => {
void loadCrashLogs();
}, [loadCrashLogs]);
const expandRequestRef = React.useRef(0);
const handleExpandCrashLog = useCallback(async (fileName: string) => {
if (expandedLog === fileName) {
setExpandedLog(null);
setLogEntries([]);
return;
}
const bridge = netcattyBridge.get();
if (!bridge?.readCrashLog) return;
const requestId = ++expandRequestRef.current;
// Optimistically show expanded state while loading
setExpandedLog(fileName);
setLogEntries([]);
try {
const entries = await bridge.readCrashLog(fileName);
// Discard if user clicked a different file while awaiting
if (expandRequestRef.current !== requestId) return;
setLogEntries(entries);
} catch (err) {
if (expandRequestRef.current !== requestId) return;
console.error("[SettingsSystemTab] Failed to read crash log:", err);
}
}, [expandedLog]);
const handleClearCrashLogs = useCallback(async () => {
const bridge = netcattyBridge.get();
if (!bridge?.clearCrashLogs) return;
setIsClearingCrashLogs(true);
setCrashLogClearResult(null);
try {
const result = await bridge.clearCrashLogs();
setCrashLogClearResult(result);
setExpandedLog(null);
setLogEntries([]);
// Reload the list so partial failures still show remaining files
await loadCrashLogs();
} catch (err) {
console.error("[SettingsSystemTab] Failed to clear crash logs:", err);
} finally {
setIsClearingCrashLogs(false);
}
}, [loadCrashLogs]);
const handleOpenCrashLogsDir = useCallback(async () => {
const bridge = netcattyBridge.get();
if (!bridge?.openCrashLogsDir) return;
await bridge.openCrashLogsDir();
}, []);
const handleClearTempFiles = useCallback(async () => {
const bridge = netcattyBridge.get();
if (!bridge?.clearTempDir) return;
@@ -449,6 +547,148 @@ const SettingsSystemTab: React.FC<SettingsSystemTabProps> = ({
</div>
</div>
{/* Crash Logs Section */}
<div className="space-y-4">
<div className="flex items-center gap-2">
<AlertTriangle size={18} className="text-muted-foreground" />
<h3 className="text-base font-medium">{t("settings.system.crashLogs.title")}</h3>
</div>
<div className="bg-muted/30 rounded-lg p-4 space-y-3">
<p className="text-sm text-muted-foreground">
{t("settings.system.crashLogs.description")}
</p>
{crashLogs.length === 0 && !isLoadingCrashLogs && (
<p className="text-sm text-muted-foreground italic">
{t("settings.system.crashLogs.noLogs")}
</p>
)}
{crashLogs.length > 0 && (
<div className="space-y-2">
{crashLogs.map((log) => (
<div key={log.fileName} className="border border-border/60 rounded-md overflow-hidden">
<button
onClick={() => handleExpandCrashLog(log.fileName)}
className="w-full flex items-center justify-between px-3 py-2 text-sm hover:bg-muted/50 transition-colors"
>
<div className="flex items-center gap-2">
{expandedLog === log.fileName ? <ChevronDown size={14} /> : <ChevronRight size={14} />}
<span className="font-mono">{log.date}</span>
<span className="text-muted-foreground">
({t("settings.system.crashLogs.entries").replace("{count}", String(log.entryCount))})
</span>
</div>
<span className="text-xs text-muted-foreground">{formatBytes(log.size)}</span>
</button>
{expandedLog === log.fileName && logEntries.length > 0 && (
<div className="border-t border-border/60 max-h-64 overflow-y-auto">
{logEntries.map((entry, idx) => (
<div key={idx} className="px-3 py-2 text-xs border-b border-border/30 last:border-b-0 space-y-1">
<div className="flex items-center gap-3 flex-wrap">
<span className="font-mono text-muted-foreground">
{new Date(entry.timestamp).toLocaleTimeString()}
</span>
<span className="px-1.5 py-0.5 rounded bg-destructive/10 text-destructive font-medium">
{entry.source}
</span>
</div>
<p className="font-mono break-all">{entry.message}</p>
{entry.errorMeta && Object.keys(entry.errorMeta).length > 0 && (
<div className="flex items-center gap-2 flex-wrap">
{Object.entries(entry.errorMeta).map(([k, v]) => (
<span key={k} className="px-1.5 py-0.5 rounded bg-muted font-mono">
{k}={String(v)}
</span>
))}
</div>
)}
{entry.extra && Object.keys(entry.extra).length > 0 && (
<div className="flex items-center gap-2 flex-wrap">
{Object.entries(entry.extra).map(([k, v]) => (
<span key={k} className="px-1.5 py-0.5 rounded bg-muted font-mono">
{k}={String(v)}
</span>
))}
</div>
)}
{(() => {
const parts: string[] = [];
if (entry.version) parts.push(`v${entry.version}`);
if (entry.electronVersion) parts.push(`Electron ${entry.electronVersion}`);
if (entry.platform) parts.push(`${entry.platform}/${entry.arch}`);
if (entry.osVersion) parts.push(`OS ${entry.osVersion}`);
if (entry.pid) parts.push(`PID ${entry.pid}`);
if (entry.activeSessionCount != null && entry.activeSessionCount >= 0) parts.push(`Sessions: ${entry.activeSessionCount}`);
if (entry.memoryMB) parts.push(`RAM: ${entry.memoryMB.rss}MB`);
if (entry.uptimeSeconds != null) parts.push(`Uptime: ${entry.uptimeSeconds}s`);
const text = parts.join(' ');
return text ? (
<div className="text-muted-foreground truncate" title={text}>
{text}
</div>
) : null;
})()}
{entry.stack && (
<pre className="mt-1 p-2 bg-muted rounded text-[11px] leading-relaxed overflow-x-auto whitespace-pre-wrap break-all text-muted-foreground">
{entry.stack}
</pre>
)}
</div>
))}
</div>
)}
</div>
))}
</div>
)}
{/* Actions */}
<div className="flex items-center gap-2 pt-2">
<Button
variant="outline"
size="sm"
onClick={loadCrashLogs}
disabled={isLoadingCrashLogs}
className="gap-1.5"
>
<RefreshCw size={14} className={isLoadingCrashLogs ? "animate-spin" : ""} />
{t("settings.system.refresh")}
</Button>
<Button
variant="outline"
size="sm"
onClick={handleClearCrashLogs}
disabled={isClearingCrashLogs || crashLogs.length === 0}
className="gap-1.5 text-destructive hover:text-destructive hover:bg-destructive/10"
>
<Trash2 size={14} />
{t("settings.system.crashLogs.clear")}
</Button>
<Button
variant="ghost"
size="icon"
onClick={handleOpenCrashLogsDir}
title={t("settings.system.openFolder")}
>
<FolderOpen size={16} />
</Button>
</div>
{crashLogClearResult && (
<p className="text-sm text-muted-foreground">
{t("settings.system.crashLogs.cleared").replace("{count}", String(crashLogClearResult.deletedCount))}
</p>
)}
</div>
<p className="text-xs text-muted-foreground">
{t("settings.system.crashLogs.hint")}
</p>
</div>
{/* Temp Directory Section */}
<div className="space-y-4">
<div className="flex items-center gap-2">

View File

@@ -616,6 +616,13 @@ export default function SettingsTerminalTab(props: {
<Toggle checked={terminalSettings.scrollOnPaste} onChange={(v) => updateTerminalSetting("scrollOnPaste", v)} />
</SettingRow>
<SettingRow
label={t("settings.terminal.behavior.smoothScrolling")}
description={t("settings.terminal.behavior.smoothScrolling.desc")}
>
<Toggle checked={terminalSettings.smoothScrolling} onChange={(v) => updateTerminalSetting("smoothScrolling", v)} />
</SettingRow>
<SettingRow
label={t("settings.terminal.behavior.linkModifier")}
description={t("settings.terminal.behavior.linkModifier.desc")}

View File

@@ -88,7 +88,7 @@ const SftpFileRowInner: React.FC<SftpFileRowProps> = ({
<Link size={8} className="absolute -bottom-0.5 -right-0.5 text-muted-foreground" aria-hidden="true" />
)}
</div>
<span className={cn("truncate", entry.type === 'symlink' && "italic pr-1")}>
<span className={cn("truncate", entry.type === 'symlink' && "italic pr-1")} title={entry.name}>
{entry.name}
{entry.type === 'symlink' && <span className="sr-only"> (symbolic link)</span>}
</span>

View File

@@ -1,5 +1,5 @@
import React, { useCallback, useMemo } from "react";
import { AlertCircle, ArrowDown, Copy, Download, Edit2, ExternalLink, FilePlus, Folder, FolderPlus, Loader2, Pencil, RefreshCw, Shield, Trash2 } from "lucide-react";
import React, { useCallback, useMemo, useState } from "react";
import { AlertCircle, ArrowDown, ChevronDown, Copy, Download, Edit2, ExternalLink, FilePlus, Folder, FolderPlus, Loader2, Pencil, RefreshCw, Shield, Trash2 } from "lucide-react";
import { Button } from "../ui/button";
import {
ContextMenu,
@@ -58,6 +58,46 @@ interface SftpPaneFileListProps {
visibleRows: { entry: SftpFileEntry; index: number; top: number }[];
}
const SftpErrorWithLogs: React.FC<{
error: string;
connectionLogs: string[];
onRetry: () => void;
t: (key: string) => string;
}> = ({ error, connectionLogs, onRetry, t }) => {
const [showLogs, setShowLogs] = useState(false);
return (
<div className="flex flex-col items-center justify-center h-full gap-2 text-destructive">
<AlertCircle size={24} />
<span className="text-sm text-center px-4">{t(error)}</span>
<div className="flex items-center gap-2">
<Button variant="outline" size="sm" onClick={onRetry}>
{t("sftp.retry")}
</Button>
{connectionLogs.length > 0 && (
<Button
variant="ghost"
size="sm"
className="text-muted-foreground"
onClick={() => setShowLogs(!showLogs)}
>
<ChevronDown size={14} className={`mr-1 transition-transform ${showLogs ? 'rotate-180' : ''}`} />
{showLogs ? "Hide logs" : "Show logs"}
</Button>
)}
</div>
{showLogs && connectionLogs.length > 0 && (
<div className="w-full max-w-sm mt-1 p-2 rounded-md bg-secondary/50 border border-border/60 space-y-0.5 max-h-40 overflow-y-auto">
{connectionLogs.map((log, i) => (
<div key={i} className="text-[11px] text-muted-foreground truncate font-mono">
{log}
</div>
))}
</div>
)}
</div>
);
};
export const SftpPaneFileList: React.FC<SftpPaneFileListProps> = ({
t,
pane,
@@ -340,17 +380,25 @@ export const SftpPaneFileList: React.FC<SftpPaneFileListProps> = ({
onScroll={handleFileListScroll}
>
{pane.loading && sortedDisplayFiles.length === 0 ? (
<div className="flex items-center justify-center h-full">
<div className="flex flex-col items-center justify-center h-full gap-2">
<Loader2 size={24} className="animate-spin text-muted-foreground" />
{pane.connectionLogs.length > 0 && (
<div className="w-full max-w-sm mt-2 space-y-0.5 px-4">
{pane.connectionLogs.map((log, i) => (
<div key={i} className="text-[11px] text-muted-foreground truncate">
{log}
</div>
))}
</div>
)}
</div>
) : pane.error && !pane.reconnecting ? (
<div className="flex flex-col items-center justify-center h-full gap-2 text-destructive">
<AlertCircle size={24} />
<span className="text-sm">{t(pane.error)}</span>
<Button variant="outline" size="sm" onClick={onRefresh}>
{t("sftp.retry")}
</Button>
</div>
<SftpErrorWithLogs
error={pane.error}
connectionLogs={pane.connectionLogs}
onRetry={onRefresh}
t={t}
/>
) : sortedDisplayFiles.length === 0 ? (
<div className="flex flex-col items-center justify-center h-full text-muted-foreground">
<Folder size={32} className="mb-2 opacity-50" />
@@ -410,10 +458,19 @@ export const SftpPaneFileList: React.FC<SftpPaneFileListProps> = ({
</span>
</div>
{/* Loading overlay - covers entire pane when navigating directories */}
{/* Loading overlay - covers entire pane when navigating or reconnecting */}
{pane.loading && sortedDisplayFiles.length > 0 && !pane.reconnecting && (
<div className="absolute inset-0 flex items-center justify-center bg-background/40 backdrop-blur-[1px] z-10">
<div className="absolute inset-0 flex flex-col items-center justify-center bg-background/40 backdrop-blur-[1px] z-10">
<Loader2 size={24} className="animate-spin text-muted-foreground" />
{pane.connectionLogs.length > 0 && (
<div className="w-full max-w-sm mt-2 space-y-0.5 px-4">
{pane.connectionLogs.map((log, i) => (
<div key={i} className="text-[11px] text-muted-foreground truncate">
{log}
</div>
))}
</div>
)}
</div>
)}

View File

@@ -7,6 +7,7 @@ import React from 'react';
import { useI18n } from '../../application/i18n/I18nProvider';
import { cn } from '../../lib/utils';
import { Host, SSHKey } from '../../types';
import { formatHostPort } from '../../domain/host';
import { DistroAvatar } from '../DistroAvatar';
import { Button } from '../ui/button';
import { TerminalAuthDialog, TerminalAuthDialogProps } from './TerminalAuthDialog';
@@ -85,12 +86,12 @@ export const TerminalConnectionDialog: React.FC<TerminalConnectionDialogProps> =
)}>
<div className="w-[560px] max-w-[90vw] bg-background/95 border border-border/60 rounded-xl shadow-xl p-6 space-y-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<DistroAvatar host={host} fallback={host.label.slice(0, 2).toUpperCase()} className="h-10 w-10 rounded-lg" />
<div>
<div className="flex items-center gap-3 min-w-0 flex-1">
<DistroAvatar host={host} fallback={host.label.slice(0, 2).toUpperCase()} className="h-10 w-10 rounded-lg shrink-0" />
<div className="min-w-0">
{chainProgress ? (
<>
<div className="text-sm font-semibold">
<div className="text-sm font-semibold truncate">
<span className="text-muted-foreground">
{t('terminal.connection.chainOf', {
current: chainProgress.currentHop,
@@ -100,21 +101,21 @@ export const TerminalConnectionDialog: React.FC<TerminalConnectionDialogProps> =
</span>
<span>{chainProgress.currentHostLabel}</span>
</div>
<div className="text-[11px] text-muted-foreground font-mono">
{t(protocolInfo.i18nKey)} {protocolInfo.showPort ? `${host.hostname}:${protocolInfo.port}` : host.hostname}
<div className="text-[11px] text-muted-foreground font-mono truncate">
{t(protocolInfo.i18nKey)} {protocolInfo.showPort ? formatHostPort(host.hostname, protocolInfo.port) : host.hostname}
</div>
</>
) : (
<>
<div className="text-lg font-semibold">{host.label}</div>
<div className="text-[11px] text-muted-foreground font-mono">
{t(protocolInfo.i18nKey)} {protocolInfo.showPort ? `${host.hostname}:${protocolInfo.port}` : host.hostname}
<div className="text-lg font-semibold truncate">{host.label}</div>
<div className="text-[11px] text-muted-foreground font-mono truncate">
{t(protocolInfo.i18nKey)} {protocolInfo.showPort ? formatHostPort(host.hostname, protocolInfo.port) : host.hostname}
</div>
</>
)}
</div>
</div>
<div className="flex items-center gap-2">
<div className="flex items-center gap-2 shrink-0 ml-3">
{!needsAuth && (
<Button
size="sm"

View File

@@ -48,7 +48,7 @@ interface UseServerStatsOptions {
sessionId: string;
enabled: boolean; // Whether stats collection is enabled (from settings)
refreshInterval: number; // Refresh interval in seconds
isLinux: boolean; // Only collect stats for Linux servers
isSupportedOs: boolean; // Only collect stats for Linux/macOS servers
isConnected: boolean; // Only collect when connected
}
@@ -56,7 +56,7 @@ export function useServerStats({
sessionId,
enabled,
refreshInterval,
isLinux,
isSupportedOs,
isConnected,
}: UseServerStatsOptions) {
const [stats, setStats] = useState<ServerStats>({
@@ -86,7 +86,7 @@ export function useServerStats({
const isMountedRef = useRef(true);
const fetchStats = useCallback(async () => {
if (!enabled || !isLinux || !isConnected || !sessionId) {
if (!enabled || !isSupportedOs || !isConnected || !sessionId) {
return;
}
@@ -137,7 +137,7 @@ export function useServerStats({
setIsLoading(false);
}
}
}, [sessionId, enabled, isLinux, isConnected]);
}, [sessionId, enabled, isSupportedOs, isConnected]);
// Initial fetch and periodic refresh
useEffect(() => {
@@ -149,8 +149,7 @@ export function useServerStats({
intervalRef.current = null;
}
// Don't run if not enabled or not a Linux system
if (!enabled || !isLinux || !isConnected) {
if (!enabled || !isSupportedOs || !isConnected) {
// Reset stats when disabled or not connected
setStats({
cpu: null,
@@ -193,7 +192,7 @@ export function useServerStats({
intervalRef.current = null;
}
};
}, [enabled, isLinux, isConnected, refreshInterval, fetchStats]);
}, [enabled, isSupportedOs, isConnected, refreshInterval, fetchStats]);
// Manual refresh function
const refresh = useCallback(() => {

View File

@@ -10,6 +10,19 @@ interface CompiledRule {
color: string;
}
interface CachedDecorationRange {
x: number;
width: number;
color: string;
}
/** Shared empty array for non-matching lines to avoid per-call allocations. */
const EMPTY_RANGES: readonly CachedDecorationRange[] = Object.freeze([]);
/** ASCII-only test — when true, string indices equal cell columns. */
// eslint-disable-next-line no-control-regex
const RE_ASCII_ONLY = /^[\x00-\x7f]*$/;
/**
* Manages terminal decorations for keyword highlighting.
* Uses xterm.js Decoration API to overlay styles without modifying the data stream.
@@ -20,6 +33,9 @@ export class KeywordHighlighter implements IDisposable {
private compiledRules: CompiledRule[] = [];
private decorations: { decoration: IDecoration; marker: IMarker }[] = [];
private debounceTimer: NodeJS.Timeout | null = null;
private animationFrameId: number | null = null;
private lastRefreshTime: number = 0;
private matchCache = new Map<string, CachedDecorationRange[]>();
private enabled: boolean = false;
private disposables: IDisposable[] = [];
private lastViewportY: number = -1;
@@ -31,23 +47,22 @@ export class KeywordHighlighter implements IDisposable {
this.disposables.push(
// When user scrolls, refresh visible area
this.term.onScroll(() => {
// console.log('[KeywordHighlighter] onScroll');
this.triggerRefresh();
this.triggerRefresh("debounced");
}),
// When new data is written, refresh
// When new data is written, refresh on the next frame so highlights land
// with the freshly rendered content instead of trailing behind it.
this.term.onWriteParsed(() => {
// console.log('[KeywordHighlighter] onWriteParsed');
this.triggerRefresh();
this.triggerRefresh("immediate");
}),
// Also refresh on resize as viewport content changes
this.term.onResize(() => this.triggerRefresh()),
this.term.onResize(() => this.triggerRefresh("debounced")),
// onRender fires after each render cycle - catch scrolls that onScroll might miss
this.term.onRender(() => {
// Only trigger refresh if viewport position changed
const currentViewportY = this.term.buffer.active?.viewportY ?? 0;
if (currentViewportY !== this.lastViewportY) {
this.lastViewportY = currentViewportY;
this.triggerRefresh();
this.triggerRefresh("debounced");
}
})
);
@@ -55,6 +70,7 @@ export class KeywordHighlighter implements IDisposable {
public setRules(rules: KeywordHighlightRule[], enabled: boolean) {
this.enabled = enabled;
this.matchCache.clear();
// Pre-compile all patterns into regexes for better performance
// This avoids creating new RegExp objects on every viewport refresh
@@ -76,7 +92,7 @@ export class KeywordHighlighter implements IDisposable {
// Clear existing and force an immediate refresh if enabling
this.clearDecorations();
if (this.enabled && this.compiledRules.length > 0) {
this.triggerRefresh();
this.triggerRefresh("immediate");
}
}
@@ -87,9 +103,14 @@ export class KeywordHighlighter implements IDisposable {
if (this.debounceTimer) {
clearTimeout(this.debounceTimer);
}
if (this.animationFrameId !== null) {
cancelAnimationFrame(this.animationFrameId);
this.animationFrameId = null;
}
this.matchCache.clear();
}
private triggerRefresh() {
private triggerRefresh(mode: "immediate" | "debounced") {
if (!this.enabled || this.compiledRules.length === 0) return;
// Optimization: Disable highlighting in Alternate Buffer (e.g. Vim, Htop)
@@ -101,12 +122,72 @@ export class KeywordHighlighter implements IDisposable {
return;
}
if (mode === "immediate") {
// Throttle: skip if a rAF is already pending.
// Don't clear the debounce timer here — in a hidden tab rAF never
// fires, so the fallback timer is the only path that will run.
if (this.animationFrameId !== null) {
return;
}
const now = performance.now();
const minInterval = XTERM_PERFORMANCE_CONFIG.highlighting.immediateMinIntervalMs;
if (now - this.lastRefreshTime < minInterval) {
// Too soon — fall through to debounced path instead of dropping
this.triggerRefresh("debounced");
return;
}
this.animationFrameId = requestAnimationFrame(() => {
this.animationFrameId = null;
// rAF fired — cancel the fallback timer to avoid a redundant refresh
if (this.debounceTimer) {
clearTimeout(this.debounceTimer);
this.debounceTimer = null;
}
this.executeRefresh();
});
// Arm a debounced fallback: rAF does not fire in background/hidden
// tabs (Chromium throttles it), so the timer ensures highlights
// still update for ongoing output. If rAF fires first it cancels
// this timer (see above), preventing a double refresh.
if (!this.debounceTimer) {
this.debounceTimer = setTimeout(() => {
this.debounceTimer = null;
this.executeRefresh();
}, XTERM_PERFORMANCE_CONFIG.highlighting.debounceMs);
}
return;
}
if (this.animationFrameId !== null) {
return;
}
if (this.debounceTimer) {
clearTimeout(this.debounceTimer);
}
const delay = XTERM_PERFORMANCE_CONFIG.highlighting.debounceMs;
this.debounceTimer = setTimeout(() => this.refreshViewport(), delay);
this.debounceTimer = setTimeout(() => {
this.debounceTimer = null;
this.executeRefresh();
}, delay);
}
/** Shared refresh execution for both rAF and timer callbacks. */
private executeRefresh() {
// Cancel any stale rAF that will never fire (e.g. hidden tab)
if (this.animationFrameId !== null) {
cancelAnimationFrame(this.animationFrameId);
this.animationFrameId = null;
}
// Re-check state: may have changed since the refresh was scheduled
if (!this.enabled || this.compiledRules.length === 0) return;
if (this.term.buffer.active.type === 'alternate') {
if (this.decorations.length > 0) this.clearDecorations();
return;
}
this.lastRefreshTime = performance.now();
this.refreshViewport();
}
private clearDecorations() {
@@ -140,8 +221,14 @@ export class KeywordHighlighter implements IDisposable {
// Skip continuation cells (width 0) - these are the 2nd cell of wide characters
if (width === 0) continue;
// Map each character in this cell to the current cell column
for (let i = 0; i < chars.length; i++) {
if (chars.length > 0) {
// Map each character in this cell to the current cell column
for (let i = 0; i < chars.length; i++) {
map.push(cellCol);
}
} else {
// Empty cell (codepoint 0) — translateToString() outputs a space
// for it, so we must push one entry to keep the map aligned.
map.push(cellCol);
}
@@ -177,49 +264,106 @@ export class KeywordHighlighter implements IDisposable {
const lineText = line.translateToString(true); // true = trim right whitespace
if (!lineText) continue;
// Build mapping from string index to cell column for wide char support
const cellMap = this.buildStringToCellMap(line);
const cachedRanges = this.getCachedRanges(line, lineText);
if (cachedRanges.length === 0) continue;
// Process each pre-compiled rule
for (const { regex, color } of this.compiledRules) {
// Reset regex state for reuse (global flag maintains lastIndex)
regex.lastIndex = 0;
let match;
// Calculate offset relative to the absolute cursor position
// offset = targetLineAbs - (baseY + cursorY)
const offset = lineY - cursorAbsoluteY;
while ((match = regex.exec(lineText)) !== null) {
const strStart = match.index;
const strEnd = strStart + match[0].length;
for (const range of cachedRanges) {
const marker = this.term.registerMarker(offset);
// Map string indices to cell columns
const cellStartCol = cellMap[strStart] ?? strStart;
const cellEndCol = cellMap[strEnd] ?? strEnd;
const cellWidth = cellEndCol - cellStartCol;
if (marker) {
const deco = this.term.registerDecoration({
marker,
x: range.x,
width: range.width,
foregroundColor: range.color,
});
// Skip if width is 0 or negative (shouldn't happen, but be safe)
if (cellWidth <= 0) continue;
// Calculate offset relative to the absolute cursor position
// offset = targetLineAbs - (baseY + cursorY)
const offset = lineY - cursorAbsoluteY;
const marker = this.term.registerMarker(offset);
if (marker) {
const deco = this.term.registerDecoration({
marker,
x: cellStartCol,
width: cellWidth,
foregroundColor: color,
});
if (deco) {
this.decorations.push({ decoration: deco, marker });
} else {
// If decoration failed, cleanup marker
marker.dispose();
}
if (deco) {
this.decorations.push({ decoration: deco, marker });
} else {
// If decoration failed, cleanup marker
marker.dispose();
}
}
}
}
}
private getCachedRanges(line: IBufferLine, lineText: string): CachedDecorationRange[] {
const cached = this.matchCache.get(lineText);
if (cached) {
// LRU: move to end
this.matchCache.delete(lineText);
this.matchCache.set(lineText, cached);
return cached;
}
const ranges = this.scanLine(line, lineText);
this.matchCache.set(lineText, ranges);
const maxEntries = XTERM_PERFORMANCE_CONFIG.highlighting.cacheEntries;
if (this.matchCache.size > maxEntries) {
const oldestKey = this.matchCache.keys().next().value;
if (oldestKey !== undefined) {
this.matchCache.delete(oldestKey);
}
}
return ranges;
}
private scanLine(line: IBufferLine, lineText: string): CachedDecorationRange[] {
// ASCII-only lines have a 1:1 string-index-to-cell-column mapping,
// so we can skip the expensive buildStringToCellMap call entirely.
const asciiOnly = RE_ASCII_ONLY.test(lineText);
let cellMap: number[] | null = null;
let ranges: CachedDecorationRange[] | null = null;
// Process each pre-compiled rule
for (const { regex, color } of this.compiledRules) {
// Reset regex state for reuse (global flag maintains lastIndex)
regex.lastIndex = 0;
let match;
while ((match = regex.exec(lineText)) !== null) {
const strStart = match.index;
const strEnd = strStart + match[0].length;
let cellStartCol: number;
let cellEndCol: number;
if (asciiOnly) {
cellStartCol = strStart;
cellEndCol = strEnd;
} else {
// Lazily build cellMap only when a match is found
if (cellMap === null) {
cellMap = this.buildStringToCellMap(line);
}
cellStartCol = cellMap[strStart] ?? strStart;
cellEndCol = cellMap[strEnd] ?? strEnd;
}
const cellWidth = cellEndCol - cellStartCol;
// Skip if width is 0 or negative (shouldn't happen, but be safe)
if (cellWidth <= 0) continue;
if (ranges === null) {
ranges = [];
}
ranges.push({
x: cellStartCol,
width: cellWidth,
color,
});
}
}
return ranges ?? (EMPTY_RANGES as CachedDecorationRange[]);
}
}

View File

@@ -44,7 +44,7 @@ type TerminalBackendApi = {
cb: (evt: { exitCode?: number; signal?: number; error?: string; reason?: "exited" | "error" | "timeout" | "closed" }) => void,
) => () => void;
onChainProgress: (
cb: (hop: number, total: number, label: string, status: string) => void,
cb: (sessionId: string, hop: number, total: number, label: string, status: string, error?: string) => void,
) => (() => void) | undefined;
writeToSession: (sessionId: string, data: string) => void;
resizeSession: (sessionId: string, cols: number, rows: number) => void;
@@ -323,7 +323,7 @@ export const createTerminalSessionStarters = (ctx: TerminalSessionStartersContex
: undefined;
const jumpHostsWithUnavailableCredentials: string[] = [];
const jumpHosts = ctx.resolvedChainHosts.map<NetcattyJumpHost>((jumpHost) => {
const jumpHosts = ctx.resolvedChainHosts.map<NetcattyJumpHost>((jumpHost, index) => {
const jumpAuth = resolveHostAuth({
host: jumpHost,
keys: ctx.keys,
@@ -336,13 +336,20 @@ export const createTerminalSessionStarters = (ctx: TerminalSessionStartersContex
const jumpPassword = sanitizeCredentialValue(rawJumpPassword);
const jumpPrivateKey = sanitizeCredentialValue(rawJumpPrivateKey);
const jumpPassphrase = sanitizeCredentialValue(rawJumpPassphrase);
const hasConfiguredJumpProxyEndpoint =
index === 0 &&
!!(jumpHost.proxyConfig?.host && jumpHost.proxyConfig?.port);
const hasEncryptedJumpProxyCredential =
hasConfiguredJumpProxyEndpoint &&
Boolean(jumpHost.proxyConfig?.username) &&
isEncryptedCredentialPlaceholder(jumpHost.proxyConfig?.password);
const hasEncryptedJumpCredential =
isEncryptedCredentialPlaceholder(rawJumpPassword) ||
isEncryptedCredentialPlaceholder(rawJumpPrivateKey) ||
isEncryptedCredentialPlaceholder(rawJumpPassphrase);
if (hasEncryptedJumpCredential && !jumpPassword && !jumpPrivateKey) {
if (hasEncryptedJumpProxyCredential || (hasEncryptedJumpCredential && !jumpPassword && !jumpPrivateKey)) {
jumpHostsWithUnavailableCredentials.push(jumpHost.label || jumpHost.hostname);
}
@@ -358,10 +365,21 @@ export const createTerminalSessionStarters = (ctx: TerminalSessionStartersContex
keyId: jumpAuth.keyId,
keySource: jumpKey?.source,
label: jumpHost.label,
proxy: jumpHost.proxyConfig?.host && jumpHost.proxyConfig?.port
? {
type: jumpHost.proxyConfig.type,
host: jumpHost.proxyConfig.host,
port: jumpHost.proxyConfig.port,
username: jumpHost.proxyConfig.username,
password: sanitizeCredentialValue(jumpHost.proxyConfig.password),
}
: undefined,
identityFilePaths: jumpHost.identityFilePaths,
};
});
if (hasEncryptedProxyPassword && !proxyConfig?.password && proxyConfig?.username) {
const usesTargetProxyForFirstHop = !!proxyConfig && !jumpHosts[0]?.proxy;
if (usesTargetProxyForFirstHop && hasEncryptedProxyPassword && !proxyConfig?.password && proxyConfig?.username) {
const message = tr(
"terminal.auth.proxyCredentialsUnavailable",
"Proxy credentials cannot be decrypted on this device. Open host settings and re-enter the proxy password.",
@@ -403,21 +421,64 @@ export const createTerminalSessionStarters = (ctx: TerminalSessionStartersContex
currentHostLabel:
jumpHosts[0]?.label || jumpHosts[0]?.hostname || ctx.host.hostname,
});
ctx.setProgressLogs((prev) => [
...prev,
`Starting chain connection (${totalHops} hops)...`,
]);
}
const unsub = ctx.terminalBackend.onChainProgress((hop, total, label, status) => {
ctx.setChainProgress({
currentHop: hop,
totalHops: total,
currentHostLabel: label,
});
ctx.setProgressLogs((prev) => [
...prev,
`Chain ${hop} of ${total}: ${label} - ${status}`,
]);
{
const unsub = ctx.terminalBackend.onChainProgress((sid, hop, total, label, status, error) => {
// P1: Only process events for this session
if (sid !== ctx.sessionId) return;
// P3: Only show chain progress UI for multi-hop connections
if (total > 1) {
ctx.setChainProgress({
currentHop: hop,
totalHops: total,
currentHostLabel: label,
});
}
// Build human-readable log line
let logLine: string;
const prefix = total > 1 ? `[${hop}/${total}] ` : '';
switch (status) {
case 'connecting':
logLine = `${prefix}${tr("terminal.progress.connecting", "Connecting to")} ${label}...`;
break;
case 'authenticating':
logLine = `${prefix}${label} - ${tr("terminal.progress.keyExchangeComplete", "Key exchange complete")}`;
break;
case 'auth-attempt':
if (error?.endsWith('rejected')) {
logLine = `${prefix}${label} - ✗ ${error}`;
} else if (error === 'all methods exhausted') {
logLine = `${prefix}${label} - ✗ All authentication methods exhausted`;
} else if (error === 'waiting for user input...' || error === 'user responded') {
logLine = `${prefix}${label} - ${error}`;
} else {
logLine = `${prefix}${label} - ${tr("terminal.progress.trying", "Trying")} ${error}...`;
}
break;
case 'authenticated':
logLine = `${prefix}${label} - ${tr("terminal.progress.authenticated", "Authenticated")}`;
break;
case 'connected':
logLine = `${prefix}${label} - ${tr("terminal.progress.connected", "Connected")}`;
break;
case 'forwarding':
logLine = `${prefix}${label} - ${tr("terminal.progress.forwarding", "Forwarding")}...`;
break;
case 'shell':
logLine = `${prefix}${tr("terminal.progress.openingShell", "Opening shell")}...`;
break;
case 'error':
logLine = `${prefix}${label} - ${tr("terminal.progress.error", "Error")}${error ? `: ${error}` : ''}`;
break;
default:
logLine = `${prefix}${label} - ${status}${error ? `: ${error}` : ''}`;
}
ctx.setProgressLogs((prev) => [...prev, logLine]);
const hopProgress = (hop / total) * 80 + 10;
ctx.setProgressValue(Math.min(95, hopProgress));
});
@@ -456,6 +517,8 @@ export const createTerminalSessionStarters = (ctx: TerminalSessionStartersContex
jumpHosts: jumpHosts.length > 0 ? jumpHosts : undefined,
keepaliveInterval: ctx.terminalSettings?.keepaliveInterval,
sessionLog: ctx.sessionLog?.enabled ? ctx.sessionLog : undefined,
// Only pass local key paths if no vault key is explicitly configured
identityFilePaths: attempt.key ? undefined : ctx.host.identityFilePaths,
});
};
@@ -547,6 +610,18 @@ export const createTerminalSessionStarters = (ctx: TerminalSessionStartersContex
}
}, 600);
}
// Run OS detection only after successful connection
setTimeout(
() =>
void runDistroDetection(ctx, {
username: effectiveUsername,
password: usedPassword,
key: usedKey,
passphrase: effectivePassphrase,
}),
600,
);
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
const authError = isAuthError(err);
@@ -572,17 +647,6 @@ export const createTerminalSessionStarters = (ctx: TerminalSessionStartersContex
ctx.setChainProgress(null);
if (unsubscribeChainProgress) unsubscribeChainProgress();
}
setTimeout(
() =>
void runDistroDetection(ctx, {
username: effectiveUsername,
password: usedPassword,
key: usedKey,
passphrase: effectivePassphrase,
}),
600,
);
};
const startTelnet = async (term: XTerm) => {

View File

@@ -161,6 +161,9 @@ export const createXTermRuntime = (ctx: CreateXTermRuntimeContext): XTermRuntime
const lineHeight = 1 + (settings?.linePadding ?? 0) / 10;
const minimumContrastRatio = settings?.minimumContrastRatio ?? 1;
const scrollOnUserInput = shouldEnableNativeUserInputAutoScroll(settings);
const smoothScrollDuration = settings?.smoothScrolling
? performanceConfig.options.smoothScrollDuration
: 0;
const altIsMeta = settings?.altAsMeta ?? false;
const wordSeparator = settings?.wordSeparators ?? " ()[]{}'\"";
const keywordHighlightRules = settings?.keywordHighlightRules ?? [];
@@ -213,6 +216,7 @@ export const createXTermRuntime = (ctx: CreateXTermRuntimeContext): XTermRuntime
allowProposedApi: true,
drawBoldTextInBrightColors,
minimumContrastRatio,
smoothScrollDuration,
scrollOnUserInput,
macOptionClickForcesSelection: true,
altClickMovesCursor: !altIsMeta,
@@ -391,13 +395,17 @@ export const createXTermRuntime = (ctx: CreateXTermRuntimeContext): XTermRuntime
e.preventDefault();
e.stopPropagation();
// Send the snippet command to the terminal
const payload = snippet.noAutoRun
? normalizeLineEndings(snippet.command)
: `${normalizeLineEndings(snippet.command)}\r`;
ctx.terminalBackend.writeToSession(id, payload);
let snippetData = normalizeLineEndings(snippet.command);
if (!snippet.noAutoRun) snippetData = `${snippetData}\r`;
// Broadcast the normalized (un-wrapped) data so each target
// session can apply its own bracket paste state
if (ctx.isBroadcastEnabledRef.current && ctx.onBroadcastInputRef.current) {
ctx.onBroadcastInputRef.current(payload, ctx.sessionId);
ctx.onBroadcastInputRef.current(snippetData, ctx.sessionId);
}
// Wrap for this terminal only, after broadcasting
const snippetIsMultiLine = snippetData.includes("\n");
if (snippetIsMultiLine && term.modes.bracketedPasteMode && !ctx.terminalSettingsRef.current?.disableBracketedPaste) snippetData = wrapBracketedPaste(snippetData);
ctx.terminalBackend.writeToSession(id, snippetData);
if (!snippet.noAutoRun && ctx.onCommandExecuted) {
const cmd = snippet.command.trim();
if (cmd) ctx.onCommandExecuted(cmd, ctx.host.id, ctx.host.label, ctx.sessionId);

View File

@@ -48,6 +48,14 @@ export const getEffectiveHostDistro = (
return detected;
};
/** Format hostname:port for display, wrapping IPv6 addresses in brackets. */
export const formatHostPort = (hostname: string, port?: number | null): string => {
if (port == null) return hostname;
const isIPv6 = hostname.includes(':') && !hostname.startsWith('[');
const display = isIPv6 ? `[${hostname}]` : hostname;
return `${display}:${port}`;
};
export const sanitizeHost = (host: Host): Host => {
const cleanHostname = (host.hostname || '').split(/\s+/)[0];
const cleanDistro = normalizeDistroId(host.distro);

View File

@@ -113,6 +113,9 @@ export interface Host {
keywordHighlightEnabled?: boolean;
// Legacy SSH algorithm support for older network equipment (switches, routers)
legacyAlgorithms?: boolean;
// Local SSH key file paths (from SSH config IdentityFile or user-added)
// Resolved at connection time — the app reads the file content when connecting.
identityFilePaths?: string[];
}
export type KeyType = 'RSA' | 'ECDSA' | 'ED25519';
@@ -410,6 +413,8 @@ export interface TerminalSettings {
scrollOnKeyPress: boolean; // Scroll terminal to bottom on key press
scrollOnPaste: boolean; // Scroll terminal to bottom on paste
smoothScrolling: boolean; // Animate viewport scrolling instead of jumping instantly
// Mouse
rightClickBehavior: RightClickBehavior;
copyOnSelect: boolean; // Automatically copy selected text
@@ -532,6 +537,7 @@ const DEFAULT_TERMINAL_SETTINGS: TerminalSettings = {
scrollOnOutput: false,
scrollOnKeyPress: false,
scrollOnPaste: true,
smoothScrolling: false,
rightClickBehavior: 'context-menu',
copyOnSelect: false,
middleClickPaste: true,

View File

@@ -9,15 +9,45 @@ interface QuickConnectParseResult {
warnings: string[];
}
/** Test whether a string looks like a bare (un-bracketed) IPv6 address.
* Must have only hex digits and colons, with either:
* - A "::" shorthand (unambiguously IPv6), or
* - Exactly 7 colons (full 8-group notation like 2607:f130:0:179:0:0:b0df:eec4)
* This avoids false positives on MAC addresses (6 groups, 5 colons). */
const BARE_IPV6_RE = /^[a-fA-F0-9:]+$/;
const isBareIPv6 = (s: string): boolean => {
if (!BARE_IPV6_RE.test(s)) return false;
if (s.includes('::')) return true;
return (s.match(/:/g) || []).length === 7;
};
const parseDirectTarget = (input: string): QuickConnectTarget | null => {
const trimmed = input.trim();
if (!trimmed) return null;
// Pattern: [user@]hostname[:port]
// Hostname can be IP (v4 or v6) or domain name
// Hostname can be IP (v4 or v6 in brackets) or domain name
const regex = /^(?:([^@]+)@)?([^\s:]+|\[[^\]]+\])(?::(\d+))?$/;
const match = trimmed.match(regex);
if (!match) return null;
// If the main regex fails, try bare IPv6: [user@]ipv6_address
// Bare IPv6 contains colons so the main regex can't distinguish host:port.
// Port must be specified via brackets: [ipv6]:port
if (!match) {
const bareIpv6Regex = /^(?:([^@]+)@)?([a-fA-F0-9:]+)$/;
const bareMatch = trimmed.match(bareIpv6Regex);
if (bareMatch) {
const [, bareUser, bareHost] = bareMatch;
if (isBareIPv6(bareHost)) {
return {
hostname: bareHost,
username: bareUser || undefined,
port: undefined,
};
}
}
return null;
}
const [, username, hostname, portStr] = match;

View File

@@ -113,6 +113,15 @@ export const serializeHostsToSshConfig = (hosts: Host[], allHosts?: Host[]): str
lines.push(` Port ${host.port}`);
}
// Serialize IdentityFile paths
if (host.identityFilePaths && host.identityFilePaths.length > 0) {
for (const keyPath of host.identityFilePaths) {
// Quote paths that contain spaces
const formatted = keyPath.includes(" ") ? `"${keyPath}"` : keyPath;
lines.push(` IdentityFile ${formatted}`);
}
}
// Serialize ProxyJump if host has a chain
const proxyJumpValue = buildProxyJumpValue(host, hostsForLookup, managedHostIds);
if (proxyJumpValue) {

View File

@@ -74,6 +74,7 @@ const SYNCABLE_TERMINAL_KEYS = [
'scrollback', 'drawBoldInBrightColors', 'fontLigatures', 'fontWeight', 'fontWeightBold',
'linePadding', 'cursorShape', 'cursorBlink', 'minimumContrastRatio',
'scrollOnInput', 'scrollOnOutput', 'scrollOnKeyPress', 'scrollOnPaste',
'smoothScrolling',
'rightClickBehavior', 'copyOnSelect', 'middleClickPaste', 'wordSeparators',
'linkModifier', 'keywordHighlightEnabled', 'keywordHighlightRules',
'keepaliveInterval', 'disableBracketedPaste', 'osc52Clipboard',

View File

@@ -519,6 +519,7 @@ const importFromSshConfig = (text: string): VaultImportResult => {
username?: string;
port?: number;
proxyJump?: string;
identityFiles?: string[];
};
const blocks: Block[] = [];
@@ -557,6 +558,12 @@ const importFromSshConfig = (text: string): VaultImportResult => {
else if (keyword === "user") current.username = value;
else if (keyword === "port") current.port = parsePort(value);
else if (keyword === "proxyjump") current.proxyJump = value;
else if (keyword === "identityfile") {
if (!current.identityFiles) current.identityFiles = [];
// Remove surrounding quotes (ssh_config allows quoted paths with spaces)
const unquoted = value.replace(/^["']|["']$/g, "");
current.identityFiles.push(unquoted);
}
}
flush();
@@ -597,6 +604,11 @@ const importFromSshConfig = (text: string): VaultImportResult => {
protocol: "ssh",
});
// Attach IdentityFile paths if present
if (block.identityFiles && block.identityFiles.length > 0) {
host.identityFilePaths = [...block.identityFiles];
}
parsedHosts.push(host);
// Store ProxyJump using hostname key (survives deduplication)

View File

@@ -6,7 +6,12 @@ module.exports = {
productName: 'Netcatty',
artifactName: '${productName}-${version}-${os}-${arch}.${ext}',
icon: 'public/icon.png',
npmRebuild: false,
// npmRebuild must stay enabled for macOS and Windows builds — without it,
// node-pty's native module is not recompiled for the Electron ABI, causing
// "posix_spawnp failed" on macOS. Linux builds set npm_config_arch in CI
// and run ensure-node-pty-linux.sh before packaging, so the rebuild is
// redundant but harmless there.
npmRebuild: true,
directories: {
buildResources: 'build',
output: 'release'
@@ -91,20 +96,7 @@ module.exports = {
shortcutName: 'Netcatty'
},
linux: {
target: [
{
target: 'AppImage',
arch: ['x64', 'arm64']
},
{
target: 'deb',
arch: ['x64', 'arm64']
},
{
target: 'rpm',
arch: ['x64', 'arm64']
}
],
target: ['AppImage', 'deb', 'rpm'],
category: 'Development'
},
deb: {

View File

@@ -43,53 +43,89 @@ function subscribeToPtyData(ptyStream, onData) {
throw new Error("PTY stream does not support data subscriptions");
}
function hasExpectedPromptSuffix(text, expectedPrompt) {
if (!expectedPrompt) return false;
const normalizedText = stripAnsi(String(text || "")).replace(/\r/g, "");
const normalizedPrompt = stripAnsi(String(expectedPrompt || "")).replace(/\r/g, "");
return !!normalizedPrompt && normalizedText.endsWith(normalizedPrompt);
}
function escapePosixSingleQuoted(text) {
return String(text || "").replace(/'/g, "'\\''");
}
function escapePowerShellSingleQuoted(text) {
return String(text || "").replace(/'/g, "''");
}
function escapeFishSingleQuoted(text) {
return String(text || "").replace(/\\/g, "\\\\").replace(/'/g, "\\'");
}
function escapeCmdForNestedShell(text) {
return String(text || "").replace(/"/g, '""').replace(/%/g, "%%");
}
function buildWrappedCommand(command, shellKind, marker) {
switch (shellKind) {
case "powershell": {
// Combine into 2 PTY lines (like posix) to minimise prompt echo duplication:
// Line 1: start marker + pager env + user command
// Line 2: capture exit code + end marker
// __NCMCP_ prefix ensures the echo line is buffered/filtered even if
// the PTY delivers it in small chunks (the marker must appear early).
const psPager = "$env:PAGER='cat'; $env:SYSTEMD_PAGER=''; $env:GIT_PAGER='cat'; $env:LESS=''; ";
const psEscaped = escapePowerShellSingleQuoted(command);
return (
`Write-Output '${marker}_S'; ${psPager}${command}\r\n` +
`Write-Output "${marker}_E:$LASTEXITCODE"\r\n`
`$${marker}=0; $${marker}_cmd='${psEscaped}'; Write-Host '> ${psEscaped}'; & { Write-Output '${marker}_S'; ${psPager}$LASTEXITCODE=$null; try { Invoke-Expression $${marker}_cmd; $${marker}_rc = if ($LASTEXITCODE -ne $null) { $LASTEXITCODE } elseif ($?) { 0 } else { 1 } } catch { $${marker}_rc = 1 }; Write-Output "${marker}_E:$${marker}_rc" }\r\n`
);
}
case "cmd":
return [
'set "PAGER=cat"',
'set "SYSTEMD_PAGER="',
'set "GIT_PAGER=cat"',
'set "LESS="',
`echo ${marker}_S`,
command,
`echo ${marker}_E:%errorlevel%`,
"",
].join("\r\n");
case "cmd": {
const cmdEscaped = escapeCmdForNestedShell(command);
return (
`set "${marker}=0" & set "${marker}_CMD=${cmdEscaped}" & call <nul set /p "=^> %%${marker}_CMD%%" & echo( & (echo ${marker}_S & set "PAGER=cat" & set "SYSTEMD_PAGER=" & set "GIT_PAGER=cat" & set "LESS=" & call cmd /d /s /c "%%${marker}_CMD%%" & call echo ${marker}_E:^%errorlevel^%)\r\n`
);
}
case "fish":
return [
"set -gx PAGER cat",
"set -gx SYSTEMD_PAGER ''",
"set -gx GIT_PAGER cat",
"set -gx LESS ''",
`printf '%s\\n' '${marker}_S'`,
command,
"set __NCMCP_rc $status",
`printf '%s\\n' '${marker}_E:'$__NCMCP_rc`,
"",
].join("\n");
// set __NCMCP_... at the start ensures early marker presence in echo.
return (
`set ${marker} 0; function __ncmcp_int --on-signal INT; printf '%s\\n' '${marker}_E:130'; functions -e __ncmcp_int; end; ` +
// Clear the current terminal row before the user-visible echo.
`set -l ${marker}_cmd '${escapeFishSingleQuoted(command)}'; printf '\\r\\033[2K> %s\\n' '${escapeFishSingleQuoted(command)}'; ` +
`begin; set -gx PAGER cat; set -gx SYSTEMD_PAGER ''; set -gx GIT_PAGER cat; set -gx LESS ''; ` +
`printf '%s\\n' '${marker}_S'; eval -- \$${marker}_cmd; set __NCMCP_rc $status; ` +
`functions -e __ncmcp_int; printf '%s\\n' '${marker}_E:'\$__NCMCP_rc; end\n`
);
case "posix":
default: {
// Combine into 2 PTY lines to minimise prompt echo duplication:
// Line 1: start marker + pager env + user command
// Line 2: capture exit code + end marker + restore exit code
// Single-line compound command with early marker & visible command echo.
//
// Layout: __NCMCP_xxx=0; printf echo; { ... MARKER_S; eval command; MARKER_E; }
//
// Key design decisions:
//
// 1) __NCMCP_xxx=0 at the VERY START ensures the PTY echo line
// contains __NCMCP_ in its first few bytes. This is critical:
// preload.cjs filters chunks by buffering incomplete lines that
// contain __NCMCP_. Without this prefix, the first chunk of a
// long echo line might not contain the marker and would leak
// through to the terminal as garbage.
//
// 2) printf clears the current row and outputs "> command\n"
// (no marker) → visible to user without prompt residue.
//
// 3) The user command is executed via eval on a quoted string. This
// keeps shell syntax errors inside the eval call so the wrapper
// can still emit the end marker and return a non-zero exit code.
//
// 4) Single-line { ... } is parsed fully before execution, so SIGINT
// cannot cause bash to flush the end marker from the input buffer.
// trap ':' INT lets child processes receive SIGINT normally while
// preventing the shell from aborting the compound command.
const noPager = "PAGER=cat SYSTEMD_PAGER= GIT_PAGER=cat LESS= ";
const escaped = escapePosixSingleQuoted(command);
return (
`printf '%s\\n' '${marker}_S';${noPager}${command}\n` +
`__NCMCP_rc=$?;printf '%s\\n' '${marker}_E:'"$__NCMCP_rc";(exit $__NCMCP_rc)\n`
`${marker}=0; ${marker}_cmd='${escaped}'; printf '\\r\\033[2K> %s\\n' '${escaped}'; { printf '%s\\n' '${marker}_S'; trap ':' INT; ${noPager}eval "$${marker}_cmd"; __NCMCP_rc=$?; trap - INT; printf '%s\\n' '${marker}_E:'\"$__NCMCP_rc\"; (exit $__NCMCP_rc); }\n`
);
}
}
@@ -106,6 +142,9 @@ function buildWrappedCommand(command, shellKind, marker) {
* @param {boolean} [options.stripMarkers=false] - Strip leaked MCP markers from output
* @param {Map} [options.trackForCancellation] - Map to register this execution in for cancellation
* @param {number} [options.timeoutMs=60000] - Command timeout in milliseconds
* @param {string} [options.chatSessionId] - Chat session ID for scoped cancellation
* @param {AbortSignal} [options.abortSignal] - AbortSignal to cancel execution
* @param {string} [options.expectedPrompt] - Last observed idle prompt for exact fallback matching
*/
function execViaPty(ptyStream, command, options) {
const {
@@ -113,33 +152,51 @@ function execViaPty(ptyStream, command, options) {
trackForCancellation = null,
timeoutMs = 60000,
shellKind,
chatSessionId,
abortSignal,
expectedPrompt,
} = options || {};
const marker = `__NCMCP_${Date.now().toString(36)}_${crypto.randomBytes(16).toString('hex')}__`;
const resolvedShellKind = shellKind || "posix";
// Fast-path: already aborted before we even start
if (abortSignal?.aborted) {
return Promise.resolve({ ok: false, stdout: "", stderr: "", exitCode: -1, error: "Cancelled" });
}
return new Promise((resolve) => {
let output = "";
let foundStart = false;
let timeoutId = null;
let promptFallbackTimer = null;
let finished = false;
let unsubscribe = null;
const cleanupFns = [];
// Buffer for incomplete line data when searching for start marker.
// SSH channels can split data at arbitrary byte boundaries, so the
// start marker may arrive across two chunks. We keep the content
// after the last \n (i.e. the current incomplete line) and prepend
// it to the next chunk so indexOf can match the full marker.
let pendingStart = "";
const onData = (data) => {
const text = data.toString();
if (!foundStart) {
// Look for the start marker at a line boundary (actual printf output),
// not inside the echo of the printf command argument.
const combined = pendingStart + text;
pendingStart = "";
const startMarker = marker + "_S";
let matched = false;
let pos = 0;
while (pos < text.length) {
const idx = text.indexOf(startMarker, pos);
while (pos < combined.length) {
const idx = combined.indexOf(startMarker, pos);
if (idx === -1) break;
// Accept if at start of text, or preceded by \n or \r (line boundary)
if (idx === 0 || text[idx - 1] === '\n' || text[idx - 1] === '\r') {
if (idx === 0 || combined[idx - 1] === '\n' || combined[idx - 1] === '\r') {
foundStart = true;
const afterMarker = text.slice(idx);
matched = true;
const afterMarker = combined.slice(idx);
const nlIdx = afterMarker.indexOf("\n");
if (nlIdx !== -1) {
output += afterMarker.slice(nlIdx + 1);
@@ -148,14 +205,42 @@ function execViaPty(ptyStream, command, options) {
}
pos = idx + 1;
}
if (foundStart) checkEnd();
if (!matched) {
// Keep the last incomplete line for cross-chunk matching
const lastNl = combined.lastIndexOf("\n");
pendingStart = lastNl === -1 ? combined : combined.slice(lastNl + 1);
}
if (foundStart) {
schedulePromptFallback();
checkEnd();
}
return;
}
output += text;
schedulePromptFallback();
checkEnd();
};
function clearPromptFallback() {
if (promptFallbackTimer) {
clearTimeout(promptFallbackTimer);
promptFallbackTimer = null;
}
}
function schedulePromptFallback() {
clearPromptFallback();
if (!hasExpectedPromptSuffix(output, expectedPrompt)) return;
// Fallback for shells that visibly return to the same idle prompt but
// never emit the wrapped end marker line.
promptFallbackTimer = setTimeout(() => {
if (!hasExpectedPromptSuffix(output, expectedPrompt)) return;
finish(output, null, null);
}, 250);
}
function checkEnd() {
// Look for the end marker at a line boundary (actual printf output),
// not inside the echo of the printf command argument.
@@ -179,39 +264,43 @@ function execViaPty(ptyStream, command, options) {
}
}
function finish(stdout, exitCode) {
function finish(stdout, exitCode, error) {
if (finished) return;
finished = true;
clearTimeout(timeoutId);
clearPromptFallback();
unsubscribe?.();
for (const fn of cleanupFns) { try { fn(); } catch { /* ignore */ } }
if (trackForCancellation) {
trackForCancellation.delete(marker);
}
let cleaned = stripAnsi(stdout || "").trim();
let cleaned = stripAnsi(stdout || "").replace(/\r/g, "");
if (stripMarkers) {
cleaned = cleaned.replace(/^[^\r\n]*__NCMCP_[^\r\n]*[\r\n]*/gm, "").trim();
cleaned = cleaned.replace(/^[^\r\n]*__NCMCP_[^\r\n]*[\r\n]*/gm, "");
}
const normalizedPrompt = stripAnsi(String(expectedPrompt || "")).replace(/\r/g, "");
if (normalizedPrompt && cleaned.endsWith(normalizedPrompt)) {
cleaned = cleaned.slice(0, cleaned.length - normalizedPrompt.length);
}
cleaned = cleaned.trim();
if (error) {
resolve({ ok: false, stdout: cleaned, stderr: "", exitCode: exitCode ?? -1, error });
} else {
resolve({
ok: exitCode === 0 || exitCode === null,
stdout: cleaned,
stderr: "",
exitCode: exitCode ?? 0,
});
}
resolve({
ok: exitCode === 0 || exitCode === null,
stdout: cleaned,
stderr: "",
exitCode: exitCode ?? 0,
});
}
timeoutId = setTimeout(() => {
if (finished) return;
finished = true;
unsubscribe?.();
if (trackForCancellation) {
trackForCancellation.delete(marker);
}
// Send Ctrl+C to kill the timed-out command
if (typeof ptyStream.write === "function") ptyStream.write("\x03");
const cleaned = stripAnsi(output).trim();
const timeoutSec = Math.round(timeoutMs / 1000);
resolve({ ok: false, stdout: cleaned, stderr: "", exitCode: -1, error: `Command timed out (${timeoutSec}s)` });
finish(output, -1, `Command timed out (${timeoutSec}s)`);
}, timeoutMs);
unsubscribe = subscribeToPtyData(ptyStream, onData);
@@ -220,6 +309,11 @@ function execViaPty(ptyStream, command, options) {
if (trackForCancellation) {
trackForCancellation.set(marker, {
ptyStream,
chatSessionId: chatSessionId || null,
cancel: () => {
if (typeof ptyStream.write === "function") ptyStream.write("\x03");
finish(output, -1, "Cancelled");
},
cleanup: () => {
clearTimeout(timeoutId);
unsubscribe?.();
@@ -227,6 +321,35 @@ function execViaPty(ptyStream, command, options) {
});
}
// Stream close/error detection — resolve immediately instead of waiting for timeout
if (typeof ptyStream.on === "function") {
const onClose = () => finish(output, null, "Stream closed unexpectedly");
const onError = (err) => finish(output, -1, `Stream error: ${err?.message || err}`);
ptyStream.on("close", onClose);
ptyStream.on("end", onClose);
ptyStream.on("error", onError);
cleanupFns.push(() => {
try { ptyStream.removeListener("close", onClose); } catch { /* */ }
try { ptyStream.removeListener("end", onClose); } catch { /* */ }
try { ptyStream.removeListener("error", onError); } catch { /* */ }
});
}
// node-pty uses onExit instead of close/end
if (typeof ptyStream.onExit === "function") {
const disposable = ptyStream.onExit(() => finish(output, null, "Process exited"));
cleanupFns.push(() => { try { disposable?.dispose?.(); } catch { /* */ } });
}
// AbortSignal handling — send Ctrl+C and resolve when aborted
if (abortSignal) {
const onAbort = () => {
if (typeof ptyStream.write === "function") ptyStream.write("\x03");
finish(output, -1, "Cancelled");
};
abortSignal.addEventListener("abort", onAbort, { once: true });
cleanupFns.push(() => abortSignal.removeEventListener("abort", onAbort));
}
// Markers are filtered from terminal display by preload.cjs (MCP_MARKER_RE).
ptyStream.write(buildWrappedCommand(command, resolvedShellKind, marker));
});
@@ -244,6 +367,7 @@ function execViaChannel(sshClient, command, options) {
const {
timeoutMs = 60000,
trackForCancellation = null,
chatSessionId,
} = options || {};
return new Promise((resolve) => {
@@ -276,6 +400,11 @@ function execViaChannel(sshClient, command, options) {
}, timeoutMs);
if (trackForCancellation) {
trackForCancellation.set(marker, {
chatSessionId: chatSessionId || null,
cancel: () => {
try { execStream.close(); } catch { /* ignore */ }
finish({ ok: false, stdout, stderr, exitCode: -1, error: "Cancelled" });
},
cleanup: () => {
clearTimeout(timeoutId);
try { execStream.close(); } catch { /* ignore */ }

View File

@@ -16,6 +16,7 @@ const ANSI_ESCAPE_REGEX = /\u001B\[[0-?]*[ -/]*[@-~]/g;
const ANSI_OSC_REGEX = /\u001B\][^\u0007]*(?:\u0007|\u001B\\)/g;
const URL_CANDIDATE_REGEX = /https?:\/\/[^\s]+/g;
const WINDOWS_RUNNABLE_EXTENSIONS = [".exe", ".cmd", ".bat", ".com"];
const MAX_PROMPT_TRACK_TAIL = 4096;
// ── ANSI stripping ──
@@ -23,6 +24,36 @@ function stripAnsi(input) {
return String(input || "").replace(ANSI_OSC_REGEX, "").replace(ANSI_ESCAPE_REGEX, "");
}
function extractTrailingIdlePrompt(output) {
const normalized = stripAnsi(output).replace(/\r/g, "");
if (!normalized || normalized.endsWith("\n")) return "";
const lastLine = normalized.split("\n").pop() || "";
const rightTrimmed = lastLine.replace(/\s+$/, "");
if (!rightTrimmed) return "";
if (/^[^\s@]+@[^\s:]+(?::[^\n\r]*)?[#$]$/.test(rightTrimmed)) {
return lastLine;
}
return "";
}
function trackSessionIdlePrompt(session, chunk) {
if (!session || typeof chunk !== "string" || !chunk) return "";
const nextTail = `${session._promptTrackTail || ""}${chunk}`.slice(-MAX_PROMPT_TRACK_TAIL);
session._promptTrackTail = nextTail;
const prompt = extractTrailingIdlePrompt(nextTail);
if (prompt) {
session.lastIdlePrompt = prompt;
session.lastIdlePromptAt = Date.now();
}
return prompt;
}
// ── URL helpers ──
function isLocalhostHostname(hostname) {
@@ -271,6 +302,8 @@ function serializeStreamChunk(chunk) {
module.exports = {
stripAnsi,
extractTrailingIdlePrompt,
trackSessionIdlePrompt,
isLocalhostHostname,
extractFirstNonLocalhostUrl,
normalizeCliPathForPlatform,

View File

@@ -60,6 +60,7 @@ const MAX_CONCURRENT_AGENTS = 5;
const acpProviders = new Map();
const acpActiveStreams = new Map();
const acpRequestSessions = new Map();
const acpPendingCancelRequests = new Set();
const acpForceProviderReset = new Set();
const acpChatRuns = new Map();
@@ -881,7 +882,7 @@ function registerHandlers(ipcMain) {
});
// Execute a command on a terminal session (for Catty Agent)
ipcMain.handle("netcatty:ai:exec", async (event, { sessionId, command }) => {
ipcMain.handle("netcatty:ai:exec", async (event, { sessionId, command, chatSessionId }) => {
// Validate IPC sender (Issue #17)
if (!validateSender(event)) {
return { ok: false, error: "Unauthorized IPC sender" };
@@ -915,8 +916,11 @@ function registerHandlers(ipcMain) {
const timeoutMs = mcpServerBridge.getCommandTimeoutMs ? mcpServerBridge.getCommandTimeoutMs() : 60000;
return execViaPty(ptyStream, command, {
stripMarkers: true,
trackForCancellation: mcpServerBridge.activePtyExecs,
timeoutMs,
shellKind: session.shellKind,
chatSessionId,
expectedPrompt: session.lastIdlePrompt || "",
});
}
@@ -925,7 +929,11 @@ function registerHandlers(ipcMain) {
if (sshClient && typeof sshClient.exec === "function") {
const { execViaChannel } = require("./ai/ptyExec.cjs");
const channelTimeoutMs = mcpServerBridge.getCommandTimeoutMs ? mcpServerBridge.getCommandTimeoutMs() : 60000;
return execViaChannel(sshClient, command, { timeoutMs: channelTimeoutMs });
return execViaChannel(sshClient, command, {
timeoutMs: channelTimeoutMs,
trackForCancellation: mcpServerBridge.activePtyExecs,
chatSessionId,
});
}
return { ok: false, error: "No terminal stream or SSH client available for this session" };
@@ -934,6 +942,15 @@ function registerHandlers(ipcMain) {
}
});
// Cancel in-flight Catty Agent command executions for a chat session
ipcMain.handle("netcatty:ai:catty:cancel", async (event, { chatSessionId }) => {
if (!validateSender(event)) {
return { ok: false, error: "Unauthorized IPC sender" };
}
mcpServerBridge.cancelPtyExecsForSession(chatSessionId);
return { ok: true };
});
// Write to terminal session (send input like a user typing)
ipcMain.handle("netcatty:ai:terminal:write", async (event, { sessionId, data }) => {
// Validate IPC sender (Issue #17)
@@ -1715,11 +1732,39 @@ function registerHandlers(ipcMain) {
}
let abortController = null;
try {
const existingRun = acpChatRuns.get(chatSessionId);
if (existingRun && existingRun.requestId !== requestId) {
existingRun.cancelRequested = true;
const existingController = acpActiveStreams.get(existingRun.requestId);
if (existingController) {
existingController.abort();
acpActiveStreams.delete(existingRun.requestId);
}
acpRequestSessions.delete(existingRun.requestId);
cleanupAcpProvider(chatSessionId);
}
mcpServerBridge.setChatSessionCancelled?.(chatSessionId, false);
abortController = new AbortController();
acpActiveStreams.set(requestId, abortController);
acpRequestSessions.set(requestId, chatSessionId);
acpChatRuns.set(chatSessionId, { requestId, cancelRequested: false });
const consumePendingStartupCancel = () => {
if (!acpPendingCancelRequests.has(requestId)) return false;
acpPendingCancelRequests.delete(requestId);
abortController?.abort();
return true;
};
const shouldAbortStartup = () =>
Boolean(abortController?.signal?.aborted || consumePendingStartupCancel());
const { createACPProvider } = require("@mcpc-tech/acp-ai-provider");
const { streamText, stepCountIs } = require("ai");
const shellEnv = await getShellEnv();
if (shouldAbortStartup()) return { ok: true };
const sessionCwd = cwd || process.cwd();
const isCodexAgent = acpCommand === "codex-acp";
const isClaudeAgent = acpCommand === "claude-agent-acp";
@@ -1730,6 +1775,7 @@ function registerHandlers(ipcMain) {
if (isCodexAgent && !apiKey) {
const validation = await validateCodexChatGptAuth({ maxAgeMs: 10000 });
if (shouldAbortStartup()) return { ok: true };
if (!validation.ok) {
if (isCodexAuthError(validation)) {
try {
@@ -1752,6 +1798,7 @@ function registerHandlers(ipcMain) {
const mcpSnapshot = isCodexAgent
? await resolveCodexMcpSnapshot(sessionCwd)
: { mcpServers: [], fingerprint: getCodexMcpFingerprint([]) };
if (shouldAbortStartup()) return { ok: true };
// Inject Netcatty MCP server for scoped terminal-session access
try {
@@ -1762,23 +1809,12 @@ function registerHandlers(ipcMain) {
} catch (err) {
console.error("[ACP] Failed to inject Netcatty MCP server:", err?.message || err);
}
if (shouldAbortStartup()) return { ok: true };
// Recalculate fingerprint after injection
mcpSnapshot.fingerprint = getCodexMcpFingerprint(mcpSnapshot.mcpServers);
const currentPermissionMode = mcpServerBridge.getPermissionMode();
const existingRun = acpChatRuns.get(chatSessionId);
if (existingRun && existingRun.requestId !== requestId) {
existingRun.cancelRequested = true;
const existingController = acpActiveStreams.get(existingRun.requestId);
if (existingController) {
existingController.abort();
acpActiveStreams.delete(existingRun.requestId);
}
acpRequestSessions.delete(existingRun.requestId);
cleanupAcpProvider(chatSessionId);
}
let providerEntry = acpProviders.get(chatSessionId);
const shouldForceProviderReset = acpForceProviderReset.has(chatSessionId);
const shouldReuseProvider = Boolean(
@@ -1841,6 +1877,7 @@ function registerHandlers(ipcMain) {
let modelInstance = providerEntry.provider.languageModel(model || undefined);
try {
await providerEntry.provider.initSession(providerEntry.provider.tools);
if (shouldAbortStartup()) return { ok: true };
} catch (err) {
const attemptedResumeSessionId = providerEntry.provider?.getSessionId?.() || existingSessionId;
if (!attemptedResumeSessionId || !isUnsupportedLoadSessionError(err)) {
@@ -1882,6 +1919,7 @@ function registerHandlers(ipcMain) {
acpProviders.set(chatSessionId, providerEntry);
modelInstance = providerEntry.provider.languageModel(model || undefined);
await providerEntry.provider.initSession(providerEntry.provider.tools);
if (shouldAbortStartup()) return { ok: true };
}
const activeProviderSessionId = providerEntry.provider.getSessionId?.() || null;
if (activeProviderSessionId) {
@@ -1891,11 +1929,6 @@ function registerHandlers(ipcMain) {
});
}
abortController = new AbortController();
acpActiveStreams.set(requestId, abortController);
acpRequestSessions.set(requestId, chatSessionId);
acpChatRuns.set(chatSessionId, { requestId, cancelRequested: false });
// Prepend context hint so the agent uses Netcatty MCP tools for the scoped sessions
const contextualPrompt =
`[Context: You are inside Netcatty, a multi-session terminal manager. ` +
@@ -2055,6 +2088,7 @@ function registerHandlers(ipcMain) {
} finally {
acpActiveStreams.delete(requestId);
acpRequestSessions.delete(requestId);
acpPendingCancelRequests.delete(requestId);
const activeRun = acpChatRuns.get(chatSessionId);
if (activeRun?.requestId === requestId) {
if (abortController?.signal?.aborted || activeRun.cancelRequested) {
@@ -2069,20 +2103,24 @@ function registerHandlers(ipcMain) {
ipcMain.handle("netcatty:ai:acp:cancel", async (event, { requestId, chatSessionId }) => {
if (!validateSender(event)) return { ok: false, error: "Unauthorized IPC sender" };
// Cancel any active PTY executions (send Ctrl+C)
mcpServerBridge.cancelAllPtyExecs();
const effectiveChatSessionId = chatSessionId || acpRequestSessions.get(requestId);
const activeRun = effectiveChatSessionId ? acpChatRuns.get(effectiveChatSessionId) : null;
const effectiveRequestId = requestId || activeRun?.requestId || "";
// Cancel PTY executions scoped to this chat session (send Ctrl+C)
mcpServerBridge.cancelPtyExecsForSession(effectiveChatSessionId);
mcpServerBridge.setChatSessionCancelled?.(effectiveChatSessionId, true);
mcpServerBridge.clearPendingApprovals(effectiveChatSessionId);
const activeRun = effectiveChatSessionId ? acpChatRuns.get(effectiveChatSessionId) : null;
if (activeRun && activeRun.requestId === requestId) {
if (activeRun && activeRun.requestId === effectiveRequestId) {
activeRun.cancelRequested = true;
}
const controller = acpActiveStreams.get(requestId);
const controller = acpActiveStreams.get(effectiveRequestId);
let cancelled = false;
if (controller) {
controller.abort();
acpActiveStreams.delete(requestId);
acpActiveStreams.delete(effectiveRequestId);
cancelled = true;
} else if (effectiveRequestId) {
acpPendingCancelRequests.add(effectiveRequestId);
cancelled = true;
}
if (effectiveChatSessionId) {
@@ -2093,7 +2131,7 @@ function registerHandlers(ipcMain) {
// continue within the same persisted conversation context. Full provider
// cleanup is handled by netcatty:ai:acp:cleanup when the chat is deleted.
if (effectiveChatSessionId) cancelled = true;
acpRequestSessions.delete(requestId);
if (effectiveRequestId) acpRequestSessions.delete(effectiveRequestId);
return cancelled ? { ok: true } : { ok: false, error: "Stream not found" };
});

View File

@@ -0,0 +1,326 @@
/**
* Crash Log Bridge - Captures main-process errors and writes them to local log files.
*
* Log files are stored as JSONL (one JSON object per line) under
* {userData}/crash-logs/crash-YYYY-MM-DD.log so that appending is cheap and
* atomic. Files older than 30 days are pruned on startup.
*/
const fs = require("node:fs");
const path = require("node:path");
const os = require("node:os");
// ---------------------------------------------------------------------------
// State
// ---------------------------------------------------------------------------
let logDir = null;
let electronApp = null;
let electronShell = null;
let sessionsMap = null;
const LOG_RETENTION_DAYS = 30;
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
function ensureLogDir() {
if (logDir) return logDir;
try {
// Try the stored app reference first, then fall back to requiring electron
// directly so crash logging works even before init() is called.
let userDataPath = null;
if (electronApp) {
userDataPath = electronApp.getPath("userData");
} else {
try {
const { app } = require("node:electron");
userDataPath = app?.getPath?.("userData") ?? null;
} catch {
try {
const { app } = require("electron");
userDataPath = app?.getPath?.("userData") ?? null;
} catch {
// Electron not available yet
}
}
}
if (!userDataPath) return null;
logDir = path.join(userDataPath, "crash-logs");
if (!fs.existsSync(logDir)) {
fs.mkdirSync(logDir, { recursive: true });
}
return logDir;
} catch {
return null;
}
}
function todayFileName() {
const d = new Date();
const ymd = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
return `crash-${ymd}.log`;
}
function buildEntry(source, err, extra) {
const error = err instanceof Error ? err : new Error(String(err ?? "unknown"));
let mem;
try {
const m = process.memoryUsage();
mem = {
rss: Math.round(m.rss / 1048576),
heapUsed: Math.round(m.heapUsed / 1048576),
heapTotal: Math.round(m.heapTotal / 1048576),
};
} catch {
// ignore
}
// Extract extra properties from the error object (code, errno, syscall, etc.)
const errorMeta = {};
for (const key of ["code", "errno", "syscall", "hostname", "port", "signal", "level"]) {
if (error[key] !== undefined) {
errorMeta[key] = error[key];
}
}
return {
timestamp: new Date().toISOString(),
source,
message: error.message || String(err),
stack: error.stack || undefined,
errorMeta: Object.keys(errorMeta).length > 0 ? errorMeta : undefined,
extra: extra || undefined,
pid: process.pid,
platform: process.platform,
arch: process.arch,
version: electronApp?.getVersion?.() ?? "unknown",
electronVersion: process.versions?.electron ?? "unknown",
osVersion: os.release(),
memoryMB: mem,
activeSessionCount: sessionsMap?.size ?? -1,
uptimeSeconds: Math.round(process.uptime()),
};
}
// ---------------------------------------------------------------------------
// Public API
// ---------------------------------------------------------------------------
/**
* Write a crash/error entry to today's log file (sync, safe for use in
* uncaughtException handlers).
*/
function captureError(source, err, extra) {
try {
const dir = ensureLogDir();
if (!dir) return;
const entry = buildEntry(source, err, extra);
const filePath = path.join(dir, todayFileName());
fs.appendFileSync(filePath, JSON.stringify(entry) + "\n", "utf-8");
} catch {
// Never throw from the crash logger itself.
}
}
/**
* Delete log files older than LOG_RETENTION_DAYS.
*/
function pruneOldLogs() {
try {
const dir = ensureLogDir();
if (!dir) return;
const cutoff = Date.now() - LOG_RETENTION_DAYS * 86400000;
const files = fs.readdirSync(dir);
for (const file of files) {
if (!file.startsWith("crash-") || !file.endsWith(".log")) continue;
try {
const filePath = path.join(dir, file);
const stat = fs.statSync(filePath);
if (stat.mtimeMs < cutoff) {
fs.unlinkSync(filePath);
console.log(`[CrashLog] Pruned old log: ${file}`);
}
} catch {
// skip
}
}
} catch {
// skip
}
}
// ---------------------------------------------------------------------------
// IPC handlers
// ---------------------------------------------------------------------------
/**
* Count newlines in a file by streaming instead of reading entire content.
*/
async function countLines(filePath) {
return new Promise((resolve) => {
let count = 0;
const stream = fs.createReadStream(filePath, { encoding: "utf-8" });
stream.on("data", (chunk) => {
for (let i = 0; i < chunk.length; i++) {
if (chunk[i] === "\n") count++;
}
});
stream.on("end", () => resolve(count));
stream.on("error", () => resolve(0));
});
}
async function listLogs() {
const dir = ensureLogDir();
if (!dir) return [];
try {
const files = await fs.promises.readdir(dir);
const results = [];
for (const file of files) {
if (!file.startsWith("crash-") || !file.endsWith(".log")) continue;
try {
const filePath = path.join(dir, file);
const stat = await fs.promises.stat(filePath);
const entryCount = await countLines(filePath);
results.push({
fileName: file,
date: file.replace("crash-", "").replace(".log", ""),
size: stat.size,
entryCount,
});
} catch {
// skip unreadable files
}
}
// Sort newest first
results.sort((a, b) => b.date.localeCompare(a.date));
return results;
} catch {
return [];
}
}
const MAX_READ_ENTRIES = 500;
// Read up to ~256KB from the tail of the file to cap memory/CPU usage
const MAX_TAIL_BYTES = 256 * 1024;
async function readLog(fileName) {
const dir = ensureLogDir();
if (!dir) return [];
// Validate fileName to prevent path traversal
if (!/^crash-\d{4}-\d{2}-\d{2}\.log$/.test(fileName)) return [];
try {
const filePath = path.join(dir, fileName);
const stat = await fs.promises.stat(filePath);
let content;
if (stat.size > MAX_TAIL_BYTES) {
// Only read the tail of the file
const buf = Buffer.alloc(MAX_TAIL_BYTES);
const fd = await fs.promises.open(filePath, "r");
try {
await fd.read(buf, 0, MAX_TAIL_BYTES, stat.size - MAX_TAIL_BYTES);
} finally {
await fd.close();
}
const raw = buf.toString("utf-8");
// Drop the first partial line
const firstNewline = raw.indexOf("\n");
content = firstNewline >= 0 ? raw.slice(firstNewline + 1) : raw;
} else {
content = await fs.promises.readFile(filePath, "utf-8");
}
const lines = content.split("\n").filter(Boolean);
// Only parse the last MAX_READ_ENTRIES lines
const tail = lines.slice(-MAX_READ_ENTRIES);
const entries = [];
for (const line of tail) {
try {
entries.push(JSON.parse(line));
} catch {
// skip malformed lines
}
}
return entries;
} catch {
return [];
}
}
async function clearLogs() {
const dir = ensureLogDir();
if (!dir) return { deletedCount: 0 };
let deletedCount = 0;
try {
const files = await fs.promises.readdir(dir);
for (const file of files) {
if (!file.startsWith("crash-") || !file.endsWith(".log")) continue;
try {
await fs.promises.unlink(path.join(dir, file));
deletedCount++;
} catch {
// skip
}
}
} catch {
// skip
}
return { deletedCount };
}
async function openDir() {
const dir = ensureLogDir();
if (!dir || !electronShell?.openPath) return { success: false };
try {
const errorMessage = await electronShell.openPath(dir);
// shell.openPath resolves to an error string on failure, empty string on success
return { success: !errorMessage };
} catch {
return { success: false };
}
}
// ---------------------------------------------------------------------------
// Lifecycle
// ---------------------------------------------------------------------------
function init(deps) {
const { electronModule, sessions } = deps;
const { app, shell } = electronModule || {};
electronApp = app;
electronShell = shell;
sessionsMap = sessions || null;
ensureLogDir();
pruneOldLogs();
console.log(`[CrashLog] Crash log directory: ${logDir}`);
}
function registerHandlers(ipcMain) {
ipcMain.handle("netcatty:crashLogs:list", async () => listLogs());
ipcMain.handle("netcatty:crashLogs:read", async (_event, { fileName }) => readLog(fileName));
ipcMain.handle("netcatty:crashLogs:clear", async () => clearLogs());
ipcMain.handle("netcatty:crashLogs:openDir", async () => openDir());
}
module.exports = {
init,
captureError,
registerHandlers,
};

View File

@@ -145,16 +145,30 @@ function clearPendingApprovals(chatSessionId) {
function cancelAllPtyExecs() {
for (const [marker, entry] of activePtyExecs) {
try {
entry.cleanup();
// Send Ctrl+C to kill the running command
if (entry.ptyStream && typeof entry.ptyStream.write === "function") {
entry.ptyStream.write("\x03");
}
if (typeof entry.cancel === "function") entry.cancel();
else entry.cleanup();
} catch { /* ignore */ }
activePtyExecs.delete(marker);
}
activePtyExecs.clear();
}
/**
* Cancel PTY executions scoped to a specific chat session.
* Only affects entries whose chatSessionId matches.
*/
function cancelPtyExecsForSession(chatSessionId) {
if (!chatSessionId) return;
for (const [marker, entry] of activePtyExecs) {
if (entry.chatSessionId !== chatSessionId) continue;
try {
if (typeof entry.cancel === "function") entry.cancel();
else entry.cleanup();
} catch { /* ignore */ }
activePtyExecs.delete(marker);
}
}
function init(deps) {
sessions = deps.sessions;
sftpClients = deps.sftpClients;
@@ -598,6 +612,7 @@ function handleExec(params) {
trackForCancellation: activePtyExecs,
timeoutMs: commandTimeoutMs,
shellKind: session.shellKind,
expectedPrompt: session.lastIdlePrompt || "",
});
}
@@ -966,7 +981,9 @@ module.exports = {
getScopedSessionIds,
getOrCreateHost,
buildMcpServerConfig,
activePtyExecs,
cancelAllPtyExecs,
cancelPtyExecsForSession,
cleanupScopedMetadata,
cleanup,
setMainWindowGetter,

View File

@@ -3,19 +3,38 @@
* Extracted from main.cjs for single responsibility
*/
const fs = require("node:fs");
const os = require("node:os");
const path = require("node:path");
const net = require("node:net");
const { Client: SSHClient } = require("ssh2");
const { NetcattyAgent } = require("./netcattyAgent.cjs");
const keyboardInteractiveHandler = require("./keyboardInteractiveHandler.cjs");
const { connectThroughChain } = require("./sshBridge.cjs");
const { createProxySocket } = require("./proxyUtils.cjs");
const {
buildAuthHandler,
createKeyboardInteractiveHandler,
applyAuthToConnOpts,
findAllDefaultPrivateKeys: findAllDefaultPrivateKeysFromHelper,
isKeyEncrypted,
} = require("./sshAuthHelper.cjs");
const passphraseHandler = require("./passphraseHandler.cjs");
// Active port forwarding tunnels
const portForwardingTunnels = new Map();
function cleanupChainConnections(connections) {
if (!Array.isArray(connections)) return;
for (const chainConn of connections) {
try { chainConn.end(); } catch { /* ignore */ }
}
}
function isTunnelCancelled(tunnelState) {
return Boolean(tunnelState?.cancelled);
}
/**
* Send message to renderer safely
*/
@@ -44,11 +63,30 @@ async function startPortForward(event, payload) {
username,
password,
privateKey,
certificate,
keyId,
passphrase,
proxy,
jumpHosts = [],
identityFilePaths,
} = payload;
const conn = new SSHClient();
const sender = event.sender;
const hasJumpHosts = jumpHosts.length > 0;
const hasProxy = !!proxy;
let chainConnections = [];
let connectionSocket = null;
const tunnelState = {
type,
conn,
pendingConn: null,
server: null,
chainConnections,
status: 'connecting',
webContentsId: sender.id,
cancelled: false,
};
const sendStatus = (status, error = null) => {
if (!sender.isDestroyed()) {
@@ -66,9 +104,53 @@ async function startPortForward(event, payload) {
tryKeyboard: true,
};
if (privateKey) {
const hasCertificate = typeof certificate === "string" && certificate.trim().length > 0;
if (hasCertificate) {
connectOpts.agent = new NetcattyAgent({
mode: "certificate",
webContents: sender,
meta: {
label: keyId || username || "",
certificate,
privateKey,
passphrase,
},
});
} else if (privateKey) {
connectOpts.privateKey = privateKey;
}
// Read identity files from local paths (e.g. SSH config IdentityFile)
// when no explicit key/certificate was already configured.
if (!connectOpts.privateKey && !connectOpts.agent && identityFilePaths?.length > 0) {
for (const keyPath of identityFilePaths) {
try {
const resolvedPath = keyPath.startsWith("~/")
? path.join(os.homedir(), keyPath.slice(2))
: keyPath;
const keyContent = await fs.promises.readFile(resolvedPath, "utf8");
connectOpts.privateKey = keyContent;
if (isKeyEncrypted(keyContent)) {
const result = await passphraseHandler.requestPassphrase(
sender,
resolvedPath,
path.basename(resolvedPath),
hostname,
);
if (result?.passphrase) {
connectOpts.passphrase = result.passphrase;
} else {
delete connectOpts.privateKey;
continue;
}
}
break;
} catch (err) {
console.warn(`[PortForward] Failed to read identity file ${keyPath}:`, err.message);
}
}
}
if (passphrase) {
connectOpts.passphrase = passphrase;
}
@@ -76,19 +158,101 @@ async function startPortForward(event, payload) {
connectOpts.password = password;
}
// Get default keys
const defaultKeys = await findAllDefaultPrivateKeysFromHelper();
sendStatus('connecting');
portForwardingTunnels.set(tunnelId, tunnelState);
// Build auth handler using shared helper
const authConfig = buildAuthHandler({
privateKey,
password,
passphrase,
username: connectOpts.username,
logPrefix: "[PortForward]",
defaultKeys,
});
applyAuthToConnOpts(connectOpts, authConfig);
let defaultKeys = [];
try {
// Get default keys
defaultKeys = await findAllDefaultPrivateKeysFromHelper();
if (isTunnelCancelled(tunnelState)) {
portForwardingTunnels.delete(tunnelId);
return { tunnelId, success: false, cancelled: true };
}
// Build auth handler using shared helper
const authConfig = buildAuthHandler({
privateKey: connectOpts.privateKey,
password,
passphrase: connectOpts.passphrase,
agent: connectOpts.agent,
username: connectOpts.username,
logPrefix: "[PortForward]",
defaultKeys,
});
applyAuthToConnOpts(connectOpts, authConfig);
if (isTunnelCancelled(tunnelState)) {
portForwardingTunnels.delete(tunnelId);
return { tunnelId, success: false, cancelled: true };
}
if (hasJumpHosts) {
const chainResult = await connectThroughChain(
event,
{
hostname,
port,
username,
password,
privateKey,
passphrase,
proxy,
jumpHosts,
_defaultKeys: defaultKeys,
_connectionsRef: chainConnections,
_tunnelRef: tunnelState,
},
jumpHosts,
hostname,
port,
tunnelId,
);
connectionSocket = chainResult.socket;
chainConnections = chainResult.connections;
tunnelState.chainConnections = chainConnections;
if (isTunnelCancelled(tunnelState)) {
cleanupChainConnections(chainConnections);
portForwardingTunnels.delete(tunnelId);
return { tunnelId, success: false, cancelled: true };
}
connectOpts.sock = connectionSocket;
delete connectOpts.host;
delete connectOpts.port;
} else if (hasProxy) {
connectionSocket = await createProxySocket(proxy, hostname, port, {
onSocket: (socket) => {
tunnelState.pendingConn = socket;
},
});
if (isTunnelCancelled(tunnelState)) {
try { connectionSocket?.end?.(); } catch { /* ignore */ }
try { connectionSocket?.destroy?.(); } catch { /* ignore */ }
portForwardingTunnels.delete(tunnelId);
return { tunnelId, success: false, cancelled: true };
}
tunnelState.pendingConn = null;
connectOpts.sock = connectionSocket;
delete connectOpts.host;
delete connectOpts.port;
}
} catch (err) {
if (isTunnelCancelled(tunnelState)) {
portForwardingTunnels.delete(tunnelId);
return { tunnelId, success: false, cancelled: true };
}
tunnelState.cancelled = true;
if (tunnelState.pendingConn) {
try { tunnelState.pendingConn.end(); } catch { /* ignore */ }
}
cleanupChainConnections(tunnelState.chainConnections);
if (connectionSocket) {
try { connectionSocket.end?.(); } catch { /* ignore */ }
try { connectionSocket.destroy?.(); } catch { /* ignore */ }
}
portForwardingTunnels.delete(tunnelId);
sendStatus('error', err?.message || String(err));
throw err;
}
// Handle keyboard-interactive authentication (2FA/MFA)
conn.on("keyboard-interactive", createKeyboardInteractiveHandler({
@@ -133,20 +297,20 @@ async function startPortForward(event, payload) {
console.error(`[PortForward] Server error:`, err.message);
sendStatus('error', err.message);
conn.end();
portForwardingTunnels.delete(tunnelId);
settled = true;
reject(err);
});
server.listen(localPort, bindAddress, () => {
console.log(`[PortForward] Local forwarding active: ${bindAddress}:${localPort} -> ${remoteHost}:${remotePort}`);
portForwardingTunnels.set(tunnelId, {
type: 'local',
conn,
server,
status: 'active',
webContentsId: sender.id
});
tunnelState.type = 'local';
tunnelState.conn = conn;
tunnelState.server = server;
tunnelState.chainConnections = chainConnections;
tunnelState.status = 'active';
tunnelState.webContentsId = sender.id;
tunnelState.pendingConn = null;
portForwardingTunnels.set(tunnelId, tunnelState);
sendStatus('active');
settled = true;
resolve({ tunnelId, success: true });
@@ -165,12 +329,14 @@ async function startPortForward(event, payload) {
}
console.log(`[PortForward] Remote forwarding active: remote ${bindAddress}:${localPort} -> local ${remoteHost}:${remotePort}`);
portForwardingTunnels.set(tunnelId, {
type: 'remote',
conn,
status: 'active',
webContentsId: sender.id
});
tunnelState.type = 'remote';
tunnelState.conn = conn;
tunnelState.server = null;
tunnelState.chainConnections = chainConnections;
tunnelState.status = 'active';
tunnelState.webContentsId = sender.id;
tunnelState.pendingConn = null;
portForwardingTunnels.set(tunnelId, tunnelState);
sendStatus('active');
settled = true;
resolve({ tunnelId, success: true });
@@ -273,20 +439,20 @@ async function startPortForward(event, payload) {
console.error(`[PortForward] SOCKS server error:`, err.message);
sendStatus('error', err.message);
conn.end();
portForwardingTunnels.delete(tunnelId);
settled = true;
reject(err);
});
server.listen(localPort, bindAddress, () => {
console.log(`[PortForward] Dynamic SOCKS5 proxy active on ${bindAddress}:${localPort}`);
portForwardingTunnels.set(tunnelId, {
type: 'dynamic',
conn,
server,
status: 'active',
webContentsId: sender.id
});
tunnelState.type = 'dynamic';
tunnelState.conn = conn;
tunnelState.server = server;
tunnelState.chainConnections = chainConnections;
tunnelState.status = 'active';
tunnelState.webContentsId = sender.id;
tunnelState.pendingConn = null;
portForwardingTunnels.set(tunnelId, tunnelState);
sendStatus('active');
settled = true;
resolve({ tunnelId, success: true });
@@ -297,10 +463,11 @@ async function startPortForward(event, payload) {
}
});
conn.once('error', (err) => {
conn.on('error', (err) => {
console.error(`[PortForward] SSH error:`, err.message);
if (settled) return;
sendStatus('error', err.message);
portForwardingTunnels.delete(tunnelId);
cleanupChainConnections(chainConnections);
settled = true;
reject(err);
});
@@ -314,6 +481,12 @@ async function startPortForward(event, payload) {
if (tunnel.server) {
try { tunnel.server.close(); } catch { }
}
if (Array.isArray(tunnel.chainConnections)) {
cleanupChainConnections(tunnel.chainConnections);
}
if (tunnel.pendingConn) {
try { tunnel.pendingConn.end(); } catch { /* ignore */ }
}
sendStatus('inactive');
portForwardingTunnels.delete(tunnelId);
}
@@ -329,18 +502,6 @@ async function startPortForward(event, payload) {
}
});
sendStatus('connecting');
// Register the connection BEFORE the handshake starts so that
// stopPortForwardByRuleId can find and kill it at any point,
// including during the SSH handshake window. The conn.on('ready')
// handler updates the entry to include the server object later.
portForwardingTunnels.set(tunnelId, {
type,
conn,
server: null,
status: 'connecting',
webContentsId: sender.id,
});
conn.connect(connectOpts);
});
}
@@ -363,6 +524,10 @@ async function stopPortForward(event, payload) {
if (tunnel.server) {
tunnel.server.close();
}
if (tunnel.pendingConn) {
tunnel.pendingConn.end();
}
cleanupChainConnections(tunnel.chainConnections);
if (tunnel.conn) {
tunnel.conn.end();
}
@@ -417,6 +582,10 @@ function stopAllPortForwards() {
if (tunnel.server) {
tunnel.server.close();
}
if (tunnel.pendingConn) {
tunnel.pendingConn.end();
}
cleanupChainConnections(tunnel.chainConnections);
if (tunnel.conn) {
tunnel.conn.end();
}
@@ -446,6 +615,8 @@ function stopPortForwardByRuleId(_event, { ruleId }) {
// close handler resolves gracefully instead of rejecting.
tunnel.cancelled = true;
if (tunnel.server) tunnel.server.close();
if (tunnel.pendingConn) tunnel.pendingConn.end();
cleanupChainConnections(tunnel.chainConnections);
if (tunnel.conn) tunnel.conn.end();
// Don't delete here — let the conn.on('close') handler delete
// the entry so it can read tunnel.cancelled first.

View File

@@ -15,9 +15,12 @@ const net = require("node:net");
* @param {string} [proxy.password] - Optional password for auth
* @param {string} targetHost - Target host to connect through proxy
* @param {number} targetPort - Target port to connect through proxy
* @param {Object} [options]
* @param {(socket: net.Socket) => void} [options.onSocket] - Called immediately with the underlying socket
* @returns {Promise<net.Socket>} Connected socket through proxy
*/
function createProxySocket(proxy, targetHost, targetPort) {
function createProxySocket(proxy, targetHost, targetPort, options = {}) {
const { onSocket } = options;
return new Promise((resolve, reject) => {
if (proxy.type === 'http') {
// HTTP CONNECT proxy
@@ -45,6 +48,7 @@ function createProxySocket(proxy, targetHost, targetPort) {
};
socket.on('data', onData);
});
try { onSocket?.(socket); } catch { /* ignore */ }
socket.on('error', reject);
} else if (proxy.type === 'socks5') {
// SOCKS5 proxy
@@ -123,6 +127,7 @@ function createProxySocket(proxy, targetHost, targetPort) {
socket.on('data', onData);
});
try { onSocket?.(socket); } catch { /* ignore */ }
socket.on('error', reject);
} else {
reject(new Error(`Unknown proxy type: ${proxy.type}`));

View File

@@ -22,12 +22,14 @@ try {
const { NetcattyAgent } = require("./netcattyAgent.cjs");
const fileWatcherBridge = require("./fileWatcherBridge.cjs");
const keyboardInteractiveHandler = require("./keyboardInteractiveHandler.cjs");
const passphraseHandler = require("./passphraseHandler.cjs");
const { createProxySocket } = require("./proxyUtils.cjs");
const {
buildAuthHandler,
createKeyboardInteractiveHandler,
applyAuthToConnOpts,
safeSend: authSafeSend,
isKeyEncrypted,
findAllDefaultPrivateKeys: findAllDefaultPrivateKeysFromHelper,
getAvailableAgentSocket,
} = require("./sshAuthHelper.cjs");
@@ -225,6 +227,11 @@ const requireSftpChannel = async (client) => {
return sftp;
};
const realpathAsync = (sftp, targetPath) =>
new Promise((resolve, reject) => {
sftp.realpath(targetPath, (err, absPath) => (err ? reject(err) : resolve(absPath)));
});
const statAsync = (sftp, targetPath) =>
new Promise((resolve, reject) => {
sftp.stat(targetPath, (err, stats) => (err ? reject(err) : resolve(stats)));
@@ -425,6 +432,18 @@ function init(deps) {
electronModule = deps.electronModule;
}
/**
* Send SFTP connection progress to the renderer for user-visible logging
*/
function sendSftpProgress(sender, sessionId, label, status, detail) {
try {
if (!sender || sender.isDestroyed()) return;
sender.send("netcatty:sftp:connection-progress", { sessionId, label, status, detail });
} catch {
// Ignore destroyed webContents
}
}
/**
* Connect through a chain of jump hosts for SFTP
*/
@@ -439,9 +458,10 @@ async function connectThroughChainForSftp(event, options, jumpHosts, targetHost,
const jump = jumpHosts[i];
const isFirst = i === 0;
const isLast = i === jumpHosts.length - 1;
const hopLabel = jump.label || `${jump.hostname}:${jump.port || 22}`;
const hopLabel = jump.label || (jump.hostname.includes(':') && !jump.hostname.startsWith('[') ? `[${jump.hostname}]:${jump.port || 22}` : `${jump.hostname}:${jump.port || 22}`);
console.log(`[SFTP Chain] Hop ${i + 1}/${jumpHosts.length}: Connecting to ${hopLabel}...`);
sendSftpProgress(sender, connId, hopLabel, 'connecting');
const conn = new SSHClient();
// Increase max listeners to prevent Node.js warning
@@ -480,7 +500,59 @@ async function connectThroughChainForSftp(event, options, jumpHosts, targetHost,
connOpts.agent = authAgent;
} else if (jump.privateKey) {
connOpts.privateKey = jump.privateKey;
if (jump.passphrase) connOpts.passphrase = jump.passphrase;
if (jump.passphrase) {
connOpts.passphrase = jump.passphrase;
} else if (isKeyEncrypted(jump.privateKey)) {
// Key is encrypted but no passphrase provided — prompt the user
console.log(`[SFTP Chain] Hop ${i + 1}: key is encrypted, requesting passphrase`);
const keyLabel = jump.label || hopLabel;
const result = await passphraseHandler.requestPassphrase(
sender,
`SSH key for ${keyLabel}`,
keyLabel,
hopLabel
);
if (result?.passphrase) {
connOpts.passphrase = result.passphrase;
} else {
delete connOpts.privateKey;
if (result?.cancelled) {
throw new Error(`Passphrase entry cancelled for ${hopLabel}`);
}
}
}
}
// Read identity files from local paths (e.g. from SSH config IdentityFile)
if (!connOpts.privateKey && !connOpts.agent && jump.identityFilePaths?.length > 0) {
for (const keyPath of jump.identityFilePaths) {
try {
const resolvedPath = keyPath.startsWith("~/")
? path.join(os.homedir(), keyPath.slice(2))
: keyPath;
const keyContent = await fs.promises.readFile(resolvedPath, "utf8");
connOpts.privateKey = keyContent;
if (isKeyEncrypted(keyContent)) {
console.log(`[SFTP Chain] Hop ${i + 1}: identity file ${resolvedPath} is encrypted, requesting passphrase`);
const result = await passphraseHandler.requestPassphrase(
sender,
resolvedPath,
path.basename(resolvedPath),
hopLabel
);
if (result?.passphrase) {
connOpts.passphrase = result.passphrase;
} else {
delete connOpts.privateKey;
continue;
}
}
console.log(`[SFTP Chain] Hop ${i + 1}: loaded identity file ${resolvedPath}`);
break;
} catch (err) {
console.warn(`[SFTP Chain] Hop ${i + 1}: failed to read identity file ${keyPath}:`, err.message);
}
}
}
if (jump.password) connOpts.password = jump.password;
@@ -500,12 +572,17 @@ async function connectThroughChainForSftp(event, options, jumpHosts, targetHost,
unlockedEncryptedKeys: options._unlockedEncryptedKeys || [],
defaultKeys,
sshAgentSocketOverride: agentSocket,
onAuthAttempt: (method) => {
sendSftpProgress(sender, connId, hopLabel, 'auth-attempt', method);
},
});
applyAuthToConnOpts(connOpts, authConfig);
// If first hop and proxy is configured, connect through proxy
if (isFirst && options.proxy) {
currentSocket = await createProxySocket(options.proxy, jump.hostname, jump.port || 22);
const hasUsableJumpProxy = !!(jump.proxy?.host && jump.proxy?.port);
const effectiveHopProxy = isFirst ? ((hasUsableJumpProxy ? jump.proxy : null) || options.proxy) : null;
if (effectiveHopProxy) {
currentSocket = await createProxySocket(effectiveHopProxy, jump.hostname, jump.port || 22);
connOpts.sock = currentSocket;
delete connOpts.host;
delete connOpts.port;
@@ -518,8 +595,12 @@ async function connectThroughChainForSftp(event, options, jumpHosts, targetHost,
// Connect this hop
await new Promise((resolve, reject) => {
conn.once('handshake', () => {
sendSftpProgress(sender, connId, hopLabel, 'authenticating');
});
conn.once('ready', () => {
console.log(`[SFTP Chain] Hop ${i + 1}/${jumpHosts.length}: ${hopLabel} connected`);
sendSftpProgress(sender, connId, hopLabel, 'connected');
resolve();
});
conn.on('error', (err) => {
@@ -529,6 +610,7 @@ async function connectThroughChainForSftp(event, options, jumpHosts, targetHost,
return;
}
console.error(`[SFTP Chain] Hop ${i + 1}/${jumpHosts.length}: ${hopLabel} error:`, err.message);
sendSftpProgress(sender, connId, hopLabel, 'error', err.message);
reject(err);
});
conn.once('timeout', () => {
@@ -536,13 +618,23 @@ async function connectThroughChainForSftp(event, options, jumpHosts, targetHost,
reject(new Error(`Connection timeout to ${hopLabel}`));
});
// Handle keyboard-interactive authentication for jump hosts (2FA/MFA)
conn.on('keyboard-interactive', createKeyboardInteractiveHandler({
const sftpChainKiHandler = createKeyboardInteractiveHandler({
sender,
sessionId: connId,
hostname: hopLabel,
password: jump.password,
logPrefix: `[SFTP Chain] Hop ${i + 1}/${jumpHosts.length}`,
}));
});
conn.on('keyboard-interactive', (name, instructions, lang, prompts, finish) => {
if (prompts && prompts.length > 0) {
sendSftpProgress(sender, connId, hopLabel, 'auth-attempt', 'waiting for user input...');
}
const wrappedFinish = (...args) => {
sendSftpProgress(sender, connId, hopLabel, 'auth-attempt', 'user responded');
finish(...args);
};
sftpChainKiHandler(name, instructions, lang, prompts, wrappedFinish);
});
conn.connect(connOpts);
});
@@ -901,7 +993,69 @@ async function openSftp(event, options) {
connectOpts.agent = authAgent;
} else if (options.privateKey) {
connectOpts.privateKey = options.privateKey;
if (options.passphrase) connectOpts.passphrase = options.passphrase;
if (options.passphrase) {
connectOpts.passphrase = options.passphrase;
} else if (isKeyEncrypted(options.privateKey)) {
// Key is encrypted but no passphrase provided — prompt the user
console.log(`[SFTP] Key is encrypted, requesting passphrase for ${options.hostname}`);
const result = await passphraseHandler.requestPassphrase(
event.sender,
`SSH key for ${options.hostname}`,
options.hostname,
options.hostname
);
if (result?.passphrase) {
connectOpts.passphrase = result.passphrase;
} else {
delete connectOpts.privateKey;
if (result?.cancelled) {
// Clean up any chain/proxy connections and proxy socket opened earlier
for (const c of chainConnections) {
try { c.end(); } catch {}
}
if (connectionSocket) {
try { connectionSocket.destroy(); } catch {}
}
// Use "authentication" in the message so the SFTP frontend's
// isAuthError() check recognizes this and falls back to password.
const err = new Error(`Authentication cancelled — passphrase not provided for ${options.hostname}`);
err.level = 'client-authentication';
throw err;
}
}
}
}
// Read identity files from local paths (e.g. from SSH config IdentityFile)
if (!connectOpts.privateKey && !connectOpts.agent && options.identityFilePaths?.length > 0) {
for (const keyPath of options.identityFilePaths) {
try {
const resolvedPath = keyPath.startsWith("~/")
? path.join(os.homedir(), keyPath.slice(2))
: keyPath;
const keyContent = await fs.promises.readFile(resolvedPath, "utf8");
connectOpts.privateKey = keyContent;
if (isKeyEncrypted(keyContent)) {
console.log(`[SFTP] Identity file ${resolvedPath} is encrypted, requesting passphrase`);
const result = await passphraseHandler.requestPassphrase(
event.sender,
resolvedPath,
path.basename(resolvedPath),
options.hostname
);
if (result?.passphrase) {
connectOpts.passphrase = result.passphrase;
} else {
delete connectOpts.privateKey;
continue;
}
}
console.log(`[SFTP] Loaded identity file ${resolvedPath}`);
break;
} catch (err) {
console.warn(`[SFTP] Failed to read identity file ${keyPath}:`, err.message);
}
}
}
if (options.password) connectOpts.password = options.password;
@@ -917,6 +1071,9 @@ async function openSftp(event, options) {
logPrefix: "[SFTP]",
defaultKeys,
sshAgentSocketOverride: agentSocket,
onAuthAttempt: (method) => {
sendSftpProgress(event.sender, connId, options.hostname, 'auth-attempt', method);
},
});
applyAuthToConnOpts(connectOpts, authConfig);
@@ -930,7 +1087,17 @@ async function openSftp(event, options) {
});
// Add keyboard-interactive listener BEFORE connecting
client.on("keyboard-interactive", kiHandler);
// Wrap to emit progress events for the SFTP connection log
client.on("keyboard-interactive", (name, instructions, lang, prompts, finish) => {
if (prompts && prompts.length > 0) {
sendSftpProgress(event.sender, connId, options.hostname, 'auth-attempt', 'waiting for user input...');
}
const wrappedFinish = (...args) => {
sendSftpProgress(event.sender, connId, options.hostname, 'auth-attempt', 'user responded');
finish(...args);
};
kiHandler(name, instructions, lang, prompts, wrappedFinish);
});
// Increase timeout to allow for keyboard-interactive auth
connectOpts.readyTimeout = 120000; // 2 minutes for 2FA input
@@ -978,14 +1145,24 @@ async function openSftp(event, options) {
sshClient.removeListener('error', onError);
sshClient.removeListener('end', onEnd);
sshClient.removeListener('close', onClose);
// Keep a catch-all error listener so post-ready errors (e.g. connection
// drops during an active SFTP session) don't become uncaught exceptions.
sshClient.on('error', (err) => {
console.error(`[SFTP] Post-ready SSH error for ${connId}:`, err.message);
});
};
sshClient.on('error', onError);
sshClient.on('end', onEnd);
sshClient.on('close', onClose);
sshClient.once('handshake', () => {
sendSftpProgress(event.sender, connId, options.hostname, 'authenticating');
});
sshClient.once('ready', () => {
cleanup();
sendSftpProgress(event.sender, connId, options.hostname, 'connected');
if (options.sudo) {
console.log(`[SFTP] Using sudo mode for connection: ${connId}`);
@@ -1028,6 +1205,7 @@ async function openSftp(event, options) {
}
});
sendSftpProgress(event.sender, connId, options.hostname, 'connecting');
try {
sshClient.connect(connectOpts);
} catch (e) {
@@ -1586,6 +1764,62 @@ async function chmodSftp(event, payload) {
return true;
}
/**
* Resolve the remote user's home directory.
* Strategy: exec `echo ~` via SSH, fallback to SFTP realpath('.').
*/
async function getSftpHomeDir(_event, payload) {
const { sftpId } = payload;
const client = sftpClients.get(sftpId);
if (!client) return { success: false, error: "SFTP session not found" };
// Method 1: SSH exec `echo ~` (with 5s timeout to avoid hanging on
// hosts with blocking shell init scripts or forced commands)
const sshClient = client.client;
if (sshClient && typeof sshClient.exec === "function") {
let execStream = null;
try {
const execPromise = new Promise((resolve, reject) => {
sshClient.exec("echo ~", (err, stream) => {
if (err) return reject(err);
execStream = stream;
let stdout = "";
stream.on("close", (code) => resolve({ stdout, code }));
stream.on("data", (data) => { stdout += data.toString(); });
stream.stderr.on("data", () => {});
});
});
const result = await Promise.race([
execPromise,
new Promise((_, reject) => setTimeout(() => reject(new Error("timeout")), 5000)),
]);
const home = result.stdout?.trim();
if (home && home.startsWith("/")) {
return { success: true, homeDir: home };
}
} catch {
// Timeout or error — kill the exec channel if still open
try { execStream?.close?.(); } catch {}
try { execStream?.destroy?.(); } catch {}
// Fall through to SFTP realpath
}
}
// Method 2: SFTP realpath('.') — skip if result is '/' for non-root users
// because some SFTP servers start in '/' rather than the user's home
try {
const sftp = await requireSftpChannel(client);
const absPath = await realpathAsync(sftp, ".");
if (absPath && absPath !== "/") {
return { success: true, homeDir: absPath };
}
} catch {
// ignore
}
return { success: false, error: "Could not determine home directory" };
}
/**
* Register IPC handlers for SFTP operations
*/
@@ -1604,6 +1838,7 @@ function registerHandlers(ipcMain) {
ipcMain.handle("netcatty:sftp:rename", renameSftp);
ipcMain.handle("netcatty:sftp:stat", statSftp);
ipcMain.handle("netcatty:sftp:chmod", chmodSftp);
ipcMain.handle("netcatty:sftp:homeDir", getSftpHomeDir);
}
/**

View File

@@ -11,7 +11,20 @@ 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"];
const PREFERRED_KEY_NAMES = ["id_ed25519", "id_ecdsa", "id_rsa"];
const SSH_KEY_PATTERN = /^id_[\w-]+$/;
/**
* Quick check if file content looks like an SSH private key.
* Rejects non-key files that happen to match the id_* filename pattern.
*/
function looksLikePrivateKey(content) {
if (!content || typeof content !== "string") return false;
const trimmed = content.trimStart();
return trimmed.startsWith("-----BEGIN") ||
trimmed.startsWith("openssh-key-v1") ||
trimmed.startsWith("PuTTY-User-Key-File");
}
/**
* Check if an SSH private key is encrypted (requires passphrase)
@@ -21,6 +34,13 @@ const DEFAULT_KEY_NAMES = ["id_ed25519", "id_ecdsa", "id_rsa"];
function isKeyEncrypted(keyContent) {
if (!keyContent || typeof keyContent !== "string") return false;
// Check for PuTTY PPK encrypted format (Encryption: aes256-cbc, etc.)
// PPK keys with "Encryption: none" are unencrypted
const ppkEncMatch = keyContent.match(/^Encryption:\s*(.+)$/m);
if (ppkEncMatch && ppkEncMatch[1].trim() !== "none") {
return true;
}
// Check for PKCS#8 encrypted format (-----BEGIN ENCRYPTED PRIVATE KEY-----)
if (keyContent.includes("-----BEGIN ENCRYPTED PRIVATE KEY-----")) {
return true;
@@ -73,14 +93,25 @@ function isKeyEncrypted(keyContent) {
*/
async function findDefaultPrivateKey() {
const sshDir = path.join(os.homedir(), ".ssh");
for (const name of DEFAULT_KEY_NAMES) {
let allNames = [];
try {
const entries = await fs.promises.readdir(sshDir);
allNames = entries.filter(f => SSH_KEY_PATTERN.test(f));
} catch {
return null;
}
const preferred = PREFERRED_KEY_NAMES.filter(n => allNames.includes(n));
const rest = allNames.filter(n => !PREFERRED_KEY_NAMES.includes(n)).sort();
const sorted = [...preferred, ...rest];
for (const name of sorted) {
const keyPath = path.join(sshDir, name);
try {
await fs.promises.access(keyPath, fs.constants.F_OK);
const stat = await fs.promises.stat(keyPath);
if (!stat.isFile()) continue; // Skip directories, FIFOs, sockets, etc.
const privateKey = await fs.promises.readFile(keyPath, "utf8");
if (isKeyEncrypted(privateKey)) {
continue;
}
if (!looksLikePrivateKey(privateKey)) continue;
if (isKeyEncrypted(privateKey)) continue;
return { privateKey, keyPath, keyName: name };
} catch {
continue;
@@ -99,11 +130,24 @@ async function findAllDefaultPrivateKeys(options = {}) {
const { includeEncrypted = false } = options;
const sshDir = path.join(os.homedir(), ".ssh");
const promises = DEFAULT_KEY_NAMES.map(async (name) => {
let allNames = [];
try {
const entries = await fs.promises.readdir(sshDir);
allNames = entries.filter(f => SSH_KEY_PATTERN.test(f));
} catch {
return [];
}
const preferred = PREFERRED_KEY_NAMES.filter(n => allNames.includes(n));
const rest = allNames.filter(n => !PREFERRED_KEY_NAMES.includes(n)).sort();
const sorted = [...preferred, ...rest];
const promises = sorted.map(async (name) => {
const keyPath = path.join(sshDir, name);
try {
await fs.promises.access(keyPath, fs.constants.F_OK);
const stat = await fs.promises.stat(keyPath);
if (!stat.isFile()) return null;
const privateKey = await fs.promises.readFile(keyPath, "utf8");
if (!looksLikePrivateKey(privateKey)) return null;
const encrypted = isKeyEncrypted(privateKey);
if (encrypted && !includeEncrypted) {
return null;
@@ -213,7 +257,7 @@ async function getAvailableAgentSocket() {
* @param {Array} [options.unlockedEncryptedKeys] - Array of unlocked encrypted keys with passphrases
*/
function buildAuthHandler(options) {
const { privateKey, password, passphrase, agent, username, logPrefix = "[SSH]", unlockedEncryptedKeys = [], defaultKeys = [], sshAgentSocketOverride } = options;
const { privateKey, password, passphrase, agent, username, logPrefix = "[SSH]", unlockedEncryptedKeys = [], defaultKeys = [], sshAgentSocketOverride, onAuthAttempt } = options;
// Determine what type of explicit auth the user configured
const hasExplicitKey = !!privateKey;
@@ -259,7 +303,7 @@ function buildAuthHandler(options) {
// If only simple auth methods and no fallback keys needed, use array-based handler
if (hasExplicitAuth && !hasFallbackOptions) {
const authMethods = [];
const authMethods = ["none"]; // Always try none first per RFC 4252
if (effectiveAgent) authMethods.push("agent");
if (privateKey) authMethods.push("publickey");
if (password) authMethods.push("password");
@@ -380,11 +424,29 @@ function buildAuthHandler(options) {
// Use dynamic authHandler to try all keys
let authIndex = 0;
let lastAttemptedLabel = null;
const attemptedMethodIds = new Set();
let triedNone = false;
const authHandler = (methodsLeft, partialSuccess, callback) => {
// Per RFC 4252, always try "none" first to discover available methods
// and to support passwordless login (e.g. embedded devices).
// This matches the behavior of OpenSSH and Tabby.
if (methodsLeft === null && !triedNone) {
triedNone = true;
lastAttemptedLabel = "none (no credentials)";
onAuthAttempt?.("none (no credentials)");
return callback("none");
}
const availableMethods = methodsLeft || ["publickey", "password", "keyboard-interactive", "agent"];
// Log rejection of previous method (authHandler is called again when server rejects)
if (lastAttemptedLabel && !partialSuccess) {
onAuthAttempt?.(`${lastAttemptedLabel} rejected`);
}
while (authIndex < authMethods.length) {
const method = authMethods[authIndex];
authIndex++;
@@ -394,9 +456,21 @@ function buildAuthHandler(options) {
if (method.type === "agent" && (availableMethods.includes("publickey") || availableMethods.includes("agent"))) {
console.log(`${logPrefix} Trying agent auth`);
lastAttemptedLabel = "SSH agent";
onAuthAttempt?.("SSH agent");
return callback("agent");
} else if (method.type === "publickey" && availableMethods.includes("publickey")) {
console.log(`${logPrefix} Trying publickey auth:`, method.id);
// Build a readable label for the key
const keyLabel = method.id.startsWith("publickey-default-")
? `key ${method.id.replace("publickey-default-", "")}`
: method.id.startsWith("publickey-encrypted-")
? `key ${method.id.replace("publickey-encrypted-", "")} (encrypted)`
: method.id === "publickey-user"
? "configured key"
: method.id;
lastAttemptedLabel = keyLabel;
onAuthAttempt?.(keyLabel);
const pubkeyAuth = {
type: "publickey",
username,
@@ -408,15 +482,20 @@ function buildAuthHandler(options) {
return callback(pubkeyAuth);
} else if (method.type === "password" && availableMethods.includes("password")) {
console.log(`${logPrefix} Trying password auth`);
lastAttemptedLabel = "password";
onAuthAttempt?.("password");
return callback({
type: "password",
username,
password,
});
} else if (method.type === "keyboard-interactive" && availableMethods.includes("keyboard-interactive")) {
lastAttemptedLabel = "keyboard-interactive";
onAuthAttempt?.("keyboard-interactive");
return callback("keyboard-interactive");
}
}
onAuthAttempt?.("all methods exhausted");
return callback(false);
};
@@ -577,7 +656,9 @@ async function requestPassphrasesForEncryptedKeys(sender, hostname) {
}
module.exports = {
DEFAULT_KEY_NAMES,
PREFERRED_KEY_NAMES,
SSH_KEY_PATTERN,
looksLikePrivateKey,
isKeyEncrypted,
findDefaultPrivateKey,
findAllDefaultPrivateKeys,

View File

@@ -23,9 +23,24 @@ const {
getSshAgentSocket,
} = require("./sshAuthHelper.cjs");
const sessionLogStreamManager = require("./sessionLogStreamManager.cjs");
const { trackSessionIdlePrompt } = require("./ai/shellUtils.cjs");
// Default SSH key names in priority order
const DEFAULT_KEY_NAMES = ["id_ed25519", "id_ecdsa", "id_rsa"];
// Default SSH key names in priority order (preferred keys tried first)
const PREFERRED_KEY_NAMES = ["id_ed25519", "id_ecdsa", "id_rsa"];
// Match any private key file: id_* but not *.pub
const SSH_KEY_PATTERN = /^id_[\w-]+$/;
/**
* Quick check if file content looks like an SSH private key.
* Rejects non-key files that happen to match the id_* filename pattern.
*/
function looksLikePrivateKey(content) {
if (!content || typeof content !== "string") return false;
const trimmed = content.trimStart();
return trimmed.startsWith("-----BEGIN") ||
trimmed.startsWith("openssh-key-v1") ||
trimmed.startsWith("PuTTY-User-Key-File");
}
/**
* Check if an SSH private key is encrypted (requires passphrase)
@@ -33,6 +48,12 @@ const DEFAULT_KEY_NAMES = ["id_ed25519", "id_ecdsa", "id_rsa"];
* @returns {boolean} - True if the key is encrypted
*/
function isKeyEncrypted(keyContent) {
// Check for PuTTY PPK encrypted format
const ppkEncMatch = keyContent.match(/^Encryption:\s*(.+)$/m);
if (ppkEncMatch && ppkEncMatch[1].trim() !== "none") {
return true;
}
// Check for PKCS#8 encrypted format (-----BEGIN ENCRYPTED PRIVATE KEY-----)
if (keyContent.includes("-----BEGIN ENCRYPTED PRIVATE KEY-----")) {
return true;
@@ -82,14 +103,31 @@ function isKeyEncrypted(keyContent) {
*/
async function findDefaultPrivateKey() {
const sshDir = path.join(os.homedir(), ".ssh");
log("Searching for default SSH keys", { sshDir, keyNames: DEFAULT_KEY_NAMES });
for (const name of DEFAULT_KEY_NAMES) {
// Scan ~/.ssh/ for all files matching id_* (same as Tabby/OpenSSH),
// with preferred key types tried first
let allNames = [];
try {
const entries = await fs.promises.readdir(sshDir);
allNames = entries.filter(f => SSH_KEY_PATTERN.test(f));
} catch {
return null;
}
// Sort: preferred keys first (in order), then rest alphabetically
const preferred = PREFERRED_KEY_NAMES.filter(n => allNames.includes(n));
const rest = allNames.filter(n => !PREFERRED_KEY_NAMES.includes(n)).sort();
const sorted = [...preferred, ...rest];
log("Searching for default SSH keys", { sshDir, found: sorted });
for (const name of sorted) {
const keyPath = path.join(sshDir, name);
try {
await fs.promises.access(keyPath, fs.constants.F_OK);
const stat = await fs.promises.stat(keyPath);
if (!stat.isFile()) continue;
const privateKey = await fs.promises.readFile(keyPath, "utf8");
// Skip encrypted keys - they require a passphrase and would abort
// authentication before password/keyboard-interactive can be tried
if (!looksLikePrivateKey(privateKey)) {
log("Skipping non-key file", { keyPath, keyName: name });
continue;
}
const encrypted = isKeyEncrypted(privateKey);
log("Key file read", { keyPath, keyName: name, encrypted, keyLength: privateKey.length });
if (encrypted) {
@@ -114,13 +152,28 @@ async function findDefaultPrivateKey() {
*/
async function findAllDefaultPrivateKeys() {
const sshDir = path.join(os.homedir(), ".ssh");
log("Searching for ALL default SSH keys", { sshDir, keyNames: DEFAULT_KEY_NAMES });
let allNames = [];
try {
const entries = await fs.promises.readdir(sshDir);
allNames = entries.filter(f => SSH_KEY_PATTERN.test(f));
} catch {
return [];
}
const preferred = PREFERRED_KEY_NAMES.filter(n => allNames.includes(n));
const rest = allNames.filter(n => !PREFERRED_KEY_NAMES.includes(n)).sort();
const sorted = [...preferred, ...rest];
log("Searching for ALL default SSH keys", { sshDir, found: sorted });
const promises = DEFAULT_KEY_NAMES.map(async (name) => {
const promises = sorted.map(async (name) => {
const keyPath = path.join(sshDir, name);
try {
await fs.promises.access(keyPath, fs.constants.F_OK);
const stat = await fs.promises.stat(keyPath);
if (!stat.isFile()) return null;
const privateKey = await fs.promises.readFile(keyPath, "utf8");
if (!looksLikePrivateKey(privateKey)) {
log("Skipping non-key file", { keyPath, keyName: name });
return null;
}
const encrypted = isKeyEncrypted(privateKey);
if (!encrypted) {
log("Found default key for fallback", { keyPath, keyName: name });
@@ -330,12 +383,12 @@ function init(deps) {
*/
async function connectThroughChain(event, options, jumpHosts, targetHost, targetPort, sessionId) {
const sender = event.sender;
const connections = [];
const connections = options?._connectionsRef || [];
let currentSocket = null;
const sendProgress = (hop, total, label, status) => {
const sendProgress = (hop, total, label, status, error) => {
if (!sender.isDestroyed()) {
sender.send("netcatty:chain:progress", { hop, total, label, status });
sender.send("netcatty:chain:progress", { sessionId, hop, total, label, status, error });
}
};
@@ -347,11 +400,15 @@ async function connectThroughChain(event, options, jumpHosts, targetHost, target
const jump = jumpHosts[i];
const isFirst = i === 0;
const isLast = i === jumpHosts.length - 1;
const hopLabel = jump.label || `${jump.hostname}:${jump.port || 22}`;
const hopLabel = jump.label || (jump.hostname.includes(':') && !jump.hostname.startsWith('[') ? `[${jump.hostname}]:${jump.port || 22}` : `${jump.hostname}:${jump.port || 22}`);
sendProgress(i + 1, totalHops + 1, hopLabel, 'connecting');
const conn = new SSHClient();
if (options?._tunnelRef) {
options._tunnelRef.pendingConn = conn;
options._tunnelRef.chainConnections = connections;
}
// Build connection options
const connOpts = {
@@ -387,7 +444,64 @@ async function connectThroughChain(event, options, jumpHosts, targetHost, target
connOpts.agent = authAgent;
} else if (jump.privateKey) {
connOpts.privateKey = jump.privateKey;
if (jump.passphrase) connOpts.passphrase = jump.passphrase;
if (jump.passphrase) {
connOpts.passphrase = jump.passphrase;
} else if (isKeyEncrypted(jump.privateKey)) {
// Key is encrypted but no passphrase provided — prompt the user
console.log(`[Chain] Hop ${i + 1}: key is encrypted, requesting passphrase`);
sendProgress(i + 1, totalHops + 1, hopLabel, 'auth-attempt', 'passphrase required');
const keyLabel = jump.label || hopLabel;
const result = await passphraseHandler.requestPassphrase(
sender,
`SSH key for ${keyLabel}`,
keyLabel,
hopLabel
);
if (result?.passphrase) {
connOpts.passphrase = result.passphrase;
} else {
// No passphrase (cancelled/skipped/timeout) — remove the encrypted
// key so buildAuthHandler won't try it and stall auth.
delete connOpts.privateKey;
if (result?.cancelled) {
throw new Error(`Passphrase entry cancelled for ${hopLabel}`);
}
}
}
}
// Read identity files from local paths (e.g. from SSH config IdentityFile)
if (!connOpts.privateKey && !connOpts.agent && jump.identityFilePaths?.length > 0) {
for (const keyPath of jump.identityFilePaths) {
try {
const resolvedPath = keyPath.startsWith("~/")
? path.join(os.homedir(), keyPath.slice(2))
: keyPath;
const keyContent = await fs.promises.readFile(resolvedPath, "utf8");
connOpts.privateKey = keyContent;
if (isKeyEncrypted(keyContent)) {
console.log(`[Chain] Hop ${i + 1}: identity file ${resolvedPath} is encrypted, requesting passphrase`);
sendProgress(i + 1, totalHops + 1, hopLabel, 'auth-attempt', 'passphrase required');
const result = await passphraseHandler.requestPassphrase(
sender,
resolvedPath,
path.basename(resolvedPath),
hopLabel
);
if (result?.passphrase) {
connOpts.passphrase = result.passphrase;
} else {
// Cancelled/skipped/timeout — clear encrypted key, try next file
delete connOpts.privateKey;
continue;
}
}
console.log(`[Chain] Hop ${i + 1}: loaded identity file ${resolvedPath}`);
break;
} catch (err) {
console.warn(`[Chain] Hop ${i + 1}: failed to read identity file ${keyPath}:`, err.message);
}
}
}
if (jump.password) connOpts.password = jump.password;
@@ -406,12 +520,28 @@ async function connectThroughChain(event, options, jumpHosts, targetHost, target
logPrefix: `[Chain] Hop ${i + 1}`,
unlockedEncryptedKeys: options._unlockedEncryptedKeys || [],
defaultKeys,
onAuthAttempt: (method) => {
sendProgress(i + 1, totalHops + 1, hopLabel, 'auth-attempt', method);
},
});
applyAuthToConnOpts(connOpts, authConfig);
// If first hop and proxy is configured, connect through proxy
if (isFirst && options.proxy) {
currentSocket = await createProxySocket(options.proxy, jump.hostname, jump.port || 22);
const hasUsableJumpProxy = !!(jump.proxy?.host && jump.proxy?.port);
const effectiveHopProxy = isFirst ? ((hasUsableJumpProxy ? jump.proxy : null) || options.proxy) : null;
if (effectiveHopProxy) {
currentSocket = await createProxySocket(effectiveHopProxy, jump.hostname, jump.port || 22, {
onSocket: (socket) => {
if (options?._tunnelRef) {
options._tunnelRef.pendingConn = socket;
options._tunnelRef.chainConnections = connections;
}
},
});
if (options?._tunnelRef) {
options._tunnelRef.pendingConn = null;
options._tunnelRef.chainConnections = connections;
}
connOpts.sock = currentSocket;
delete connOpts.host;
delete connOpts.port;
@@ -424,28 +554,48 @@ async function connectThroughChain(event, options, jumpHosts, targetHost, target
// Connect this hop
await new Promise((resolve, reject) => {
conn.once('handshake', () => {
console.log(`[Chain] Hop ${i + 1}/${totalHops}: ${hopLabel} handshake complete`);
sendProgress(i + 1, totalHops + 1, hopLabel, 'authenticating');
});
conn.once('ready', () => {
console.log(`[Chain] Hop ${i + 1}/${totalHops}: ${hopLabel} connected`);
sendProgress(i + 1, totalHops + 1, hopLabel, 'connected');
if (options?._tunnelRef) {
options._tunnelRef.pendingConn = null;
options._tunnelRef.chainConnections = connections;
}
resolve();
});
conn.once('error', (err) => {
console.error(`[Chain] Hop ${i + 1}/${totalHops}: ${hopLabel} error:`, err.message);
sendProgress(i + 1, totalHops + 1, hopLabel, 'error');
sendProgress(i + 1, totalHops + 1, hopLabel, 'error', err.message);
reject(err);
});
conn.once('timeout', () => {
console.error(`[Chain] Hop ${i + 1}/${totalHops}: ${hopLabel} timeout`);
reject(new Error(`Connection timeout to ${hopLabel}`));
const errMsg = `Connection timeout to ${hopLabel}`;
sendProgress(i + 1, totalHops + 1, hopLabel, 'error', errMsg);
reject(new Error(errMsg));
});
// Handle keyboard-interactive authentication for jump hosts (2FA/MFA)
conn.on('keyboard-interactive', createKeyboardInteractiveHandler({
const chainKiHandler = createKeyboardInteractiveHandler({
sender,
sessionId,
hostname: hopLabel,
password: jump.password,
logPrefix: `[Chain] Hop ${i + 1}/${totalHops}`,
}));
});
conn.on('keyboard-interactive', (name, instructions, lang, prompts, finish) => {
if (prompts && prompts.length > 0) {
sendProgress(i + 1, totalHops + 1, hopLabel, 'auth-attempt', 'waiting for user input...');
}
const wrappedFinish = (...args) => {
sendProgress(i + 1, totalHops + 1, hopLabel, 'auth-attempt', 'user responded');
finish(...args);
};
chainKiHandler(name, instructions, lang, prompts, wrappedFinish);
});
console.log(`[Chain] Hop ${i + 1}/${totalHops}: Connecting to ${hopLabel}...`);
conn.connect(connOpts);
});
@@ -488,6 +638,10 @@ async function connectThroughChain(event, options, jumpHosts, targetHost, target
sendProgress
};
} catch (err) {
if (options?._tunnelRef) {
options._tunnelRef.pendingConn = null;
options._tunnelRef.chainConnections = connections;
}
// Cleanup on error
for (const conn of connections) {
try { conn.end(); } catch { }
@@ -508,9 +662,9 @@ async function startSSHSession(event, options) {
const rows = options.rows || 24;
const sender = event.sender;
const sendProgress = (hop, total, label, status) => {
const sendProgress = (hop, total, label, status, error) => {
if (!sender.isDestroyed()) {
sender.send("netcatty:chain:progress", { hop, total, label, status });
sender.send("netcatty:chain:progress", { sessionId, hop, total, label, status, error });
}
};
@@ -581,6 +735,41 @@ async function startSSHSession(event, options) {
}
}
// Read identity files from local paths (e.g. from SSH config IdentityFile)
// Only if no explicit key was already configured
if (!connectOpts.privateKey && !connectOpts.agent && options.identityFilePaths?.length > 0) {
for (const keyPath of options.identityFilePaths) {
try {
const resolvedPath = keyPath.startsWith("~/")
? path.join(os.homedir(), keyPath.slice(2))
: keyPath;
const keyContent = await fs.promises.readFile(resolvedPath, "utf8");
connectOpts.privateKey = keyContent;
// Check if key is encrypted — if so, prompt for passphrase
if (isKeyEncrypted(keyContent)) {
log("Identity file is encrypted, requesting passphrase", { keyPath: resolvedPath });
const result = await passphraseHandler.requestPassphrase(
sender,
resolvedPath,
path.basename(resolvedPath),
options.hostname
);
if (result?.passphrase) {
connectOpts.passphrase = result.passphrase;
} else {
// Cancelled/skipped/timeout — clear encrypted key, try next file
delete connectOpts.privateKey;
continue;
}
}
log("Loaded identity file", { keyPath: resolvedPath, encrypted: isKeyEncrypted(keyContent) });
break; // Use the first successfully loaded key
} catch (err) {
log("Failed to read identity file", { keyPath, error: err.message });
}
}
}
if (options.password && typeof options.password === "string" && options.password.trim().length > 0) {
connectOpts.password = options.password;
}
@@ -659,7 +848,7 @@ async function startSSHSession(event, options) {
let lastTriedMethod = null;
if (authAgent) {
const order = ["agent"];
const order = ["none", "agent"];
if (connectOpts.password) order.push("password");
// Add default key fallback if available and no user key configured
// Must also set connectOpts.privateKey for ssh2 to actually try publickey auth
@@ -737,8 +926,9 @@ async function startSSHSession(event, options) {
}
}
// Use dynamic authHandler if we have multiple auth options
if (authMethods.length > 1) {
// Always use dynamic authHandler to ensure consistent "none" probing
// and auth method logging regardless of how many methods are configured
if (authMethods.length >= 1) {
let authIndex = 0;
// Track methods that have been attempted (to avoid re-trying on failure)
// This prevents reusing the same key when server requires multiple publickey auth steps
@@ -752,6 +942,22 @@ async function startSSHSession(event, options) {
connectOpts.authHandler = (methodsLeft, partialSuccess, callback) => {
log("authHandler called", { methodsLeft, partialSuccess, authIndex, attemptedMethodIds: Array.from(attemptedMethodIds) });
// Log rejection of previous method
if (lastTriedMethod && !partialSuccess) {
sendProgress(totalHops, totalHops, options.hostname, 'auth-attempt', `${lastTriedMethod} rejected`);
}
// On the very first call (methodsLeft === null), try "none" auth.
// Per RFC 4252, the "none" request is how the client discovers which
// methods the server supports. It also allows passwordless login on
// embedded devices. This matches the behavior of OpenSSH and Tabby.
if (methodsLeft === null && !attemptedMethodIds.has("none")) {
attemptedMethodIds.add("none");
lastTriedMethod = "none";
sendProgress(totalHops, totalHops, options.hostname, 'auth-attempt', 'none (no credentials)');
return callback("none");
}
// methodsLeft can be null on first call (before server responds with available methods)
// Include "agent" for SSH agent-based auth (used with agentForwarding)
const availableMethods = methodsLeft || ["publickey", "password", "keyboard-interactive", "agent"];
@@ -850,10 +1056,19 @@ async function startSSHSession(event, options) {
// Only log safe identifier, not the full agent object which may contain private keys
const agentType = typeof connectOpts.agent === "string" ? "path" : "NetcattyAgent";
log("Trying agent auth", { id: method.id, agentType });
sendProgress(totalHops, totalHops, options.hostname, 'auth-attempt', 'SSH agent');
// Return "agent" string to use SSH agent for authentication
return callback("agent");
} else if (method.type === "publickey") {
log("Trying publickey auth", { id: method.id, isDefault: method.isDefault || false });
const keyLabel = method.id.startsWith("publickey-default-")
? `key ${method.id.replace("publickey-default-", "")}`
: method.id.startsWith("publickey-encrypted-")
? `key ${method.id.replace("publickey-encrypted-", "")} (encrypted)`
: method.id === "publickey-user"
? "configured key"
: method.id;
sendProgress(totalHops, totalHops, options.hostname, 'auth-attempt', keyLabel);
return callback({
type: "publickey",
username: connectOpts.username,
@@ -862,6 +1077,7 @@ async function startSSHSession(event, options) {
});
} else if (method.type === "password") {
log("Trying password auth", { id: method.id });
sendProgress(totalHops, totalHops, options.hostname, 'auth-attempt', 'password');
return callback({
type: "password",
username: connectOpts.username,
@@ -869,6 +1085,7 @@ async function startSSHSession(event, options) {
});
} else if (method.type === "keyboard-interactive") {
log("Trying keyboard-interactive auth", { id: method.id });
sendProgress(totalHops, totalHops, options.hostname, 'auth-attempt', 'keyboard-interactive');
// Return string instead of object - ssh2 requires a prompt function
// for keyboard-interactive objects. Returning the string lets ssh2
// use its default handling and trigger the keyboard-interactive event.
@@ -877,6 +1094,7 @@ async function startSSHSession(event, options) {
}
log("All auth methods exhausted");
sendProgress(totalHops, totalHops, options.hostname, 'auth-attempt', 'all methods exhausted');
return callback(false);
};
@@ -924,10 +1142,20 @@ async function startSSHSession(event, options) {
connectOpts.sock = connectionSocket;
delete connectOpts.host;
delete connectOpts.port;
} else {
// Direct connection (no jump hosts, no proxy)
sendProgress(1, 1, options.hostname, 'connecting');
}
return new Promise((resolve, reject) => {
const logPrefix = hasJumpHosts ? '[Chain]' : '[SSH]';
let settled = false;
conn.once("handshake", () => {
console.log(`${logPrefix} ${options.hostname} handshake complete`);
sendProgress(totalHops, totalHops, options.hostname, 'authenticating');
});
conn.once("ready", () => {
console.log(`${logPrefix} ${options.hostname} ready`);
@@ -939,9 +1167,8 @@ async function startSSHSession(event, options) {
}
}
if (hasJumpHosts || hasProxy) {
sendProgress(totalHops, totalHops, options.hostname, 'connected');
}
sendProgress(totalHops, totalHops, options.hostname, 'authenticated');
sendProgress(totalHops, totalHops, options.hostname, 'shell');
conn.shell(
{
@@ -958,14 +1185,18 @@ async function startSSHSession(event, options) {
},
(err, stream) => {
if (err) {
settled = true;
conn.end();
for (const c of chainConnections) {
try { c.end(); } catch { }
}
sendProgress(totalHops, totalHops, options.hostname, 'error', `Failed to open shell: ${err.message}`);
reject(err);
return;
}
sendProgress(totalHops, totalHops, options.hostname, 'connected');
const session = {
conn,
stream,
@@ -975,6 +1206,9 @@ async function startSSHSession(event, options) {
hostname: options.host || options.hostname || '',
username: options.username || '',
label: options.label || '',
lastIdlePrompt: '',
lastIdlePromptAt: 0,
_promptTrackTail: '',
};
sessions.set(sessionId, session);
@@ -1022,6 +1256,7 @@ async function startSSHSession(event, options) {
stream.on("data", (data) => {
const decoder = getSessionDecoder(sessionId, "stdout");
const decoded = decoder.write(data);
trackSessionIdlePrompt(session, decoded);
bufferData(decoded);
sessionLogStreamManager.appendData(sessionId, decoded);
});
@@ -1047,17 +1282,29 @@ async function startSSHSession(event, options) {
});
stream.on("close", () => {
// Flush any remaining data before close
// Always flush buffered data regardless of session state
if (flushTimeout) {
clearTimeout(flushTimeout);
}
flushBuffer();
sessionLogStreamManager.stopStream(sessionId);
const contents = event.sender;
safeSend(contents, "netcatty:exit", { sessionId, exitCode: streamExitCode, reason: streamExited ? "exited" : "closed" });
sessions.delete(sessionId);
sessionEncodings.delete(sessionId);
sessionDecoders.delete(sessionId);
// Only send exit if session hasn't already been cleaned up by
// conn.once("close") — which fires before stream.on("close")
// in ssh2 when the transport drops.
if (sessions.has(sessionId)) {
const contents = event.sender;
const session = sessions.get(sessionId);
const transportError = session?._transportError;
if (transportError) {
safeSend(contents, "netcatty:exit", { sessionId, exitCode: 1, error: transportError, reason: "error" });
} else {
safeSend(contents, "netcatty:exit", { sessionId, exitCode: streamExitCode, reason: streamExited ? "exited" : "closed" });
}
sessions.delete(sessionId);
sessionEncodings.delete(sessionId);
sessionDecoders.delete(sessionId);
}
conn.end();
for (const c of chainConnections) {
try { c.end(); } catch { }
@@ -1076,12 +1323,29 @@ async function startSSHSession(event, options) {
}, 300);
}
settled = true;
resolve({ sessionId });
}
);
});
conn.on("error", (err) => {
// After the promise is settled, we can't reject again. But if the
// session was already established (resolved), we still need to notify
// the renderer about transport errors so the session shows as failed
// rather than silently closing.
// Don't send netcatty:exit here — the stream close handler will flush
// any buffered data first and then send exit with this error info.
if (settled) {
console.warn(`${logPrefix} ${options.hostname} post-settle error:`, err.message);
// Store the error so the close handler can include it in the exit event
if (sessions.has(sessionId)) {
const session = sessions.get(sessionId);
if (session) session._transportError = err.message;
}
return;
}
const contents = event.sender;
const isAuthError = err.message?.toLowerCase().includes('authentication') ||
@@ -1102,6 +1366,7 @@ async function startSSHSession(event, options) {
console.error(`${logPrefix} ${options.hostname} error:`, err.message);
}
sendProgress(totalHops, totalHops, options.hostname, 'error', err.message);
safeSend(contents, "netcatty:exit", { sessionId, exitCode: 1, error: err.message, reason: "error" });
sessionLogStreamManager.stopStream(sessionId);
sessions.delete(sessionId);
@@ -1110,6 +1375,10 @@ async function startSSHSession(event, options) {
for (const c of chainConnections) {
try { c.end(); } catch { }
}
// Destroy the connection to prevent further socket errors from leaking
// as uncaught exceptions (e.g. ECONNRESET on embedded devices).
try { conn.destroy(); } catch { }
settled = true;
reject(err);
});
@@ -1117,6 +1386,7 @@ async function startSSHSession(event, options) {
console.error(`${logPrefix} ${options.hostname} connection timeout`);
const err = new Error(`Connection timeout to ${options.hostname}`);
const contents = event.sender;
sendProgress(totalHops, totalHops, options.hostname, 'error', err.message);
safeSend(contents, "netcatty:exit", { sessionId, exitCode: 1, error: err.message, reason: "timeout" });
sessionLogStreamManager.stopStream(sessionId);
sessions.delete(sessionId);
@@ -1125,12 +1395,29 @@ async function startSSHSession(event, options) {
for (const c of chainConnections) {
try { c.end(); } catch { }
}
try { conn.destroy(); } catch { }
settled = true;
reject(err);
});
conn.once("close", () => {
const contents = event.sender;
safeSend(contents, "netcatty:exit", { sessionId, exitCode: 0, reason: "closed" });
if (!settled) {
sendProgress(totalHops, totalHops, options.hostname, 'error', `Connection to ${options.hostname} closed unexpectedly`);
}
// Only send exit if the session hasn't already been cleaned up by the
// error handler (avoids sending a misleading exitCode:0 "closed" after
// a real transport error was already reported).
if (sessions.has(sessionId)) {
const session = sessions.get(sessionId);
const transportError = session?._transportError;
if (transportError) {
// A transport error was recorded — report it as an error exit
safeSend(contents, "netcatty:exit", { sessionId, exitCode: 1, error: transportError, reason: "error" });
} else {
safeSend(contents, "netcatty:exit", { sessionId, exitCode: 0, reason: "closed" });
}
}
sessionLogStreamManager.stopStream(sessionId);
sessions.delete(sessionId);
sessionEncodings.delete(sessionId);
@@ -1138,6 +1425,10 @@ async function startSSHSession(event, options) {
for (const c of chainConnections) {
try { c.end(); } catch { }
}
if (!settled) {
settled = true;
reject(new Error(`Connection to ${options.hostname} closed unexpectedly`));
}
});
// Handle keyboard-interactive authentication (2FA/MFA)
@@ -1156,12 +1447,15 @@ async function startSSHSession(event, options) {
return;
}
sendProgress(totalHops, totalHops, options.hostname, 'auth-attempt', 'waiting for user input...');
// Forward ALL prompts to user - no auto-fill to avoid semantic detection issues
// (Prompt text is admin-customizable and may not contain expected keywords)
const requestId = keyboardInteractiveHandler.generateRequestId('ssh');
keyboardInteractiveHandler.storeRequest(requestId, (userResponses) => {
console.log(`${logPrefix} Received user responses, finishing keyboard-interactive`);
sendProgress(totalHops, totalHops, options.hostname, 'auth-attempt', 'user responded');
finish(userResponses);
}, sender.id, sessionId);
@@ -1277,10 +1571,11 @@ async function execCommand(event, payload) {
});
});
})
.once("error", (err) => {
.on("error", (err) => {
if (settled) return;
clearTimeout(timer);
settled = true;
conn.end();
reject(err);
})
.once("end", () => {
@@ -1462,7 +1757,11 @@ async function startSSHSessionWrapper(event, options) {
authError.isAuthError = true;
throw authError;
}
throw retryErr;
// Wrap non-auth retry errors as connection errors to prevent crash
const connError = new Error(retryErr.message);
connError.level = retryErr.level || 'client-socket';
connError.code = retryErr.code;
throw connError;
}
} else {
console.log('[SSH] User did not unlock any keys, not retrying');
@@ -1477,7 +1776,15 @@ async function startSSHSessionWrapper(event, options) {
authError.isAuthError = true;
throw authError;
}
throw err;
// Non-auth errors (e.g. ECONNRESET, ETIMEDOUT) — wrap in a clean Error
// so Electron's ipcMain.handle can serialize it back to the renderer
// instead of it becoming an uncaught exception that crashes the app.
// See: https://github.com/nicely-gg/netcatty/issues/482
const connError = new Error(err.message);
connError.level = err.level || 'client-socket';
connError.code = err.code;
throw connError;
}
}
@@ -1547,11 +1854,41 @@ async function getServerStats(event, payload) {
const conn = session.conn;
// macOS stats command: uses sysctl, vm_stat, top, ps, df, netstat
// CPU reported as direct percentage (top computes delta internally)
// cpuPerCore not available on macOS without sudo
const macosStatsCommand = [
`cores=$(sysctl -n hw.logicalcpu 2>/dev/null || echo "1")`,
`pagesize=$(sysctl -n hw.pagesize 2>/dev/null || echo "4096")`,
`memsize=$(sysctl -n hw.memsize 2>/dev/null || echo "0")`,
// CPU usage: top -l 1 gives one logging sample, parse idle%
`cpuline=$(top -l 1 -s 0 -n 0 2>/dev/null | grep "CPU usage:" | head -1)`,
`cpupct=$(echo "$cpuline" | awk '{for(i=1;i<=NF;i++){if($(i+1)~/^idle/){v=$i;gsub(/%/,"",v);idle=v+0;found=1}};if(found)printf "%.0f",100-idle}')`,
// Memory: single vm_stat pipe → awk extracts all page counts (strip trailing dots with gsub)
// Outputs: "memfree memcached" in MB
`vmmem=$(vm_stat 2>/dev/null | awk -v ps="$pagesize" '/^Pages free:/{gsub(/[^0-9]/,"",$NF);free=$NF+0} /^Pages speculative:/{gsub(/[^0-9]/,"",$NF);spec=$NF+0} /^Pages inactive:/{gsub(/[^0-9]/,"",$NF);inact=$NF+0} /^Pages purgeable:/{gsub(/[^0-9]/,"",$NF);purg=$NF+0} END{mfree=int((free+spec)*ps/1024/1024);mcached=int((inact+purg)*ps/1024/1024);printf "%d %d",mfree,mcached}')`,
`memtotal=$(echo "$memsize" | awk '{printf "%d",$1/1024/1024}')`,
`memfree=$(echo "$vmmem" | awk '{print $1}')`,
`memcached=$(echo "$vmmem" | awk '{print $2}')`,
// Swap
`swapraw=$(sysctl vm.swapusage 2>/dev/null)`,
`swaptotal=$(echo "$swapraw" | awk '{for(i=1;i<=NF;i++){if($i=="total"&&$(i+1)=="="){v=$(i+2);m=1;if(v~/G/)m=1024;gsub(/[MmGg]/,"",v);st=v*m}};printf "%.0f",st+0}')`,
`swapused=$(echo "$swapraw" | awk '{for(i=1;i<=NF;i++){if($i=="used"&&$(i+1)=="="){v=$(i+2);m=1;if(v~/G/)m=1024;gsub(/[MmGg]/,"",v);su=v*m}};printf "%.0f",su+0}')`,
`swapfree=$(echo "$swaptotal $swapused" | awk '{printf "%.0f",$1-$2}')`,
// Top processes by memory%
`procs=$(ps -A -o pid=,%mem=,comm= 2>/dev/null | sort -k2 -rn | head -10 | awk '{gsub(/;/,"_",$3);printf "%s;%.1f;%s,",$1,$2,$3}' | sed 's/,$//')`,
// Disk: only show root "/" and external volumes "/Volumes/*", skip system APFS snapshots
`disks=$(df -k 2>/dev/null | awk 'NR>1&&index($1,"/dev/")==1&&NF>=9&&($NF=="/"||index($NF,"/Volumes/")==1){u=$3/1048576;t=$2/1048576;p=$5;gsub(/%/,"",p);printf "%s:%.0f:%.0f:%s,",$NF,u,t,p}' | sed 's/,$//')`,
// Network: Link# lines only, exclude loopback, detect column shift (no MAC addr → cols shift left)
`net=$(netstat -ib 2>/dev/null | awk '/^[a-z]/&&$3~/Link/&&$1!~/^lo/{if($4~/:/){rx=$7;tx=$10}else{rx=$6;tx=$9};if((rx+0)>0){gsub(/[*]/,"",$1);printf "%s:%s:%s,",$1,rx,tx}}' | sed 's/,$//')`,
`echo "CPU:$cpupct|CORES:$cores|MEMINFO:$memtotal $memfree 0 $memcached $swaptotal $swapfree|PROCS:$procs|DISKS:$disks|NET:$net"`,
].join('; ');
// Command to get CPU (overall + per-core), Memory, Disk, and Network stats
// This command is designed to work across most Linux distributions
// Note: Using semicolons and avoiding comments for single-line execution
// CPU: Output raw values (total and idle) instead of percentage - we calculate delta on backend
const statsCommand = [
const linuxStatsCommand = [
// Get number of CPU cores
`cores=$(nproc 2>/dev/null || grep -c "^processor" /proc/cpuinfo 2>/dev/null || echo "1")`,
// Get raw CPU values from /proc/stat: "total idle" for overall CPU
@@ -1575,6 +1912,8 @@ async function getServerStats(event, payload) {
`echo "CPURAW:$cpuraw|CORES:$cores|PERCORERAW:$percoreraw|MEMINFO:$meminfo|PROCS:$procs|DISKS:$disks|NET:$net"`
].join('; ');
// Auto-detect OS via uname — only Linux and macOS are supported
const statsCommand = `ostype=$(uname -s 2>/dev/null || echo "Unknown"); if [ "$ostype" = "Darwin" ]; then ${macosStatsCommand}; elif [ "$ostype" = "Linux" ]; then ${linuxStatsCommand}; else echo "UNSUPPORTED_OS:$ostype"; fi`;
return new Promise((resolve) => {
const timeout = setTimeout(() => {
resolve({ success: false, error: 'Timeout getting server stats' });
@@ -1603,8 +1942,16 @@ async function getServerStats(event, payload) {
// Parse the output
const output = stdout.trim();
// Unsupported OS — stop polling this session
if (output.startsWith('UNSUPPORTED_OS:')) {
resolve({ success: false, error: `Server stats not supported on this OS (${output.substring(15)})` });
return;
}
const parts = output.split('|');
let cpuDirect = null; // macOS: direct CPU percentage from top
let cpuRawTotal = null;
let cpuRawIdle = null;
let cpuPerCoreRaw = []; // Array of { total, idle }
@@ -1621,7 +1968,11 @@ async function getServerStats(event, payload) {
let networkInterfaces = []; // Array of { name, rxBytes, txBytes }
for (const part of parts) {
if (part.startsWith('CPURAW:')) {
if (part.startsWith('CPU:')) {
// macOS: top reports CPU% directly (no delta needed)
const val = parseFloat(part.substring(4).trim());
if (!isNaN(val)) cpuDirect = Math.min(100, Math.max(0, Math.round(val)));
} else if (part.startsWith('CPURAW:')) {
const rawParts = part.substring(7).trim().split(/\s+/);
if (rawParts.length >= 2) {
cpuRawTotal = parseInt(rawParts[0], 10);
@@ -1798,6 +2149,11 @@ async function getServerStats(event, payload) {
}
}
// macOS: use direct percentage from top (no delta needed)
if (cpu === null && cpuDirect !== null) {
cpu = cpuDirect;
}
// Calculate per-core CPU usage from deltas
if (cpuPerCoreRaw.length > 0 && prevCpu.perCore.length > 0) {
cpuPerCore = cpuPerCoreRaw.map((core, index) => {
@@ -1833,6 +2189,12 @@ async function getServerStats(event, payload) {
const diskUsed = rootDisk ? rootDisk.used : null;
const diskTotal = rootDisk ? rootDisk.total : null;
// If no meaningful data was parsed, treat as failure to stop futile polling
if (cpu === null && memTotal === null && cpuCores === null) {
resolve({ success: false, error: 'Unable to parse server stats (unsupported OS or shell)' });
return;
}
resolve({
success: true,
stats: {
@@ -1895,14 +2257,17 @@ function registerHandlers(ipcMain) {
ipcMain.handle("netcatty:ssh:get-default-keys", async () => {
const sshDir = path.join(os.homedir(), ".ssh");
const keys = [];
for (const name of DEFAULT_KEY_NAMES) {
const keyPath = path.join(sshDir, name);
try {
await fs.promises.access(keyPath, fs.constants.F_OK);
keys.push({ name, path: keyPath });
} catch {
// ignore missing keys
try {
const entries = await fs.promises.readdir(sshDir);
const names = entries.filter(f => SSH_KEY_PATTERN.test(f));
// Preferred first, then rest alphabetically
const preferred = PREFERRED_KEY_NAMES.filter(n => names.includes(n));
const rest = names.filter(n => !PREFERRED_KEY_NAMES.includes(n)).sort();
for (const name of [...preferred, ...rest]) {
keys.push({ name, path: path.join(sshDir, name) });
}
} catch {
// ~/.ssh doesn't exist
}
return keys;
});
@@ -1915,6 +2280,7 @@ function registerHandlers(ipcMain) {
module.exports = {
init,
registerHandlers,
connectThroughChain,
createProxySocket,
startSSHSession,
execCommand,

View File

@@ -13,6 +13,7 @@ const { SerialPort } = require("serialport");
const sessionLogStreamManager = require("./sessionLogStreamManager.cjs");
const { detectShellKind } = require("./ai/ptyExec.cjs");
const { trackSessionIdlePrompt } = require("./ai/shellUtils.cjs");
// Shared references
let sessions = null;
@@ -52,6 +53,51 @@ function init(deps) {
electronModule = deps.electronModule;
}
/**
* Create an 8ms/16KB PTY data buffer for reduced IPC overhead.
* Mirrors the SSH stream buffering strategy in sshBridge.cjs.
* @param {Function} sendFn - called with the accumulated string to deliver
* @returns {{ bufferData: (data: string) => void, flush: () => void }}
*/
function createPtyBuffer(sendFn) {
const FLUSH_INTERVAL = 8; // ms - flush every 8ms (~120fps equivalent)
const MAX_BUFFER_SIZE = 16384; // 16KB - flush immediately if buffer grows too large
let dataBuffer = '';
let flushTimeout = null;
const flushBuffer = () => {
if (dataBuffer.length > 0) {
sendFn(dataBuffer);
dataBuffer = '';
}
flushTimeout = null;
};
const flush = () => {
if (flushTimeout) {
clearTimeout(flushTimeout);
flushTimeout = null;
}
flushBuffer();
};
const bufferData = (data) => {
dataBuffer += data;
if (dataBuffer.length >= MAX_BUFFER_SIZE) {
if (flushTimeout) {
clearTimeout(flushTimeout);
flushTimeout = null;
}
flushBuffer();
} else if (!flushTimeout) {
flushTimeout = setTimeout(flushBuffer, FLUSH_INTERVAL);
}
};
return { bufferData, flush };
}
/**
* Find executable path on Windows
*/
@@ -245,6 +291,10 @@ function startLocalSession(event, payload) {
label: "Local Terminal",
shellExecutable: shell,
shellKind,
flushPendingData: null,
lastIdlePrompt: "",
lastIdlePromptAt: 0,
_promptTrackTail: "",
};
sessions.set(sessionId, session);
@@ -259,13 +309,20 @@ function startLocalSession(event, payload) {
});
}
proc.onData((data) => {
const { bufferData: bufferLocalData, flush: flushLocal } = createPtyBuffer((data) => {
const contents = electronModule.webContents.fromId(session.webContentsId);
contents?.send("netcatty:data", { sessionId, data });
});
session.flushPendingData = flushLocal;
proc.onData((data) => {
trackSessionIdlePrompt(session, data);
bufferLocalData(data);
sessionLogStreamManager.appendData(sessionId, data);
});
proc.onExit((evt) => {
flushLocal();
sessionLogStreamManager.stopStream(sessionId);
sessions.delete(sessionId);
const contents = electronModule.webContents.fromId(session.webContentsId);
@@ -434,7 +491,12 @@ async function startTelnetSession(event, options) {
webContentsId: event.sender.id,
cols,
rows,
flushPendingData: null,
lastIdlePrompt: "",
lastIdlePromptAt: 0,
_promptTrackTail: "",
};
session.flushPendingData = flushTelnet;
sessions.set(sessionId, session);
// Start real-time session log stream if configured
@@ -463,6 +525,12 @@ async function startTelnetSession(event, options) {
const telnetDecoder = new StringDecoder(charsetToNodeEncoding(options.charset));
const telnetWebContentsId = event.sender.id;
const { bufferData: bufferTelnetData, flush: flushTelnet } = createPtyBuffer((data) => {
const contents = electronModule.webContents.fromId(telnetWebContentsId);
contents?.send("netcatty:data", { sessionId, data });
});
socket.on('data', (data) => {
const session = sessions.get(sessionId);
if (!session) return;
@@ -472,8 +540,8 @@ async function startTelnetSession(event, options) {
if (cleanData.length > 0) {
const decoded = telnetDecoder.write(cleanData);
if (decoded) {
const contents = electronModule.webContents.fromId(session.webContentsId);
contents?.send("netcatty:data", { sessionId, data: decoded });
trackSessionIdlePrompt(session, decoded);
bufferTelnetData(decoded);
sessionLogStreamManager.appendData(sessionId, decoded);
}
}
@@ -486,6 +554,7 @@ async function startTelnetSession(event, options) {
if (!connected) {
reject(new Error(`Failed to connect: ${err.message}`));
} else {
flushTelnet();
sessionLogStreamManager.stopStream(sessionId);
const session = sessions.get(sessionId);
if (session) {
@@ -500,6 +569,7 @@ async function startTelnetSession(event, options) {
console.log(`[Telnet] Connection closed${hadError ? ' with error' : ''}`);
clearTimeout(connectTimeout);
flushTelnet();
sessionLogStreamManager.stopStream(sessionId);
const session = sessions.get(sessionId);
if (session) {
@@ -584,6 +654,10 @@ async function startMoshSession(event, options) {
label: options.label || options.hostname || 'Mosh Session',
shellKind: 'posix',
shellExecutable: 'remote-shell',
flushPendingData: null,
lastIdlePrompt: "",
lastIdlePromptAt: 0,
_promptTrackTail: "",
};
sessions.set(sessionId, session);
@@ -598,13 +672,20 @@ async function startMoshSession(event, options) {
});
}
proc.onData((data) => {
const { bufferData: bufferMoshData, flush: flushMosh } = createPtyBuffer((data) => {
const contents = electronModule.webContents.fromId(session.webContentsId);
contents?.send("netcatty:data", { sessionId, data });
});
session.flushPendingData = flushMosh;
proc.onData((data) => {
trackSessionIdlePrompt(session, data);
bufferMoshData(data);
sessionLogStreamManager.appendData(sessionId, data);
});
proc.onExit((evt) => {
flushMosh();
sessionLogStreamManager.stopStream(sessionId);
sessions.delete(sessionId);
const contents = electronModule.webContents.fromId(session.webContentsId);
@@ -798,6 +879,7 @@ function closeSession(event, payload) {
if (!session) return;
try {
session.flushPendingData?.();
if (session.stream) {
session.stream.close();
session.conn?.end();

View File

@@ -5,7 +5,12 @@
const path = require("node:path");
const fs = require("node:fs");
const globalShortcutBridge = require("./globalShortcutBridge.cjs");
const V8_CACHE_OPTIONS = "bypassHeatCheck";
function getGlobalShortcutBridge() {
return require("./globalShortcutBridge.cjs");
}
// Theme colors configuration
const THEME_COLORS = {
@@ -443,6 +448,7 @@ function createAppWindowOpenHandler(shell, { backgroundColor, appIcon }) {
nodeIntegration: false,
// Sandboxed because this window renders remote content and does not need a preload bridge.
sandbox: true,
v8CacheOptions: V8_CACHE_OPTIONS,
},
},
};
@@ -687,11 +693,24 @@ async function createWindow(electronModule, options) {
contextIsolation: true,
nodeIntegration: false,
sandbox: false,
v8CacheOptions: V8_CACHE_OPTIONS,
},
});
mainWindow = win;
// Log renderer crashes for diagnostics (skip normal clean exits)
win.webContents.on("render-process-gone", (_event, details) => {
if (details?.reason === "clean-exit") return;
try {
const crashLogBridge = require("./crashLogBridge.cjs");
crashLogBridge.captureError("render-process-gone", new Error(
`Renderer process gone: reason=${details?.reason}, exitCode=${details?.exitCode}`
), { reason: details?.reason, exitCode: details?.exitCode });
} catch {}
console.error("[WindowManager] Renderer process gone:", details);
});
// Prevent top-level navigation away from the app origin. If a remote origin ever
// loads in a privileged window (with preload), it can become an RCE vector.
const allowedOrigins = new Set(["app://netcatty"]);
@@ -770,12 +789,12 @@ async function createWindow(electronModule, options) {
// Save state when window is about to close
win.on("close", (event) => {
// Check if close-to-tray is enabled
if (!isQuitting && globalShortcutBridge.handleWindowClose(event, win)) {
if (!isQuitting && getGlobalShortcutBridge().handleWindowClose(event, win)) {
// Window was hidden to tray - save state before returning
if (saveStateTimer) clearTimeout(saveStateTimer);
const state = getWindowBoundsState(win, lastNormalBounds);
if (state) saveWindowStateSync(state);
closeSettingsWindow();
hideSettingsWindow();
return;
}
@@ -891,12 +910,13 @@ async function createWindow(electronModule, options) {
/**
* Create or focus the settings window
*/
async function openSettingsWindow(electronModule, options) {
async function openSettingsWindow(electronModule, options, { showOnLoad = true } = {}) {
const { BrowserWindow, shell } = electronModule;
const { preload, devServerUrl, isDev, appIcon, isMac, electronDir } = options;
// If settings window already exists, just focus it
// If settings window already exists, show and focus it
if (settingsWindow && !settingsWindow.isDestroyed()) {
settingsWindow.show();
settingsWindow.focus();
return settingsWindow;
}
@@ -943,6 +963,7 @@ async function openSettingsWindow(electronModule, options) {
contextIsolation: true,
nodeIntegration: false,
sandbox: false,
v8CacheOptions: V8_CACHE_OPTIONS,
},
});
@@ -1027,10 +1048,20 @@ async function openSettingsWindow(electronModule, options) {
// ignore
}
// Defer show until renderer is ready; use fallback timeout to avoid keeping window hidden forever.
setupDeferredShow(win, { timeoutMs: isDev ? 1200 : 600, waitForRendererReady: false });
// Hide instead of close so the window can be reused instantly.
// When the app is quitting, allow normal close/destroy.
win.on('close', (event) => {
if (!isQuitting) {
event.preventDefault();
try {
win.hide();
} catch {
// ignore
}
}
});
// Clean up reference when closed
// Clean up reference when actually destroyed
win.on('closed', () => {
settingsWindow = null;
});
@@ -1042,6 +1073,7 @@ async function openSettingsWindow(electronModule, options) {
try {
const baseUrl = getDevRendererBaseUrl(devServerUrl);
await win.loadURL(`${baseUrl}${settingsPath}`);
if (showOnLoad) { win.show(); win.focus(); }
return win;
} catch (e) {
console.warn("Dev server not reachable for settings window", e);
@@ -1050,20 +1082,51 @@ async function openSettingsWindow(electronModule, options) {
// Production mode - load via custom protocol.
await win.loadURL("app://netcatty/index.html#/settings");
if (showOnLoad) { win.show(); win.focus(); }
return win;
}
/**
* Close the settings window
* Destroy the settings window (used when the app is quitting).
*/
function closeSettingsWindow() {
if (settingsWindow && !settingsWindow.isDestroyed()) {
settingsWindow.close();
try {
settingsWindow.destroy();
} catch {
// ignore
}
settingsWindow = null;
}
}
/**
* Hide the settings window without destroying it (used when main window hides to tray).
*/
function hideSettingsWindow() {
if (settingsWindow && !settingsWindow.isDestroyed()) {
try {
settingsWindow.hide();
} catch {
// ignore
}
}
}
/**
* Pre-warm the settings window in the background so that opening it later is instant.
* The window is created hidden and fully loaded; `openSettingsWindow` will simply show it.
*/
async function prewarmSettingsWindow(electronModule, options) {
if (settingsWindow && !settingsWindow.isDestroyed()) return;
try {
await openSettingsWindow(electronModule, options, { showOnLoad: false });
} catch (err) {
debugLog("Failed to pre-warm settings window", { error: String(err) });
}
}
/**
* Register window control IPC handlers (only once)
*/
@@ -1164,13 +1227,13 @@ function registerWindowHandlers(ipcMain, nativeTheme) {
// Settings window close handler
ipcMain.handle("netcatty:settings:close", (event) => {
// Prefer closing the tracked settings window (if any).
// Prefer hiding the tracked settings window (reused on next open).
if (settingsWindow && !settingsWindow.isDestroyed()) {
debugLog("settings:close (tracked)", {
senderId: event?.sender?.id,
settingsId: settingsWindow.webContents?.id,
});
closeSettingsWindow();
hideSettingsWindow();
return true;
}
@@ -1300,6 +1363,7 @@ module.exports = {
createWindow,
openSettingsWindow,
closeSettingsWindow,
prewarmSettingsWindow,
buildAppMenu,
getMainWindow,
getSettingsWindow,

View File

@@ -18,16 +18,82 @@ if (process.env.ELECTRON_RUN_AS_NODE) {
delete process.env.ELECTRON_RUN_AS_NODE;
}
// Handle uncaught exceptions for EPIPE errors
// Load crash log bridge early so process-level error handlers can use it
const crashLogBridge = require("./bridges/crashLogBridge.cjs");
// SSH / network errors that must never crash the process.
// ssh2 can emit multiple 'error' events per connection (e.g. ECONNRESET followed
// by "Connection lost before handshake"). If a listener is consumed after the first
// event, the second becomes an uncaught exception. These are non-fatal for the app.
function isNonFatalNetworkError(err) {
if (!err) return false;
// Any error with an ssh2 `level` property is a connection/auth-level error,
// never a reason to kill the entire multi-session app.
if (err.level) return true;
const code = err.code;
// Common TCP/DNS/routing errors that can surface from Node.js sockets
// without an ssh2 `level` (e.g. proxy sockets, raw net.connect calls).
switch (code) {
case 'ECONNRESET':
case 'ECONNREFUSED':
case 'ECONNABORTED':
case 'ETIMEDOUT':
case 'ENOTFOUND':
case 'EHOSTUNREACH':
case 'EHOSTDOWN':
case 'ENETUNREACH':
case 'ENETDOWN':
case 'EADDRNOTAVAIL':
case 'EPROTO':
case 'EPERM':
return true;
default:
return false;
}
}
// Handle uncaught exceptions — log all, only re-throw truly fatal ones
process.on('uncaughtException', (err) => {
// Skip benign stream teardown errors — don't pollute crash logs with false positives
if (err.code === 'EPIPE' || err.code === 'ERR_STREAM_DESTROYED') {
console.warn('Ignored stream error:', err.code);
return;
}
// Non-fatal SSH/network errors: log but do NOT crash the process
if (isNonFatalNetworkError(err)) {
if (!err.__fromUnhandledRejection) {
try { crashLogBridge.captureError('uncaughtException', err); } catch {}
}
console.warn('Non-fatal uncaught exception (suppressed):', err.message);
return;
}
// Skip logging if already captured by unhandledRejection handler
if (!err.__fromUnhandledRejection) {
try { crashLogBridge.captureError('uncaughtException', err); } catch {}
}
console.error('Uncaught exception:', err);
throw err;
});
process.on('unhandledRejection', (reason) => {
// Skip benign stream teardown errors
const code = reason?.code;
if (code === 'EPIPE' || code === 'ERR_STREAM_DESTROYED') return;
// Non-fatal SSH/network errors: log but do NOT re-throw
if (isNonFatalNetworkError(reason)) {
try { crashLogBridge.captureError('unhandledRejection', reason); } catch {}
console.warn('Non-fatal unhandled rejection (suppressed):', reason?.message || reason);
return;
}
try { crashLogBridge.captureError('unhandledRejection', reason); } catch {}
console.error('Unhandled rejection:', reason);
// Re-throw to preserve fatal semantics. Mark so uncaughtException handler
// can skip duplicate logging.
const err = reason instanceof Error ? reason : new Error(String(reason));
err.__fromUnhandledRejection = true;
throw err;
});
// Load Electron
let electronModule;
try {
@@ -64,6 +130,16 @@ try {
// Apply ssh2 protocol patch needed for OpenSSH sk-* signature layouts.
function createLazyModule(modulePath) {
let cachedModule = null;
return () => {
if (!cachedModule) {
cachedModule = require(modulePath);
}
return cachedModule;
};
}
// Import bridge modules
const sshBridge = require("./bridges/sshBridge.cjs");
const sftpBridge = require("./bridges/sftpBridge.cjs");
@@ -71,21 +147,22 @@ const localFsBridge = require("./bridges/localFsBridge.cjs");
const transferBridge = require("./bridges/transferBridge.cjs");
const portForwardingBridge = require("./bridges/portForwardingBridge.cjs");
const terminalBridge = require("./bridges/terminalBridge.cjs");
const oauthBridge = require("./bridges/oauthBridge.cjs");
const githubAuthBridge = require("./bridges/githubAuthBridge.cjs");
const googleAuthBridge = require("./bridges/googleAuthBridge.cjs");
const onedriveAuthBridge = require("./bridges/onedriveAuthBridge.cjs");
const cloudSyncBridge = require("./bridges/cloudSyncBridge.cjs");
const fileWatcherBridge = require("./bridges/fileWatcherBridge.cjs");
const tempDirBridge = require("./bridges/tempDirBridge.cjs");
const sessionLogsBridge = require("./bridges/sessionLogsBridge.cjs");
const sessionLogStreamManager = require("./bridges/sessionLogStreamManager.cjs");
const compressUploadBridge = require("./bridges/compressUploadBridge.cjs");
const globalShortcutBridge = require("./bridges/globalShortcutBridge.cjs");
const credentialBridge = require("./bridges/credentialBridge.cjs");
const autoUpdateBridge = require("./bridges/autoUpdateBridge.cjs");
const aiBridge = require("./bridges/aiBridge.cjs");
const windowManager = require("./bridges/windowManager.cjs");
// crashLogBridge is required at the top of the file (before error handlers)
const getOauthBridge = createLazyModule("./bridges/oauthBridge.cjs");
const getGithubAuthBridge = createLazyModule("./bridges/githubAuthBridge.cjs");
const getGoogleAuthBridge = createLazyModule("./bridges/googleAuthBridge.cjs");
const getOnedriveAuthBridge = createLazyModule("./bridges/onedriveAuthBridge.cjs");
const getCloudSyncBridge = createLazyModule("./bridges/cloudSyncBridge.cjs");
const getFileWatcherBridge = createLazyModule("./bridges/fileWatcherBridge.cjs");
const getTempDirBridge = createLazyModule("./bridges/tempDirBridge.cjs");
const getSessionLogsBridge = createLazyModule("./bridges/sessionLogsBridge.cjs");
const getCompressUploadBridge = createLazyModule("./bridges/compressUploadBridge.cjs");
const getGlobalShortcutBridge = createLazyModule("./bridges/globalShortcutBridge.cjs");
const getCredentialBridge = createLazyModule("./bridges/credentialBridge.cjs");
const getAutoUpdateBridge = createLazyModule("./bridges/autoUpdateBridge.cjs");
const getAiBridge = createLazyModule("./bridges/aiBridge.cjs");
const getWindowManager = createLazyModule("./bridges/windowManager.cjs");
// GPU settings
// NOTE: Do not disable Chromium sandbox by default.
@@ -317,6 +394,19 @@ const registerBridges = (win) => {
const { ipcMain } = electronModule;
const { safeStorage } = electronModule;
const oauthBridge = getOauthBridge();
const githubAuthBridge = getGithubAuthBridge();
const googleAuthBridge = getGoogleAuthBridge();
const onedriveAuthBridge = getOnedriveAuthBridge();
const cloudSyncBridge = getCloudSyncBridge();
const fileWatcherBridge = getFileWatcherBridge();
const tempDirBridge = getTempDirBridge();
const sessionLogsBridge = getSessionLogsBridge();
const compressUploadBridge = getCompressUploadBridge();
const globalShortcutBridge = getGlobalShortcutBridge();
const credentialBridge = getCredentialBridge();
const autoUpdateBridge = getAutoUpdateBridge();
const aiBridge = getAiBridge();
const getCloudSyncPasswordPath = () => {
try {
@@ -381,6 +471,7 @@ const registerBridges = (win) => {
fileWatcherBridge.init(deps);
globalShortcutBridge.init(deps);
aiBridge.init(deps);
crashLogBridge.init(deps);
// Initialize compress upload bridge with transferBridge dependency
compressUploadBridge.init({
@@ -412,11 +503,12 @@ const registerBridges = (win) => {
autoUpdateBridge.init(deps);
autoUpdateBridge.registerHandlers(ipcMain);
aiBridge.registerHandlers(ipcMain);
crashLogBridge.registerHandlers(ipcMain);
// Settings window handler
ipcMain.handle("netcatty:settings:open", async () => {
try {
await windowManager.openSettingsWindow(electronModule, {
await getWindowManager().openSettingsWindow(electronModule, {
preload,
devServerUrl: effectiveDevServerUrl,
isDev,
@@ -606,6 +698,24 @@ const registerBridges = (win) => {
return result.filePath;
});
// Select a file and return the selected path
ipcMain.handle("netcatty:selectFile", async (_event, { title, defaultPath, filters }) => {
const { dialog } = electronModule;
const result = await dialog.showOpenDialog({
title: title || "Select File",
defaultPath: defaultPath || os.homedir(),
filters: filters || [{ name: "All Files", extensions: ["*"] }],
properties: ["openFile", "showHiddenFiles"],
});
if (result.canceled || !result.filePaths.length) {
return null;
}
return result.filePaths[0];
});
// Select a directory and return the selected path
ipcMain.handle("netcatty:selectDirectory", async (_event, { title, defaultPath }) => {
const { dialog } = electronModule;
@@ -632,7 +742,7 @@ const registerBridges = (win) => {
const client = require("./bridges/sftpBridge.cjs");
// Use tempDirBridge for dedicated Netcatty temp directory
const localPath = await tempDirBridge.getTempFilePath(fileName);
const localPath = await getTempDirBridge().getTempFilePath(fileName);
console.log(`[Main] Local temp path: ${localPath}`);
@@ -671,7 +781,7 @@ const registerBridges = (win) => {
// 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 localPath = await getTempDirBridge().getTempFilePath(fileName);
const cleanupPartialDownload = async () => {
try {
await fs.promises.rm(localPath, { force: true });
@@ -712,7 +822,7 @@ const registerBridges = (win) => {
ipcMain.handle("netcatty:deleteTempFile", async (_event, { filePath }) => {
try {
// Only allow deleting files in Netcatty temp directory for security
const netcattyTempDir = path.resolve(tempDirBridge.getTempDir());
const netcattyTempDir = path.resolve(getTempDirBridge().getTempDir());
const resolvedPath = path.resolve(String(filePath || ""));
if (!isPathInside(netcattyTempDir, resolvedPath)) {
console.warn(`[Main] Refused to delete file outside Netcatty temp dir: ${filePath}`);
@@ -736,7 +846,7 @@ const registerBridges = (win) => {
* Create the main application window
*/
async function createWindow() {
const win = await windowManager.createWindow(electronModule, {
const win = await getWindowManager().createWindow(electronModule, {
preload,
devServerUrl: effectiveDevServerUrl,
isDev,
@@ -795,11 +905,12 @@ if (!gotLock) {
}
// Build and set application menu
const menu = windowManager.buildAppMenu(Menu, app, isMac);
const menu = getWindowManager().buildAppMenu(Menu, app, isMac);
Menu.setApplicationMenu(menu);
app.on("browser-window-created", (_event, win) => {
try {
const windowManager = getWindowManager();
const mainWin = windowManager.getMainWindow();
const settingsWin = windowManager.getSettingsWindow();
const isPrimary = win === mainWin || win === settingsWin;
@@ -818,7 +929,20 @@ if (!gotLock) {
void createWindow().then(() => {
// Trigger auto-update check 5 s after window creation.
// startAutoCheck() is a no-op on unsupported platforms (Linux deb/rpm/snap).
autoUpdateBridge.startAutoCheck(5000);
getAutoUpdateBridge().startAutoCheck(5000);
// Pre-warm the settings window in the background so it opens instantly.
// Delay slightly to avoid competing with main window first-paint resources.
setTimeout(() => {
getWindowManager().prewarmSettingsWindow(electronModule, {
preload,
devServerUrl: effectiveDevServerUrl,
isDev,
appIcon,
isMac,
electronDir,
});
}, 3000);
}).catch((err) => {
console.error("[Main] Failed to create main window:", err);
showStartupError(err);
@@ -832,7 +956,7 @@ if (!gotLock) {
// If the main window was hidden (e.g. "close to tray"), clicking the Dock icon
// should bring it back. Fallback to creating a new window if none exists.
try {
const mainWin = windowManager.getMainWindow?.();
const mainWin = getWindowManager().getMainWindow?.();
if (mainWin && !mainWin.isDestroyed?.()) {
if (mainWin.isMinimized?.()) mainWin.restore();
mainWin.show?.();
@@ -862,7 +986,7 @@ if (!gotLock) {
});
app.on("before-quit", () => {
windowManager.setIsQuitting(true);
getWindowManager().setIsQuitting(true);
});
// Cleanup all PTY sessions and port forwarding tunnels before quitting
@@ -883,12 +1007,12 @@ if (!gotLock) {
console.warn("Error during port forwarding cleanup:", err);
}
try {
globalShortcutBridge.cleanup();
getGlobalShortcutBridge().cleanup();
} catch (err) {
console.warn("Error during global shortcut cleanup:", err);
}
try {
aiBridge.cleanup();
getAiBridge().cleanup();
} catch (err) {
console.warn("Error during AI bridge cleanup:", err);
}

View File

@@ -8,6 +8,7 @@ const transferCompleteListeners = new Map();
const transferErrorListeners = new Map();
const transferCancelledListeners = new Map();
const chainProgressListeners = new Map();
const sftpConnectionProgressListeners = new Set();
const authFailedListeners = new Map();
const languageChangeListeners = new Set();
const fullscreenChangeListeners = new Set();
@@ -34,6 +35,7 @@ function cleanupTransferListeners(transferId) {
// chunk, then filter complete lines that contain the marker.
const _mcpLineBufs = new Map(); // sessionId -> trailing fragment string
const _mcpFlushTimers = new Map(); // sessionId -> delayed-flush timer
// Returns true if `s` ends with a non-empty prefix of "__NCMCP_"
// (i.e. the next chunk might complete it into a marker-containing line).
@@ -46,6 +48,13 @@ function _endsWithMarkerPrefix(s) {
}
function filterMcpChunk(sessionId, chunk) {
// Cancel any pending delayed flush — new data arrived
const pendingTimer = _mcpFlushTimers.get(sessionId);
if (pendingTimer) {
clearTimeout(pendingTimer);
_mcpFlushTimers.delete(sessionId);
}
// Prepend any buffered fragment from the previous chunk
const held = _mcpLineBufs.get(sessionId) || "";
const data = held + chunk;
@@ -58,14 +67,18 @@ function filterMcpChunk(sessionId, chunk) {
// Slow path: scan line by line
let result = "";
let droppedAny = false;
let pos = 0;
while (pos < data.length) {
const nlIdx = data.indexOf("\n", pos);
if (nlIdx === -1) {
// Incomplete trailing line — no newline yet
// Incomplete trailing line — no newline yet.
// If we dropped any marker line in this chunk, or the tail itself
// looks like it could contain a marker, buffer it. Long command
// echoes can wrap across PTY lines; wrapped fragments that don't
// contain __NCMCP_ would otherwise leak through as garbage.
const tail = data.slice(pos);
if (tail.includes("__NCMCP_") || _endsWithMarkerPrefix(tail)) {
// Hold it; next chunk might complete or confirm the marker
if (droppedAny || tail.includes("__NCMCP_") || _endsWithMarkerPrefix(tail)) {
_mcpLineBufs.set(sessionId, tail);
} else {
result += tail; // safe to display immediately
@@ -75,34 +88,52 @@ function filterMcpChunk(sessionId, chunk) {
const line = data.slice(pos, nlIdx + 1); // includes the \n
if (!line.includes("__NCMCP_")) {
result += line;
} else {
droppedAny = true;
}
// else: drop it — it's a wrapper marker line (or echo of one)
pos = nlIdx + 1;
}
// Also strip Posix pager prefix and Fish env lines that have no __NCMCP_
if (result) {
result = result
.replace(/PAGER=cat SYSTEMD_PAGER= GIT_PAGER=cat LESS= /g, "")
.replace(/^set -gx (?:PAGER|SYSTEMD_PAGER|GIT_PAGER|LESS) [^\r\n]*[\r\n]*/gm, "")
.replace(/^set "(?:PAGER|SYSTEMD_PAGER|GIT_PAGER|LESS)=[^"]*"[\r\n]*/gm, "");
}
return result;
}
/**
* Deliver data to session listeners. Used both by the normal data path
* and by the delayed-flush timer.
*/
function _deliverToListeners(sessionId, data) {
const set = dataListeners.get(sessionId);
if (!set || !data) return;
set.forEach((cb) => {
try { cb(data); } catch (err) { console.error("Data callback failed", err); }
});
}
ipcRenderer.on("netcatty:data", (_event, payload) => {
const set = dataListeners.get(payload.sessionId);
if (!set) return;
const data = filterMcpChunk(payload.sessionId, payload.data);
if (!data) return;
set.forEach((cb) => {
try {
cb(data);
} catch (err) {
console.error("Data callback failed", err);
}
});
if (data) {
set.forEach((cb) => {
try {
cb(data);
} catch (err) {
console.error("Data callback failed", err);
}
});
}
// If there is buffered content waiting for more data (e.g. a prompt
// right after a dropped marker line), schedule a delayed flush so it
// appears after a short pause instead of staying hidden forever.
if (_mcpLineBufs.has(payload.sessionId)) {
const sid = payload.sessionId;
_mcpFlushTimers.set(sid, setTimeout(() => {
const held = _mcpLineBufs.get(sid);
_mcpLineBufs.delete(sid);
_mcpFlushTimers.delete(sid);
if (held) _deliverToListeners(sid, held);
}, 80));
}
});
ipcRenderer.on("netcatty:exit", (_event, payload) => {
@@ -118,22 +149,38 @@ ipcRenderer.on("netcatty:exit", (_event, payload) => {
}
dataListeners.delete(payload.sessionId);
exitListeners.delete(payload.sessionId);
const pendingTimer = _mcpFlushTimers.get(payload.sessionId);
if (pendingTimer) {
clearTimeout(pendingTimer);
_mcpFlushTimers.delete(payload.sessionId);
}
_mcpLineBufs.delete(payload.sessionId); // clean up any held fragment
});
// Chain progress events (for jump host connections)
ipcRenderer.on("netcatty:chain:progress", (_event, payload) => {
const { hop, total, label, status } = payload;
const { sessionId, hop, total, label, status, error } = payload;
// Notify all registered chain progress listeners
chainProgressListeners.forEach((cb) => {
try {
cb(hop, total, label, status);
cb(sessionId, hop, total, label, status, error);
} catch (err) {
console.error("Chain progress callback failed", err);
}
});
});
// SFTP connection progress events (auth method logs)
ipcRenderer.on("netcatty:sftp:connection-progress", (_event, payload) => {
sftpConnectionProgressListeners.forEach((cb) => {
try {
cb(payload.sessionId, payload.label, payload.status, payload.detail);
} catch (err) {
console.error("SFTP connection progress callback failed", err);
}
});
});
ipcRenderer.on("netcatty:languageChanged", (_event, language) => {
languageChangeListeners.forEach((cb) => {
try {
@@ -605,6 +652,9 @@ const api = {
chmodSftp: async (sftpId, path, mode, encoding) => {
return ipcRenderer.invoke("netcatty:sftp:chmod", { sftpId, path, mode, encoding });
},
getSftpHomeDir: async (sftpId) => {
return ipcRenderer.invoke("netcatty:sftp:homeDir", { sftpId });
},
// Write binary with real-time progress callback
writeSftpBinaryWithProgress: async (sftpId, path, content, transferId, encoding, onProgress, onComplete, onError) => {
// Register callbacks
@@ -805,6 +855,13 @@ const api = {
chainProgressListeners.delete(id);
};
},
// SFTP connection progress listener (auth method logs)
onSftpConnectionProgress: (cb) => {
sftpConnectionProgressListeners.add(cb);
return () => {
sftpConnectionProgressListeners.delete(cb);
};
},
// OAuth callback server
startOAuthCallback: (expectedState) => ipcRenderer.invoke("oauth:startCallback", expectedState),
@@ -875,6 +932,8 @@ const api = {
ipcRenderer.invoke("netcatty:showSaveDialog", { defaultPath, filters }),
selectDirectory: (title, defaultPath) =>
ipcRenderer.invoke("netcatty:selectDirectory", { title, defaultPath }),
selectFile: (title, defaultPath, filters) =>
ipcRenderer.invoke("netcatty:selectFile", { title, defaultPath, filters }),
// File watcher for auto-sync feature
startFileWatch: (localPath, remotePath, sftpId, encoding) =>
@@ -918,6 +977,16 @@ const api = {
openSessionLogsDir: (directory) =>
ipcRenderer.invoke("netcatty:sessionLogs:openDir", { directory }),
// Crash Logs
getCrashLogs: () =>
ipcRenderer.invoke("netcatty:crashLogs:list"),
readCrashLog: (fileName) =>
ipcRenderer.invoke("netcatty:crashLogs:read", { fileName }),
clearCrashLogs: () =>
ipcRenderer.invoke("netcatty:crashLogs:clear"),
openCrashLogsDir: () =>
ipcRenderer.invoke("netcatty:crashLogs:openDir"),
// Global Toggle Hotkey (Quake Mode)
registerGlobalHotkey: (hotkey) =>
ipcRenderer.invoke("netcatty:globalHotkey:register", { hotkey }),
@@ -1057,8 +1126,11 @@ const api = {
aiAllowlistAddHost: async (baseURL) => {
return ipcRenderer.invoke("netcatty:ai:allowlist:add-host", { baseURL });
},
aiExec: async (sessionId, command) => {
return ipcRenderer.invoke("netcatty:ai:exec", { sessionId, command });
aiExec: async (sessionId, command, chatSessionId) => {
return ipcRenderer.invoke("netcatty:ai:exec", { sessionId, command, chatSessionId });
},
aiCattyCancelExec: async (chatSessionId) => {
return ipcRenderer.invoke("netcatty:ai:catty:cancel", { chatSessionId });
},
aiTerminalWrite: async (sessionId, data) => {
return ipcRenderer.invoke("netcatty:ai:terminal:write", { sessionId, data });

46
global.d.ts vendored
View File

@@ -38,6 +38,8 @@ declare global {
keyId?: string;
keySource?: 'generated' | 'imported';
label?: string; // Display label for UI
proxy?: NetcattyProxyConfig;
identityFilePaths?: string[];
}
// Host key information for verification
@@ -84,6 +86,8 @@ declare global {
sudo?: boolean;
// Session log configuration for real-time streaming
sessionLog?: { enabled: boolean; directory: string; format: string };
// Local SSH key file paths (from SSH config IdentityFile)
identityFilePaths?: string[];
}
interface SftpStatResult {
@@ -117,12 +121,18 @@ declare global {
username: string;
password?: string;
privateKey?: string;
certificate?: string;
keyId?: string;
passphrase?: string;
proxy?: NetcattyProxyConfig;
jumpHosts?: NetcattyJumpHost[];
identityFilePaths?: string[];
}
interface PortForwardResult {
tunnelId: string;
success: boolean;
cancelled?: boolean;
error?: string;
}
@@ -208,7 +218,7 @@ declare global {
}): Promise<{ stdout: string; stderr: string; code: number | null }>;
/** Get current working directory from an active SSH session */
getSessionPwd?(sessionId: string): Promise<{ success: boolean; cwd?: string; error?: string }>;
/** Get server stats (CPU, Memory, Disk, Network) from an active SSH session - Linux only */
/** Get server stats (CPU, Memory, Disk, Network) from an active SSH session */
getServerStats?(sessionId: string): Promise<{
success: boolean;
error?: string;
@@ -314,6 +324,7 @@ declare global {
renameSftp?(sftpId: string, oldPath: string, newPath: string, encoding?: SftpFilenameEncoding): Promise<void>;
statSftp?(sftpId: string, path: string, encoding?: SftpFilenameEncoding): Promise<SftpStatResult>;
chmodSftp?(sftpId: string, path: string, mode: string, encoding?: SftpFilenameEncoding): Promise<void>;
getSftpHomeDir?(sftpId: string): Promise<{ success: boolean; homeDir?: string; error?: string }>;
// Write binary with real-time progress callback
writeSftpBinaryWithProgress?(
@@ -461,8 +472,11 @@ declare global {
onLanguageChanged?(cb: (language: string) => void): () => void;
// Chain progress listener for jump host connections
// Callback receives: (currentHop: number, totalHops: number, hostLabel: string, status: string)
onChainProgress?(cb: (hop: number, total: number, label: string, status: string) => void): () => void;
// Callback receives: (sessionId: string, currentHop: number, totalHops: number, hostLabel: string, status: string, error?: string)
onChainProgress?(cb: (sessionId: string, hop: number, total: number, label: string, status: string, error?: string) => void): () => void;
// SFTP connection progress listener (auth method logs)
onSftpConnectionProgress?(cb: (sessionId: string, label: string, status: string, detail?: string) => void): () => void;
// OAuth callback server for cloud sync
startOAuthCallback?(expectedState?: string): Promise<{ code: string; state?: string }>;
@@ -578,6 +592,7 @@ declare global {
// Save dialog for file downloads
showSaveDialog?(defaultPath: string, filters?: Array<{ name: string; extensions: string[] }>): Promise<string | null>;
selectDirectory?(title?: string, defaultPath?: string): Promise<string | null>;
selectFile?(title?: string, defaultPath?: string, filters?: Array<{ name: string; extensions: string[] }>): Promise<string | null>;
// File watcher for auto-sync feature
startFileWatch?(localPath: string, remotePath: string, sftpId: string, encoding?: SftpFilenameEncoding): Promise<{ watchId: string }>;
@@ -590,6 +605,28 @@ declare global {
// Temp file cleanup
deleteTempFile?(filePath: string): Promise<{ success: boolean }>;
// Crash Logs
getCrashLogs?(): Promise<Array<{ fileName: string; date: string; size: number; entryCount: number }>>;
readCrashLog?(fileName: string): Promise<Array<{
timestamp: string;
source: string;
message: string;
stack?: string;
errorMeta?: Record<string, unknown>;
extra?: Record<string, unknown>;
pid?: number;
platform?: string;
arch?: string;
version?: string;
electronVersion?: string;
osVersion?: string;
memoryMB?: { rss: number; heapUsed: number; heapTotal: number };
activeSessionCount?: number;
uptimeSeconds?: number;
}>>;
clearCrashLogs?(): Promise<{ deletedCount: number }>;
openCrashLogsDir?(): Promise<{ success: boolean }>;
// Temp directory management
getTempDirInfo?(): Promise<{ path: string; fileCount: number; totalSize: number }>;
clearTempDir?(): Promise<{ deletedCount: number; failedCount: number; error?: string }>;
@@ -631,7 +668,8 @@ declare global {
aiChatCancel?(requestId: string): Promise<boolean>;
aiFetch?(url: string, method?: string, headers?: Record<string, string>, body?: string, providerId?: string): Promise<{ ok: boolean; status: number; data: string; error?: string }>;
aiAllowlistAddHost?(baseURL: string): Promise<{ ok: boolean; error?: string }>;
aiExec?(sessionId: string, command: string): Promise<{ ok: boolean; stdout?: string; stderr?: string; exitCode?: number | null; error?: string }>;
aiExec?(sessionId: string, command: string, chatSessionId?: string): Promise<{ ok: boolean; stdout?: string; stderr?: string; exitCode?: number | null; error?: string }>;
aiCattyCancelExec?(chatSessionId: string): Promise<{ ok: boolean; error?: string }>;
aiTerminalWrite?(sessionId: string, data: string): Promise<{ ok: boolean; error?: string }>;
aiDiscoverAgents?(): Promise<Array<{
command: string;

View File

@@ -13,6 +13,72 @@ import { ToastProvider } from './components/ui/toast';
const LazySettingsPage = lazy(() => import('./components/SettingsPage'));
const LazyTrayPanel = lazy(() => import('./components/TrayPanel'));
function SettingsWindowFallback() {
return (
<div
style={{
height: '100vh',
display: 'flex',
flexDirection: 'column',
background: 'hsl(var(--background))',
color: 'hsl(var(--foreground))',
fontFamily: 'Space Grotesk, system-ui, sans-serif',
}}
>
<div
style={{
flexShrink: 0,
borderBottom: '1px solid hsl(var(--border))',
padding: '20px 16px 12px',
}}
>
<div style={{ fontSize: 18, fontWeight: 600 }}>Settings</div>
<div style={{ marginTop: 6, fontSize: 13, color: 'hsl(var(--muted-foreground))' }}>
Loading preferences...
</div>
</div>
<div style={{ display: 'flex', flex: 1, minHeight: 0 }}>
<div
style={{
width: 224,
flexShrink: 0,
borderRight: '1px solid hsl(var(--border))',
padding: 12,
display: 'flex',
flexDirection: 'column',
gap: 8,
}}
>
{Array.from({ length: 7 }).map((_, index) => (
<div
key={index}
style={{
height: 36,
borderRadius: 8,
background: index === 0 ? 'hsl(var(--card))' : 'hsl(var(--muted) / 0.45)',
}}
/>
))}
</div>
<div style={{ flex: 1, padding: 20, display: 'flex', flexDirection: 'column', gap: 14 }}>
{Array.from({ length: 6 }).map((_, index) => (
<div
key={index}
style={{
height: index === 0 ? 54 : 76,
borderRadius: 12,
background: 'hsl(var(--muted) / 0.38)',
}}
/>
))}
</div>
</div>
</div>
);
}
const rootElement = document.getElementById('root');
if (!rootElement) {
throw new Error("Could not find root element to mount to");
@@ -37,7 +103,7 @@ const renderApp = () => {
if (route === 'settings') {
root.render(
<ToastProvider>
<Suspense fallback={null}>
<Suspense fallback={<SettingsWindowFallback />}>
<LazySettingsPage />
</Suspense>
</ToastProvider>

View File

@@ -17,6 +17,7 @@ export interface NetcattyBridge {
aiExec(
sessionId: string,
command: string,
chatSessionId?: string,
): Promise<{
ok: boolean;
stdout?: string;
@@ -82,6 +83,7 @@ export function createToolExecutor(
commandBlocklist?: string[],
permissionMode: AIPermissionMode = 'confirm',
webSearchConfig?: WebSearchConfig,
chatSessionId?: string,
): (toolCall: ToolCall) => Promise<ToolResult> {
return async (toolCall: ToolCall): Promise<ToolResult> => {
if (!bridge) {
@@ -92,7 +94,7 @@ export function createToolExecutor(
};
}
const deps: ToolDeps = { bridge, context, commandBlocklist, permissionMode, webSearchConfig };
const deps: ToolDeps = { bridge, context, commandBlocklist, permissionMode, webSearchConfig, chatSessionId };
const args = toolCall.arguments;
try {

View File

@@ -37,7 +37,7 @@ export function createCattyTools(
webSearchConfig?: WebSearchConfig,
chatSessionId?: string,
) {
const deps: ToolDeps = { bridge, context, commandBlocklist, permissionMode, webSearchConfig };
const deps: ToolDeps = { bridge, context, commandBlocklist, permissionMode, webSearchConfig, chatSessionId };
return {
terminal_execute: tool({

View File

@@ -31,6 +31,7 @@ export interface ToolDeps {
commandBlocklist?: string[];
permissionMode: AIPermissionMode;
webSearchConfig?: WebSearchConfig;
chatSessionId?: string;
}
// ---------------------------------------------------------------------------
@@ -82,7 +83,7 @@ export async function executeTerminalExecute(
return { ok: false, error: `Command blocked by safety policy. Matched pattern: ${safety.matchedPattern}` };
}
const result = await bridge.aiExec(sessionId, command);
const result = await bridge.aiExec(sessionId, command, deps.chatSessionId);
// Real execution failures (timeout, disconnect, no stream) have an `error` field
if (!result.ok && result.error) {
const parts = [result.error];

View File

@@ -87,5 +87,6 @@ export const STORAGE_KEY_AI_COMMAND_BLOCKLIST = 'netcatty_ai_command_blocklist_v
export const STORAGE_KEY_AI_COMMAND_TIMEOUT = 'netcatty_ai_command_timeout_v1';
export const STORAGE_KEY_AI_MAX_ITERATIONS = 'netcatty_ai_max_iterations_v1';
export const STORAGE_KEY_AI_SESSIONS = 'netcatty_ai_sessions_v1';
export const STORAGE_KEY_AI_ACTIVE_SESSION_MAP = 'netcatty_ai_active_session_map_v1';
export const STORAGE_KEY_AI_AGENT_MODEL_MAP = 'netcatty_ai_agent_model_map_v1';
export const STORAGE_KEY_AI_WEB_SEARCH = 'netcatty_ai_web_search_v1';

View File

@@ -36,6 +36,9 @@ export const XTERM_PERFORMANCE_CONFIG = {
// Font rendering settings
letterSpacing: 0,
lineHeight: 1,
// Keep viewport movement smooth without feeling sluggish.
smoothScrollDuration: 120,
},
// WebGL-specific optimizations
@@ -94,6 +97,11 @@ export const XTERM_PERFORMANCE_CONFIG = {
// Debounce time for viewport scanning (ms)
// Higher values = better scrolling performance, but slower highlight "catch up"
debounceMs: 200,
// Minimum interval between immediate (rAF) refreshes in ms.
// Prevents heavy output (e.g. tail -f) from refreshing every frame.
immediateMinIntervalMs: 50,
// Number of unique line scan results to keep cached.
cacheEntries: 1200,
},
};
@@ -110,6 +118,7 @@ export type ResolvedXTermPerformance = {
customGlyphs: boolean;
letterSpacing: number;
lineHeight: number;
smoothScrollDuration: number;
documentOverride: boolean;
tabStopWidth: number;
convertEol: boolean;
@@ -177,6 +186,7 @@ export function resolveXTermPerformanceConfig({
customGlyphs: baseConfig.rendering.customGlyphs,
letterSpacing: baseConfig.rendering.letterSpacing,
lineHeight: baseConfig.rendering.lineHeight,
smoothScrollDuration: baseConfig.rendering.smoothScrollDuration,
documentOverride: baseConfig.events.documentOverride,
tabStopWidth: baseConfig.events.tabStopWidth,
convertEol: baseConfig.events.convertEol,

View File

@@ -4,7 +4,9 @@
* for establishing and managing SSH port forwarding tunnels.
*/
import { Host,PortForwardingRule } from '../../domain/models';
import { Host, Identity, PortForwardingRule, SSHKey } from '../../domain/models';
import { isEncryptedCredentialPlaceholder, sanitizeCredentialValue } from '../../domain/credentials';
import { resolveHostAuth } from '../../domain/sshAuth';
import { logger } from '../../lib/logger';
import { netcattyBridge } from './netcattyBridge';
@@ -357,7 +359,9 @@ export const reconcileWithBackend = async (): Promise<{
export const startPortForward = async (
rule: PortForwardingRule,
host: Host,
keys: { id: string; privateKey: string; passphrase: string }[],
hosts: Host[],
keys: SSHKey[],
identities: Identity[],
onStatusChange: (status: PortForwardingRule['status'], error?: string) => void,
enableReconnect = false
): Promise<{ success: boolean; error?: string }> => {
@@ -375,16 +379,71 @@ export const startPortForward = async (
try {
// Generate a unique tunnel ID
const tunnelId = `pf-${rule.id}-${Date.now()}`;
// Get the private key and passphrase if using key auth
let privateKey: string | undefined;
let passphrase: string | undefined;
if (host.identityFileId) {
const key = keys.find(k => k.id === host.identityFileId);
if (key) {
privateKey = key.privateKey;
passphrase = key.passphrase;
const resolved = resolveHostAuth({ host, keys, identities });
const key = resolved.key;
const proxy = host.proxyConfig
? {
type: host.proxyConfig.type,
host: host.proxyConfig.host,
port: host.proxyConfig.port,
username: host.proxyConfig.username,
password: sanitizeCredentialValue(host.proxyConfig.password),
}
: undefined;
let jumpHosts: NetcattyJumpHost[] | undefined;
if (host.hostChain?.hostIds?.length) {
const resolvedJumpHosts = host.hostChain.hostIds.map((hostId) =>
hosts.find((candidate) => candidate.id === hostId),
);
const missingJumpHostIds = host.hostChain.hostIds.filter((_, index) => !resolvedJumpHosts[index]);
if (missingJumpHostIds.length > 0) {
throw new Error(`Missing jump host configuration for host chain: ${missingJumpHostIds.join(", ")}`);
}
jumpHosts = resolvedJumpHosts
.filter((jumpHost): jumpHost is Host => Boolean(jumpHost))
.map((jumpHost, index) => {
const hasConfiguredJumpProxyEndpoint =
index === 0 &&
!!(jumpHost.proxyConfig?.host && jumpHost.proxyConfig?.port);
if (
hasConfiguredJumpProxyEndpoint &&
jumpHost.proxyConfig?.username &&
isEncryptedCredentialPlaceholder(jumpHost.proxyConfig.password) &&
!sanitizeCredentialValue(jumpHost.proxyConfig.password)
) {
throw new Error(`Proxy credentials for jump host "${jumpHost.label || jumpHost.hostname}" cannot be decrypted on this device. Open host settings and re-enter the proxy password.`);
}
const jumpResolved = resolveHostAuth({ host: jumpHost, keys, identities });
const jumpKey = jumpResolved.key;
return {
hostname: jumpHost.hostname,
port: jumpHost.port || 22,
username: jumpResolved.username || 'root',
password: jumpResolved.password,
privateKey: jumpKey?.privateKey,
certificate: jumpKey?.certificate,
passphrase: jumpResolved.passphrase || jumpKey?.passphrase,
publicKey: jumpKey?.publicKey,
keyId: jumpResolved.keyId,
keySource: jumpKey?.source,
label: jumpHost.label,
proxy: jumpHost.proxyConfig?.host && jumpHost.proxyConfig?.port
? {
type: jumpHost.proxyConfig.type,
host: jumpHost.proxyConfig.host,
port: jumpHost.proxyConfig.port,
username: jumpHost.proxyConfig.username,
password: sanitizeCredentialValue(jumpHost.proxyConfig.password),
}
: undefined,
identityFilePaths: jumpHost.identityFilePaths,
};
});
}
const usesTargetProxyForFirstHop = !!proxy && !jumpHosts?.[0]?.proxy;
if (usesTargetProxyForFirstHop && host.proxyConfig?.username && isEncryptedCredentialPlaceholder(host.proxyConfig.password) && !proxy?.password) {
throw new Error('Proxy credentials cannot be decrypted on this device. Open host settings and re-enter the proxy password.');
}
// Subscribe to status updates first
@@ -428,10 +487,15 @@ export const startPortForward = async (
remotePort: rule.remotePort,
hostname: host.hostname,
port: host.port,
username: host.username,
password: host.password,
privateKey,
passphrase,
username: resolved.username,
password: resolved.password,
privateKey: key?.privateKey,
certificate: key?.certificate,
keyId: resolved.keyId,
passphrase: resolved.passphrase || key?.passphrase,
proxy,
jumpHosts: jumpHosts && jumpHosts.length > 0 ? jumpHosts : undefined,
identityFilePaths: host.identityFilePaths,
});
if (!result.success) {

View File

@@ -54,6 +54,7 @@ const KNOWN_MONOSPACE_FONTS = new Set([
'noto sans mono',
'sarasa mono',
'maple mono',
'meslolgs nf',
]);
/**
@@ -124,4 +125,4 @@ export async function getMonospaceFonts(): Promise<TerminalFont[]> {
console.warn('Failed to query local fonts:', error);
return [];
}
}
}

View File

@@ -53,22 +53,43 @@ assert_loadable_native_module() {
' "${file}"
}
resolve_serialport_prebuild() {
local root="$1"
local arch="$2"
local file
file="$(find "${root}/prebuilds/linux-${arch}" -maxdepth 1 -type f -name '@serialport+bindings-cpp*.glibc.node' -print | sort | head -n 1)"
if [[ -z "${file}" ]]; then
echo "[node-pty] serialport glibc prebuild not found for linux-${arch}" >&2
exit 1
fi
echo "${file}"
}
prepare() {
local arch="$1"
local root="node_modules/node-pty"
local release_dir="${root}/build/Release"
local prebuild_dir="${root}/prebuilds/linux-${arch}"
local serialport_root="node_modules/@serialport/bindings-cpp"
local serialport_release_dir="${serialport_root}/build/Release"
local serialport_prebuild
echo "[node-pty] rebuilding native modules for Electron on linux-${arch}"
log_electron_runtime_info
npx electron-rebuild --arch "${arch}"
rm -rf "${release_dir}" "${prebuild_dir}" "${serialport_release_dir}"
npx electron-rebuild --force --arch "${arch}" -w "node-pty,@serialport/bindings-cpp"
test -f "${release_dir}/pty.node"
test -f "${serialport_release_dir}/bindings.node"
echo "[node-pty] built Linux runtime artifacts:"
log_file_info "${release_dir}/pty.node"
log_optional_spawn_helper "${release_dir}/spawn-helper"
assert_loadable_native_module "${release_dir}/pty.node"
log_file_info "${serialport_release_dir}/bindings.node"
assert_loadable_native_module "${serialport_release_dir}/bindings.node"
mkdir -p "${prebuild_dir}"
cp "${release_dir}/pty.node" "${prebuild_dir}/pty.node"
@@ -79,17 +100,26 @@ prepare() {
echo "[node-pty] mirrored Linux runtime artifacts into ${prebuild_dir}:"
log_file_info "${prebuild_dir}/pty.node"
log_optional_spawn_helper "${prebuild_dir}/spawn-helper"
serialport_prebuild="$(resolve_serialport_prebuild "${serialport_root}" "${arch}")"
echo "[node-pty] serialport packaged prebuild candidate:"
log_file_info "${serialport_prebuild}"
assert_loadable_native_module "${serialport_prebuild}"
}
verify() {
local arch="$1"
local release_dir
local prebuild_dir
local serialport_release_file
local serialport_prebuild_file
log_electron_runtime_info
release_dir="$(find release -type d -path "*/resources/app.asar.unpacked/node_modules/node-pty/build/Release" -print -quit)"
prebuild_dir="$(find release -type d -path "*/resources/app.asar.unpacked/node_modules/node-pty/prebuilds/linux-${arch}" -print -quit)"
serialport_release_file="$(find release -type f -path "*/resources/app.asar.unpacked/node_modules/@serialport/bindings-cpp/build/Release/bindings.node" -print -quit)"
serialport_prebuild_file="$(find release -type f -path "*/resources/app.asar.unpacked/node_modules/@serialport/bindings-cpp/prebuilds/linux-${arch}/@serialport+bindings-cpp*.glibc.node" -print | sort | head -n 1)"
if [[ -z "${release_dir}" ]]; then
echo "[node-pty] packaged build/Release directory not found under release/" >&2
@@ -101,6 +131,16 @@ verify() {
exit 1
fi
if [[ -z "${serialport_release_file}" ]]; then
echo "[node-pty] packaged serialport build/Release binding not found under release/" >&2
exit 1
fi
if [[ -z "${serialport_prebuild_file}" ]]; then
echo "[node-pty] packaged serialport glibc prebuild not found for linux-${arch} under release/" >&2
exit 1
fi
test -f "${release_dir}/pty.node"
test -f "${prebuild_dir}/pty.node"
@@ -114,10 +154,22 @@ verify() {
log_optional_spawn_helper "${prebuild_dir}/spawn-helper"
assert_loadable_native_module "${prebuild_dir}/pty.node"
echo "[node-pty] packaged serialport build/Release artifact:"
log_file_info "${serialport_release_file}"
assert_loadable_native_module "${serialport_release_file}"
echo "[node-pty] packaged serialport prebuild artifact:"
log_file_info "${serialport_prebuild_file}"
assert_loadable_native_module "${serialport_prebuild_file}"
echo "[node-pty] packaged artifact locations:"
find release -path "*/resources/app.asar.unpacked/node_modules/node-pty/*" \
\( -name 'pty.node' -o -name 'spawn-helper' \) \
-print | sort
find release -path "*/resources/app.asar.unpacked/node_modules/@serialport/bindings-cpp/*" \
\( -name 'bindings.node' -o -name '@serialport+bindings-cpp*.node' \) \
-print | sort
}
main() {

View File

@@ -5,7 +5,7 @@ set -euo pipefail
TEMP_DIR=""
usage() {
echo "Usage: $0 <amd64|arm64>" >&2
echo "Usage: $0 <amd64|arm64> [deb-file]" >&2
exit 1
}
@@ -67,6 +67,11 @@ assert_loadable_native_module() {
local electron_bin="$1"
local native_module="$2"
if [[ "${VERIFY_LOAD:-1}" != "1" ]]; then
echo "[deb-verify] skipping native module load check for ${native_module} (VERIFY_LOAD=${VERIFY_LOAD:-1})"
return
fi
echo "[deb-verify] loading native module with packaged Electron runtime: ${native_module}"
ELECTRON_RUN_AS_NODE=1 "${electron_bin}" -e '
const path = require("node:path");
@@ -75,8 +80,56 @@ assert_loadable_native_module() {
' "${native_module}"
}
resolve_file_from_glob() {
local search_dir="$1"
local pattern="$2"
find "${search_dir}" -maxdepth 1 -type f -name "${pattern}" -print | sort | head -n 1
}
resolve_single_file() {
local search_dir="$1"
local pattern="$2"
local file
file="$(resolve_file_from_glob "${search_dir}" "${pattern}")"
if [[ -z "${file}" ]]; then
echo "[deb-verify] no file matched ${pattern} under ${search_dir}" >&2
exit 1
fi
echo "${file}"
}
resolve_serialport_prebuild() {
local root="$1"
local arch="$2"
local prebuild_dir="${root}/prebuilds/linux-${arch}"
local file
file="$(find "${prebuild_dir}" -maxdepth 1 -type f -name '@serialport+bindings-cpp*.glibc.node' -print | sort | head -n 1)"
if [[ -z "${file}" ]]; then
echo "[deb-verify] serialport glibc prebuild not found under ${prebuild_dir}" >&2
exit 1
fi
echo "${file}"
}
verify_native_module() {
local label="$1"
local electron_bin="$2"
local file="$3"
local expected_machine="$4"
assert_exists "${file}"
echo "[deb-verify] verifying ${label}"
log_file_info "${file}"
assert_file_arch "${file}" "${expected_machine}"
assert_loadable_native_module "${electron_bin}" "${file}"
}
main() {
if [[ $# -ne 1 ]]; then
if [[ $# -lt 1 || $# -gt 2 ]]; then
usage
fi
@@ -89,6 +142,9 @@ main() {
local main_binary
local build_release_pty
local prebuild_pty
local serialport_root
local build_release_serialport
local prebuild_serialport
require_cmd dpkg-deb
require_cmd file
@@ -107,10 +163,11 @@ main() {
;;
esac
deb_file="$(find release -maxdepth 1 -type f -name "*-linux-${deb_arch}.deb" -print | sort | head -n 1)"
if [[ -z "${deb_file}" ]]; then
echo "[deb-verify] no deb artifact found for ${deb_arch} under release/" >&2
exit 1
if [[ $# -eq 2 ]]; then
deb_file="$2"
assert_exists "${deb_file}"
else
deb_file="$(resolve_single_file "release" "*-linux-${deb_arch}.deb")"
fi
echo "[deb-verify] verifying deb artifact: ${deb_file}"
@@ -131,22 +188,19 @@ main() {
main_binary="${TEMP_DIR}/opt/Netcatty/netcatty"
build_release_pty="${TEMP_DIR}/opt/Netcatty/resources/app.asar.unpacked/node_modules/node-pty/build/Release/pty.node"
prebuild_pty="${TEMP_DIR}/opt/Netcatty/resources/app.asar.unpacked/node_modules/node-pty/prebuilds/linux-${prebuild_arch}/pty.node"
serialport_root="${TEMP_DIR}/opt/Netcatty/resources/app.asar.unpacked/node_modules/@serialport/bindings-cpp"
build_release_serialport="${serialport_root}/build/Release/bindings.node"
prebuild_serialport="$(resolve_serialport_prebuild "${serialport_root}" "${prebuild_arch}")"
assert_executable "${electron_bin}"
assert_exists "${build_release_pty}"
assert_exists "${prebuild_pty}"
echo "[deb-verify] verifying packaged binary architectures"
log_file_info "${main_binary}"
log_file_info "${build_release_pty}"
log_file_info "${prebuild_pty}"
assert_file_arch "${main_binary}" "${expected_machine}"
assert_file_arch "${build_release_pty}" "${expected_machine}"
assert_file_arch "${prebuild_pty}" "${expected_machine}"
assert_loadable_native_module "${electron_bin}" "${build_release_pty}"
assert_loadable_native_module "${electron_bin}" "${prebuild_pty}"
verify_native_module "node-pty build/Release" "${electron_bin}" "${build_release_pty}" "${expected_machine}"
verify_native_module "node-pty prebuild" "${electron_bin}" "${prebuild_pty}" "${expected_machine}"
verify_native_module "serialport build/Release" "${electron_bin}" "${build_release_serialport}" "${expected_machine}"
verify_native_module "serialport glibc prebuild" "${electron_bin}" "${prebuild_serialport}" "${expected_machine}"
echo "[deb-verify] deb artifact verification passed for ${deb_file}"
}