Compare commits

...

71 Commits

Author SHA1 Message Date
陈大猫
664fe90c10 feat: add legacy SSH algorithm support for older network equipment (#216)
Some checks failed
build-packages / build-macos-latest (push) Has been cancelled
build-packages / build-ubuntu-latest (push) Has been cancelled
build-packages / build-windows-latest (push) Has been cancelled
build-packages / release (push) Has been cancelled
2026-03-01 07:45:13 +08:00
Rory Chou
2215d52b09 feat: credential protection guards for enc:v1: placeholders (#212)
Some checks failed
build-packages / build-macos-latest (push) Has been cancelled
build-packages / build-ubuntu-latest (push) Has been cancelled
build-packages / build-windows-latest (push) Has been cancelled
build-packages / release (push) Has been cancelled
* feat: add credential protection guards for enc:v1: placeholders

Prevent encrypted credential placeholders from being sent as
actual passwords when safeStorage decryption is unavailable
(e.g. different device/user profile). Adds guards at terminal
connection, cloud sync, and settings boundaries with user-facing
warnings and i18n support.

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

* fix: validate base64 format in encrypted credential detection

Only treat values as encrypted placeholders when the content after
the enc:v1: prefix is valid base64. Prevents false positives if a
real password happens to start with the prefix literal.

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

* fix: resolve regressions in master-key change flow and credential placeholder detection

- Make ensureSyncablePayload non-blocking in changeMasterKey handler so
  success toast and dialog close always fire after a successful key change,
  even when the payload contains unresolved enc:v1: placeholders
- Add MIN_CIPHERTEXT_BASE64_LENGTH (32) threshold to
  isEncryptedCredentialPlaceholder to avoid false-positive matches on
  plaintext credentials that happen to start with enc:v1: (e.g. enc:v1:hello)

* fix: clean up chain-progress listener on credential reentry and gate proxy check on auth usage

- Unsubscribe onChainProgress before returning in needsCredentialReentry
  branch to prevent listener leaks across connection attempts
- Only block connection for undecryptable proxy password when proxy
  authentication is actually in use (has a username)

* fix: reduce enc:v1 placeholder false positives

* fix: require syncable payload before master key rotation

---------

Co-authored-by: rorychou <roryechou@gmail.com>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Co-authored-by: bincxz <16399091+binaricat@users.noreply.github.com>
2026-02-25 15:32:24 +08:00
Rory Chou
c9059a4f29 feat: add swap usage display in server stats (#210)
Collect SwapTotal and SwapFree from /proc/meminfo and display swap
usage in the memory HoverCard with a dedicated progress bar (rose color).
Only shown when the server has swap configured (swapTotal > 0).

Co-authored-by: rorychou <roryechou@gmail.com>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-24 10:49:11 +08:00
Rory Chou
4445bf578c security: harden external navigation / window.open (#209) 2026-02-14 16:21:11 +08:00
Rory Chou
f719350507 fix(macos): restore main window on Dock activate (#208) 2026-02-14 12:20:48 +08:00
Copilot
cfaee48553 Remove extra space next to close button on Windows (#207)
Some checks failed
build-packages / build-macos-latest (push) Has been cancelled
build-packages / build-ubuntu-latest (push) Has been cancelled
build-packages / build-windows-latest (push) Has been cancelled
build-packages / release (push) Has been cancelled
* Initial plan

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

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

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

macOS layout is unchanged.

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

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: binaricat <16399091+binaricat@users.noreply.github.com>
2026-02-13 23:51:26 +08:00
bincxz
1f05fe3efa fix: remove extra space next to close button on Windows (#207) 2026-02-13 23:50:13 +08:00
copilot-swe-agent[bot]
e9c3b82c16 fix: remove extra right spacing next to close button on Windows
On Windows/Linux, the frameless title bar had ~20px of dead space
(12px right padding + 8px drag shim) to the right of the close button.

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

macOS layout is unchanged.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-13 18:30:37 +08:00
陈大猫
c0199c43cf fix: prevent zombie processes and improve window recovery on restart (#201)
Some checks failed
build-packages / build-macos-latest (push) Has been cancelled
build-packages / build-ubuntu-latest (push) Has been cancelled
build-packages / build-windows-latest (push) Has been cancelled
build-packages / release (push) Has been cancelled
- Destroy trayPanelWindow and clear refresh timer during cleanup, preventing
  hidden BrowserWindows from keeping the Electron process alive
- Add SIGTERM/SIGINT handlers for graceful shutdown
- Detect crashed webContents in focusMainWindow() and recreate the window
  instead of silently failing on second-instance activation

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

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

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

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

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

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

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

---------

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

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

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

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

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: binaricat <16399091+binaricat@users.noreply.github.com>
2026-02-12 16:53:50 +08:00
Copilot
b5feb888d2 Fix incorrect character encoding over Telnet and Serial connections (#196)
Some checks failed
build-packages / build-macos-latest (push) Has been cancelled
build-packages / build-ubuntu-latest (push) Has been cancelled
build-packages / build-windows-latest (push) Has been cancelled
build-packages / release (push) Has been cancelled
* Initial plan

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

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

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

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

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

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

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

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

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

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

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

---------

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

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

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

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

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

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

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

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

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

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

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

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

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

* fix: preserve multi-cursor paste distribution semantics

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

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

---------

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

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

- Optimize Windows build config to build x64 and arm64 separately
2026-02-06 07:49:32 +08:00
bincxz
ec5dfcf1fa fix: add notifyStateChange after syncAllProviders completes to update UI sync status
Some checks failed
build-packages / build-macos-latest (push) Has been cancelled
build-packages / build-ubuntu-latest (push) Has been cancelled
build-packages / build-windows-latest (push) Has been cancelled
build-packages / release (push) Has been cancelled
2026-02-05 02:30:16 +08:00
bincxz
ab3b2c2055 Adds collapsable Vault sidebar with state persistence
Some checks failed
build-packages / build-macos-latest (push) Has been cancelled
build-packages / build-ubuntu-latest (push) Has been cancelled
build-packages / build-windows-latest (push) Has been cancelled
build-packages / release (push) Has been cancelled
Introduces a collapsable sidebar in the Vault view to enhance user experience and optimize screen real estate.

The sidebar's collapsed or expanded state is now persisted across sessions using local storage. Tooltips are added to navigation items, providing clear labels when the sidebar is in its collapsed state.

Additionally, improves tooltip activation behavior in the SFTP modal, delaying their appearance slightly to prevent flickering on modal open.
2026-02-05 01:42:23 +08:00
bincxz
ca8691d53d feat(TrayPanel): add humorous empty state message
When no sessions are active and no port forwarding rules exist,
display a friendly empty state with emoji and encouraging message.
2026-02-05 01:00:18 +08:00
bincxz
dddaf1a1cd fix(TrayPanel): sync port forwarding rules on delete via storage event
Add storage event listener in usePortForwardingState to sync rules between
main window and TrayPanel. When port forwarding rules are added, updated,
or deleted in the main window, the TrayPanel now receives the update via
localStorage storage event and re-renders accordingly.

Also fix TypeScript type inference in normalizeRulesWithConnections by
adding explicit return type annotations.
2026-02-05 00:57:22 +08:00
bincxz
d2469f93c8 refactor(QuickSwitcher): always show categorized view with Hosts/Tabs/Quick connect
- Remove conditional showCategorized logic, always display full categorized panel
- Remove unused "All Hosts" simplified view dead code (~45 lines)
- Remove unused Button import and onCreateWorkspace prop
- Clean up unused i18n keys: qs.recentConnections, qs.allHosts, qs.createWorkspace, qs.restore
2026-02-05 00:52:49 +08:00
bincxz
521c9141e9 fix(tray): correct IPC handler signatures and main window identification
- Fix handlerJump/handlerConnect signatures to match preload callback
  (preload strips event, callback receives only sessionId/hostId)
- Use windowManager.getMainWindow() for reliable main window reference
- Add fallback filtering for tray panel and destroyed windows
2026-02-05 00:46:32 +08:00
bincxz
d068cc99c8 feat(tray): implement workspace grouping in TrayPanel
- Group sessions by workspaceId in collapsible WorkspaceGroup component
- Add workspaceId/workspaceTitle fields to tray session types
- Update App.tsx to send workspace data in updateTrayMenuData
- Fix session focus handlers to navigate to workspace and focus session
- Align bridge typings for consistent workspace session fields
2026-02-05 00:32:51 +08:00
bincxz
a89130d732 refactor(sftp): change focus indicator from border to corner triangle 2026-02-04 23:45:51 +08:00
bincxz
dcea13172d fix: allow tray panel button clicks
- Avoid pointerdown capture handler interfering with button clicks

Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>
2026-02-04 17:50:10 +08:00
bincxz
0c420d99ed fix: enable tray panel jump and simplify to current tab
- Fix main window listeners by using netcatty bridge events (not window.electron)
- Remove recent sessions section; show only current active tab entry

Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>
2026-02-04 17:47:52 +08:00
bincxz
a3ffefa067 fix: handle tray panel clicks by dispatching actions to main window
- Tray panel click now asks main process to open main window and send jump/connect events
- Main window listens for these events and switches tab or starts a new connection

Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>
2026-02-04 17:40:16 +08:00
bincxz
80b6485d02 fix: show sessions and recent hosts in tray panel via injected menu data
- Inject main-window session list into tray panel via IPC
- Tray panel derives session jump + recent hosts from injected data
- Remove temporary tray panel debug logging

Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>
2026-02-04 17:35:44 +08:00
bincxz
884d643150 chore: improve tray panel debug logs
Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>
2026-02-04 17:32:50 +08:00
bincxz
96fde364df chore: forward tray panel renderer logs to main
Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>
2026-02-04 17:30:48 +08:00
bincxz
920d829299 chore: add tray panel debug render logs
Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>
2026-02-04 17:23:00 +08:00
bincxz
4f7d3bccc8 chore: log tray panel url in dev
Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>
2026-02-04 17:20:45 +08:00
bincxz
a3eb6ab26c feat: add tray panel session jump and recent hosts
- Show connected/connecting sessions for quick jump to tab
- Add recent hosts (last 5) and allow starting a new session from tray panel

Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>
2026-02-04 17:18:04 +08:00
bincxz
c402c45e1d ui: add tooltip for truncated tray panel labels
Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>
2026-02-04 17:12:35 +08:00
bincxz
d4978f267a fix: refresh tray panel data while open
- Periodically signal tray panel to refresh while visible
- Add preload/api typings + backend hook support for refresh events

Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>
2026-02-04 17:10:51 +08:00
bincxz
41ee8e7160 ui: improve tray panel i18n and status indicators
- Wrap tray panel with I18nProvider so translations render
- Add colored status dots and spinner for connecting
- Stop setting tray context menu to avoid Quit showing alongside panel

Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>
2026-02-04 17:09:22 +08:00
bincxz
3d9f153fe9 ui: polish tray panel and avoid context menu
- Keep tray context menu minimal to avoid menu + panel overlap
- Add tray panel i18n keys for labels/status (en/zh-CN)
- Show active tab highlight and localized status text in tray panel

Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>
2026-02-04 17:05:11 +08:00
bincxz
4332537da0 feat: add tray panel popover window
- Replace tray click menu with a custom panel window (#/tray)
- Panel lists sessions + port forwards and allows toggling without closing
- Add tray panel IPC (hide panel, open main window)

Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>
2026-02-04 17:00:54 +08:00
bincxz
4284b9a9dc Revert "ui: localize tray menu labels and status text"
This reverts commit 1d06e48966.
2026-02-04 16:50:31 +08:00
bincxz
3afe6ed489 fix: restore dev startup after tray i18n attempt
- Remove invalid require() of TS i18n modules from electron main
- Keep tray status text in main process to avoid module resolution issues

Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>
2026-02-04 16:49:32 +08:00
bincxz
1d06e48966 ui: localize tray menu labels and status text
- Replace status dots with localized status text suffix
- Localize tray menu labels via i18n keys

Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>
2026-02-04 16:47:05 +08:00
bincxz
3ce2481753 ui: simplify tray port forwarding toggle
- Remove submenu; click rule to start/stop directly
- Use colored status dots for sessions and port forwards

Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>
2026-02-04 16:39:02 +08:00
bincxz
ce61c28162 fix: localize tray port forwarding errors
- Use existing i18n key pf.error.hostNotFound for tray action toast

Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>
2026-02-04 16:27:08 +08:00
bincxz
55463441f0 feat: expand tray menu with sessions and port forwarding
- Click tray icon to open menu (Open Main Window, sessions, port forwards, quit)
- Sync session + port forward data from renderer to main via IPC
- Handle tray actions in renderer (focus session, start/stop port forward)
- Fix tray listener unsubscribe to remove only registered handler

Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>
2026-02-04 16:03:41 +08:00
Copilot
c17833ae31 feat: Add global hotkey to toggle window visibility (Quake mode) (#187)
* Initial plan

* feat: implement global hotkey toggle and system tray functionality

- Add globalShortcutBridge.cjs for global keyboard shortcuts and system tray
- Register/unregister global shortcuts using Electron's globalShortcut API
- Implement system tray with context menu for show/hide/quit
- Add close-to-tray behavior option
- Add new storage keys for toggle window hotkey and close-to-tray settings
- Add settings UI in System tab for configuring global hotkey
- Add i18n translations for English and Chinese
- Update preload.cjs with new IPC methods for global hotkey
- Update global.d.ts with TypeScript type definitions

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

* fix: address code review feedback for global hotkey feature

- Use imported fs module instead of inline require in globalShortcutBridge
- Simplify macOS platform detection (remove mobile device patterns)
- Add warning logs for hotkey registration and tray errors

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

* fix: map Control modifier to Control

* Add default tray icon fallback

* fix: bypass close-to-tray during quit

* Fix global hotkey failure handling

* fix: improve tray icon with platform-specific PNG files

- Replace SVG tray icon with PNG format for better compatibility
- Add macOS template images (tray-iconTemplate.png) for menu bar
- Add colored icons (tray-icon.png) for Windows/Linux
- Include @2x retina variants for high-DPI displays
- Remove unused mainWindow variable from globalShortcutBridge

Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>

* feat: enable global hotkey and close-to-tray by default

- Default hotkey: Ctrl+` (⌃+` on macOS) - similar to VS Code terminal toggle
- Default close-to-tray: enabled
- Properly distinguish between 'never set' and 'explicitly cleared'

Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>

* fix: address code review findings for global hotkey feature

P1: Fix toggleWindowVisibility to restore minimized windows
- Check isMinimized() first before checking isVisible()
- Ensures hotkey/tray toggle works when window is minimized

P2: Save window state before hiding to tray on close
- Persist window bounds before returning from close-to-tray handler
- Prevents losing window position/size on quick close after resize

P2: Surface hotkey registration failures to UI
- Add hotkeyRegistrationError state to useSettingsState
- Display error message in SettingsSystemTab instead of silently clearing

P2: Remove arbitrary renderer-provided tray icon paths
- Only use known packaged icon locations for security
- Remove iconPath parameter from setCloseToTray API

Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: binaricat <16399091+binaricat@users.noreply.github.com>
Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>
2026-02-04 14:52:28 +08:00
bincxz
56bab12b5c Add README badges and assets 2026-02-04 00:21:01 +08:00
bincxz
d1482e47d6 Add logo and GIF previews 2026-02-04 00:19:11 +08:00
bincxz
4ee3c63768 Refreshes README content and introduces GIF demos
Overhauls the README files across all languages to enhance readability and user experience.

Updates feature descriptions to better reflect the application's current capabilities, including new sections like "Why Netcatty" and "Demos". Integrates animated GIF demos for core features such as Vault views, split terminals, SFTP workflows, and personalization, offering dynamic visual explanations.

Streamlines the header by replacing image-based badges with concise text links. Replaces outdated screenshots with new, relevant images and removes a large number of old screenshot files. Updates the Electron framework version in the technology stack.
2026-02-04 00:14:40 +08:00
bincxz
2b067a9aae fix: apply host-level keyword highlight rules immediately after runtime creation
Some checks failed
build-packages / build-macos-latest (push) Has been cancelled
build-packages / build-ubuntu-latest (push) Has been cancelled
build-packages / build-windows-latest (push) Has been cancelled
build-packages / release (push) Has been cancelled
This fixes a timing issue where the useEffect for keyword highlighting
runs before the runtime is created, causing host-level rules to be missed
for fresh sessions. Now merged global and host rules are applied right
after the XTermRuntime is created in the boot() function.
2026-02-03 14:04:00 +08:00
bincxz
2d4a3a5602 Cleans up SFTP module imports and catch blocks
Removes unused React hook imports (`useState`, `useCallback`) from SFTP components to improve code clarity.

Simplifies a `catch` block in SFTP keyboard shortcuts by removing the unused `error` variable, making the code more concise.
2026-02-03 13:50:00 +08:00
Copilot
6c57ce7b28 Feature: Host-level keyword highlighting with toolbar popover (#185)
* Initial plan

* Add host-level keyword highlighting feature with popover UI

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

* Add accessibility labels to color inputs in HostKeywordHighlightPopover

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

* Disable host highlight popover for serial sessions

* Fix keyword highlight rule merging

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: binaricat <16399091+binaricat@users.noreply.github.com>
2026-02-03 13:45:48 +08:00
Copilot
6a2bd0a6a1 Fix SFTP jump connection unsupported algorithm chacha20-poly1305 error (#184)
* Initial plan

* Fix SFTP jump connection unsupported algorithm chacha20-poly1305 error

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

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: binaricat <16399091+binaricat@users.noreply.github.com>
2026-02-03 11:53:49 +08:00
102 changed files with 5302 additions and 1748 deletions

View File

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

View File

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

187
App.tsx
View File

@@ -14,6 +14,7 @@ import { initializeUIFonts } from './application/state/uiFontStore';
import { I18nProvider, useI18n } from './application/i18n/I18nProvider';
import { matchesKeyBinding } from './domain/models';
import { resolveHostAuth } from './domain/sshAuth';
import { getCredentialProtectionAvailability } from './infrastructure/services/credentialProtection';
import { netcattyBridge } from './infrastructure/services/netcattyBridge';
import { TopTabs } from './components/TopTabs';
import { Button } from './components/ui/button';
@@ -261,7 +262,7 @@ function App({ settings }: { settings: SettingsState }) {
const isMacClient = typeof navigator !== 'undefined' && /Mac|Macintosh/.test(navigator.userAgent);
// Get port forwarding rules and import function
const { rules: portForwardingRules, importRules: importPortForwardingRules } = usePortForwardingState();
const { rules: portForwardingRules, importRules: importPortForwardingRules, startTunnel, stopTunnel } = usePortForwardingState();
const portForwardingRulesForSync = useMemo(
() =>
@@ -342,6 +343,126 @@ function App({ settings }: { settings: SettingsState }) {
keys: portForwardingKeys,
});
// Sync tray menu data + handle tray actions
useEffect(() => {
const bridge = netcattyBridge.get();
if (!bridge?.updateTrayMenuData) return;
let cancelled = false;
const timer = setTimeout(() => {
if (cancelled) return;
const sessionsForTray = sessions.map((s) => {
const ws = s.workspaceId ? workspaces.find((w) => w.id === s.workspaceId) : undefined;
return {
id: s.id,
label: s.hostname,
hostLabel: s.hostLabel,
status: s.status,
workspaceId: s.workspaceId,
workspaceTitle: ws?.title,
};
});
void bridge.updateTrayMenuData({
sessions: sessionsForTray,
portForwardRules: portForwardingRules,
});
}, 250);
return () => {
cancelled = true;
clearTimeout(timer);
};
}, [sessions, portForwardingRules, workspaces]);
useEffect(() => {
const bridge = netcattyBridge.get();
if (!bridge?.onTrayFocusSession || !bridge?.onTrayTogglePortForward) return;
const unsubscribeFocus = bridge.onTrayFocusSession((sessionId) => {
// Find the session to check if it belongs to a workspace
const session = sessions.find((s) => s.id === sessionId);
if (session?.workspaceId) {
// Session is in a workspace - navigate to workspace and focus the session
setActiveTabId(session.workspaceId);
setWorkspaceFocusedSession(session.workspaceId, sessionId);
} else {
// Standalone session or session not found - just set tab
setActiveTabId(sessionId);
}
});
const unsubscribeToggle = bridge.onTrayTogglePortForward((ruleId, start) => {
const rule = portForwardingRules.find((r) => r.id === ruleId);
if (!rule) return;
const host = rule.hostId ? hosts.find((h) => h.id === rule.hostId) : undefined;
if (!host) {
toast.error(t("pf.error.hostNotFound"));
return;
}
const keysForPf = keys.map((k) => ({ id: k.id, privateKey: k.privateKey }));
if (start) {
void startTunnel(rule, host, keysForPf, (status, error) => {
if (status === "error" && error) toast.error(error);
}, rule.autoStart);
} else {
void stopTunnel(ruleId);
}
});
return () => {
unsubscribeFocus?.();
unsubscribeToggle?.();
};
}, [hosts, keys, portForwardingRules, sessions, setActiveTabId, setWorkspaceFocusedSession, startTunnel, stopTunnel, t]);
// Tray panel actions (from main process)
useEffect(() => {
const handlerJump = (sessionId: string) => {
// Find the session to check if it belongs to a workspace
const session = sessions.find((s) => s.id === sessionId);
if (session?.workspaceId) {
// Session is in a workspace - navigate to workspace and focus the session
setActiveTabId(session.workspaceId);
setWorkspaceFocusedSession(session.workspaceId, sessionId);
} else {
// Standalone session or session not found - just set tab
setActiveTabId(sessionId);
}
};
const handlerConnect = (hostId: string) => {
const host = hosts.find((h) => h.id === hostId);
if (!host) {
toast.error(t("pf.error.hostNotFound"));
return;
}
addConnectionLog({
hostId: host.id,
hostLabel: host.label,
hostname: host.hostname,
username: host.username,
localHostname: "",
saved: false,
});
connectToHost(host);
};
const bridge = netcattyBridge.get();
if (!bridge?.onTrayPanelJumpToSession || !bridge?.onTrayPanelConnectToHost) return;
const unsubscribeJump = bridge.onTrayPanelJumpToSession(handlerJump);
const unsubscribeConnect = bridge.onTrayPanelConnectToHost(handlerConnect);
return () => {
unsubscribeJump?.();
unsubscribeConnect?.();
};
}, [addConnectionLog, connectToHost, hosts, sessions, setActiveTabId, setWorkspaceFocusedSession, t]);
// Keyboard-interactive authentication (2FA/MFA) event listener
useEffect(() => {
const bridge = netcattyBridge.get();
@@ -649,11 +770,15 @@ function App({ settings }: { settings: SettingsState }) {
// Note: xterm terminal handles its own key interception via attachCustomKeyEventHandler
const target = e.target as HTMLElement;
const isFormElement = target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.isContentEditable;
const isMonacoElement =
target instanceof HTMLElement &&
!!target.closest?.('.monaco-editor, .monaco-diff-editor, .monaco-inputbox');
const isXtermInput =
target instanceof HTMLElement &&
!!target.closest?.(".xterm, .xterm-helper-textarea, .xterm-screen, .xterm-viewport");
if (isFormElement && !isXtermInput && e.key !== 'Escape') {
// Monaco is not always contentEditable/input, so treat it as an editor surface.
if ((isFormElement || isMonacoElement) && !isXtermInput && e.key !== 'Escape') {
return;
}
@@ -677,6 +802,10 @@ function App({ settings }: { settings: SettingsState }) {
const keyStr = isMac ? binding.mac : binding.pc;
if (matchesKeyBinding(e, keyStr, isMac)) {
if (HOTKEY_DEBUG) console.log('[Hotkeys] Matched binding:', binding.action, keyStr);
// SFTP shortcuts are handled by SFTP-specific hooks.
if (binding.category === 'sftp') {
continue;
}
// Terminal-specific actions should be handled by the terminal
// Don't handle them at app level
const terminalActions = ['copy', 'paste', 'selectAll', 'clearBuffer', 'searchTerminal'];
@@ -954,12 +1083,64 @@ function App({ settings }: { settings: SettingsState }) {
})();
}, [openSettingsWindow, t]);
const hasShownCredentialProtectionWarningRef = useRef(false);
useEffect(() => {
if (hasShownCredentialProtectionWarningRef.current) return;
let cancelled = false;
void (async () => {
const available = await getCredentialProtectionAvailability();
if (cancelled || available !== false) return;
hasShownCredentialProtectionWarningRef.current = true;
toast.warning(t('credentials.protectionUnavailable.message'), {
title: t('credentials.protectionUnavailable.title'),
actionLabel: t('credentials.protectionUnavailable.action'),
duration: 10000,
onClick: handleOpenSettings,
});
})();
return () => {
cancelled = true;
};
}, [handleOpenSettings, t]);
const handleEndSessionDrag = useCallback(() => {
setDraggingSessionId(null);
}, [setDraggingSessionId]);
const handleRootContextMenu = useCallback((e: React.MouseEvent<HTMLDivElement>) => {
const editableSelector =
"input, textarea, [contenteditable], .monaco-editor, .monaco-diff-editor, .monaco-inputbox, .monaco-menu-container";
const nativeEvent = e.nativeEvent;
const path = typeof nativeEvent.composedPath === "function" ? nativeEvent.composedPath() : [];
const allowFromPath = path.some(
(node) => node instanceof Element && !!node.closest(editableSelector),
);
const target = e.target;
const targetElement =
target instanceof Element
? target
: target instanceof Node
? target.parentElement
: null;
const allowFromTarget = !!targetElement?.closest(editableSelector);
const allowNativeContextMenu = allowFromPath || allowFromTarget;
if (allowNativeContextMenu) {
return;
}
e.preventDefault();
}, []);
return (
<div className="flex flex-col h-screen text-foreground font-sans netcatty-shell" onContextMenu={(e) => e.preventDefault()}>
<div className="flex flex-col h-screen text-foreground font-sans netcatty-shell" onContextMenu={handleRootContextMenu}>
<TopTabs
theme={theme}
sessions={sessions}

View File

@@ -11,13 +11,13 @@
<p align="center">
Electron、React、xterm.js で構築された機能豊富な SSH ワークスペース。<br/>
ホスト管理、分割ターミナル、SFTP、ポートフォワーディング、クラウド同期 — すべてが一つに。
分割ターミナル、Vault ビュー、SFTP ワークフロー、カスタムテーマ、キーワードハイライト — すべてが一つに。
</p>
<p align="center">
<a href="https://github.com/binaricat/Netcatty/releases/latest"><img alt="GitHub Release" src="https://img.shields.io/github/v/release/binaricat/Netcatty?style=for-the-badge&logo=github&label=Release"></a>
&nbsp;
<a href="#"><img alt="Platform" src="https://img.shields.io/badge/Platform-macOS%20%7C%20Windows-blue?style=for-the-badge&logo=electron"></a>
<a href="#"><img alt="Platform" src="https://img.shields.io/badge/Platform-macOS%20%7C%20Windows%20%7C%20Linux-blue?style=for-the-badge&logo=electron"></a>
&nbsp;
<a href="LICENSE"><img alt="License" src="https://img.shields.io/badge/License-GPL--3.0-green?style=for-the-badge"></a>
</p>
@@ -40,22 +40,20 @@
---
[![Netcatty メインインターフェース](screenshots/vault_grid_view.png)](screenshots/vault_grid_view.png)
[![Netcatty メインインターフェース](screenshots/main-window-dark.png)](screenshots/main-window-dark.png)
---
# 目次 <!-- omit in toc -->
- [Netcatty とは](#netcatty-とは)
- [なぜ Netcatty](#なぜ-netcatty)
- [機能](#機能)
- [デモ](#デモ)
- [スクリーンショット](#スクリーンショット)
- [ホスト管理](#ホスト管理)
- [ターミナル](#ターミナル)
- [SFTP](#sftp)
- [キーチェーン](#キーチェーン)
- [ポートフォワーディング](#ポートフォワーディング)
- [クラウド同期](#クラウド同期)
- [テーマとカスタマイズ](#テーマとカスタマイズ)
- [メインウィンドウ](#メインウィンドウ)
- [Vault ビュー](#vault-ビュー)
- [分割ターミナル](#分割ターミナル)
- [対応ディストリビューション](#対応ディストリビューション)
- [はじめに](#はじめに)
- [ビルドとパッケージ](#ビルドとパッケージ)
@@ -71,190 +69,119 @@
**Netcatty** は、複数のリモートサーバーを効率的に管理する必要がある開発者、システム管理者、DevOps エンジニア向けに設計された、モダンなクロスプラットフォーム SSH クライアントおよびターミナルマネージャーです。
- **Netcatty は** PuTTY、Termius、SecureCRT、macOS Terminal.app の代替となる SSH 接続ツール
- **Netcatty は** デュアルペインファイルブラウザを備えた強力な SFTP クライアント
- **Netcatty は** 強力な SFTP クライアント(ドラッグ&ドロップ + 内蔵エディタ)
- **Netcatty は** 分割ペイン、タブ、セッション管理を備えたターミナルワークスペース
- **Netcatty は** シェルの代替ではありません — SSH/Telnet またはローカルターミナル経由でリモートシェルに接続します
- **Netcatty は** シェルの代替ではありません — SSH/Telnet/Mosh やローカル/シリアル経由でシェルに接続します(環境により異なります)
---
<a name="なぜ-netcatty"></a>
# なぜ Netcatty
複数サーバーを日常的に扱うなら、Netcatty は「スピード」と「流れ」を重視した作りになっています:
- **ワークスペース中心** — 分割ペインで複数セッションを並行操作
- **Vault の見やすさ** — グリッド/リスト/ツリーで状況に合わせて切り替え
- **SFTP の作業感** — ドラッグ&ドロップと内蔵エディタでサクッと編集
---
<a name="機能"></a>
# 機能
### 🖥 ターミナルとセッション
- **xterm.js ベースのターミナル**、GPU アクセラレーションレンダリング対応
### 🗂 Vault
- **複数ビュー** — グリッド / リスト / ツリー
- **高速検索** — ホストやグループを素早く見つける
### 🖥️ ターミナルワークスペース
- **分割ペイン** — 水平・垂直分割でマルチタスク
- **タブ管理** — ドラッグ&ドロップで並べ替え可能な複数セッション
- **セッション永続化** — 再起動後もセッションを復元
- **ブロードキャストモード** — 一度の入力で複数のターミナルに送信
- **セッション管理** — 複数の接続を並行して扱う
### 🔐 SSH クライアント
- **SSH2 プロトコル**、完全な認証サポート
- **パスワード&キー認証**
- **SSH 証明書**サポート
- **ジャンプホスト / 踏み台サーバー** — 複数ホストを経由した接続
- **プロキシサポート** — HTTP CONNECT および SOCKS5 プロキシ
- **エージェント転送** — OpenSSH Agent および Pageant 対応
- **環境変数** — ホストごとにカスタム環境変数を設定
### 📁 SFTP + 内蔵エディタ
- **ファイル作業** — ドラッグ&ドロップでアップロード/ダウンロード
- **その場で編集** — 内蔵エディタで小さな修正を素早く
### 📁 SFTP
- **デュアルペインファイルブラウザ** — ローカル ↔ リモート または リモート ↔ リモート
- **Sudo 特権昇格** — sudo を使用して root 権限のファイルを閲覧および編集
- **ドラッグ&ドロップ** アップロードおよびダウンロード
- **ドラッグ&ドロップ**ファイル転送
- **キュー管理**でバッチ転送
- **進捗追跡**、転送速度表示
### 🎨 パーソナライズ
- **カスタムテーマ** — UI の見た目を好みに調整
- **キーワードハイライト** — ターミナル出力の強調表示ルールをカスタマイズ
### 🔑 キーチェーン
- **SSH キー生成** — RSA、ECDSA、ED25519
- **既存キーのインポート** — PEM、OpenSSH 形式
- **SSH 証明書**サポート
- **アイデンティティ管理** — 再利用可能なユーザー名+認証方式の組み合わせ
- **公開鍵をエクスポート**してリモートホストへ
---
### 🔌 ポートフォワーディング
- **ローカルフォワーディング** — リモートサービスをローカルに公開
- **リモートフォワーディング** — ローカルサービスをリモートに公開
- **ダイナミックフォワーディング** — SOCKS5 プロキシ
- **ビジュアルトンネル管理**
<a name="デモ"></a>
# デモ
### ☁️ クラウド同期
- **エンドツーエンド暗号化同期** — デバイスを離れる前にデータを暗号化
- **複数のプロバイダー** — GitHub Gist、S3 互換ストレージ、WebDAV、Google Drive、OneDrive
- **ホスト、キー、スニペット、設定を同期**
GIF で機能をさっと確認できます(素材は `screenshots/gifs/`
### 🎨 テーマとカスタマイズ
- **ライト&ダークモード**
- **カスタムアクセントカラー**
- **50以上のターミナル配色**
- **フォントカスタマイズ** — JetBrains Mono、Fira Code など
- **多言語対応** — English、简体中文 など
### Vault ビュー:グリッド / リスト / ツリー
状況に合わせて見え方を切り替え。グリッドで全体像、リストで密度、ツリーで階層を扱えます。
![Vault ビュー:グリッド/リスト/ツリー](screenshots/gifs/gird-list-tre-views.gif)
### 分割ターミナル + セッション管理
複数セッションを分割ペインで並べて作業。関連タスクを横並びにしてコンテキストスイッチを減らします。
![分割ターミナル + セッション管理](screenshots/gifs/dual-terminal--split-manage.gif)
### SFTPドラッグドロップ + 内蔵エディタ
ドラッグ&ドロップでファイルを移動し、内蔵エディタでそのまま編集できます。
![SFTPドラッグドロップ + 内蔵エディタ](screenshots/gifs/sftpview-with-drag-and-built-in-editor.gif)
### ドラッグでアップロード
ファイルをそのままドロップしてアップロードを開始。ダイアログ操作を減らせます。
![ドラッグでアップロード](screenshots/gifs/drag-file-upload.gif)
### カスタムテーマ
テーマを調整して自分の好みに合わせた見た目に。
![カスタムテーマ](screenshots/gifs/custom-themes.gif)
### キーワードハイライト
重要な出力(エラー/警告/マーカーなど)を見つけやすくするために、ハイライトをカスタマイズできます。
![キーワードハイライト](screenshots/gifs/custom-highlight.gif)
---
<a name="スクリーンショット"></a>
# スクリーンショット
<a name="ホスト管理"></a>
## ホスト管理
<a name="メインウィンドウ"></a>
## メインウィンドウ
Vault ビューはすべての SSH 接続を管理するコマンドセンターです。右クリックメニューで階層的なグループを作成し、グループ間でホストをドラッグ、パンくずナビゲーションでホストツリーを素早く移動できます。各ホストは接続状態、OS アイコン、クイック接続ボタンを表示。グリッドとリストビューを切り替え、強力な検索で名前、ホスト名、タグ、グループでフィルタリングできます。
メインウィンドウは、長時間の SSH 作業を前提に設計されています。セッション、ナビゲーション、主要ツールへ素早くアクセスできます。
**ダークモード**
![メインウィンドウ(ダーク)](screenshots/main-window-dark.png)
![ホスト管理](screenshots/vault_grid_view.png)
![メインウィンドウ(ライト)](screenshots/main-window-light.png)
**ネストされたフォルダと整理**
<a name="vault-ビュー"></a>
## Vault ビュー
![ネストされたフォルダ](screenshots/nested_folder_structure.png)
作業に合わせて見え方を切り替え:グリッドで全体像、リストでスキャン、ツリーで整理と階層ナビゲーション。
**リストビュー**
![Vault グリッドビュー](screenshots/vault_grid_view.png)
![リストビュー](screenshots/vault_list_view.png)
![Vault リストビュー](screenshots/vault_list_view.png)
<a name="ターミナル"></a>
## ターミナル
![Vault ツリービュー(ダーク)](screenshots/treeview-dark.png)
WebGL アクセラレーション対応の xterm.js ベースのターミナルで、スムーズでレスポンシブな体験を提供。ワークスペースを水平または垂直に分割して、複数のセッションを同時に監視。ブロードキャストモードを有効にすると、すべてのターミナルに一度にコマンドを送信できます — フリート管理に最適。テーマカスタマイズパネルでは、50以上の配色スキームをライブプレビュー、フォントサイズの調整、JetBrains Mono や Fira Code を含む複数のフォントファミリーを選択できます。
![Vault ツリービュー(ライト)](screenshots/treeview-light.png)
**分割ウィンドウ**
<a name="分割ターミナル"></a>
## 分割ターミナル
**ブロードキャストモード**
分割ペインで複数のサーバー/タスクを同時に扱えます(例:デプロイ + ログ + 監視)。
一度入力すれば、どこでも実行できます。複数のサーバーを同時にメンテナンスするのに最適です。
![ブロードキャストモード](screenshots/broadcast_mode.png)
**パフォーマンス情報とカスタマイズ**
接続の健全性を監視し、ターミナルのあらゆる側面をカスタマイズします。
![ターミナルパフォーマンス](screenshots/terminal_performance.png)
<a name="sftp"></a>
## SFTP
デュアルペイン SFTP ブラウザは、ローカルからリモート、リモートからリモートへのファイル転送をサポート。シングルクリックでディレクトリを移動、ペイン間でファイルをドラッグ&ドロップ、転送進捗をリアルタイムで監視。インターフェースにはファイル権限、サイズ、変更日時を表示。複数の転送をキューに入れ、詳細な速度と進捗インジケーターで完了を確認。コンテキストメニューから名前変更、削除、ダウンロード、アップロード操作にすばやくアクセス。
![SFTP デュアルペイン](screenshots/sftp_dual_pane.png)
**転送キュー**
![転送キュー](screenshots/sftp_transfer_queue.png)
<a name="キーチェーン"></a>
## キーチェーン
キーチェーンは SSH 認証情報を保管する安全な保管庫です。新しいキーを生成、既存のキーをインポート、エンタープライズ認証用の SSH 証明書を管理できます。
| キータイプ | アルゴリズム | 推奨用途 |
|----------|------------|---------|
| **ED25519** | EdDSA | モダン、高速、最も安全(推奨) |
| **ECDSA** | NIST P-256/384/521 | 高いセキュリティ、広くサポート |
| **RSA** | RSA 2048/4096 | レガシー互換性、ユニバーサルサポート |
| **証明書** | CA 署名 | エンタープライズ環境、短期認証 |
**機能:**
- 🔑 カスタマイズ可能なビット長でキーを生成
- 📥 PEM/OpenSSH 形式のキーをインポート
- 👤 再利用可能なアイデンティティを作成(ユーザー名+認証方式)
- 📤 ワンクリックで公開鍵をリモートホストにエクスポート
![キーマネージャー](screenshots/key-manager.png)
**キー生成**
![キー生成](screenshots/key_generator_ui.png)
<a name="ポートフォワーディング"></a>
## ポートフォワーディング
直感的なビジュアルインターフェースで SSH トンネルをセットアップ。各トンネルはリアルタイムステータスを表示し、アクティブ、接続中、エラー状態を明確に示します。トンネル設定を保存してセッション間で素早く再利用。
| タイプ | 方向 | ユースケース | 例 |
|-------|-----|------------|---|
| **ローカル** | リモート → ローカル | リモートサービスをローカルマシンでアクセス | リモート MySQL `3306``localhost:3306` に転送 |
| **リモート** | ローカル → リモート | ローカルサービスをリモートサーバーと共有 | ローカル開発サーバーをリモートマシンに公開 |
| **ダイナミック** | SOCKS5 プロキシ | SSH トンネル経由で安全にブラウジング | 暗号化された SSH 接続経由でインターネットをブラウズ |
![ポートフォワーディング](screenshots/port-forwadring.png)
<a name="クラウド同期"></a>
## クラウド同期
エンドツーエンド暗号化で、すべてのデバイス間でホスト、キー、スニペット、設定を同期。マスターパスワードがアップロード前にすべてのデータをローカルで暗号化 — クラウドプロバイダーは平文を見ることはありません。
| プロバイダー | 最適な用途 | セットアップ複雑度 |
|------------|----------|-----------------|
| **GitHub Gist** | クイックセットアップ、バージョン履歴 | ⭐ 簡単 |
| **Google Drive** | 個人利用、大容量ストレージ | ⭐ 簡単 |
| **OneDrive** | Microsoft エコシステムユーザー | ⭐ 簡単 |
| **S3 互換** | AWS、MinIO、Cloudflare R2、セルフホスト | ⭐⭐ 中程度 |
| **WebDAV** | Nextcloud、ownCloud、セルフホスト | ⭐⭐ 中程度 |
**同期対象:**
- ✅ ホストと接続設定
- ✅ SSH キーと証明書
- ✅ アイデンティティと認証情報
- ✅ スニペットとスクリプト
- ✅ カスタムグループとタグ
- ✅ ポートフォワーディングルール
- ✅ アプリケーション設定
![クラウド同期](screenshots/cloud-sync.png)
<a name="テーマとカスタマイズ"></a>
## テーマとカスタマイズ
Netcatty を自分だけのものに。ライトモードとダークモードを切り替えたり、システム設定に従わせたり。好みに合わせてアクセントカラーを選択。アプリケーションは English や简体中文を含む複数の言語をサポートしており、コミュニティによる翻訳貢献を歓迎しています。クラウド同期を有効にすると、すべての設定がデバイス間で同期され、パーソナライズされた体験がどこでも利用できます。
![テーマと国際化](screenshots/app-themes-i18n.png)
![分割ウィンドウ](screenshots/split-window.png)
---
<a name="対応ディストリビューション"></a>
# 対応ディストリビューション
Netcatty は接続したホストの OS アイコンを自動的に検出・表示します:
Netcatty は接続したホストの OS を検出し、ホスト一覧でアイコンとして表示します:
<p align="center">
<img src="public/distro/ubuntu.svg" width="48" alt="Ubuntu" title="Ubuntu">
@@ -346,7 +273,7 @@ npm run pack
# 特定のプラットフォーム用にパッケージ
npm run pack:mac # macOS (DMG + ZIP)
npm run pack:win # Windows (NSIS インストーラー)
npm run pack:linux # Linux (AppImage, deb, rpm)
npm run pack:linux # Linux (AppImage + DEB + RPM)
```
---
@@ -356,7 +283,7 @@ npm run pack:linux # Linux (AppImage, deb, rpm)
| カテゴリ | テクノロジー |
|--------|------------|
| フレームワーク | Electron 39 |
| フレームワーク | Electron 40 |
| フロントエンド | React 19, TypeScript |
| ビルドツール | Vite 7 |
| ターミナル | xterm.js 5 |
@@ -382,17 +309,6 @@ npm run pack:linux # Linux (AppImage, deb, rpm)
---
<a name="コントリビューター"></a>
# コントリビューター
貢献してくれたすべての人々に感謝します!
<a href="https://github.com/binaricat/Netcatty/graphs/contributors">
<img src="https://contrib.rocks/image?repo=binaricat/Netcatty" alt="contributors" />
</a>
---
<a name="ライセンス"></a>
# ライセンス

241
README.md
View File

@@ -11,7 +11,7 @@
<p align="center">
A beautiful, feature-rich SSH workspace built with Electron, React, and xterm.js.<br/>
Host management, split terminals, SFTP, port forwarding, and cloud sync — all in one.
Split terminals, Vault views, SFTP workflows, custom themes, and keyword highlighting — all in one.
</p>
<p align="center">
@@ -40,22 +40,20 @@
---
[![Netcatty Main Interface](screenshots/vault_grid_view.png)](screenshots/vault_grid_view.png)
[![Netcatty Main Interface](screenshots/main-window-dark.png)](screenshots/main-window-dark.png)
---
# Contents <!-- omit in toc -->
- [What is Netcatty](#what-is-netcatty)
- [Why Netcatty](#why-netcatty)
- [Features](#features)
- [Demos](#demos)
- [Screenshots](#screenshots)
- [Host Management](#host-management)
- [Terminal](#terminal)
- [SFTP](#sftp)
- [Keychain](#keychain)
- [Port Forwarding](#port-forwarding)
- [Cloud Sync](#cloud-sync)
- [Themes & Customization](#themes--customization)
- [Main Window](#main-window)
- [Vault Views](#vault-views)
- [Split Terminals](#split-terminals)
- [Supported Distros](#supported-distros)
- [Getting Started](#getting-started)
- [Build & Package](#build--package)
@@ -73,180 +71,111 @@
- **Netcatty is** an alternative to PuTTY, Termius, SecureCRT, and macOS Terminal.app for SSH connections
- **Netcatty is** a powerful SFTP client with dual-pane file browser
- **Netcatty is** a terminal workspace with split panes, tabs, and session management
- **Netcatty is not** a shell replacement — it connects to remote shells via SSH/Telnet or local terminals
- **Netcatty supports** SSH, local terminal, Telnet, Mosh, and Serial connections (when available)
- **Netcatty is not** a shell replacement — it connects to shells via SSH/Telnet/Mosh or local/serial sessions
---
<a name="why-netcatty"></a>
# Why Netcatty
If you regularly work with a fleet of servers, Netcatty is built for speed and flow:
- **Workspace-first** — split panes + tabs + session restore for “always-on” workflows
- **Vault organization** — grid/list/tree views with fast search and drag-friendly workflows
- **Serious SFTP** — built-in editor + drag & drop + smooth file operations
---
<a name="features"></a>
# Features
### 🖥 Terminal & Sessions
- **xterm.js-based terminal** with GPU-accelerated rendering
### 🗂 Vault
- **Multiple views** — grid / list / tree
- **Fast search** — locate hosts and groups quickly
### 🖥️ Terminal Workspaces
- **Split panes** — horizontal and vertical splits for multi-tasking
- **Tab management** — multiple sessions with drag-to-reorder
- **Session persistence** — restore sessions on restart
- **Broadcast mode** — type once, send to multiple terminals
- **Session management** — run multiple connections side-by-side
### 🔐 SSH Client
- **SSH2 protocol** with full authentication support
- **Password & key-based authentication**
- **SSH certificates** support
- **Jump hosts / Bastion** — chain through multiple hosts
- **Proxy support** — HTTP CONNECT and SOCKS5 proxies
- **Agent forwarding** — including OpenSSH Agent and Pageant
- **Environment variables** — set custom env vars per host
### 📁 SFTP + Built-in Editor
- **File workflows** — drag & drop uploads/downloads
- **Edit in place** — built-in editor for quick changes
### 📁 SFTP
- **Dual-pane file browser** — local ↔ remote or remote ↔ remote
- **Sudo Privilege Escalation** — Browse and edit root-owned files with sudo
- **Drag & Drop** uploads and downloads
- **Queue management** for batch transfers
- **Progress tracking** with transfer speed
### 🎨 Personalization
- **Custom themes** — tune the app appearance to your taste
- **Keyword highlighting** — customize highlight rules for terminal output
### 🔑 Keychain
- **Generate SSH keys** — RSA, ECDSA, ED25519
- **Import existing keys** — PEM, OpenSSH formats
- **SSH certificates** support
- **Identity management** — reusable username + auth combinations
- **Export public keys** to remote hosts
---
### 🔌 Port Forwarding
- **Local forwarding** — expose remote services locally
- **Remote forwarding** — expose local services remotely
- **Dynamic forwarding** — SOCKS5 proxy
- **Visual tunnel management**
<a name="demos"></a>
# Demos
### ☁️ Cloud Sync
- **End-to-end encrypted sync** — your data is encrypted before leaving your device
- **Multiple providers** — GitHub Gist, S3-compatible storage, WebDAV, Google Drive, OneDrive
- **Sync hosts, keys, snippets, and settings**
GIF previews (stored in `screenshots/gifs/`), rendered inline on GitHub:
### 🎨 Themes & Customization
- **Light & Dark mode**
- **Custom accent colors**
- **50+ terminal color schemes**
- **Font customization** — JetBrains Mono, Fira Code, and more
- **i18n support** — English, 简体中文, and more
### Vault views: grid / list / tree
Switch between different Vault views to match your workflow: overview in grid, dense scanning in list, and hierarchical navigation in tree.
![Vault views: grid/list/tree](screenshots/gifs/gird-list-tre-views.gif)
### Split terminals + session management
Work in multiple sessions at once with split panes. Keep related tasks side-by-side and reduce context switching.
![Split terminals + session management](screenshots/gifs/dual-terminal--split-manage.gif)
### SFTP: drag & drop + built-in editor
Move files with drag & drop, then edit quickly using the built-in editor without leaving the app.
![SFTP: drag & drop + built-in editor](screenshots/gifs/sftpview-with-drag-and-built-in-editor.gif)
### Drag file upload
Drop files into the app to kick off uploads without hunting through dialogs.
![Drag file upload](screenshots/gifs/drag-file-upload.gif)
### Custom themes
Make Netcatty yours: customize themes and UI appearance.
![Custom themes](screenshots/gifs/custom-themes.gif)
### Keyword highlighting
Highlight important terminal output so errors, warnings, and key events stand out at a glance.
![Keyword highlighting](screenshots/gifs/custom-highlight.gif)
---
<a name="screenshots"></a>
# Screenshots
<a name="host-management"></a>
## Host Management
<a name="main-window"></a>
## Main Window
The Vault view is your command center for managing all SSH connections. Create hierarchical groups with right-click context menus, drag hosts between groups, and use breadcrumb navigation to quickly traverse your host tree. Each host displays its connection status, OS icon, and quick-connect button. Switch between grid and list views based on your preference, and use the powerful search to filter hosts by name, hostname, tags, or group.
The main window is designed for long-running SSH workflows: quick access to sessions, navigation, and core tools in one place.
**Dark Mode**
![Main Window (Dark)](screenshots/main-window-dark.png)
![Host Management](screenshots/vault_grid_view.png)
![Main Window (Light)](screenshots/main-window-light.png)
**Nested Folders & Organization**
<a name="vault-views"></a>
## Vault Views
![Nested Folders](screenshots/nested_folder_structure.png)
Organize and navigate your hosts using the view that best fits the moment: grid for overview, list for scanning, tree for structure.
**List View**
![Vault Grid View](screenshots/vault_grid_view.png)
![List View](screenshots/vault_list_view.png)
![Vault List View](screenshots/vault_list_view.png)
<a name="terminal"></a>
## Terminal
![Vault Tree View (Dark)](screenshots/treeview-dark.png)
Powered by xterm.js with WebGL acceleration, the terminal delivers a smooth, responsive experience. Split your workspace horizontally or vertically to monitor multiple sessions simultaneously. Enable broadcast mode to send commands to all terminals at once — perfect for fleet management. The theme customization panel offers 50+ color schemes with live preview, adjustable font size, and multiple font family options including JetBrains Mono and Fira Code.
![Vault Tree View (Light)](screenshots/treeview-light.png)
**Split Windows**
<a name="split-terminals"></a>
## Split Terminals
**Broadcast Mode**
Split panes help you monitor multiple servers/services at the same time (deploy + logs + metrics) without juggling windows.
Type once, execute everywhere. Great for maintaining multiple servers simultaneously.
![Broadcast Mode](screenshots/broadcast_mode.png)
**Performance Info & Customization**
Monitor your connection health and customize every aspect of your terminal.
![Terminal Performance](screenshots/terminal_performance.png)
<a name="sftp"></a>
## SFTP
The dual-pane SFTP browser supports local-to-remote and remote-to-remote file transfers. Navigate directories with single-click, drag files between panes, and monitor transfer progress in real-time. The interface shows file permissions, sizes, and modification dates. Queue multiple transfers and watch them complete with detailed speed and progress indicators. Context menus provide quick access to rename, delete, download, and upload operations.
![SFTP Dual Pane](screenshots/sftp_dual_pane.png)
**Transfer Queue**
![Transfer Queue](screenshots/sftp_transfer_queue.png)
<a name="keychain"></a>
## Keychain
The Keychain is your secure vault for SSH credentials. Generate new keys, import existing ones, or manage SSH certificates for enterprise authentication.
| Key Type | Algorithm | Recommended Use |
|----------|-----------|----------------|
| **ED25519** | EdDSA | Modern, fast, most secure (recommended) |
| **ECDSA** | NIST P-256/384/521 | Good security, widely supported |
| **RSA** | RSA 2048/4096 | Legacy compatibility, universal support |
| **Certificate** | CA-signed | Enterprise environments, short-lived auth |
**Features:**
- 🔑 Generate keys with customizable bit lengths
- 📥 Import PEM/OpenSSH format keys
- 👤 Create reusable identities (username + auth method)
- 📤 One-click export public keys to remote hosts
![Key Manager](screenshots/key-manager.png)
**Key Generator**
![Key Generator](screenshots/key_generator_ui.png)
<a name="port-forwarding"></a>
## Port Forwarding
Set up SSH tunnels with an intuitive visual interface. Each tunnel shows real-time status with clear indicators for active, connecting, or error states. Save tunnel configurations for quick reuse across sessions.
| Type | Direction | Use Case | Example |
|------|-----------|----------|--------|
| **Local** | Remote → Local | Access remote services on your machine | Forward remote MySQL `3306` to `localhost:3306` |
| **Remote** | Local → Remote | Share local services with remote server | Expose local dev server to remote machine |
| **Dynamic** | SOCKS5 Proxy | Secure browsing through SSH tunnel | Browse internet via encrypted SSH connection |
![Port Forwarding](screenshots/port-forwadring.png)
<a name="cloud-sync"></a>
## Cloud Sync
Keep your hosts, keys, snippets, and settings synchronized across all your devices with end-to-end encryption. Your master password encrypts all data locally before upload — the cloud provider never sees plaintext.
| Provider | Best For | Setup Complexity |
|----------|----------|------------------|
| **GitHub Gist** | Quick setup, version history | ⭐ Easy |
| **Google Drive** | Personal use, large storage | ⭐ Easy |
| **OneDrive** | Microsoft ecosystem users | ⭐ Easy |
| **S3-Compatible** | AWS, MinIO, Cloudflare R2, self-hosted | ⭐⭐ Medium |
| **WebDAV** | Nextcloud, ownCloud, self-hosted | ⭐⭐ Medium |
**What syncs:**
- ✅ Hosts & connection settings
- ✅ SSH keys & certificates
- ✅ Identities & credentials
- ✅ Snippets & scripts
- ✅ Custom groups & tags
- ✅ Port forwarding rules
- ✅ Application preferences
![Cloud Sync](screenshots/cloud-sync.png)
<a name="themes--customization"></a>
## Themes & Customization
Make Netcatty truly yours with extensive customization options. Toggle between light and dark modes, or let the app follow your system preference. Pick any accent color to match your style. The application supports multiple languages including English and 简体中文, with more translations welcome via community contributions. All preferences sync across devices when cloud sync is enabled, so your personalized experience follows you everywhere.
![Themes & i18n](screenshots/app-themes-i18n.png)
![Split Windows](screenshots/split-window.png)
---
@@ -270,8 +199,6 @@ Netcatty automatically detects and displays OS icons for connected hosts:
<img src="public/distro/kali.svg" width="48" alt="Kali Linux" title="Kali Linux">
</p>
---
<a name="getting-started"></a>
# Getting Started
@@ -355,7 +282,7 @@ npm run pack:linux # Linux (AppImage + DEB + RPM)
| Category | Technology |
|----------|------------|
| Framework | Electron 39 |
| Framework | Electron 40 |
| Frontend | React 19, TypeScript |
| Build Tool | Vite 7 |
| Terminal | xterm.js 5 |
@@ -386,9 +313,7 @@ See [agents.md](agents.md) for architecture overview and coding conventions.
Thanks to all the people who contribute!
<a href="https://github.com/binaricat/Netcatty/graphs/contributors">
<img src="https://contrib.rocks/image?repo=binaricat/Netcatty" alt="contributors" />
</a>
See: https://github.com/binaricat/Netcatty/graphs/contributors
---

View File

@@ -11,13 +11,13 @@
<p align="center">
一个基于 Electron、React 和 xterm.js 构建的功能丰富的 SSH 工作空间。<br/>
主机管理、分屏终端、SFTP、端口转发、云同步 —— 一应俱全。
分屏终端、Vault 多视图、SFTP 工作流、自定义主题、关键词高亮 —— 一应俱全。
</p>
<p align="center">
<a href="https://github.com/binaricat/Netcatty/releases/latest"><img alt="GitHub Release" src="https://img.shields.io/github/v/release/binaricat/Netcatty?style=for-the-badge&logo=github&label=Release"></a>
&nbsp;
<a href="#"><img alt="Platform" src="https://img.shields.io/badge/Platform-macOS%20%7C%20Windows-blue?style=for-the-badge&logo=electron"></a>
<a href="#"><img alt="Platform" src="https://img.shields.io/badge/Platform-macOS%20%7C%20Windows%20%7C%20Linux-blue?style=for-the-badge&logo=electron"></a>
&nbsp;
<a href="LICENSE"><img alt="License" src="https://img.shields.io/badge/License-GPL--3.0-green?style=for-the-badge"></a>
</p>
@@ -40,22 +40,20 @@
---
[![Netcatty 主界面](screenshots/vault_grid_view.png)](screenshots/vault_grid_view.png)
[![Netcatty 主界面](screenshots/main-window-dark.png)](screenshots/main-window-dark.png)
---
# 目录 <!-- omit in toc -->
- [Netcatty 是什么](#netcatty-是什么)
- [为什么是 Netcatty](#为什么是-netcatty)
- [功能特性](#功能特性)
- [演示](#演示)
- [界面截图](#界面截图)
- [机管理](#主机管理)
- [终端](#终端)
- [SFTP](#sftp)
- [密钥管理](#密钥管理)
- [端口转发](#端口转发)
- [云同步](#云同步)
- [主题与定制](#主题与定制)
- [界面](#主界面)
- [Vault 视图](#vault-视图)
- [分屏终端](#分屏终端)
- [支持的发行版](#支持的发行版)
- [快速开始](#快速开始)
- [构建与打包](#构建与打包)
@@ -73,188 +71,118 @@
- **Netcatty 是** PuTTY、Termius、SecureCRT 和 macOS Terminal.app 的现代替代品
- **Netcatty 是** 一个强大的 SFTP 客户端,支持双窗格文件浏览
- **Netcatty 是** 一个终端工作空间,支持分屏、标签页和会话管理
- **Netcatty 不是** Shell 替代品 —— 它通过 SSH/Telnet 或本地终端连接到远程 Shell
- **Netcatty 支持** SSH、本地终端、Telnet、Mosh、串口Serial等连接方式视环境而定
- **Netcatty 不是** Shell 替代品 —— 它通过 SSH/Telnet/Mosh 或本地/串口会话连接到 Shell
---
<a name="为什么是-netcatty"></a>
# 为什么是 Netcatty
如果你需要同时维护多台服务器Netcatty 更像是“工作台”而不是单一终端:
- **以工作区为核心** —— 分屏 + 多会话并行,适合长期驻留的工作流
- **Vault 管理** —— 网格/列表/树形视图,配合搜索与拖拽更顺手
- **认真做的 SFTP** —— 内置编辑器 + 拖拽上传,文件操作更丝滑
---
<a name="功能特性"></a>
# 功能特性
### 🖥 终端与会话
- **基于 xterm.js 的终端**,支持 GPU 加速渲染
- **分屏功能** —— 水平和垂直分割,多任务并行
- **标签页管理** —— 多会话支持,拖拽排序
- **会话持久化** —— 重启后恢复会话
- **广播模式** —— 一次输入,发送到多个终端
### 🗂 Vault
- **多种视图** —— 网格 / 列表 / 树形
- **快速搜索** —— 迅速定位主机与分组
### 🔐 SSH 客户端
- **SSH2 协议**,完整的认证支持
- **密码和密钥认证**
- **SSH 证书**支持
- **跳板机 / 堡垒机** —— 多主机链式连接
- **代理支持** —— HTTP CONNECT 和 SOCKS5 代理
- **Agent 转发** —— 支持 OpenSSH Agent 和 Pageant
- **环境变量** —— 为每个主机设置自定义环境变量
### 🖥️ 终端工作区
- **分屏** —— 水平/垂直分割,多任务并行
- **多会话管理** —— 多连接并排处理
### 📁 SFTP
- **双窗格文件浏览器** —— 本地 ↔ 远程 或 远程 ↔ 远程
- **Sudo 提权支持** —— 使用 sudo 浏览和编辑 root 权限文件
- **拖放操作** —— 支持上传和下载
- **拖放传输** 文件
- **队列管理** 批量传输
- **进度跟踪** 显示传输速度
### 📁 SFTP + 内置编辑器
- **文件工作流** —— 拖拽上传/下载更直观
- **就地编辑** —— 内置编辑器快速修改文件
### 🔑 密钥管理
- **生成 SSH 密钥** —— RSA、ECDSA、ED25519
- **导入已有密钥** —— PEM、OpenSSH 格式
- **SSH 证书**支持
- **身份管理** —— 可复用的用户名 + 认证方式组合
- **导出公钥**到远程主机
### 🎨 个性化
- **自定义主题** —— 按喜好调整应用外观
- **关键词高亮** —— 自定义终端输出高亮规则
### 🔌 端口转发
- **本地转发** —— 将远程服务暴露到本地
- **远程转发** —— 将本地服务暴露到远程
- **动态转发** —— SOCKS5 代理
- **可视化隧道管理**
---
### ☁️ 云同步
- **端到端加密同步** —— 数据在离开设备前加密
- **多种存储后端** —— GitHub Gist、S3 兼容存储、WebDAV、Google Drive、OneDrive
- **同步主机、密钥、代码片段和设置**
<a name="演示"></a>
# 演示
### 🎨 主题与定制
- **浅色 & 深色模式**
- **自定义强调色**
- **50+ 终端配色方案**
- **字体自定义** —— JetBrains Mono、Fira Code 等
- **多语言支持** —— English、简体中文 等
GIF 预览(素材均在 `screenshots/gifs/`),在 GitHub README 中可直接观看:
### Vault 视图:网格 / 列表 / 树形
根据不同场景自由切换视图:网格适合总览,列表适合密集浏览,树形适合层级导航与整理。
![Vault 视图:网格/列表/树形](screenshots/gifs/gird-list-tre-views.gif)
### 分屏终端 + 会话管理
用分屏把多个会话并排放在同一个工作区里,降低来回切换窗口/标签页的成本。
![分屏终端 + 会话管理](screenshots/gifs/dual-terminal--split-manage.gif)
### SFTP拖拽 + 内置编辑器
通过拖拽完成文件传输,并用内置编辑器快速修改文件内容,不用来回切换工具。
![SFTP拖拽 + 内置编辑器](screenshots/gifs/sftpview-with-drag-and-built-in-editor.gif)
### 拖拽文件上传
把文件直接拖进应用即可触发上传流程,省去多层对话框与路径选择。
![拖拽文件上传](screenshots/gifs/drag-file-upload.gif)
### 自定义主题
按自己的审美与习惯定制主题与界面外观,让日常使用更顺手。
![自定义主题](screenshots/gifs/custom-themes.gif)
### 关键词高亮
让关键输出一眼可见:错误、告警或特定标记被高亮后更容易扫到与定位。
![关键词高亮](screenshots/gifs/custom-highlight.gif)
---
<a name="界面截图"></a>
# 界面截图
<a name="主机管理"></a>
## 主机管理
<a name="主界面"></a>
## 主界面
Vault 视图是管理所有 SSH 连接的控制中心。通过右键菜单创建层级分组,在分组间拖拽主机,使用面包屑导航快速遍历主机树。每个主机显示连接状态、操作系统图标和快速连接按钮。根据偏好在网格和列表视图之间切换,使用强大的搜索按名称、主机名、标签或分组过滤主机
主界面围绕长期 SSH 工作流设计:把会话、导航和常用工具集中到同一处,减少切换成本
**深色模式**
![主界面(深色)](screenshots/main-window-dark.png)
![机管理](screenshots/vault_grid_view.png)
![界面(浅色)](screenshots/main-window-light.png)
**层级文件夹与分组**
<a name="vault-视图"></a>
## Vault 视图
![层级文件夹](screenshots/nested_folder_structure.png)
用更适合当前任务的方式管理与浏览主机:网格看全局,列表做筛选,树形做整理与层级导航。
**列表视图**
![Vault 网格视图](screenshots/vault_grid_view.png)
![列表视图](screenshots/vault_list_view.png)
![Vault 列表视图](screenshots/vault_list_view.png)
<a name="终端"></a>
## 终端
![Vault 树形视图(深色)](screenshots/treeview-dark.png)
基于 xterm.js 的 WebGL 加速终端,提供流畅、响应迅速的体验。水平或垂直分割工作区,同时监控多个会话。启用广播模式可一次向所有终端发送命令 —— 非常适合批量管理。主题定制面板提供 50+ 配色方案和实时预览、可调节字号以及多种字体选择,包括 JetBrains Mono 和 Fira Code。
![Vault 树形视图(浅色)](screenshots/treeview-light.png)
**分屏窗口**
<a name="分屏终端"></a>
## 分屏终端
**广播模式**
分屏适合同时处理多个任务(例如部署 + 日志 + 排障),不用频繁切换窗口。
一次输入,多处执行。非常适合同时维护这多台服务器。
![广播模式](screenshots/broadcast_mode.png)
**性能信息与定制**
监控连接健康状况,并自定义终端的方方面面。
![终端性能](screenshots/terminal_performance.png)
<a name="sftp"></a>
## SFTP
双窗格 SFTP 浏览器支持本地到远程和远程到远程的文件传输。单击导航目录,在窗格之间拖放文件,实时监控传输进度。界面显示文件权限、大小和修改日期。批量传输队列管理,详细的速度和进度指示器。右键菜单快速访问重命名、删除、下载和上传操作。
![SFTP 双窗格](screenshots/sftp_dual_pane.png)
**传输队列**
![传输队列](screenshots/sftp_transfer_queue.png)
<a name="密钥管理"></a>
## 密钥管理
密钥库是您存储 SSH 凭证的安全保险库。生成新密钥、导入已有密钥或管理企业认证的 SSH 证书。
| 密钥类型 | 算法 | 推荐用途 |
|---------|------|---------|
| **ED25519** | EdDSA | 现代、快速、最安全(推荐) |
| **ECDSA** | NIST P-256/384/521 | 安全性好、广泛支持 |
| **RSA** | RSA 2048/4096 | 旧版兼容、通用支持 |
| **证书** | CA 签名 | 企业环境、短期认证 |
**功能:**
- 🔑 生成可自定义位长的密钥
- 📥 导入 PEM/OpenSSH 格式密钥
- 👤 创建可复用身份(用户名 + 认证方式)
- 📤 一键导出公钥到远程主机
![密钥管理器](screenshots/key-manager.png)
**密钥生成器**
![密钥生成器](screenshots/key_generator_ui.png)
<a name="端口转发"></a>
## 端口转发
通过直观的可视化界面设置 SSH 隧道。每个隧道显示实时状态,清晰指示活动、连接中或错误状态。保存隧道配置以便跨会话快速复用。
| 类型 | 方向 | 使用场景 | 示例 |
|-----|-----|---------|-----|
| **本地** | 远程 → 本地 | 在本机访问远程服务 | 将远程 MySQL `3306` 转发到 `localhost:3306` |
| **远程** | 本地 → 远程 | 与远程服务器共享本地服务 | 将本地开发服务器暴露给远程机器 |
| **动态** | SOCKS5 代理 | 通过 SSH 隧道安全浏览 | 通过加密 SSH 连接浏览互联网 |
![端口转发](screenshots/port-forwadring.png)
<a name="云同步"></a>
## 云同步
通过端到端加密在所有设备间同步主机、密钥、代码片段和设置。主密码在上传前本地加密所有数据 —— 云服务商永远看不到明文。
| 服务商 | 最适合 | 配置复杂度 |
|-------|-------|----------|
| **GitHub Gist** | 快速设置、版本历史 | ⭐ 简单 |
| **Google Drive** | 个人使用、大容量存储 | ⭐ 简单 |
| **OneDrive** | 微软生态用户 | ⭐ 简单 |
| **S3 兼容存储** | AWS、MinIO、Cloudflare R2、自托管 | ⭐⭐ 中等 |
| **WebDAV** | Nextcloud、ownCloud、自托管 | ⭐⭐ 中等 |
**同步内容:**
- ✅ 主机与连接设置
- ✅ SSH 密钥与证书
- ✅ 身份与凭证
- ✅ 代码片段与脚本
- ✅ 自定义分组与标签
- ✅ 端口转发规则
- ✅ 应用程序偏好设置
![云同步](screenshots/cloud-sync.png)
<a name="主题与定制"></a>
## 主题与定制
让 Netcatty 真正属于你。在浅色和深色模式之间切换,或让应用跟随系统偏好。选择任意强调色来匹配你的风格。应用支持多种语言,包括 English 和简体中文,欢迎社区贡献更多翻译。启用云同步后,所有偏好设置都会跨设备同步,个性化体验随处可用。
![主题与国际化](screenshots/app-themes-i18n.png)
![分屏窗口](screenshots/split-window.png)
---
<a name="支持的发行版"></a>
# 支持的发行版
Netcatty 自动检测并显示已连接主机的操作系统图标:
Netcatty 自动识别并在主机列表中展示对应的系统图标:
<p align="center">
<img src="public/distro/ubuntu.svg" width="48" alt="Ubuntu" title="Ubuntu">
@@ -271,8 +199,6 @@ Netcatty 自动检测并显示已连接主机的操作系统图标:
<img src="public/distro/kali.svg" width="48" alt="Kali Linux" title="Kali Linux">
</p>
---
<a name="快速开始"></a>
# 快速开始
@@ -356,7 +282,7 @@ npm run pack:linux # Linux (AppImage, deb, rpm)
| 分类 | 技术 |
|-----|-----|
| 框架 | Electron 39 |
| 框架 | Electron 40 |
| 前端 | React 19, TypeScript |
| 构建工具 | Vite 7 |
| 终端 | xterm.js 5 |
@@ -387,9 +313,7 @@ npm run pack:linux # Linux (AppImage, deb, rpm)
感谢所有参与贡献的人!
<a href="https://github.com/binaricat/Netcatty/graphs/contributors">
<img src="https://contrib.rocks/image?repo=binaricat/Netcatty" alt="contributors" />
</a>
查看:https://github.com/binaricat/Netcatty/graphs/contributors
---

View File

@@ -57,6 +57,9 @@ const en: Messages = {
'placeholder.sessionName': 'Session name',
'placeholder.searchHosts': 'Search hosts...',
'toast.settingsUnavailable': 'Settings window is unavailable on this platform.',
'credentials.protectionUnavailable.title': 'Credential Protection Unavailable',
'credentials.protectionUnavailable.message': 'Saved passwords and keys cannot be auto-decrypted on this device. Re-enter credentials before connecting.',
'credentials.protectionUnavailable.action': 'Open Settings',
// Settings shell
'settings.title': 'Settings',
@@ -80,6 +83,14 @@ const en: Messages = {
'settings.system.clearing': 'Clearing...',
'settings.system.clearResult': 'Deleted {deleted} file(s), {failed} failed.',
'settings.system.tempDirectoryHint': 'Temporary files are created when opening remote files with external applications. They are automatically cleaned up when SFTP sessions close.',
'settings.system.credentials.title': 'Credential Protection',
'settings.system.credentials.status': 'Status',
'settings.system.credentials.checking': 'Checking...',
'settings.system.credentials.available': 'Available (OS keychain ready)',
'settings.system.credentials.unavailable': 'Unavailable (cannot decrypt saved credentials)',
'settings.system.credentials.unknown': 'Unknown (not supported in this environment)',
'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 > Session Logs
'settings.sessionLogs.title': 'Session Logs',
@@ -99,6 +110,35 @@ const en: Messages = {
'settings.sessionLogs.formatHtml': 'HTML (.html)',
'settings.sessionLogs.hint': 'Session logs capture all terminal output for troubleshooting and auditing purposes.',
// Settings > Global Hotkey (Quake Mode)
'settings.globalHotkey.title': 'Global Hotkey',
'settings.globalHotkey.toggleWindow': 'Toggle Window',
'settings.globalHotkey.toggleWindowDesc': 'Press a key combination to set a global shortcut for showing/hiding the window.',
'settings.globalHotkey.notSet': 'Not set',
'settings.globalHotkey.reset': 'Reset to default',
'settings.globalHotkey.closeToTray': 'Close to System Tray',
'settings.globalHotkey.closeToTrayDesc': 'When enabled, closing the window will minimize to the system tray instead of quitting.',
'settings.globalHotkey.hint': 'Global hotkey works system-wide to quickly show or hide the window (Quake-style terminal).',
// Tray Panel
'tray.openMainWindow': 'Open Main Window',
'tray.sessions': 'Sessions',
'tray.portForwarding': 'Port Forwarding',
'tray.status.connected': 'Connected',
'tray.status.connecting': 'Connecting',
'tray.status.disconnected': 'Disconnected',
'tray.status.active': 'Active',
'tray.status.inactive': 'Inactive',
'tray.status.error': 'Error',
'tray.recentHosts': 'Recent Hosts',
'tray.empty.title': 'Nothing here yet',
'tray.empty.subtitle': 'Go connect to a server, they miss you 🚀',
'tray.quit': 'Quit Netcatty',
// Vault Sidebar
'vault.sidebar.collapse': 'Collapse sidebar',
'vault.sidebar.expand': 'Expand sidebar',
// Settings > Application
'settings.application.checkUpdates': 'Check for updates',
'settings.application.reportProblem': 'Report a problem',
@@ -295,6 +335,7 @@ const en: Messages = {
'sync.autoSync.vaultLocked': 'Vault is locked. Open Settings → Sync & Cloud to unlock.',
'sync.autoSync.conflictDetected': 'Sync conflict detected. Open Settings → Sync & Cloud to resolve.',
'sync.autoSync.syncFailed': 'Sync failed',
'sync.credentialsUnavailable': 'This device cannot decrypt some saved credentials. Re-enter credentials locally before syncing.',
'time.never': 'Never',
'time.justNow': 'Just now',
'time.minutesAgo': '{minutes}m ago',
@@ -361,6 +402,8 @@ const en: Messages = {
'vault.hosts.deselectAll': 'Deselect All',
'vault.hosts.deleteSelected': 'Delete ({count})',
'vault.hosts.deleteMultiple.success': 'Deleted {count} hosts',
'vault.hosts.empty.title': 'Set up your hosts',
'vault.hosts.empty.desc': 'Save hosts to quickly connect to your servers, VMs, and containers.',
// Vault import
'vault.import.title': 'Add data to your vault',
@@ -685,9 +728,6 @@ const en: Messages = {
// Quick Switcher
'qs.search.placeholder': 'Search hosts or tabs',
'qs.recentConnections': 'Recent connections',
'qs.createWorkspace': 'Create a workspace',
'qs.restore': 'Restore',
'qs.jumpTo': 'Jump To',
'qs.localTerminal': 'Local Terminal',
@@ -753,6 +793,10 @@ const en: Messages = {
'hostDetails.agentForwarding.agentNotRunning': 'SSH Agent is not running',
'hostDetails.agentForwarding.agentNotRunningHint': 'Enable OpenSSH Authentication Agent service in Windows Services (services.msc) for agent forwarding to work.',
'hostDetails.section.agentForwarding': 'SSH Agent',
'hostDetails.section.legacyAlgorithms': 'Legacy Algorithms',
'hostDetails.legacyAlgorithms': 'Allow Legacy Algorithms',
'hostDetails.legacyAlgorithms.desc': 'Enable deprecated SSH algorithms (diffie-hellman-group1, ssh-dss, 3des-cbc, etc.) for connecting to older network equipment.',
'hostDetails.legacyAlgorithms.warning': 'These algorithms have known security weaknesses. Only enable for legacy devices that do not support modern cryptography.',
'hostDetails.jumpHosts': 'Proxy via Hosts',
'hostDetails.jumpHosts.hops': '{count} hop(s)',
'hostDetails.jumpHosts.direct': 'Direct',
@@ -871,6 +915,15 @@ const en: Messages = {
'terminal.toolbar.focus': 'Focus',
'terminal.toolbar.focusMode': 'Focus Mode',
'terminal.toolbar.closeSession': 'Close session',
'terminal.toolbar.hostHighlight.title': 'Host Keyword Highlighting',
'terminal.toolbar.hostHighlight.noRules': 'No custom highlight rules defined for this host',
'terminal.toolbar.hostHighlight.addRule': 'Add New Rule',
'terminal.toolbar.hostHighlight.labelPlaceholder': 'Label (e.g., Error)',
'terminal.toolbar.hostHighlight.patternPlaceholder': 'Regex pattern (e.g., \\bfailed\\b)',
'terminal.toolbar.hostHighlight.invalidPattern': 'Invalid regex pattern',
'terminal.toolbar.hostHighlight.clearAll': 'Clear All',
'terminal.toolbar.hostHighlight.changeColor': 'Change highlight color for',
'terminal.toolbar.hostHighlight.selectColor': 'Select color for new rule',
'terminal.serverStats.cpu': 'CPU Usage',
'terminal.serverStats.cpuCores': 'CPU Core Usage',
'terminal.serverStats.memory': 'Memory Usage',
@@ -879,6 +932,10 @@ const en: Messages = {
'terminal.serverStats.memBuffers': 'Buffers',
'terminal.serverStats.memCached': 'Cache',
'terminal.serverStats.memFree': 'Free',
'terminal.serverStats.swap': 'Swap',
'terminal.serverStats.swapUsed': 'Swap Used',
'terminal.serverStats.swapFree': 'Swap Free',
'terminal.serverStats.swapTotal': 'Total',
'terminal.serverStats.topProcesses': 'Top Processes by Memory',
'terminal.serverStats.disk': 'Disk Usage (Root)',
'terminal.serverStats.diskDetails': 'Mounted Disks',
@@ -915,6 +972,10 @@ const en: Messages = {
'terminal.auth.selectKey': 'Select Key',
'terminal.auth.noKeysHint': 'No keys available. Add keys in Keychain.',
'terminal.auth.continueSave': 'Continue & Save',
'terminal.auth.credentialsUnavailable': 'Saved credentials cannot be decrypted on this device. Please re-enter and save them again.',
'terminal.auth.jumpCredentialsUnavailable': 'A jump host has saved credentials that cannot be decrypted on this device. Open host settings and re-enter them.',
'terminal.auth.proxyCredentialsUnavailable': 'Proxy credentials cannot be decrypted on this device. Open host settings and re-enter the proxy password.',
'terminal.auth.keyUnavailableFallbackPassword': 'Saved SSH key is unavailable on this device. Falling back to password authentication.',
'terminal.progress.timeoutIn': 'Timeout in {seconds}s',
'terminal.progress.disconnected': 'Disconnected',
'terminal.progress.cancelling': 'Cancelling...',
@@ -1321,6 +1382,9 @@ const en: Messages = {
'passphrase.unlock': 'Unlock',
'passphrase.unlocking': 'Unlocking...',
'passphrase.skip': 'Skip',
// Text Editor
'sftp.editor.wordWrap': 'Word Wrap',
};
export default en;

View File

@@ -42,6 +42,9 @@ const zhCN: Messages = {
'placeholder.workspaceName': '工作区名称',
'placeholder.sessionName': '会话名称',
'toast.settingsUnavailable': '当前平台无法打开设置窗口。',
'credentials.protectionUnavailable.title': '凭据保护不可用',
'credentials.protectionUnavailable.message': '当前设备无法自动解密已保存的密码和密钥。连接前请重新输入凭据。',
'credentials.protectionUnavailable.action': '打开设置',
// Settings shell
'settings.title': '设置',
@@ -65,6 +68,14 @@ const zhCN: Messages = {
'settings.system.clearing': '清理中...',
'settings.system.clearResult': '已删除 {deleted} 个文件,{failed} 个失败。',
'settings.system.tempDirectoryHint': '临时文件在使用外部应用打开远程文件时创建。SFTP 会话关闭时会自动清理。',
'settings.system.credentials.title': '凭据保护',
'settings.system.credentials.status': '状态',
'settings.system.credentials.checking': '检查中...',
'settings.system.credentials.available': '可用(系统钥匙串正常)',
'settings.system.credentials.unavailable': '不可用(无法解密已保存凭据)',
'settings.system.credentials.unknown': '未知(当前环境不支持)',
'settings.system.credentials.unavailableHint': '在其他用户或机器上加密的凭据无法在此处解密。请在当前设备重新输入并保存凭据。',
'settings.system.credentials.portabilityHint': '云同步可跨设备,因为使用主密钥加密;本地 safeStorage 加密仅绑定当前系统用户/设备。',
// Settings > Session Logs
'settings.sessionLogs.title': '会话日志',
@@ -84,6 +95,35 @@ const zhCN: Messages = {
'settings.sessionLogs.formatHtml': 'HTML (.html)',
'settings.sessionLogs.hint': '会话日志用于记录终端输出,便于故障排查和审计。',
// Settings > Global Hotkey (Quake Mode)
'settings.globalHotkey.title': '全局快捷键',
'settings.globalHotkey.toggleWindow': '切换窗口',
'settings.globalHotkey.toggleWindowDesc': '按下组合键以设置显示/隐藏窗口的全局快捷键。',
'settings.globalHotkey.notSet': '未设置',
'settings.globalHotkey.reset': '恢复默认',
'settings.globalHotkey.closeToTray': '关闭时最小化到托盘',
'settings.globalHotkey.closeToTrayDesc': '启用后,关闭窗口将最小化到系统托盘而不是退出程序。',
'settings.globalHotkey.hint': '全局快捷键在系统范围内工作,可快速显示或隐藏窗口(下拉式终端风格)。',
// Tray Panel
'tray.openMainWindow': '打开主窗口',
'tray.sessions': '会话',
'tray.portForwarding': '端口转发',
'tray.status.connected': '已连接',
'tray.status.connecting': '连接中',
'tray.status.disconnected': '已断开',
'tray.status.active': '已启用',
'tray.status.inactive': '未启用',
'tray.status.error': '错误',
'tray.recentHosts': '最近连接的主机',
'tray.empty.title': '一切都很安静',
'tray.empty.subtitle': '去连接个服务器吧,它们想念你了 🚀',
'tray.quit': '退出 Netcatty',
// Vault Sidebar
'vault.sidebar.collapse': '收起侧边栏',
'vault.sidebar.expand': '展开侧边栏',
// Settings > Application
'settings.application.checkUpdates': '检查更新',
'settings.application.reportProblem': '反馈问题',
@@ -162,6 +202,7 @@ const zhCN: Messages = {
'sync.autoSync.vaultLocked': 'Vault 处于锁定状态。请打开 设置 → Sync & Cloud 解锁。',
'sync.autoSync.conflictDetected': '检测到同步冲突。请打开 设置 → Sync & Cloud 处理。',
'sync.autoSync.syncFailed': '同步失败',
'sync.credentialsUnavailable': '当前设备无法解密部分已保存凭据。请先在本地重新输入凭据后再同步。',
'time.never': '从未',
'time.justNow': '刚刚',
'time.minutesAgo': '{minutes} 分钟前',
@@ -228,6 +269,8 @@ const zhCN: Messages = {
'vault.hosts.deselectAll': '取消全选',
'vault.hosts.deleteSelected': '删除 ({count})',
'vault.hosts.deleteMultiple.success': '已删除 {count} 个主机',
'vault.hosts.empty.title': '设置你的主机',
'vault.hosts.empty.desc': '保存主机以快速连接到你的服务器、虚拟机和容器。',
// Vault import
'vault.import.title': '添加数据到你的 Vault',
@@ -404,9 +447,6 @@ const zhCN: Messages = {
// Quick Switcher
'qs.search.placeholder': '搜索主机或标签页',
'qs.recentConnections': '最近连接',
'qs.createWorkspace': '创建工作区',
'qs.restore': '恢复',
'qs.jumpTo': '跳转到',
'qs.localTerminal': '本地终端',
@@ -468,6 +508,10 @@ const zhCN: Messages = {
'hostDetails.agentForwarding.agentNotRunning': 'SSH Agent 未运行',
'hostDetails.agentForwarding.agentNotRunningHint': '请在 Windows 服务管理器 (services.msc) 中启用 OpenSSH Authentication Agent 服务。',
'hostDetails.section.agentForwarding': 'SSH 代理',
'hostDetails.section.legacyAlgorithms': '旧版算法',
'hostDetails.legacyAlgorithms': '允许旧版算法',
'hostDetails.legacyAlgorithms.desc': '启用已弃用的 SSH 算法diffie-hellman-group1、ssh-dss、3des-cbc 等)以连接老旧网络设备。',
'hostDetails.legacyAlgorithms.warning': '这些算法存在已知安全漏洞,仅建议在老旧设备不支持现代加密时启用。',
'hostDetails.jumpHosts': '通过主机代理',
'hostDetails.jumpHosts.hops': '{count} 跳',
'hostDetails.jumpHosts.direct': '直连',
@@ -557,6 +601,15 @@ const zhCN: Messages = {
'terminal.toolbar.focus': '聚焦',
'terminal.toolbar.focusMode': '聚焦模式',
'terminal.toolbar.closeSession': '关闭会话',
'terminal.toolbar.hostHighlight.title': '主机关键字高亮',
'terminal.toolbar.hostHighlight.noRules': '此主机未定义自定义高亮规则',
'terminal.toolbar.hostHighlight.addRule': '添加新规则',
'terminal.toolbar.hostHighlight.labelPlaceholder': '标签(例如:错误)',
'terminal.toolbar.hostHighlight.patternPlaceholder': '正则表达式(例如:\\bfailed\\b',
'terminal.toolbar.hostHighlight.invalidPattern': '无效的正则表达式',
'terminal.toolbar.hostHighlight.clearAll': '清除全部',
'terminal.toolbar.hostHighlight.changeColor': '更改高亮颜色',
'terminal.toolbar.hostHighlight.selectColor': '选择新规则的颜色',
'terminal.serverStats.cpu': 'CPU 使用率',
'terminal.serverStats.cpuCores': 'CPU 核心使用率',
'terminal.serverStats.memory': '内存使用',
@@ -565,6 +618,10 @@ const zhCN: Messages = {
'terminal.serverStats.memBuffers': '缓冲区',
'terminal.serverStats.memCached': '缓存',
'terminal.serverStats.memFree': '空闲',
'terminal.serverStats.swap': '交换空间',
'terminal.serverStats.swapUsed': '已用交换',
'terminal.serverStats.swapFree': '空闲交换',
'terminal.serverStats.swapTotal': '总计',
'terminal.serverStats.topProcesses': '内存占用前十进程',
'terminal.serverStats.disk': '磁盘使用(根分区)',
'terminal.serverStats.diskDetails': '已挂载磁盘',
@@ -601,6 +658,10 @@ const zhCN: Messages = {
'terminal.auth.selectKey': '选择密钥',
'terminal.auth.noKeysHint': '暂无密钥,请先在钥匙串中添加。',
'terminal.auth.continueSave': '继续并保存',
'terminal.auth.credentialsUnavailable': '当前设备无法解密已保存凭据,请重新输入并再次保存。',
'terminal.auth.jumpCredentialsUnavailable': '某个跳板机的已保存凭据无法在当前设备解密,请到主机设置中重新填写。',
'terminal.auth.proxyCredentialsUnavailable': '代理凭据无法在当前设备解密,请到主机设置中重新填写代理密码。',
'terminal.auth.keyUnavailableFallbackPassword': '已保存 SSH 密钥在当前设备不可用,改用密码认证。',
'terminal.connectionErrorTitle': '连接错误',
'terminal.progress.timeoutIn': '将在 {seconds}s 后超时',
'terminal.progress.disconnected': '已断开',
@@ -1307,6 +1368,9 @@ const zhCN: Messages = {
'passphrase.unlock': '解锁',
'passphrase.unlocking': '解锁中...',
'passphrase.skip': '跳过',
// Text Editor
'sftp.editor.wordWrap': '自动换行',
};
export default zhCN;

View File

@@ -12,6 +12,9 @@ import { useCloudSync } from './useCloudSync';
import { useI18n } from '../i18n/I18nProvider';
import { getCloudSyncManager } from '../../infrastructure/services/CloudSyncManager';
import { netcattyBridge } from '../../infrastructure/services/netcattyBridge';
import {
findSyncPayloadEncryptedCredentialPaths,
} from '../../domain/credentials';
import type { SyncPayload } from '../../domain/sync';
import { toast } from '../../components/ui/toast';
@@ -109,6 +112,12 @@ export const useAutoSync = (config: AutoSyncConfig) => {
}
const payload = buildPayload();
const encryptedCredentialPaths = findSyncPayloadEncryptedCredentialPaths(payload);
if (encryptedCredentialPaths.length > 0) {
console.warn('[AutoSync] Blocked: encrypted credential placeholders found at:', encryptedCredentialPaths.join(', '));
throw new Error(t('sync.credentialsUnavailable'));
}
const results = await sync.syncNow(payload);
for (const result of results.values()) {

View File

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

View File

@@ -286,7 +286,7 @@ export const useCloudSync = (): CloudSyncHook => {
// Open browser after starting server
setTimeout(() => {
window.open(data.url, '_blank', 'width=600,height=700');
window.open(data.url, "_blank", "width=600,height=700,noopener,noreferrer");
}, 100);
// Wait for callback
@@ -319,7 +319,7 @@ export const useCloudSync = (): CloudSyncHook => {
// Open browser after starting server
setTimeout(() => {
window.open(data.url, '_blank', 'width=600,height=700');
window.open(data.url, "_blank", "width=600,height=700,noopener,noreferrer");
}, 100);
// Wait for callback

View File

@@ -79,8 +79,8 @@ const setGlobalRules = (newRules: PortForwardingRule[]) => {
localStorageAdapter.write(STORAGE_KEY_PORT_FORWARDING, newRules);
};
const normalizeRulesWithConnections = (rules: PortForwardingRule[]) => {
return rules.map((rule) => {
const normalizeRulesWithConnections = (rules: PortForwardingRule[]): PortForwardingRule[] => {
return rules.map((rule): PortForwardingRule => {
const connection = getActiveConnection(rule.id);
if (connection) {
return {
@@ -92,7 +92,7 @@ const normalizeRulesWithConnections = (rules: PortForwardingRule[]) => {
return {
...rule,
status: "inactive",
status: "inactive" as const,
error: undefined,
};
});
@@ -152,6 +152,31 @@ export const usePortForwardingState = (): UsePortForwardingStateResult => {
};
}, [rules]);
// Listen for storage events for cross-window sync (main window <-> tray panel)
useEffect(() => {
const handleStorageChange = (e: StorageEvent) => {
// Only handle changes from our specific key
if (e.key !== STORAGE_KEY_PORT_FORWARDING) return;
// Parse the new value
if (e.newValue) {
try {
const newRules = JSON.parse(e.newValue) as PortForwardingRule[];
if (Array.isArray(newRules)) {
// Update global state without triggering another localStorage write
globalRules = normalizeRulesWithConnections(newRules);
notifyListeners();
}
} catch {
// ignore parse errors
}
}
};
window.addEventListener("storage", handleStorageChange);
return () => window.removeEventListener("storage", handleStorageChange);
}, []);
const addRule = useCallback(
(
rule: Omit<PortForwardingRule, "id" | "createdAt" | "status">,

View File

@@ -1,29 +1,32 @@
import { useCallback,useEffect,useLayoutEffect,useMemo,useState } from 'react';
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState, type SetStateAction } from 'react';
import { SyncConfig, TerminalSettings, DEFAULT_TERMINAL_SETTINGS, HotkeyScheme, CustomKeyBindings, DEFAULT_KEY_BINDINGS, KeyBinding, UILanguage, SessionLogFormat } from '../../domain/models';
import {
STORAGE_KEY_COLOR,
STORAGE_KEY_SYNC,
STORAGE_KEY_TERM_THEME,
STORAGE_KEY_THEME,
STORAGE_KEY_TERM_FONT_FAMILY,
STORAGE_KEY_TERM_FONT_SIZE,
STORAGE_KEY_TERM_SETTINGS,
STORAGE_KEY_HOTKEY_SCHEME,
STORAGE_KEY_CUSTOM_KEY_BINDINGS,
STORAGE_KEY_HOTKEY_RECORDING,
STORAGE_KEY_CUSTOM_CSS,
STORAGE_KEY_UI_LANGUAGE,
STORAGE_KEY_ACCENT_MODE,
STORAGE_KEY_UI_THEME_LIGHT,
STORAGE_KEY_UI_THEME_DARK,
STORAGE_KEY_UI_FONT_FAMILY,
STORAGE_KEY_SFTP_DOUBLE_CLICK_BEHAVIOR,
STORAGE_KEY_SFTP_AUTO_SYNC,
STORAGE_KEY_SFTP_SHOW_HIDDEN_FILES,
STORAGE_KEY_SFTP_USE_COMPRESSED_UPLOAD,
STORAGE_KEY_SESSION_LOGS_ENABLED,
STORAGE_KEY_SESSION_LOGS_DIR,
STORAGE_KEY_SESSION_LOGS_FORMAT,
STORAGE_KEY_COLOR,
STORAGE_KEY_SYNC,
STORAGE_KEY_TERM_THEME,
STORAGE_KEY_THEME,
STORAGE_KEY_TERM_FONT_FAMILY,
STORAGE_KEY_TERM_FONT_SIZE,
STORAGE_KEY_TERM_SETTINGS,
STORAGE_KEY_HOTKEY_SCHEME,
STORAGE_KEY_CUSTOM_KEY_BINDINGS,
STORAGE_KEY_HOTKEY_RECORDING,
STORAGE_KEY_CUSTOM_CSS,
STORAGE_KEY_UI_LANGUAGE,
STORAGE_KEY_ACCENT_MODE,
STORAGE_KEY_UI_THEME_LIGHT,
STORAGE_KEY_UI_THEME_DARK,
STORAGE_KEY_UI_FONT_FAMILY,
STORAGE_KEY_SFTP_DOUBLE_CLICK_BEHAVIOR,
STORAGE_KEY_SFTP_AUTO_SYNC,
STORAGE_KEY_SFTP_SHOW_HIDDEN_FILES,
STORAGE_KEY_SFTP_USE_COMPRESSED_UPLOAD,
STORAGE_KEY_EDITOR_WORD_WRAP,
STORAGE_KEY_SESSION_LOGS_ENABLED,
STORAGE_KEY_SESSION_LOGS_DIR,
STORAGE_KEY_SESSION_LOGS_FORMAT,
STORAGE_KEY_TOGGLE_WINDOW_HOTKEY,
STORAGE_KEY_CLOSE_TO_TRAY,
} from '../../infrastructure/config/storageKeys';
import { DEFAULT_UI_LOCALE, resolveSupportedLocale } from '../../infrastructure/config/i18n';
import { TERMINAL_THEMES } from '../../infrastructure/config/terminalThemes';
@@ -52,6 +55,9 @@ const DEFAULT_SFTP_AUTO_SYNC = false;
const DEFAULT_SFTP_SHOW_HIDDEN_FILES = false;
const DEFAULT_SFTP_USE_COMPRESSED_UPLOAD = true;
// Editor defaults
const DEFAULT_EDITOR_WORD_WRAP = false;
// Session Logs defaults
const DEFAULT_SESSION_LOGS_ENABLED = false;
const DEFAULT_SESSION_LOGS_FORMAT: SessionLogFormat = 'txt';
@@ -87,9 +93,15 @@ const isValidUiFontId = (value: string): boolean => {
if (value.startsWith('local-')) return true;
// Check bundled fonts first, then check dynamically loaded fonts
return UI_FONTS.some((font) => font.id === value) ||
uiFontStore.getAvailableFonts().some((font) => font.id === value);
uiFontStore.getAvailableFonts().some((font) => font.id === value);
};
const serializeTerminalSettings = (settings: TerminalSettings): string =>
JSON.stringify(settings);
const areTerminalSettingsEqual = (a: TerminalSettings, b: TerminalSettings): boolean =>
serializeTerminalSettings(a) === serializeTerminalSettings(b);
const applyThemeTokens = (
theme: 'light' | 'dark',
tokens: UiThemeTokens,
@@ -167,7 +179,7 @@ export const useSettingsState = () => {
const stored = readStoredString(STORAGE_KEY_UI_LANGUAGE);
return resolveSupportedLocale(stored || DEFAULT_UI_LOCALE);
});
const [terminalSettings, setTerminalSettings] = useState<TerminalSettings>(() => {
const [terminalSettings, setTerminalSettingsState] = useState<TerminalSettings>(() => {
const stored = localStorageAdapter.read<TerminalSettings>(STORAGE_KEY_TERM_SETTINGS);
return stored ? { ...DEFAULT_TERMINAL_SETTINGS, ...stored } : DEFAULT_TERMINAL_SETTINGS;
});
@@ -206,6 +218,12 @@ export const useSettingsState = () => {
return DEFAULT_SFTP_USE_COMPRESSED_UPLOAD;
});
// Editor Settings
const [editorWordWrap, setEditorWordWrapState] = useState<boolean>(() => {
const stored = readStoredString(STORAGE_KEY_EDITOR_WORD_WRAP);
return stored === 'true' ? true : DEFAULT_EDITOR_WORD_WRAP;
});
// Session Logs Settings
const [sessionLogsEnabled, setSessionLogsEnabled] = useState<boolean>(() => {
const stored = readStoredString(STORAGE_KEY_SESSION_LOGS_ENABLED);
@@ -220,6 +238,50 @@ export const useSettingsState = () => {
return DEFAULT_SESSION_LOGS_FORMAT;
});
// Global Toggle Window Settings (Quake Mode)
const [toggleWindowHotkey, setToggleWindowHotkey] = useState<string>(() => {
const stored = readStoredString(STORAGE_KEY_TOGGLE_WINDOW_HOTKEY);
if (stored !== null) return stored;
// Default: Ctrl+` (Control+backtick) - similar to VS Code terminal toggle
const isMac = typeof navigator !== 'undefined' && /Mac/i.test(navigator.platform);
return isMac ? '⌃ + `' : 'Ctrl + `';
});
const [closeToTray, setCloseToTray] = useState<boolean>(() => {
const stored = readStoredString(STORAGE_KEY_CLOSE_TO_TRAY);
// Default to true (enabled)
if (stored === null) return true;
return stored === 'true';
});
const [hotkeyRegistrationError, setHotkeyRegistrationError] = useState<string | null>(null);
const incomingTerminalSettingsSignatureRef = useRef<string | null>(null);
const localTerminalSettingsVersionRef = useRef(0);
const broadcastedLocalTerminalSettingsVersionRef = useRef(0);
const setTerminalSettings = useCallback((nextValue: SetStateAction<TerminalSettings>) => {
setTerminalSettingsState((prev) => {
const next = typeof nextValue === 'function'
? (nextValue as (prevState: TerminalSettings) => TerminalSettings)(prev)
: nextValue;
if (areTerminalSettingsEqual(prev, next)) {
return prev;
}
localTerminalSettingsVersionRef.current += 1;
return next;
});
}, []);
const mergeIncomingTerminalSettings = useCallback((incoming: Partial<TerminalSettings>) => {
setTerminalSettingsState((prev) => {
const next = { ...prev, ...incoming };
if (areTerminalSettingsEqual(prev, next)) {
return prev;
}
// Mark the exact incoming snapshot so only this state is skipped for IPC rebroadcast.
incomingTerminalSettingsSignatureRef.current = serializeTerminalSettings(next);
return next;
});
}, []);
// Helper to notify other windows about settings changes via IPC
const notifySettingsChanged = useCallback((key: string, value: unknown) => {
try {
@@ -289,11 +351,11 @@ export const useSettingsState = () => {
}, [uiFontFamilyId, uiFontsLoaded, notifySettingsChanged]);
// Listen for settings changes from other windows via IPC
useEffect(() => {
const bridge = netcattyBridge.get();
if (!bridge?.onSettingsChanged) return;
const unsubscribe = bridge.onSettingsChanged((payload) => {
const { key, value } = payload;
useEffect(() => {
const bridge = netcattyBridge.get();
if (!bridge?.onSettingsChanged) return;
const unsubscribe = bridge.onSettingsChanged((payload) => {
const { key, value } = payload;
if (
key === STORAGE_KEY_THEME ||
key === STORAGE_KEY_UI_THEME_LIGHT ||
@@ -307,8 +369,8 @@ export const useSettingsState = () => {
if (key === STORAGE_KEY_UI_LANGUAGE && typeof value === 'string') {
const next = resolveSupportedLocale(value);
setUiLanguage((prev) => (prev === next ? prev : next));
document.documentElement.lang = next;
}
document.documentElement.lang = next;
}
if (key === STORAGE_KEY_CUSTOM_CSS && typeof value === 'string') {
syncCustomCssFromStorage();
}
@@ -326,6 +388,21 @@ export const useSettingsState = () => {
if (key === STORAGE_KEY_TERM_FONT_SIZE && typeof value === 'number') {
setTerminalFontSize(value);
}
if (key === STORAGE_KEY_TERM_SETTINGS) {
if (typeof value === 'string') {
try {
const parsed = JSON.parse(value) as Partial<TerminalSettings>;
mergeIncomingTerminalSettings(parsed);
} catch {
// ignore parse errors
}
} else if (value && typeof value === 'object') {
mergeIncomingTerminalSettings(value as Partial<TerminalSettings>);
}
}
if (key === STORAGE_KEY_EDITOR_WORD_WRAP && typeof value === 'boolean') {
setEditorWordWrapState((prev) => (prev === value ? prev : value));
}
if (key === STORAGE_KEY_HOTKEY_SCHEME && (value === 'disabled' || value === 'mac' || value === 'pc')) {
setHotkeyScheme(value);
}
@@ -351,7 +428,7 @@ export const useSettingsState = () => {
// ignore
}
};
}, [syncAppearanceFromStorage, syncCustomCssFromStorage]);
}, [mergeIncomingTerminalSettings, syncAppearanceFromStorage, syncCustomCssFromStorage]);
useEffect(() => {
const bridge = netcattyBridge.get();
@@ -429,14 +506,14 @@ export const useSettingsState = () => {
}
}
// Sync terminal settings from other windows
if (e.key === STORAGE_KEY_TERM_SETTINGS && e.newValue) {
try {
const newSettings = JSON.parse(e.newValue) as TerminalSettings;
setTerminalSettings(_prev => ({ ...DEFAULT_TERMINAL_SETTINGS, ...newSettings }));
} catch {
// ignore parse errors
}
}
if (e.key === STORAGE_KEY_TERM_SETTINGS && e.newValue) {
try {
const newSettings = JSON.parse(e.newValue) as TerminalSettings;
mergeIncomingTerminalSettings({ ...DEFAULT_TERMINAL_SETTINGS, ...newSettings });
} catch {
// ignore parse errors
}
}
// Sync terminal theme from other windows
if (e.key === STORAGE_KEY_TERM_THEME && e.newValue) {
if (e.newValue !== terminalThemeId) {
@@ -476,6 +553,12 @@ export const useSettingsState = () => {
setSftpShowHiddenFiles(newValue);
}
}
if (e.key === STORAGE_KEY_EDITOR_WORD_WRAP && e.newValue !== null) {
const newValue = e.newValue === 'true';
if (newValue !== editorWordWrap) {
setEditorWordWrapState(newValue);
}
}
// Sync SFTP compressed upload setting from other windows
if (e.key === STORAGE_KEY_SFTP_USE_COMPRESSED_UPLOAD && e.newValue !== null) {
const newValue = e.newValue === 'true' || e.newValue === 'enabled';
@@ -487,7 +570,7 @@ export const useSettingsState = () => {
window.addEventListener('storage', handleStorageChange);
return () => window.removeEventListener('storage', handleStorageChange);
}, [theme, lightUiThemeId, darkUiThemeId, accentMode, customAccent, customCSS, uiFontFamilyId, hotkeyScheme, uiLanguage, terminalThemeId, terminalFontFamilyId, terminalFontSize, sftpDoubleClickBehavior, sftpAutoSync, sftpShowHiddenFiles, sftpUseCompressedUpload]);
}, [theme, lightUiThemeId, darkUiThemeId, accentMode, customAccent, customCSS, uiFontFamilyId, hotkeyScheme, uiLanguage, terminalThemeId, terminalFontFamilyId, terminalFontSize, sftpDoubleClickBehavior, sftpAutoSync, sftpShowHiddenFiles, sftpUseCompressedUpload, editorWordWrap, mergeIncomingTerminalSettings]);
useEffect(() => {
localStorageAdapter.writeString(STORAGE_KEY_TERM_THEME, terminalThemeId);
@@ -506,7 +589,17 @@ export const useSettingsState = () => {
useEffect(() => {
localStorageAdapter.write(STORAGE_KEY_TERM_SETTINGS, terminalSettings);
}, [terminalSettings]);
const currentSignature = serializeTerminalSettings(terminalSettings);
const hasPendingUnbroadcastLocalChanges =
localTerminalSettingsVersionRef.current !== broadcastedLocalTerminalSettingsVersionRef.current;
if (incomingTerminalSettingsSignatureRef.current === currentSignature && !hasPendingUnbroadcastLocalChanges) {
incomingTerminalSettingsSignatureRef.current = null;
return;
}
incomingTerminalSettingsSignatureRef.current = null;
notifySettingsChanged(STORAGE_KEY_TERM_SETTINGS, terminalSettings);
broadcastedLocalTerminalSettingsVersionRef.current = localTerminalSettingsVersionRef.current;
}, [terminalSettings, notifySettingsChanged]);
useEffect(() => {
localStorageAdapter.writeString(STORAGE_KEY_HOTKEY_SCHEME, hotkeyScheme);
@@ -578,6 +671,49 @@ export const useSettingsState = () => {
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
const bridge = netcattyBridge.get();
if (bridge?.registerGlobalHotkey) {
if (toggleWindowHotkey) {
setHotkeyRegistrationError(null);
bridge
.registerGlobalHotkey(toggleWindowHotkey)
.then((result) => {
if (result?.success === false) {
console.warn('[GlobalHotkey] Hotkey registration failed:', result.error);
setHotkeyRegistrationError(result.error || 'Failed to register hotkey');
}
})
.catch((err) => {
console.warn('[GlobalHotkey] Failed to register hotkey:', err);
setHotkeyRegistrationError(err?.message || 'Failed to register hotkey');
});
} else {
setHotkeyRegistrationError(null);
bridge.unregisterGlobalHotkey?.().catch((err) => {
console.warn('[GlobalHotkey] Failed to unregister hotkey:', err);
});
}
}
}, [toggleWindowHotkey, 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
const bridge = netcattyBridge.get();
if (bridge?.setCloseToTray) {
bridge.setCloseToTray(closeToTray).catch((err) => {
console.warn('[SystemTray] Failed to set close-to-tray:', err);
});
}
}, [closeToTray, notifySettingsChanged]);
// Get merged key bindings (defaults + custom overrides)
const keyBindings = useMemo((): KeyBinding[] => {
return DEFAULT_KEY_BINDINGS.map(binding => {
@@ -645,7 +781,7 @@ export const useSettingsState = () => {
value: TerminalSettings[K]
) => {
setTerminalSettings(prev => ({ ...prev, [key]: value }));
}, []);
}, [setTerminalSettings]);
return {
theme,
@@ -694,6 +830,13 @@ export const useSettingsState = () => {
setSftpShowHiddenFiles,
sftpUseCompressedUpload,
setSftpUseCompressedUpload,
// Editor Settings
editorWordWrap,
setEditorWordWrap: useCallback((enabled: boolean) => {
setEditorWordWrapState(enabled);
localStorageAdapter.writeString(STORAGE_KEY_EDITOR_WORD_WRAP, String(enabled));
notifySettingsChanged(STORAGE_KEY_EDITOR_WORD_WRAP, enabled);
}, [notifySettingsChanged]),
availableFonts,
// Session Logs
sessionLogsEnabled,
@@ -702,5 +845,11 @@ export const useSettingsState = () => {
setSessionLogsDir,
sessionLogsFormat,
setSessionLogsFormat,
// Global Toggle Window (Quake Mode)
toggleWindowHotkey,
setToggleWindowHotkey,
closeToTray,
setCloseToTray,
hotkeyRegistrationError,
};
};

View File

@@ -0,0 +1,24 @@
import { useEffect, useState } from "react";
import { localStorageAdapter } from "../../infrastructure/persistence/localStorageAdapter";
/**
* Hook for persisting a boolean value to localStorage.
* @param storageKey - The key to use for localStorage
* @param fallback - The default value if no stored value exists (defaults to false)
* @returns A tuple of [value, setValue] similar to useState
*/
export const useStoredBoolean = (
storageKey: string,
fallback: boolean = false,
) => {
const [value, setValue] = useState<boolean>(() => {
const stored = localStorageAdapter.readBoolean(storageKey);
return stored ?? fallback;
});
useEffect(() => {
localStorageAdapter.writeBoolean(storageKey, value);
}, [storageKey, value]);
return [value, setValue] as const;
};

View File

@@ -0,0 +1,72 @@
import { useCallback } from "react";
import { netcattyBridge } from "../../infrastructure/services/netcattyBridge";
export const useTrayPanelBackend = () => {
const hideTrayPanel = useCallback(async () => {
const bridge = netcattyBridge.get();
await bridge?.hideTrayPanel?.();
}, []);
const openMainWindow = useCallback(async () => {
const bridge = netcattyBridge.get();
await bridge?.openMainWindow?.();
}, []);
const quitApp = useCallback(async () => {
const bridge = netcattyBridge.get();
await bridge?.quitApp?.();
}, []);
const jumpToSession = useCallback(async (sessionId: string) => {
const bridge = netcattyBridge.get();
await bridge?.jumpToSessionFromTrayPanel?.(sessionId);
}, []);
const connectToHostFromTrayPanel = useCallback(async (hostId: string) => {
const bridge = netcattyBridge.get();
await bridge?.connectToHostFromTrayPanel?.(hostId);
}, []);
const onTrayPanelCloseRequest = useCallback((callback: () => void) => {
const bridge = netcattyBridge.get();
return bridge?.onTrayPanelCloseRequest?.(callback);
}, []);
const onTrayPanelRefresh = useCallback((callback: () => void) => {
const bridge = netcattyBridge.get();
return bridge?.onTrayPanelRefresh?.(callback);
}, []);
const onTrayPanelMenuData = useCallback(
(
callback: (data: {
sessions?: Array<{ id: string; label: string; hostLabel: string; status: "connecting" | "connected" | "disconnected"; workspaceId?: string; workspaceTitle?: string }>;
portForwardRules?: Array<{
id: string;
label: string;
type: "local" | "remote" | "dynamic";
localPort: number;
remoteHost?: string;
remotePort?: number;
status: "inactive" | "connecting" | "active" | "error";
hostId?: string;
}>;
}) => void,
) => {
const bridge = netcattyBridge.get();
return bridge?.onTrayPanelMenuData?.(callback);
},
[],
);
return {
hideTrayPanel,
openMainWindow,
quitApp,
jumpToSession,
connectToHostFromTrayPanel,
onTrayPanelCloseRequest,
onTrayPanelRefresh,
onTrayPanelMenuData,
};
};

View File

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

View File

@@ -32,6 +32,9 @@ import {
} from 'lucide-react';
import { useCloudSync } from '../application/state/useCloudSync';
import { useI18n } from '../application/i18n/I18nProvider';
import {
findSyncPayloadEncryptedCredentialPaths,
} from '../domain/credentials';
import type { CloudProvider, ConflictInfo, SyncPayload, WebDAVAuthType, WebDAVConfig, S3Config } from '../domain/sync';
import { cn } from '../lib/utils';
import { Button } from './ui/button';
@@ -482,7 +485,7 @@ const GitHubDeviceFlowModal: React.FC<GitHubDeviceFlowModalProps> = ({
</div>
<Button
onClick={() => window.open(verificationUri, '_blank')}
onClick={() => window.open(verificationUri, "_blank", "noopener,noreferrer")}
className="w-full gap-2 mb-4"
>
<ExternalLink size={14} />
@@ -751,6 +754,17 @@ export const SyncDashboard: React.FC<SyncDashboardProps> = ({
// Clear local data dialog
const [showClearLocalDialog, setShowClearLocalDialog] = useState(false);
const ensureSyncablePayload = useCallback(
(payload: SyncPayload): boolean => {
const encryptedCredentialPaths = findSyncPayloadEncryptedCredentialPaths(payload);
if (encryptedCredentialPaths.length === 0) return true;
toast.error(t('sync.credentialsUnavailable'), t('sync.toast.errorTitle'));
return false;
},
[t],
);
// Handle conflict detection
useEffect(() => {
if (sync.currentConflict) {
@@ -958,6 +972,7 @@ export const SyncDashboard: React.FC<SyncDashboardProps> = ({
const handleSync = async (provider: CloudProvider) => {
try {
const payload = onBuildPayload();
if (!ensureSyncablePayload(payload)) return;
const result = await sync.syncToProvider(provider, payload);
if (result.success) {
@@ -982,6 +997,7 @@ export const SyncDashboard: React.FC<SyncDashboardProps> = ({
} else if (resolution === 'USE_LOCAL') {
// Re-sync with local data
const localPayload = onBuildPayload();
if (!ensureSyncablePayload(localPayload)) return;
await sync.syncNow(localPayload);
toast.success(t('cloudSync.resolve.uploaded'));
}
@@ -1556,6 +1572,16 @@ export const SyncDashboard: React.FC<SyncDashboardProps> = ({
return;
}
let payloadForReencrypt: SyncPayload | null = null;
if (sync.hasAnyConnectedProvider) {
const payload = onBuildPayload();
if (!ensureSyncablePayload(payload)) {
setChangeKeyError(t('sync.credentialsUnavailable'));
return;
}
payloadForReencrypt = payload;
}
setIsChangingKey(true);
try {
const ok = await sync.changeMasterKey(currentMasterKey, newMasterKey);
@@ -1564,9 +1590,8 @@ export const SyncDashboard: React.FC<SyncDashboardProps> = ({
return;
}
if (sync.hasAnyConnectedProvider) {
const payload = onBuildPayload();
await sync.syncNow(payload);
if (payloadForReencrypt) {
await sync.syncNow(payloadForReencrypt);
}
toast.success(t('cloudSync.changeKey.updatedToast'));

View File

@@ -16,6 +16,7 @@ import {
Plus,
Settings2,
Shield,
ShieldAlert,
Tag,
TerminalSquare,
User,
@@ -1230,6 +1231,30 @@ const HostDetailsPanel: React.FC<HostDetailsPanelProps> = ({
)}
</Card>
{/* Legacy Algorithms */}
<Card className="p-3 space-y-2 bg-card border-border/80">
<div className="flex items-center gap-2">
<ShieldAlert size={14} className="text-muted-foreground" />
<p className="text-xs font-semibold">{t("hostDetails.section.legacyAlgorithms")}</p>
</div>
<ToggleRow
label={t("hostDetails.legacyAlgorithms")}
enabled={!!form.legacyAlgorithms}
onToggle={() => update("legacyAlgorithms", !form.legacyAlgorithms)}
/>
<p className="text-xs text-muted-foreground">
{t("hostDetails.legacyAlgorithms.desc")}
</p>
{form.legacyAlgorithms && (
<div className="flex items-start gap-2 p-2 rounded-md bg-yellow-500/10 border border-yellow-500/20">
<AlertTriangle size={14} className="text-yellow-500 mt-0.5 flex-shrink-0" />
<p className="text-xs text-yellow-600 dark:text-yellow-400">
{t("hostDetails.legacyAlgorithms.warning")}
</p>
</div>
)}
</Card>
{/* Proxy via Hosts (Jump Hosts / ProxyJump) */}
<Card className="p-3 space-y-2 bg-card border-border/80">
<div className="flex items-center justify-between">

View File

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

View File

@@ -17,7 +17,6 @@ type QuickSwitcherItem = {
data?: Host | TerminalSession | Workspace;
};
import { DistroAvatar } from "./DistroAvatar";
import { Button } from "./ui/button";
import { Input } from "./ui/input";
import { ScrollArea } from "./ui/scroll-area";
@@ -68,7 +67,7 @@ interface QuickSwitcherProps {
onSelectTab: (tabId: string) => void;
onClose: () => void;
onCreateLocalTerminal?: () => void;
onCreateWorkspace?: () => void;
// onCreateWorkspace removed - feature not currently used
keyBindings?: KeyBinding[];
}
@@ -83,7 +82,6 @@ const QuickSwitcherInner: React.FC<QuickSwitcherProps> = ({
onSelectTab,
onClose,
onCreateLocalTerminal,
onCreateWorkspace,
keyBindings,
}) => {
const { t } = useI18n();
@@ -132,7 +130,8 @@ const QuickSwitcherInner: React.FC<QuickSwitcherProps> = ({
[sessions]
);
const showCategorized = query.trim().length > 0;
// Always show categorized view (Hosts/Tabs/Quick connect)
const showCategorized = true;
// Memoize flat items list and index map
const { flatItems, itemIndexMap } = useMemo(() => {
@@ -242,209 +241,163 @@ const QuickSwitcherInner: React.FC<QuickSwitcherProps> = ({
</div>
<ScrollArea className="flex-1 h-full">
{!showCategorized ? (
/* Default view: Recent connections with header */
<div>
<div className="px-4 py-2 flex items-center justify-between">
<span className="text-xs font-medium text-muted-foreground">
{t("qs.recentConnections")}
</span>
<div className="flex items-center gap-2">
<Button
size="sm"
variant="outline"
className="h-6 px-2 text-[11px]"
onClick={() => onCreateWorkspace?.()}
>
{t("qs.createWorkspace")}
</Button>
<Button
size="sm"
variant="outline"
className="h-6 px-2 text-[11px]"
disabled
>
{t("qs.restore")}
</Button>
</div>
</div>
<div>
{results.length > 0 ? (
results.map((host) => (
<HostItem
key={host.id}
host={host}
isSelected={getItemIndex("host", host.id) === selectedIndex}
onSelect={onSelect}
onMouseEnter={() => setSelectedIndex(getItemIndex("host", host.id))}
/>
))
) : (
<div className="px-4 py-6 text-sm text-muted-foreground text-center">
No recent connections
</div>
)}
</div>
{/* Categorized view: Hosts/Tabs/Quick connect */}
<div>
{/* Jump To hint */}
<div className="px-4 py-2 flex items-center gap-2">
<span className="text-xs text-muted-foreground">{t("qs.jumpTo")}</span>
{quickSwitchKey && (
<kbd className="text-[10px] text-muted-foreground bg-muted px-1 py-0.5 rounded">
{quickSwitchKey.replace(/ \+ /g, '+')}
</kbd>
)}
</div>
) : (
/* Focused/searching view: Categorized items */
{/* Hosts section */}
{results.length > 0 && (
<div>
<div className="px-4 py-1.5">
<span className="text-xs font-medium text-muted-foreground">
Hosts
</span>
</div>
{results.map((host) => (
<HostItem
key={host.id}
host={host}
isSelected={getItemIndex("host", host.id) === selectedIndex}
onSelect={onSelect}
onMouseEnter={() => setSelectedIndex(getItemIndex("host", host.id))}
/>
))}
</div>
)}
{/* Tabs section */}
<div>
{/* Jump To hint */}
<div className="px-4 py-2 flex items-center gap-2">
<span className="text-xs text-muted-foreground">{t("qs.jumpTo")}</span>
{quickSwitchKey && (
<kbd className="text-[10px] text-muted-foreground bg-muted px-1 py-0.5 rounded">
{quickSwitchKey.replace(/ \+ /g, '+')}
</kbd>
)}
<div className="px-4 py-1.5">
<span className="text-xs font-medium text-muted-foreground">
Tabs
</span>
</div>
{/* Hosts section */}
{results.length > 0 && (
<div>
<div className="px-4 py-1.5">
<span className="text-xs font-medium text-muted-foreground">
Hosts
{/* Built-in tabs */}
{["vault", "sftp"].map((tabId) => {
const idx = getItemIndex("tab", tabId);
const isSelected = idx === selectedIndex;
const icon =
tabId === "vault" ? (
<Shield size={16} />
) : (
<Folder size={16} />
);
const label = tabId === "vault" ? "Vaults" : "SFTP";
return (
<div
key={tabId}
className={`flex items-center gap-3 px-4 py-2.5 cursor-pointer transition-colors ${isSelected ? "bg-primary/15" : "hover:bg-muted/50"
}`}
onClick={() => {
onSelectTab(tabId);
onClose();
}}
onMouseEnter={() => setSelectedIndex(idx)}
>
<div className="h-6 w-6 rounded flex items-center justify-center text-muted-foreground">
{icon}
</div>
<span className="text-sm font-medium">{label}</span>
</div>
);
})}
{/* Workspaces */}
{workspaces.map((workspace) => {
const idx = getItemIndex("workspace", workspace.id);
const isSelected = idx === selectedIndex;
return (
<div
key={workspace.id}
className={`flex items-center gap-3 px-4 py-2.5 cursor-pointer transition-colors ${isSelected ? "bg-primary/15" : "hover:bg-muted/50"
}`}
onClick={() => {
onSelectTab(workspace.id);
onClose();
}}
onMouseEnter={() => setSelectedIndex(idx)}
>
<div className="h-6 w-6 rounded flex items-center justify-center text-muted-foreground">
<LayoutGrid size={16} />
</div>
<span className="text-sm font-medium">
{workspace.title}
</span>
</div>
{results.map((host) => (
<HostItem
key={host.id}
host={host}
isSelected={getItemIndex("host", host.id) === selectedIndex}
onSelect={onSelect}
onMouseEnter={() => setSelectedIndex(getItemIndex("host", host.id))}
/>
))}
);
})}
{/* Orphan sessions */}
{orphanSessions.map((session) => {
const idx = getItemIndex("tab", session.id);
const isSelected = idx === selectedIndex;
return (
<div
key={session.id}
className={`flex items-center gap-3 px-4 py-2.5 cursor-pointer transition-colors ${isSelected ? "bg-primary/15" : "hover:bg-muted/50"
}`}
onClick={() => {
onSelectTab(session.id);
onClose();
}}
onMouseEnter={() => setSelectedIndex(idx)}
>
<div className="h-6 w-6 rounded flex items-center justify-center text-muted-foreground">
<TerminalSquare size={16} />
</div>
<span className="text-sm font-medium">
{session.hostLabel}
</span>
</div>
);
})}
</div>
{/* Quick connect section */}
<div>
<div className="px-4 py-1.5">
<span className="text-xs font-medium text-muted-foreground">
Quick connect
</span>
</div>
{/* Local Terminal */}
{onCreateLocalTerminal && (
<div
className={`flex items-center gap-3 px-4 py-2.5 cursor-pointer transition-colors ${getItemIndex("action", "local-terminal") === selectedIndex
? "bg-primary/15"
: "hover:bg-muted/50"
}`}
onClick={() => {
onCreateLocalTerminal();
onClose();
}}
onMouseEnter={() =>
setSelectedIndex(getItemIndex("action", "local-terminal"))
}
>
<div className="h-6 w-6 rounded flex items-center justify-center text-muted-foreground">
<Terminal size={16} />
</div>
<span className="text-sm font-medium">{t("qs.localTerminal")}</span>
</div>
)}
{/* Tabs section */}
<div>
<div className="px-4 py-1.5">
<span className="text-xs font-medium text-muted-foreground">
Tabs
</span>
</div>
{/* Built-in tabs */}
{["vault", "sftp"].map((tabId) => {
const idx = getItemIndex("tab", tabId);
const isSelected = idx === selectedIndex;
const icon =
tabId === "vault" ? (
<Shield size={16} />
) : (
<Folder size={16} />
);
const label = tabId === "vault" ? "Vaults" : "SFTP";
return (
<div
key={tabId}
className={`flex items-center gap-3 px-4 py-2.5 cursor-pointer transition-colors ${isSelected ? "bg-primary/15" : "hover:bg-muted/50"
}`}
onClick={() => {
onSelectTab(tabId);
onClose();
}}
onMouseEnter={() => setSelectedIndex(idx)}
>
<div className="h-6 w-6 rounded flex items-center justify-center text-muted-foreground">
{icon}
</div>
<span className="text-sm font-medium">{label}</span>
</div>
);
})}
{/* Workspaces */}
{workspaces.map((workspace) => {
const idx = getItemIndex("workspace", workspace.id);
const isSelected = idx === selectedIndex;
return (
<div
key={workspace.id}
className={`flex items-center gap-3 px-4 py-2.5 cursor-pointer transition-colors ${isSelected ? "bg-primary/15" : "hover:bg-muted/50"
}`}
onClick={() => {
onSelectTab(workspace.id);
onClose();
}}
onMouseEnter={() => setSelectedIndex(idx)}
>
<div className="h-6 w-6 rounded flex items-center justify-center text-muted-foreground">
<LayoutGrid size={16} />
</div>
<span className="text-sm font-medium">
{workspace.title}
</span>
</div>
);
})}
{/* Orphan sessions */}
{orphanSessions.map((session) => {
const idx = getItemIndex("tab", session.id);
const isSelected = idx === selectedIndex;
return (
<div
key={session.id}
className={`flex items-center gap-3 px-4 py-2.5 cursor-pointer transition-colors ${isSelected ? "bg-primary/15" : "hover:bg-muted/50"
}`}
onClick={() => {
onSelectTab(session.id);
onClose();
}}
onMouseEnter={() => setSelectedIndex(idx)}
>
<div className="h-6 w-6 rounded flex items-center justify-center text-muted-foreground">
<TerminalSquare size={16} />
</div>
<span className="text-sm font-medium">
{session.hostLabel}
</span>
</div>
);
})}
</div>
{/* Quick connect section */}
<div>
<div className="px-4 py-1.5">
<span className="text-xs font-medium text-muted-foreground">
Quick connect
</span>
</div>
{/* Local Terminal */}
{onCreateLocalTerminal && (
<div
className={`flex items-center gap-3 px-4 py-2.5 cursor-pointer transition-colors ${getItemIndex("action", "local-terminal") === selectedIndex
? "bg-primary/15"
: "hover:bg-muted/50"
}`}
onClick={() => {
onCreateLocalTerminal();
onClose();
}}
onMouseEnter={() =>
setSelectedIndex(getItemIndex("action", "local-terminal"))
}
>
<div className="h-6 w-6 rounded flex items-center justify-center text-muted-foreground">
<Terminal size={16} />
</div>
<span className="text-sm font-medium">{t("qs.localTerminal")}</span>
</div>
)}
{/* Serial removed (not supported) */}
</div>
{/* Serial removed (not supported) */}
</div>
)}
</div>
</ScrollArea>
</div>
</div>

View File

@@ -42,6 +42,7 @@ interface SFTPModalProps {
proxy?: NetcattyProxyConfig;
jumpHosts?: NetcattyJumpHost[];
sftpSudo?: boolean;
legacyAlgorithms?: boolean;
};
open: boolean;
onClose: () => void;
@@ -86,7 +87,15 @@ const SFTPModal: React.FC<SFTPModalProps> = ({
showSaveDialog,
} = useSftpBackend();
const { t } = useI18n();
const { sftpAutoSync, sftpShowHiddenFiles, sftpUseCompressedUpload, hotkeyScheme, keyBindings } = useSettingsState();
const {
sftpAutoSync,
sftpShowHiddenFiles,
sftpUseCompressedUpload,
hotkeyScheme,
keyBindings,
editorWordWrap,
setEditorWordWrap,
} = useSettingsState();
const isLocalSession = host.protocol === "local";
const [filenameEncoding, setFilenameEncoding] = useState<SftpFilenameEncoding>(
host.sftpEncoding ?? "auto"
@@ -518,7 +527,7 @@ const SFTPModal: React.FC<SFTPModalProps> = ({
// Find the files to pass to confirm dialog
if (fileNames.length === 0) return;
if (!confirm(t("sftp.deleteConfirm.title", { count: fileNames.length }))) return;
// Delete files
(async () => {
try {
@@ -735,6 +744,8 @@ const SFTPModal: React.FC<SFTPModalProps> = ({
fileName={textEditorTarget?.name || ""}
initialContent={textEditorContent}
onSave={handleSaveTextFile}
editorWordWrap={editorWordWrap}
onToggleWordWrap={() => setEditorWordWrap(!editorWordWrap)}
/>
</Dialog>
);

View File

@@ -211,6 +211,11 @@ const SettingsPageContent: React.FC<{ settings: SettingsState }> = ({ settings }
setSessionLogsDir={settings.setSessionLogsDir}
sessionLogsFormat={settings.sessionLogsFormat}
setSessionLogsFormat={settings.setSessionLogsFormat}
toggleWindowHotkey={settings.toggleWindowHotkey}
setToggleWindowHotkey={settings.setToggleWindowHotkey}
closeToTray={settings.closeToTray}
setCloseToTray={settings.setCloseToTray}
hotkeyRegistrationError={settings.hotkeyRegistrationError}
/>
)}
</div>

View File

@@ -14,7 +14,7 @@
* - components/sftp/SftpHostPicker.tsx - Host selection dialog
*/
import React, { memo, useCallback, useLayoutEffect, useMemo, useRef, useState } from "react";
import React, { memo, useCallback, useLayoutEffect, useMemo, useRef } from "react";
import { useI18n } from "../application/i18n/I18nProvider";
import { useIsSftpActive } from "../application/state/activeTabStore";
import { useSftpState } from "../application/state/useSftpState";
@@ -53,7 +53,15 @@ interface SftpViewProps {
const SftpViewInner: React.FC<SftpViewProps> = ({ hosts, keys, identities }) => {
const { t } = useI18n();
const isActive = useIsSftpActive();
const { sftpDoubleClickBehavior, sftpAutoSync, sftpShowHiddenFiles, hotkeyScheme, keyBindings } = useSettingsState();
const {
sftpDoubleClickBehavior,
sftpAutoSync,
sftpShowHiddenFiles,
hotkeyScheme,
keyBindings,
editorWordWrap,
setEditorWordWrap,
} = useSettingsState();
// File watch event handlers (stable refs to avoid re-creating the useSftpState options)
const fileWatchHandlers = useMemo(() => ({
@@ -220,12 +228,22 @@ const SftpViewInner: React.FC<SftpViewProps> = ({ hosts, keys, identities }) =>
>
<div className="flex-1 grid grid-cols-1 lg:grid-cols-2 min-h-0 border-t border-border/70">
<div
className={cn(
"relative border-r border-border/70 flex flex-col",
focusedSide === "left" && "ring-1 ring-inset ring-primary/70"
)}
className="relative border-r border-border/70 flex flex-col"
onClick={() => handlePaneFocus("left")}
>
{/* Focus indicator triangle */}
{focusedSide === "left" && (
<div
className="absolute top-0 left-0 z-50 pointer-events-none"
style={{
width: 0,
height: 0,
borderStyle: 'solid',
borderWidth: '12px 12px 0 0',
borderColor: 'hsl(var(--primary)) transparent transparent transparent',
}}
/>
)}
{/* Left side tab bar - only show when there are tabs */}
{leftTabsInfo.length > 0 && (
<SftpTabBar
@@ -266,12 +284,22 @@ const SftpViewInner: React.FC<SftpViewProps> = ({ hosts, keys, identities }) =>
</div>
</div>
<div
className={cn(
"relative flex flex-col",
focusedSide === "right" && "ring-1 ring-inset ring-primary/70"
)}
className="relative flex flex-col"
onClick={() => handlePaneFocus("right")}
>
{/* Focus indicator triangle */}
{focusedSide === "right" && (
<div
className="absolute top-0 left-0 z-50 pointer-events-none"
style={{
width: 0,
height: 0,
borderStyle: 'solid',
borderWidth: '12px 12px 0 0',
borderColor: 'hsl(var(--primary)) transparent transparent transparent',
}}
/>
)}
{/* Right side tab bar - only show when there are tabs */}
{rightTabsInfo.length > 0 && (
<SftpTabBar
@@ -336,6 +364,8 @@ const SftpViewInner: React.FC<SftpViewProps> = ({ hosts, keys, identities }) =>
textEditorContent={textEditorContent}
setTextEditorContent={setTextEditorContent}
handleSaveTextFile={handleSaveTextFile}
editorWordWrap={editorWordWrap}
setEditorWordWrap={setEditorWordWrap}
showFileOpenerDialog={showFileOpenerDialog}
setShowFileOpenerDialog={setShowFileOpenerDialog}
fileOpenerTarget={fileOpenerTarget}

View File

@@ -216,12 +216,32 @@ const TerminalComponent: React.FC<TerminalProps> = ({
useEffect(() => {
if (xtermRuntimeRef.current) {
xtermRuntimeRef.current.keywordHighlighter.setRules(
terminalSettings?.keywordHighlightRules ?? [],
terminalSettings?.keywordHighlightEnabled ?? false
);
// Merge global rules with host-level rules
// Host-level rules are appended to global rules, allowing hosts to add custom highlighting
const globalRules = terminalSettings?.keywordHighlightRules ?? [];
const hostRules = host?.keywordHighlightRules ?? [];
// Check if highlighting is enabled at either global or host level
const globalEnabled = terminalSettings?.keywordHighlightEnabled ?? false;
const hostEnabled = host?.keywordHighlightEnabled ?? false;
// Merge rules: include only rules from enabled sources
const mergedRules = [
...(globalEnabled ? globalRules : []),
...(hostEnabled ? hostRules : [])
];
// Enable highlighting if either global or host-level is enabled
const isEnabled = globalEnabled || hostEnabled;
xtermRuntimeRef.current.keywordHighlighter.setRules(mergedRules, isEnabled);
}
}, [terminalSettings?.keywordHighlightEnabled, terminalSettings?.keywordHighlightRules]);
}, [
terminalSettings?.keywordHighlightEnabled,
terminalSettings?.keywordHighlightRules,
host?.keywordHighlightEnabled,
host?.keywordHighlightRules
]);
const hotkeySchemeRef = useRef(hotkeyScheme);
const keyBindingsRef = useRef(keyBindings);
@@ -396,6 +416,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
setProgressLogs,
setProgressValue,
setChainProgress,
t,
onSessionExit,
onTerminalDataCapture,
onOsDetected,
@@ -447,6 +468,20 @@ const TerminalComponent: React.FC<TerminalProps> = ({
serializeAddonRef.current = runtime.serializeAddon;
searchAddonRef.current = runtime.searchAddon;
// Apply merged keyword highlight rules immediately after runtime creation
// This fixes a timing issue where the useEffect for keyword highlighting
// runs before the runtime is created, causing host-level rules to be missed
const globalRules = terminalSettingsRef.current?.keywordHighlightRules ?? [];
const hostRules = host?.keywordHighlightRules ?? [];
const globalEnabled = terminalSettingsRef.current?.keywordHighlightEnabled ?? false;
const hostEnabled = host?.keywordHighlightEnabled ?? false;
const mergedRules = [
...(globalEnabled ? globalRules : []),
...(hostEnabled ? hostRules : [])
];
const isEnabled = globalEnabled || hostEnabled;
runtime.keywordHighlighter.setRules(mergedRules, isEnabled);
const term = runtime.term;
if (host.protocol === "serial") {
@@ -996,7 +1031,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
try {
const dropEntries = await extractDropEntries(e.dataTransfer);
if (dropEntries.length === 0) {
return;
}
@@ -1087,7 +1122,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
onSplitVertical={onSplitVertical}
onClose={inWorkspace ? () => onCloseSession?.(sessionId) : undefined}
>
<div
<div
className="relative h-full w-full flex overflow-hidden bg-gradient-to-br from-[#050910] via-[#06101a] to-[#0b1220]"
onDragEnter={handleDragEnter}
onDragOver={handleDragOver}
@@ -1100,13 +1135,13 @@ const TerminalComponent: React.FC<TerminalProps> = ({
<div className="bg-background/90 backdrop-blur-md rounded-lg shadow-lg p-6 border border-border">
<div className="text-center">
<div className="text-lg font-semibold mb-2">
{isLocalConnection
{isLocalConnection
? t("terminal.dragDrop.localTitle")
: t("terminal.dragDrop.remoteTitle")
}
</div>
<div className="text-sm text-muted-foreground">
{isLocalConnection
{isLocalConnection
? t("terminal.dragDrop.localMessage")
: t("terminal.dragDrop.remoteMessage")
}
@@ -1265,6 +1300,34 @@ const TerminalComponent: React.FC<TerminalProps> = ({
</div>
</div>
)}
{/* Swap bar */}
{serverStats.swapTotal !== null && serverStats.swapTotal > 0 && (
<div className="space-y-1.5">
<div className="font-medium text-[11px] text-muted-foreground">{t("terminal.serverStats.swap")}</div>
<div className="w-full h-3 bg-muted rounded overflow-hidden flex">
{serverStats.swapUsed !== null && serverStats.swapUsed > 0 && (
<div
className="h-full bg-rose-500"
style={{ width: `${(serverStats.swapUsed / serverStats.swapTotal) * 100}%` }}
title={`${t("terminal.serverStats.swapUsed")}: ${(serverStats.swapUsed / 1024).toFixed(1)}G`}
/>
)}
</div>
<div className="flex flex-wrap gap-x-3 gap-y-1 text-[10px]">
<div className="flex items-center gap-1">
<div className="w-2 h-2 rounded-sm bg-rose-500" />
<span>{t("terminal.serverStats.swapUsed")}: {serverStats.swapUsed !== null ? `${(serverStats.swapUsed / 1024).toFixed(1)}G` : '--'}</span>
</div>
<div className="flex items-center gap-1">
<div className="w-2 h-2 rounded-sm bg-muted border border-border" />
<span>{t("terminal.serverStats.swapFree")}: {serverStats.swapTotal !== null && serverStats.swapUsed !== null ? `${((serverStats.swapTotal - serverStats.swapUsed) / 1024).toFixed(1)}G` : '--'}</span>
</div>
<div className="flex items-center gap-1">
<span className="text-muted-foreground">{t("terminal.serverStats.swapTotal")}: {`${(serverStats.swapTotal / 1024).toFixed(1)}G`}</span>
</div>
</div>
</div>
)}
{/* Top 10 processes */}
{serverStats.topProcesses.length > 0 && (
<div className="space-y-1.5">
@@ -1483,46 +1546,46 @@ const TerminalComponent: React.FC<TerminalProps> = ({
{status !== "connected" && !needsHostKeyVerification && !(
(isLocalConnection || isSerialConnection) && status === "connecting"
) && (
<TerminalConnectionDialog
host={host}
status={status}
error={error}
progressValue={progressValue}
chainProgress={chainProgress}
needsAuth={auth.needsAuth}
showLogs={showLogs}
_setShowLogs={setShowLogs}
keys={keys}
authProps={{
authMethod: auth.authMethod,
setAuthMethod: auth.setAuthMethod,
authUsername: auth.authUsername,
setAuthUsername: auth.setAuthUsername,
authPassword: auth.authPassword,
setAuthPassword: auth.setAuthPassword,
authKeyId: auth.authKeyId,
setAuthKeyId: auth.setAuthKeyId,
authPassphrase: auth.authPassphrase,
setAuthPassphrase: auth.setAuthPassphrase,
showAuthPassphrase: auth.showAuthPassphrase,
setShowAuthPassphrase: auth.setShowAuthPassphrase,
showAuthPassword: auth.showAuthPassword,
setShowAuthPassword: auth.setShowAuthPassword,
authRetryMessage: auth.authRetryMessage,
onSubmit: () => auth.submit(),
onSubmitWithoutSave: () => auth.submit({ saveToHost: false }),
onCancel: handleCancelConnect,
isValid: auth.isValid,
}}
progressProps={{
timeLeft,
isCancelling,
progressLogs,
onCancel: handleCancelConnect,
onRetry: handleRetry,
}}
/>
)}
<TerminalConnectionDialog
host={host}
status={status}
error={error}
progressValue={progressValue}
chainProgress={chainProgress}
needsAuth={auth.needsAuth}
showLogs={showLogs}
_setShowLogs={setShowLogs}
keys={keys}
authProps={{
authMethod: auth.authMethod,
setAuthMethod: auth.setAuthMethod,
authUsername: auth.authUsername,
setAuthUsername: auth.setAuthUsername,
authPassword: auth.authPassword,
setAuthPassword: auth.setAuthPassword,
authKeyId: auth.authKeyId,
setAuthKeyId: auth.setAuthKeyId,
authPassphrase: auth.authPassphrase,
setAuthPassphrase: auth.setAuthPassphrase,
showAuthPassphrase: auth.showAuthPassphrase,
setShowAuthPassphrase: auth.setShowAuthPassphrase,
showAuthPassword: auth.showAuthPassword,
setShowAuthPassword: auth.setShowAuthPassword,
authRetryMessage: auth.authRetryMessage,
onSubmit: () => auth.submit(),
onSubmitWithoutSave: () => auth.submit({ saveToHost: false }),
onCancel: handleCancelConnect,
isValid: auth.isValid,
}}
progressProps={{
timeLeft,
isCancelling,
progressLogs,
onCancel: handleCancelConnect,
onRetry: handleRetry,
}}
/>
)}
</div>
<SFTPModal
@@ -1584,6 +1647,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
proxy: proxyConfig,
jumpHosts: jumpHosts && jumpHosts.length > 0 ? jumpHosts : undefined,
sftpSudo: host.sftpSudo,
legacyAlgorithms: host.legacyAlgorithms,
};
})()}
open={showSFTP && status === "connected"}

View File

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

View File

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

410
components/TrayPanel.tsx Normal file
View File

@@ -0,0 +1,410 @@
import React, { useCallback, useEffect, useMemo, useState } from "react";
import { Button } from "./ui/button";
import { useSessionState } from "../application/state/useSessionState";
import { usePortForwardingState } from "../application/state/usePortForwardingState";
import { useVaultState } from "../application/state/useVaultState";
import { toast } from "./ui/toast";
import { cn } from "../lib/utils";
import { useI18n } from "../application/i18n/I18nProvider";
import { I18nProvider } from "../application/i18n/I18nProvider";
import { useSettingsState } from "../application/state/useSettingsState";
import { useTrayPanelBackend } from "../application/state/useTrayPanelBackend";
import { useActiveTabId } from "../application/state/activeTabStore";
import { X, Maximize2, ChevronRight, ChevronDown, Power } from "lucide-react";
import { AppLogo } from "./AppLogo";
const StatusDot: React.FC<{ status: "success" | "warning" | "error" | "neutral"; spinning?: boolean }> = ({
status,
spinning,
}) => {
const color =
status === "success"
? "bg-emerald-500"
: status === "warning"
? "bg-amber-500"
: status === "error"
? "bg-rose-500"
: "bg-zinc-500";
return (
<span
className={cn(
"inline-block h-2 w-2 rounded-full",
color,
spinning ? "animate-spin" : "",
)}
/>
);
};
// Session type for workspace grouping
type TraySession = {
id: string;
label: string;
hostLabel: string;
status: "connecting" | "connected" | "disconnected";
workspaceId?: string;
workspaceTitle?: string;
};
// Collapsible workspace group component
const WorkspaceGroup: React.FC<{
workspaceId: string;
title: string;
sessions: TraySession[];
activeTabId: string | null;
jumpToSession: (sessionId: string) => Promise<void>;
t: (key: string) => string;
}> = ({ workspaceId, title, sessions, activeTabId, jumpToSession, t }) => {
const [expanded, setExpanded] = useState(true);
const isAnyActive = sessions.some((s) => s.id === activeTabId) || activeTabId === workspaceId;
return (
<div>
<button
onClick={() => setExpanded(!expanded)}
className={cn(
"w-full text-left px-2 py-1.5 rounded hover:bg-muted flex items-center gap-1",
isAnyActive ? "bg-muted" : "",
)}
>
{expanded ? <ChevronDown size={14} className="text-muted-foreground" /> : <ChevronRight size={14} className="text-muted-foreground" />}
<span className="font-medium truncate">{title}</span>
<span className="ml-auto text-xs text-muted-foreground">{sessions.length}</span>
</button>
{expanded && (
<div className="ml-4 mt-0.5 space-y-0.5">
{sessions.map((s) => (
<button
key={s.id}
title={s.hostLabel || s.label}
onClick={() => {
// Jump to session (using session id)
void jumpToSession(s.id);
}}
className={cn(
"w-full text-left px-2 py-1 rounded hover:bg-muted flex items-center justify-between text-sm",
s.status === "connected" ? "" : "text-muted-foreground",
activeTabId === s.id ? "bg-muted/60" : "",
)}
>
<span className="flex items-center gap-2 min-w-0">
<StatusDot
status={s.status === "connected" ? "success" : s.status === "connecting" ? "warning" : "error"}
spinning={s.status === "connecting"}
/>
<span className="truncate">{s.hostLabel || s.label}</span>
</span>
<span className="ml-2 text-xs text-muted-foreground">{t(`tray.status.${s.status}`)}</span>
</button>
))}
</div>
)}
</div>
);
};
const TrayPanelContent: React.FC = () => {
const { t } = useI18n();
const {
hideTrayPanel,
openMainWindow,
quitApp,
jumpToSession,
onTrayPanelCloseRequest,
onTrayPanelRefresh,
onTrayPanelMenuData,
} = useTrayPanelBackend();
const { hosts, keys } = useVaultState();
useSessionState();
const { rules: portForwardingRules, startTunnel, stopTunnel } = usePortForwardingState();
const activeTabId = useActiveTabId();
const [traySessions, setTraySessions] = useState<TraySession[]>([]);
const jumpableSessions = useMemo(
() => traySessions.filter((s) => s.status === "connected" || s.status === "connecting"),
[traySessions],
);
const activeSession = useMemo(() => {
if (!activeTabId) return null;
return traySessions.find((s) => s.id === activeTabId) || null;
}, [activeTabId, traySessions]);
useEffect(() => {
const unsubscribe = onTrayPanelMenuData?.((data) => {
setTraySessions(data.sessions || []);
});
return () => unsubscribe?.();
}, [onTrayPanelMenuData]);
useEffect(() => {
const unsubscribe = onTrayPanelRefresh?.(() => {
try {
window.dispatchEvent(new Event("storage"));
} catch {
// ignore
}
});
return () => unsubscribe?.();
}, [onTrayPanelRefresh]);
const keysForPf = useMemo(
() => keys.map((k) => ({ id: k.id, privateKey: k.privateKey })),
[keys],
);
const handleClose = useCallback(() => {
void hideTrayPanel();
}, [hideTrayPanel]);
useEffect(() => {
const onKeyDown = (e: KeyboardEvent) => {
if (e.key === "Escape") {
e.preventDefault();
handleClose();
}
};
window.addEventListener("keydown", onKeyDown);
return () => window.removeEventListener("keydown", onKeyDown);
}, [handleClose]);
useEffect(() => {
const onPointerDown = (e: PointerEvent) => {
const target = e.target;
if (!(target instanceof Node)) return;
if (document.body && !document.body.contains(target)) return;
// Ignore clicks on interactive elements inside the panel.
if (target instanceof HTMLElement && target.closest("button,a,input,select,textarea,[role='button']")) {
return;
}
// Clicking on background should close panel
const root = document.getElementById("tray-panel-root");
if (root && !root.contains(target)) {
handleClose();
}
};
window.addEventListener("pointerdown", onPointerDown, true);
return () => window.removeEventListener("pointerdown", onPointerDown, true);
}, [handleClose]);
useEffect(() => {
const unsubscribe = onTrayPanelCloseRequest(() => {
handleClose();
});
return () => unsubscribe?.();
}, [handleClose, onTrayPanelCloseRequest]);
const handleOpenMain = useCallback(() => {
void openMainWindow();
}, [openMainWindow]);
const handleQuit = useCallback(() => {
void quitApp();
}, [quitApp]);
return (
<div id="tray-panel-root" className="w-full h-full bg-background/95 backdrop-blur border border-border/60 rounded-lg shadow-lg overflow-hidden flex flex-col">
<div className="px-3 py-2 border-b border-border/60 flex items-center justify-between app-no-drag">
<div className="flex items-center gap-2">
<AppLogo className="w-5 h-5" />
<span className="text-sm font-medium">Netcatty</span>
</div>
<div className="flex items-center gap-1">
<button
className="p-1 rounded hover:bg-muted text-muted-foreground hover:text-foreground"
onClick={handleOpenMain}
title={t("tray.openMainWindow")}
>
<Maximize2 size={14} />
</button>
<button
className="p-1 rounded hover:bg-muted text-muted-foreground hover:text-foreground"
onClick={handleClose}
title="Close"
>
<X size={14} />
</button>
</div>
</div>
<div className="p-2 space-y-3 text-sm flex-1 overflow-y-auto min-h-0">
{jumpableSessions.length > 0 && (() => {
// Group sessions by workspace
const workspaceGroups = new Map<string, { title: string; sessions: typeof jumpableSessions }>();
const soloSessions: typeof jumpableSessions = [];
jumpableSessions.forEach((s) => {
if (s.workspaceId) {
const existing = workspaceGroups.get(s.workspaceId);
if (existing) {
existing.sessions.push(s);
} else {
workspaceGroups.set(s.workspaceId, {
title: s.workspaceTitle || "Workspace",
sessions: [s],
});
}
} else {
soloSessions.push(s);
}
});
return (
<div>
<div className="px-2 py-1 text-xs text-muted-foreground">{t("tray.sessions")}</div>
<div className="space-y-1">
{/* Workspace groups */}
{Array.from(workspaceGroups.entries()).map(([wsId, group]) => (
<WorkspaceGroup
key={wsId}
workspaceId={wsId}
title={group.title}
sessions={group.sessions}
activeTabId={activeTabId}
jumpToSession={jumpToSession}
t={t}
/>
))}
{/* Solo sessions */}
{soloSessions.map((s) => (
<button
key={s.id}
title={s.hostLabel || s.label}
onClick={() => {
void jumpToSession(s.id);
}}
className={cn(
"w-full text-left px-2 py-1.5 rounded hover:bg-muted flex items-center justify-between",
s.status === "connected" ? "" : "text-muted-foreground",
activeTabId === s.id ? "bg-muted" : "",
)}
>
<span className="flex items-center gap-2 min-w-0">
<StatusDot
status={s.status === "connected" ? "success" : s.status === "connecting" ? "warning" : "error"}
spinning={s.status === "connecting"}
/>
<span className="truncate">{s.hostLabel || s.label}</span>
</span>
<span className="ml-2 text-xs text-muted-foreground">{t(`tray.status.${s.status}`)}</span>
</button>
))}
</div>
</div>
);
})()}
{activeSession && (
<div>
<div className="px-2 py-1 text-xs text-muted-foreground">Current</div>
<Button
variant="ghost"
className="w-full justify-start px-2 h-8"
title={activeSession.hostLabel || activeSession.label}
onClick={() => {
void jumpToSession(activeSession.id);
}}
>
<span className="truncate">{activeSession.hostLabel || activeSession.label}</span>
</Button>
</div>
)}
{portForwardingRules.length > 0 && (
<div>
<div className="px-2 py-1 text-xs text-muted-foreground">{t("tray.portForwarding")}</div>
<div className="space-y-1">
{portForwardingRules.map((rule) => {
const isConnecting = rule.status === "connecting";
const isActive = rule.status === "active";
const label = rule.label || (rule.type === "dynamic"
? `SOCKS:${rule.localPort}`
: `${rule.localPort}${rule.remoteHost}:${rule.remotePort}`);
return (
<button
key={rule.id}
disabled={isConnecting}
title={label}
onClick={() => {
const host = rule.hostId ? hosts.find((h) => h.id === rule.hostId) : undefined;
if (!host) {
toast.error(t("pf.error.hostNotFound"));
return;
}
if (isActive) {
void stopTunnel(rule.id);
} else {
void startTunnel(rule, host, keysForPf, (status, error) => {
if (status === "error" && error) toast.error(error);
}, rule.autoStart);
}
}}
className={cn(
"w-full text-left px-2 py-1.5 rounded hover:bg-muted flex items-center justify-between",
isConnecting ? "opacity-60" : "",
)}
>
<span className="flex items-center gap-2 min-w-0">
<StatusDot
status={
rule.status === "active"
? "success"
: rule.status === "connecting"
? "warning"
: rule.status === "error"
? "error"
: "neutral"
}
spinning={rule.status === "connecting"}
/>
<span className="truncate">{label}</span>
</span>
<span className="ml-2 text-xs text-muted-foreground">
{t(`tray.status.${rule.status}`)}
</span>
</button>
);
})}
</div>
</div>
)}
{/* Empty state - show when nothing is active */}
{jumpableSessions.length === 0 && portForwardingRules.length === 0 && (
<div className="flex flex-col items-center justify-center py-8 text-center">
<span className="text-2xl mb-2">😴</span>
<span className="text-sm text-muted-foreground">{t("tray.empty.title")}</span>
<span className="text-xs text-muted-foreground/60 mt-1">{t("tray.empty.subtitle")}</span>
</div>
)}
</div>
{/* Quit button at the bottom */}
<div className="px-3 py-2 border-t border-border/60">
<button
className="w-full text-left px-2 py-1.5 rounded hover:bg-destructive/10 flex items-center gap-2 text-sm text-muted-foreground hover:text-destructive transition-colors"
onClick={handleQuit}
>
<Power size={14} />
<span>{t("tray.quit")}</span>
</button>
</div>
</div>
);
};
const TrayPanel: React.FC = () => {
const settings = useSettingsState();
return (
<I18nProvider locale={settings.uiLanguage}>
<TrayPanelContent />
</I18nProvider>
);
};
export default TrayPanel;

View File

@@ -30,11 +30,12 @@ import {
import React, { Suspense, lazy, memo, useCallback, useEffect, useMemo, useState } from "react";
import { useI18n } from "../application/i18n/I18nProvider";
import { useStoredViewMode } from "../application/state/useStoredViewMode";
import { useStoredBoolean } from "../application/state/useStoredBoolean";
import { useTreeExpandedState } from "../application/state/useTreeExpandedState";
import { sanitizeHost } from "../domain/host";
import { importVaultHostsFromText, exportHostsToCsvWithStats } from "../domain/vaultImport";
import type { VaultImportFormat } from "../domain/vaultImport";
import { STORAGE_KEY_VAULT_HOSTS_VIEW_MODE, STORAGE_KEY_VAULT_HOSTS_TREE_EXPANDED } from "../infrastructure/config/storageKeys";
import { STORAGE_KEY_VAULT_HOSTS_VIEW_MODE, STORAGE_KEY_VAULT_HOSTS_TREE_EXPANDED, STORAGE_KEY_VAULT_SIDEBAR_COLLAPSED } from "../infrastructure/config/storageKeys";
import { cn } from "../lib/utils";
import {
ConnectionLog,
@@ -84,6 +85,7 @@ import { Label } from "./ui/label";
import { SortDropdown, SortMode } from "./ui/sort-dropdown";
import { TagFilterDropdown } from "./ui/tag-filter-dropdown";
import { toast } from "./ui/toast";
import { Tooltip, TooltipContent, TooltipTrigger, TooltipProvider } from "./ui/tooltip";
import { Badge } from "./ui/badge";
import { HotkeyScheme, KeyBinding } from "../domain/models";
@@ -194,6 +196,12 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
const [deleteTargetPath, setDeleteTargetPath] = useState<string | null>(null);
const [deleteGroupWithHosts, setDeleteGroupWithHosts] = useState(false);
// Sidebar collapsed state with localStorage persistence
const [sidebarCollapsed, setSidebarCollapsed] = useStoredBoolean(
STORAGE_KEY_VAULT_SIDEBAR_COLLAPSED,
false,
);
// Handle external navigation requests
useEffect(() => {
if (navigateToSection) {
@@ -824,7 +832,7 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
if (sortMode !== "group") return null;
const groups: { name: string; hosts: Host[] }[] = [];
const groupMap = new Map<string, Host[]>();
for (const host of displayedHosts) {
const groupName = host.group || "";
if (!groupMap.has(groupName)) {
@@ -832,7 +840,7 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
}
groupMap.get(groupName)!.push(host);
}
const sortedKeys = [...groupMap.keys()].sort((a, b) => a.localeCompare(b));
for (const key of sortedKeys) {
groups.push({ name: key, hosts: groupMap.get(key)! });
@@ -1230,100 +1238,171 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
return (
<div className="absolute inset-0 min-h-0 flex">
{/* Sidebar */}
<div className="w-52 bg-secondary/80 border-r border-border/60 flex flex-col">
<div className="px-4 py-4 flex items-center gap-3">
<AppLogo className="h-10 w-10 rounded-xl" />
<div>
<p className="text-sm font-bold text-foreground">Netcatty</p>
<TooltipProvider delayDuration={100}>
<div className={cn(
"bg-secondary/80 border-r border-border/60 flex flex-col transition-all duration-200",
sidebarCollapsed ? "w-14" : "w-52"
)}>
<div className={cn(
"py-4 flex items-center",
sidebarCollapsed ? "px-2 justify-center" : "px-4"
)}>
<Tooltip delayDuration={500}>
<TooltipTrigger asChild>
<button
onClick={() => setSidebarCollapsed(!sidebarCollapsed)}
className="flex items-center gap-3 hover:opacity-80 transition-opacity"
>
<AppLogo className="h-10 w-10 rounded-xl flex-shrink-0" />
{!sidebarCollapsed && (
<p className="text-sm font-bold text-foreground">Netcatty</p>
)}
</button>
</TooltipTrigger>
<TooltipContent side="right">
{sidebarCollapsed ? t("vault.sidebar.expand") : t("vault.sidebar.collapse")}
</TooltipContent>
</Tooltip>
</div>
<div className={cn("space-y-1", sidebarCollapsed ? "px-1.5" : "px-3")}>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant={currentSection === "hosts" ? "secondary" : "ghost"}
className={cn(
"w-full h-10",
sidebarCollapsed ? "justify-center p-0" : "justify-start gap-3",
currentSection === "hosts" &&
"bg-foreground/10 text-foreground hover:bg-foreground/15 border-border/40",
)}
onClick={() => {
setCurrentSection("hosts");
setSelectedGroupPath(null);
}}
>
<LayoutGrid size={16} className="flex-shrink-0" />
{!sidebarCollapsed && t("vault.nav.hosts")}
</Button>
</TooltipTrigger>
{sidebarCollapsed && <TooltipContent side="right">{t("vault.nav.hosts")}</TooltipContent>}
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant={currentSection === "keys" ? "secondary" : "ghost"}
className={cn(
"w-full h-10",
sidebarCollapsed ? "justify-center p-0" : "justify-start gap-3",
currentSection === "keys" &&
"bg-foreground/10 text-foreground hover:bg-foreground/15 border-border/40",
)}
onClick={() => {
setCurrentSection("keys");
}}
>
<Key size={16} className="flex-shrink-0" />
{!sidebarCollapsed && t("vault.nav.keychain")}
</Button>
</TooltipTrigger>
{sidebarCollapsed && <TooltipContent side="right">{t("vault.nav.keychain")}</TooltipContent>}
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant={currentSection === "port" ? "secondary" : "ghost"}
className={cn(
"w-full h-10",
sidebarCollapsed ? "justify-center p-0" : "justify-start gap-3",
currentSection === "port" &&
"bg-foreground/10 text-foreground hover:bg-foreground/15 border-border/40",
)}
onClick={() => setCurrentSection("port")}
>
<Plug size={16} className="flex-shrink-0" />
{!sidebarCollapsed && t("vault.nav.portForwarding")}
</Button>
</TooltipTrigger>
{sidebarCollapsed && <TooltipContent side="right">{t("vault.nav.portForwarding")}</TooltipContent>}
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant={currentSection === "snippets" ? "secondary" : "ghost"}
className={cn(
"w-full h-10",
sidebarCollapsed ? "justify-center p-0" : "justify-start gap-3",
currentSection === "snippets" &&
"bg-foreground/10 text-foreground hover:bg-foreground/15 border-border/40",
)}
onClick={() => {
setCurrentSection("snippets");
}}
>
<FileCode size={16} className="flex-shrink-0" />
{!sidebarCollapsed && t("vault.nav.snippets")}
</Button>
</TooltipTrigger>
{sidebarCollapsed && <TooltipContent side="right">{t("vault.nav.snippets")}</TooltipContent>}
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant={currentSection === "knownhosts" ? "secondary" : "ghost"}
className={cn(
"w-full h-10",
sidebarCollapsed ? "justify-center p-0" : "justify-start gap-3",
currentSection === "knownhosts" &&
"bg-foreground/10 text-foreground hover:bg-foreground/15 border-border/40",
)}
onClick={() => setCurrentSection("knownhosts")}
>
<BookMarked size={16} className="flex-shrink-0" />
{!sidebarCollapsed && t("vault.nav.knownHosts")}
</Button>
</TooltipTrigger>
{sidebarCollapsed && <TooltipContent side="right">{t("vault.nav.knownHosts")}</TooltipContent>}
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant={currentSection === "logs" ? "secondary" : "ghost"}
className={cn(
"w-full h-10",
sidebarCollapsed ? "justify-center p-0" : "justify-start gap-3",
currentSection === "logs" &&
"bg-foreground/10 text-foreground hover:bg-foreground/15 border-border/40",
)}
onClick={() => setCurrentSection("logs")}
>
<Activity size={16} className="flex-shrink-0" />
{!sidebarCollapsed && t("vault.nav.logs")}
</Button>
</TooltipTrigger>
{sidebarCollapsed && <TooltipContent side="right">{t("vault.nav.logs")}</TooltipContent>}
</Tooltip>
</div>
<div className={cn("mt-auto pb-4 space-y-2", sidebarCollapsed ? "px-1.5" : "px-3")}>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
className={cn(
"w-full",
sidebarCollapsed ? "justify-center p-0" : "justify-start gap-3"
)}
onClick={onOpenSettings}
>
<Settings size={16} className="flex-shrink-0" />
{!sidebarCollapsed && t("common.settings")}
</Button>
</TooltipTrigger>
{sidebarCollapsed && <TooltipContent side="right">{t("common.settings")}</TooltipContent>}
</Tooltip>
</div>
</div>
<div className="px-3 space-y-1">
<Button
variant={currentSection === "hosts" ? "secondary" : "ghost"}
className={cn(
"w-full justify-start gap-3 h-10",
currentSection === "hosts" &&
"bg-foreground/10 text-foreground hover:bg-foreground/15 border-border/40",
)}
onClick={() => {
setCurrentSection("hosts");
setSelectedGroupPath(null);
}}
>
<LayoutGrid size={16} /> {t("vault.nav.hosts")}
</Button>
<Button
variant={currentSection === "keys" ? "secondary" : "ghost"}
className={cn(
"w-full justify-start gap-3 h-10",
currentSection === "keys" &&
"bg-foreground/10 text-foreground hover:bg-foreground/15 border-border/40",
)}
onClick={() => {
setCurrentSection("keys");
}}
>
<Key size={16} /> {t("vault.nav.keychain")}
</Button>
<Button
variant={currentSection === "port" ? "secondary" : "ghost"}
className={cn(
"w-full justify-start gap-3 h-10",
currentSection === "port" &&
"bg-foreground/10 text-foreground hover:bg-foreground/15 border-border/40",
)}
onClick={() => setCurrentSection("port")}
>
<Plug size={16} /> {t("vault.nav.portForwarding")}
</Button>
<Button
variant={currentSection === "snippets" ? "secondary" : "ghost"}
className={cn(
"w-full justify-start gap-3 h-10",
currentSection === "snippets" &&
"bg-foreground/10 text-foreground hover:bg-foreground/15 border-border/40",
)}
onClick={() => {
setCurrentSection("snippets");
}}
>
<FileCode size={16} /> {t("vault.nav.snippets")}
</Button>
<Button
variant={currentSection === "knownhosts" ? "secondary" : "ghost"}
className={cn(
"w-full justify-start gap-3 h-10",
currentSection === "knownhosts" &&
"bg-foreground/10 text-foreground hover:bg-foreground/15 border-border/40",
)}
onClick={() => setCurrentSection("knownhosts")}
>
<BookMarked size={16} /> {t("vault.nav.knownHosts")}
</Button>
<Button
variant={currentSection === "logs" ? "secondary" : "ghost"}
className={cn(
"w-full justify-start gap-3 h-10",
currentSection === "logs" &&
"bg-foreground/10 text-foreground hover:bg-foreground/15 border-border/40",
)}
onClick={() => setCurrentSection("logs")}
>
<Activity size={16} /> {t("vault.nav.logs")}
</Button>
</div>
<div className="mt-auto px-3 pb-4 space-y-2">
<Button
variant="ghost"
className="w-full justify-start gap-3"
onClick={onOpenSettings}
>
<Settings size={16} /> {t("common.settings")}
</Button>
</div>
</div>
</TooltipProvider>
{/* Main Area */}
<div className="flex-1 flex flex-col min-h-0 relative">
@@ -1591,93 +1670,93 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
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>
)}
{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="text-[11px] text-muted-foreground">
{t("vault.groups.hostsCount", { count: node.hosts.length })}
<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.hosts.length })}
</div>
</div>
</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>
</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>
@@ -1695,7 +1774,7 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
</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">
@@ -1738,7 +1817,7 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
</Button>
</div>
)}
{viewMode === "tree" ? (
<HostTreeView
groupTree={treeViewGroupTree}
@@ -1778,6 +1857,9 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
moveGroup={moveGroup}
managedGroupPaths={managedGroupPaths}
onUnmanageGroup={handleUnmanageGroup}
isMultiSelectMode={isMultiSelectMode}
selectedHostIds={selectedHostIds}
toggleHostSelection={toggleHostSelection}
/>
) : sortMode === "group" && groupedDisplayHosts ? (
<div className="space-y-6">
@@ -1830,7 +1912,7 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
>
<div className="flex items-center gap-3 h-full">
{isMultiSelectMode && (
<div
<div
className="shrink-0"
onClick={(e) => {
e.stopPropagation();
@@ -1921,11 +2003,10 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
<LayoutGrid size={32} className="opacity-60" />
</div>
<h3 className="text-lg font-semibold text-foreground mb-2">
Set up your hosts
{t('vault.hosts.empty.title')}
</h3>
<p className="text-sm text-center max-w-sm">
Save hosts to quickly connect to your servers, VMs,
and containers.
{t('vault.hosts.empty.desc')}
</p>
</div>
)}
@@ -1969,7 +2050,7 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
>
<div className="flex items-center gap-3 h-full">
{isMultiSelectMode && (
<div
<div
className="shrink-0"
onClick={(e) => {
e.stopPropagation();
@@ -2057,11 +2138,10 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
<LayoutGrid size={32} className="opacity-60" />
</div>
<h3 className="text-lg font-semibold text-foreground mb-2">
Set up your hosts
{t('vault.hosts.empty.title')}
</h3>
<p className="text-sm text-center max-w-sm">
Save hosts to quickly connect to your servers, VMs,
and containers.
{t('vault.hosts.empty.desc')}
</p>
</div>
)}
@@ -2386,8 +2466,8 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
<Button variant="ghost" onClick={() => setIsDeleteGroupOpen(false)}>
{t("common.cancel")}
</Button>
<Button
variant="destructive"
<Button
variant="destructive"
onClick={() => {
if (deleteTargetPath) {
const isManaged = managedGroupPaths.has(deleteTargetPath);

View File

@@ -1,14 +1,16 @@
/**
* Settings System Tab - System information, temp file management, and session logs
* Settings System Tab - System information, temp file management, session logs, and global hotkey
*/
import { FileText, FolderOpen, HardDrive, RefreshCw, Trash2 } from "lucide-react";
import { 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";
import { netcattyBridge } from "../../../infrastructure/services/netcattyBridge";
import { SessionLogFormat } from "../../../domain/models";
import { SessionLogFormat, keyEventToString } from "../../../domain/models";
import { TabsContent } from "../../ui/tabs";
import { Button } from "../../ui/button";
import { Toggle, Select, SettingRow } from "../settings-ui";
import { cn } from "../../../lib/utils";
interface TempDirInfo {
path: string;
@@ -31,6 +33,11 @@ interface SettingsSystemTabProps {
setSessionLogsDir: (dir: string) => void;
sessionLogsFormat: SessionLogFormat;
setSessionLogsFormat: (format: SessionLogFormat) => void;
toggleWindowHotkey: string;
setToggleWindowHotkey: (hotkey: string) => void;
closeToTray: boolean;
setCloseToTray: (enabled: boolean) => void;
hotkeyRegistrationError: string | null;
}
const SettingsSystemTab: React.FC<SettingsSystemTabProps> = ({
@@ -40,13 +47,23 @@ const SettingsSystemTab: React.FC<SettingsSystemTabProps> = ({
setSessionLogsDir,
sessionLogsFormat,
setSessionLogsFormat,
toggleWindowHotkey,
setToggleWindowHotkey,
closeToTray,
setCloseToTray,
hotkeyRegistrationError,
}) => {
const { t } = useI18n();
const isMac = typeof navigator !== "undefined" && /Mac/i.test(navigator.platform);
const [tempDirInfo, setTempDirInfo] = useState<TempDirInfo | null>(null);
const [isLoading, setIsLoading] = useState(false);
const [isClearing, setIsClearing] = useState(false);
const [clearResult, setClearResult] = useState<{ deletedCount: number; failedCount: number } | null>(null);
const [isRecordingHotkey, setIsRecordingHotkey] = useState(false);
const [hotkeyError, setHotkeyError] = useState<string | null>(null);
const [credentialsAvailable, setCredentialsAvailable] = useState<boolean | null>(null);
const [isCheckingCredentials, setIsCheckingCredentials] = useState(false);
const loadTempDirInfo = useCallback(async () => {
const bridge = netcattyBridge.get();
@@ -67,6 +84,20 @@ const SettingsSystemTab: React.FC<SettingsSystemTabProps> = ({
loadTempDirInfo();
}, [loadTempDirInfo]);
const loadCredentialProtectionStatus = useCallback(async () => {
setIsCheckingCredentials(true);
try {
const available = await getCredentialProtectionAvailability();
setCredentialsAvailable(available);
} finally {
setIsCheckingCredentials(false);
}
}, []);
useEffect(() => {
void loadCredentialProtectionStatus();
}, [loadCredentialProtectionStatus]);
const handleClearTempFiles = useCallback(async () => {
const bridge = netcattyBridge.get();
if (!bridge?.clearTempDir) return;
@@ -116,6 +147,56 @@ const SettingsSystemTab: React.FC<SettingsSystemTabProps> = ({
}
}, [sessionLogsDir]);
// Handle global toggle hotkey recording
const cancelHotkeyRecording = useCallback(() => {
setIsRecordingHotkey(false);
}, []);
const handleResetHotkey = useCallback(() => {
// Reset to default hotkey (Ctrl+` or ⌃+` on Mac)
const defaultHotkey = isMac ? '⌃ + `' : 'Ctrl + `';
setToggleWindowHotkey(defaultHotkey);
setHotkeyError(null);
}, [isMac, setToggleWindowHotkey]);
// Hotkey recording effect
useEffect(() => {
if (!isRecordingHotkey) return;
const handleKeyDown = (e: KeyboardEvent) => {
e.preventDefault();
e.stopPropagation();
if (e.key === "Escape") {
cancelHotkeyRecording();
return;
}
// Ignore modifier-only keys
if (["Meta", "Control", "Alt", "Shift"].includes(e.key)) return;
const keyString = keyEventToString(e, isMac);
setToggleWindowHotkey(keyString);
setHotkeyError(null);
cancelHotkeyRecording();
};
const handleClick = () => {
cancelHotkeyRecording();
};
const timer = setTimeout(() => {
window.addEventListener("click", handleClick, true);
}, 100);
window.addEventListener("keydown", handleKeyDown, true);
return () => {
clearTimeout(timer);
window.removeEventListener("keydown", handleKeyDown, true);
window.removeEventListener("click", handleClick, true);
};
}, [isRecordingHotkey, isMac, setToggleWindowHotkey, cancelHotkeyRecording]);
const formatOptions = [
{ value: "txt", label: t("settings.sessionLogs.formatTxt") },
{ value: "raw", label: t("settings.sessionLogs.formatRaw") },
@@ -137,6 +218,59 @@ const SettingsSystemTab: React.FC<SettingsSystemTabProps> = ({
</p>
</div>
{/* Credential Protection Section */}
<div className="space-y-4">
<div className="flex items-center gap-2">
<HardDrive size={18} className="text-muted-foreground" />
<h3 className="text-base font-medium">{t("settings.system.credentials.title")}</h3>
</div>
<div className="bg-muted/30 rounded-lg p-4 space-y-3">
<div className="flex items-start justify-between gap-4">
<div>
<p className="text-sm text-muted-foreground">
{t("settings.system.credentials.status")}
</p>
<p
className={cn(
"text-sm font-medium mt-1",
credentialsAvailable === true && "text-emerald-600 dark:text-emerald-400",
credentialsAvailable === false && "text-amber-600 dark:text-amber-400",
)}
>
{isCheckingCredentials
? t("settings.system.credentials.checking")
: credentialsAvailable === true
? t("settings.system.credentials.available")
: credentialsAvailable === false
? t("settings.system.credentials.unavailable")
: t("settings.system.credentials.unknown")}
</p>
</div>
<Button
variant="outline"
size="sm"
onClick={loadCredentialProtectionStatus}
disabled={isCheckingCredentials}
className="gap-1.5"
>
<RefreshCw size={14} className={isCheckingCredentials ? "animate-spin" : ""} />
{t("settings.system.refresh")}
</Button>
</div>
{credentialsAvailable === false && (
<p className="text-xs text-amber-700 dark:text-amber-400">
{t("settings.system.credentials.unavailableHint")}
</p>
)}
<p className="text-xs text-muted-foreground">
{t("settings.system.credentials.portabilityHint")}
</p>
</div>
</div>
{/* Temp Directory Section */}
<div className="space-y-4">
<div className="flex items-center gap-2">
@@ -295,6 +429,68 @@ const SettingsSystemTab: React.FC<SettingsSystemTabProps> = ({
{t("settings.sessionLogs.hint")}
</p>
</div>
{/* Global Toggle Window Section (Quake Mode) */}
<div className="space-y-4">
<div className="flex items-center gap-2">
<Keyboard size={18} className="text-muted-foreground" />
<h3 className="text-base font-medium">{t("settings.globalHotkey.title")}</h3>
</div>
<div className="bg-muted/30 rounded-lg p-4 space-y-4">
{/* Toggle Window Hotkey */}
<SettingRow
label={t("settings.globalHotkey.toggleWindow")}
description={t("settings.globalHotkey.toggleWindowDesc")}
>
<div className="flex items-center gap-2">
<button
onClick={(e) => {
e.stopPropagation();
setIsRecordingHotkey(true);
}}
className={cn(
"px-3 py-1.5 text-sm font-mono rounded border transition-colors min-w-[100px] text-center",
isRecordingHotkey
? "border-primary bg-primary/10 animate-pulse"
: "border-border hover:border-primary/50",
)}
>
{isRecordingHotkey
? t("settings.shortcuts.recording")
: toggleWindowHotkey || t("settings.globalHotkey.notSet")}
</button>
{toggleWindowHotkey && (
<button
onClick={handleResetHotkey}
className="p-1 hover:bg-muted rounded"
title={t("settings.globalHotkey.reset")}
>
<RotateCcw size={14} />
</button>
)}
</div>
</SettingRow>
{(hotkeyError || hotkeyRegistrationError) && (
<p className="text-sm text-destructive">{hotkeyError || hotkeyRegistrationError}</p>
)}
{/* Close to Tray */}
<SettingRow
label={t("settings.globalHotkey.closeToTray")}
description={t("settings.globalHotkey.closeToTrayDesc")}
>
<Toggle
checked={closeToTray}
onChange={setCloseToTray}
/>
</SettingRow>
</div>
<p className="text-xs text-muted-foreground">
{t("settings.globalHotkey.hint")}
</p>
</div>
</div>
</div>
</TabsContent>

View File

@@ -1,4 +1,4 @@
import React from "react";
import React, { useEffect, useState } from "react";
import { ArrowUp, Check, ChevronRight, FilePlus, FolderPlus, FolderUp, Home, Languages, MoreHorizontal, RefreshCw, Upload } from "lucide-react";
import { cn } from "../../lib/utils";
import type { Host, SftpFilenameEncoding } from "../../types";
@@ -88,252 +88,268 @@ export const SftpModalHeader: React.FC<SftpModalHeaderProps> = ({
onCreateFile,
onFileSelect,
onFolderSelect,
}) => (
<>
<DialogHeader className="px-4 py-3 border-b border-border/60 flex-shrink-0">
<div className="flex items-center gap-3 pr-8">
<DistroAvatar
host={host}
fallback={host.label.slice(0, 2).toUpperCase()}
className="h-8 w-8"
/>
<div className="flex-1 min-w-0">
<DialogTitle className="text-sm font-semibold">
{host.label}
</DialogTitle>
<div className="text-xs text-muted-foreground font-mono">
{credentials.username || "root"}@{credentials.hostname}:
{credentials.port || 22}
}) => {
// Delay tooltip activation to prevent flickering when modal opens
const [tooltipsReady, setTooltipsReady] = useState(false);
const [openTooltip, setOpenTooltip] = useState<string | null>(null);
useEffect(() => {
const timer = setTimeout(() => setTooltipsReady(true), 500);
return () => clearTimeout(timer);
}, []);
const handleTooltipOpenChange = (id: string) => (open: boolean) => {
if (!tooltipsReady) return;
setOpenTooltip(open ? id : null);
};
return (
<>
<DialogHeader className="px-4 py-3 border-b border-border/60 flex-shrink-0">
<div className="flex items-center gap-3 pr-8">
<DistroAvatar
host={host}
fallback={host.label.slice(0, 2).toUpperCase()}
className="h-8 w-8"
/>
<div className="flex-1 min-w-0">
<DialogTitle className="text-sm font-semibold">
{host.label}
</DialogTitle>
<div className="text-xs text-muted-foreground font-mono">
{credentials.username || "root"}@{credentials.hostname}:
{credentials.port || 22}
</div>
</div>
</div>
</div>
</DialogHeader>
</DialogHeader>
<TooltipProvider delayDuration={300}>
<div className="px-4 py-2 border-b border-border/60 flex items-center gap-2 flex-shrink-0 bg-muted/30">
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-7 w-7"
onClick={onUp}
disabled={isAtRoot}
>
<ArrowUp size={14} />
</Button>
</TooltipTrigger>
<TooltipContent>{t("sftp.nav.up")}</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-7 w-7"
onClick={onHome}
>
<Home size={14} />
</Button>
</TooltipTrigger>
<TooltipContent>{t("sftp.nav.home")}</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-7 w-7"
onClick={onRefresh}
>
<RefreshCw
size={14}
className={cn(isRefreshing && "animate-spin")}
/>
</Button>
</TooltipTrigger>
<TooltipContent>{t("sftp.nav.refresh")}</TooltipContent>
</Tooltip>
{showEncoding && (
<Popover>
<Tooltip>
<TooltipProvider delayDuration={500} skipDelayDuration={800} disableHoverableContent>
<div className="px-4 py-2 border-b border-border/60 flex items-center gap-2 flex-shrink-0 bg-muted/30">
<Tooltip open={openTooltip === 'up'} onOpenChange={handleTooltipOpenChange('up')}>
<TooltipTrigger asChild>
<PopoverTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-7 w-7"
>
<Languages size={14} />
</Button>
</PopoverTrigger>
<Button
variant="ghost"
size="icon"
className="h-7 w-7"
onClick={onUp}
disabled={isAtRoot}
>
<ArrowUp size={14} />
</Button>
</TooltipTrigger>
<TooltipContent>{t("sftp.encoding.label")}</TooltipContent>
<TooltipContent>{t("sftp.nav.up")}</TooltipContent>
</Tooltip>
<PopoverContent className="w-36 p-1" align="start">
{(["auto", "utf-8", "gb18030"] as const).map((encoding) => (
<PopoverClose asChild key={encoding}>
<Tooltip open={openTooltip === 'home'} onOpenChange={handleTooltipOpenChange('home')}>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-7 w-7"
onClick={onHome}
>
<Home size={14} />
</Button>
</TooltipTrigger>
<TooltipContent>{t("sftp.nav.home")}</TooltipContent>
</Tooltip>
<Tooltip open={openTooltip === 'refresh'} onOpenChange={handleTooltipOpenChange('refresh')}>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-7 w-7"
onClick={onRefresh}
>
<RefreshCw
size={14}
className={cn(isRefreshing && "animate-spin")}
/>
</Button>
</TooltipTrigger>
<TooltipContent>{t("sftp.nav.refresh")}</TooltipContent>
</Tooltip>
{showEncoding && (
<Popover>
<Tooltip open={openTooltip === 'encoding'} onOpenChange={handleTooltipOpenChange('encoding')}>
<TooltipTrigger asChild>
<PopoverTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-7 w-7"
>
<Languages size={14} />
</Button>
</PopoverTrigger>
</TooltipTrigger>
<TooltipContent>{t("sftp.encoding.label")}</TooltipContent>
</Tooltip>
<PopoverContent className="w-36 p-1" align="start">
{(["auto", "utf-8", "gb18030"] as const).map((encoding) => (
<PopoverClose asChild key={encoding}>
<button
className={cn(
"w-full flex items-center gap-2 px-2 py-1.5 text-sm rounded-sm hover:bg-secondary transition-colors",
filenameEncoding === encoding && "bg-secondary"
)}
onClick={() => onFilenameEncodingChange(encoding)}
>
<Check
size={14}
className={cn(
"shrink-0",
filenameEncoding === encoding ? "opacity-100" : "opacity-0"
)}
/>
{t(`sftp.encoding.${encoding === "utf-8" ? "utf8" : encoding}`)}
</button>
</PopoverClose>
))}
</PopoverContent>
</Popover>
)}
<div className="flex items-center gap-1 text-sm flex-1 min-w-0 overflow-hidden">
{isEditingPath ? (
<Input
ref={pathInputRef}
value={editingPathValue}
onChange={(e) => setEditingPathValue(e.target.value)}
onBlur={handlePathSubmit}
onKeyDown={handlePathKeyDown}
className="h-7 text-sm bg-background"
autoFocus
/>
) : (
<div
className="flex items-center gap-1 flex-1 min-w-0 cursor-text hover:bg-secondary/50 rounded px-1 py-0.5 transition-colors"
onDoubleClick={handlePathDoubleClick}
title={currentPath}
>
<button
className={cn(
"w-full flex items-center gap-2 px-2 py-1.5 text-sm rounded-sm hover:bg-secondary transition-colors",
filenameEncoding === encoding && "bg-secondary"
)}
onClick={() => onFilenameEncodingChange(encoding)}
className="text-muted-foreground hover:text-foreground px-1 shrink-0"
onClick={onRootSelect}
>
<Check
size={14}
className={cn(
"shrink-0",
filenameEncoding === encoding ? "opacity-100" : "opacity-0"
)}
/>
{t(`sftp.encoding.${encoding === "utf-8" ? "utf8" : encoding}`)}
{rootLabel}
</button>
</PopoverClose>
))}
</PopoverContent>
</Popover>
)}
{visibleBreadcrumbs.map(({ part, originalIndex }, displayIdx) => {
const isLast = originalIndex === breadcrumbs.length - 1;
const showEllipsisBefore =
needsBreadcrumbTruncation && displayIdx === 1;
<div className="flex items-center gap-1 text-sm flex-1 min-w-0 overflow-hidden">
{isEditingPath ? (
<Input
ref={pathInputRef}
value={editingPathValue}
onChange={(e) => setEditingPathValue(e.target.value)}
onBlur={handlePathSubmit}
onKeyDown={handlePathKeyDown}
className="h-7 text-sm bg-background"
autoFocus
/>
) : (
<div
className="flex items-center gap-1 flex-1 min-w-0 cursor-text hover:bg-secondary/50 rounded px-1 py-0.5 transition-colors"
onDoubleClick={handlePathDoubleClick}
title={currentPath}
>
<button
className="text-muted-foreground hover:text-foreground px-1 shrink-0"
onClick={onRootSelect}
>
{rootLabel}
</button>
{visibleBreadcrumbs.map(({ part, originalIndex }, displayIdx) => {
const isLast = originalIndex === breadcrumbs.length - 1;
const showEllipsisBefore =
needsBreadcrumbTruncation && displayIdx === 1;
return (
<React.Fragment key={originalIndex}>
{showEllipsisBefore && (
<>
return (
<React.Fragment key={originalIndex}>
{showEllipsisBefore && (
<>
<ChevronRight
size={12}
className="text-muted-foreground flex-shrink-0"
/>
<span
className="text-muted-foreground px-1 shrink-0 flex items-center cursor-default"
title={`${t("sftp.showHiddenPaths")}: ${hiddenBreadcrumbs
.map((h) => h.part)
.join(" > ")}`}
>
<MoreHorizontal size={14} />
</span>
</>
)}
<ChevronRight
size={12}
className="text-muted-foreground flex-shrink-0"
/>
<span
className="text-muted-foreground px-1 shrink-0 flex items-center cursor-default"
title={`${t("sftp.showHiddenPaths")}: ${hiddenBreadcrumbs
.map((h) => h.part)
.join(" > ")}`}
<button
className={cn(
"text-muted-foreground hover:text-foreground truncate px-1 max-w-[100px]",
isLast && "text-foreground font-medium",
)}
onClick={() => onBreadcrumbSelect(originalIndex)}
title={part}
>
<MoreHorizontal size={14} />
</span>
</>
)}
<ChevronRight
size={12}
className="text-muted-foreground flex-shrink-0"
/>
<button
className={cn(
"text-muted-foreground hover:text-foreground truncate px-1 max-w-[100px]",
isLast && "text-foreground font-medium",
)}
onClick={() => onBreadcrumbSelect(originalIndex)}
title={part}
>
{part}
</button>
</React.Fragment>
);
})}
{part}
</button>
</React.Fragment>
);
})}
</div>
)}
</div>
)}
</div>
<div className="flex items-center gap-1 ml-auto">
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="outline"
size="icon"
className="h-7 w-7"
onClick={onTriggerUpload}
disabled={uploading}
>
<Upload size={14} />
</Button>
</TooltipTrigger>
<TooltipContent>{t("sftp.upload")}</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="outline"
size="icon"
className="h-7 w-7"
onClick={onTriggerFolderUpload}
disabled={uploading}
>
<FolderUp size={14} />
</Button>
</TooltipTrigger>
<TooltipContent>{t("sftp.uploadFolder")}</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="outline"
size="icon"
className="h-7 w-7"
onClick={onCreateFolder}
>
<FolderPlus size={14} />
</Button>
</TooltipTrigger>
<TooltipContent>{t("sftp.newFolder")}</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="outline"
size="icon"
className="h-7 w-7"
onClick={onCreateFile}
>
<FilePlus size={14} />
</Button>
</TooltipTrigger>
<TooltipContent>{t("sftp.newFile")}</TooltipContent>
</Tooltip>
<input
type="file"
className="hidden"
ref={inputRef}
onChange={onFileSelect}
multiple
/>
<input
type="file"
className="hidden"
ref={folderInputRef}
onChange={onFolderSelect}
webkitdirectory=""
multiple
/>
<div className="flex items-center gap-1 ml-auto">
<Tooltip open={openTooltip === 'upload'} onOpenChange={handleTooltipOpenChange('upload')}>
<TooltipTrigger asChild>
<Button
variant="outline"
size="icon"
className="h-7 w-7"
onClick={onTriggerUpload}
disabled={uploading}
>
<Upload size={14} />
</Button>
</TooltipTrigger>
<TooltipContent>{t("sftp.upload")}</TooltipContent>
</Tooltip>
<Tooltip open={openTooltip === 'uploadFolder'} onOpenChange={handleTooltipOpenChange('uploadFolder')}>
<TooltipTrigger asChild>
<Button
variant="outline"
size="icon"
className="h-7 w-7"
onClick={onTriggerFolderUpload}
disabled={uploading}
>
<FolderUp size={14} />
</Button>
</TooltipTrigger>
<TooltipContent>{t("sftp.uploadFolder")}</TooltipContent>
</Tooltip>
<Tooltip open={openTooltip === 'newFolder'} onOpenChange={handleTooltipOpenChange('newFolder')}>
<TooltipTrigger asChild>
<Button
variant="outline"
size="icon"
className="h-7 w-7"
onClick={onCreateFolder}
>
<FolderPlus size={14} />
</Button>
</TooltipTrigger>
<TooltipContent>{t("sftp.newFolder")}</TooltipContent>
</Tooltip>
<Tooltip open={openTooltip === 'newFile'} onOpenChange={handleTooltipOpenChange('newFile')}>
<TooltipTrigger asChild>
<Button
variant="outline"
size="icon"
className="h-7 w-7"
onClick={onCreateFile}
>
<FilePlus size={14} />
</Button>
</TooltipTrigger>
<TooltipContent>{t("sftp.newFile")}</TooltipContent>
</Tooltip>
<input
type="file"
className="hidden"
ref={inputRef}
onChange={onFileSelect}
multiple
/>
<input
type="file"
className="hidden"
ref={folderInputRef}
onChange={onFolderSelect}
webkitdirectory=""
multiple
/>
</div>
</div>
</div>
</TooltipProvider>
</>
);
</TooltipProvider>
</>
);
};

View File

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

View File

@@ -20,6 +20,7 @@ interface UseSftpModalSessionParams {
proxy?: NetcattyProxyConfig;
jumpHosts?: NetcattyJumpHost[];
sftpSudo?: boolean;
legacyAlgorithms?: boolean;
};
initialPath?: string;
isLocalSession: boolean;
@@ -39,6 +40,7 @@ interface UseSftpModalSessionParams {
proxy?: NetcattyProxyConfig;
jumpHosts?: NetcattyJumpHost[];
sudo?: boolean;
legacyAlgorithms?: boolean;
}) => Promise<string>;
closeSftp: (sftpId: string) => Promise<void>;
listSftp: (sftpId: string, path: string) => Promise<RemoteFile[]>;
@@ -112,6 +114,7 @@ export const useSftpModalSession = ({
proxy: credentials.proxy,
jumpHosts: credentials.jumpHosts,
sudo: credentials.sftpSudo,
legacyAlgorithms: credentials.legacyAlgorithms,
});
sftpIdRef.current = sftpId;
return sftpId;
@@ -131,6 +134,7 @@ export const useSftpModalSession = ({
credentials.proxy,
credentials.jumpHosts,
credentials.sftpSudo,
credentials.legacyAlgorithms,
openSftp,
]);

View File

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

View File

@@ -5,7 +5,7 @@
* This store allows keyboard shortcuts to trigger dialogs in the appropriate pane.
*/
import { useSyncExternalStore, useCallback, useEffect } from "react";
import { useSyncExternalStore, useEffect } from "react";
import { sftpFocusStore, SftpFocusedSide } from "./useSftpFocusedPane";
export type SftpDialogActionType = "rename" | "delete" | "newFolder" | "newFile" | null;

View File

@@ -67,11 +67,12 @@ export const useSftpKeyboardShortcuts = ({
// Skip if focus is on an input element
const target = e.target as HTMLElement;
if (
const isEditableTarget =
target.tagName === "INPUT" ||
target.tagName === "TEXTAREA" ||
target.isContentEditable
) {
target.isContentEditable ||
!!target.closest?.(".monaco-editor, .monaco-diff-editor, .monaco-inputbox");
if (isEditableTarget) {
return;
}
@@ -224,7 +225,7 @@ export const useSftpKeyboardShortcuts = ({
sourceConnectionId: clipboard.sourceConnectionId,
onTransferComplete: handleTransferComplete,
});
} catch (error) {
} catch {
toast.error("Paste failed. Please try again.", "SFTP");
}
} else {

View File

@@ -0,0 +1,295 @@
/**
* Host Keyword Highlight Popover
* Allows users to manage host-specific keyword highlighting rules in the terminal statusbar
*/
import { Highlighter, Plus, Trash2, RotateCcw } from 'lucide-react';
import React, { useState, useCallback, useMemo } from 'react';
import { v4 as uuidv4 } from 'uuid';
import { useI18n } from '../../application/i18n/I18nProvider';
import { Host, KeywordHighlightRule } from '../../types';
import { Button } from '../ui/button';
import { Input } from '../ui/input';
import { Popover, PopoverContent, PopoverTrigger } from '../ui/popover';
import { ScrollArea } from '../ui/scroll-area';
export interface HostKeywordHighlightPopoverProps {
host?: Host;
onUpdateHost?: (host: Host) => void;
isOpen: boolean;
setIsOpen: (open: boolean) => void;
buttonClassName?: string;
}
const DEFAULT_NEW_RULE_COLOR = '#F87171';
export const HostKeywordHighlightPopover: React.FC<HostKeywordHighlightPopoverProps> = ({
host,
onUpdateHost,
isOpen,
setIsOpen,
buttonClassName = '',
}) => {
const { t } = useI18n();
const [newRuleLabel, setNewRuleLabel] = useState('');
const [newRulePattern, setNewRulePattern] = useState('');
const [newRuleColor, setNewRuleColor] = useState(DEFAULT_NEW_RULE_COLOR);
const [patternError, setPatternError] = useState<string | null>(null);
const rules = useMemo(() => host?.keywordHighlightRules ?? [], [host?.keywordHighlightRules]);
const enabled = host?.keywordHighlightEnabled ?? false;
const updateRules = useCallback((newRules: KeywordHighlightRule[]) => {
if (!host || !onUpdateHost) return;
onUpdateHost({ ...host, keywordHighlightRules: newRules });
}, [host, onUpdateHost]);
const toggleEnabled = useCallback(() => {
if (!host || !onUpdateHost) return;
onUpdateHost({ ...host, keywordHighlightEnabled: !enabled });
}, [host, onUpdateHost, enabled]);
const validatePattern = (pattern: string): boolean => {
try {
new RegExp(pattern, 'gi');
return true;
} catch {
return false;
}
};
const handleAddRule = useCallback(() => {
if (!newRuleLabel.trim() || !newRulePattern.trim()) {
return;
}
if (!validatePattern(newRulePattern)) {
setPatternError(t('terminal.toolbar.hostHighlight.invalidPattern'));
return;
}
const newRule: KeywordHighlightRule = {
id: uuidv4(),
label: newRuleLabel.trim(),
patterns: [newRulePattern.trim()],
color: newRuleColor,
enabled: true,
};
updateRules([...rules, newRule]);
// Reset form
setNewRuleLabel('');
setNewRulePattern('');
setNewRuleColor(DEFAULT_NEW_RULE_COLOR);
setPatternError(null);
// Auto-enable if adding the first rule and not enabled
if (rules.length === 0 && !enabled && host && onUpdateHost) {
onUpdateHost({ ...host, keywordHighlightRules: [newRule], keywordHighlightEnabled: true });
}
}, [newRuleLabel, newRulePattern, newRuleColor, rules, updateRules, enabled, host, onUpdateHost, t]);
const handleDeleteRule = useCallback((ruleId: string) => {
updateRules(rules.filter((r) => r.id !== ruleId));
}, [rules, updateRules]);
const handleColorChange = useCallback((ruleId: string, color: string) => {
updateRules(rules.map((r) => (r.id === ruleId ? { ...r, color } : r)));
}, [rules, updateRules]);
const handleToggleRule = useCallback((ruleId: string) => {
updateRules(rules.map((r) => (r.id === ruleId ? { ...r, enabled: !r.enabled } : r)));
}, [rules, updateRules]);
const handleClearAll = useCallback(() => {
if (!host || !onUpdateHost) return;
onUpdateHost({ ...host, keywordHighlightRules: [], keywordHighlightEnabled: false });
}, [host, onUpdateHost]);
const handlePatternChange = (value: string) => {
setNewRulePattern(value);
if (patternError && validatePattern(value)) {
setPatternError(null);
}
};
// Disable if no host (local/serial terminal sessions)
const isLocalTerminal = host?.protocol === 'local' || host?.id?.startsWith('local-');
const isSerialTerminal = host?.protocol === 'serial' || host?.id?.startsWith('serial-');
const isDisabled = !host || !onUpdateHost || isLocalTerminal || isSerialTerminal;
return (
<Popover open={isOpen} onOpenChange={setIsOpen}>
<PopoverTrigger asChild>
<Button
variant="secondary"
size="icon"
className={buttonClassName}
title={t('terminal.toolbar.hostHighlight.title')}
aria-label={t('terminal.toolbar.hostHighlight.title')}
disabled={isDisabled}
>
<Highlighter size={12} />
</Button>
</PopoverTrigger>
<PopoverContent className="w-80 p-0" align="start" side="top">
<div className="px-3 py-2 border-b bg-muted/30 flex items-center justify-between">
<span className="text-xs font-semibold uppercase text-muted-foreground">
{t('terminal.toolbar.hostHighlight.title')}
</span>
<label className="flex items-center gap-2 cursor-pointer">
<span className="text-xs text-muted-foreground">
{enabled ? t('common.enabled') : t('common.disabled')}
</span>
<button
type="button"
role="switch"
aria-checked={enabled}
onClick={toggleEnabled}
className={`
relative inline-flex h-5 w-9 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent
transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-primary focus:ring-offset-2
${enabled ? 'bg-primary' : 'bg-muted-foreground/30'}
`}
>
<span
className={`
pointer-events-none inline-block h-4 w-4 transform rounded-full bg-white shadow ring-0
transition duration-200 ease-in-out
${enabled ? 'translate-x-4' : 'translate-x-0'}
`}
/>
</button>
</label>
</div>
<ScrollArea className="max-h-64">
<div className="p-2 space-y-1.5">
{rules.length === 0 ? (
<div className="px-2 py-4 text-xs text-muted-foreground text-center italic">
{t('terminal.toolbar.hostHighlight.noRules')}
</div>
) : (
rules.map((rule) => (
<div
key={rule.id}
className="flex items-center gap-2 px-2 py-1.5 rounded-md hover:bg-accent/50 group"
>
<button
type="button"
onClick={() => handleToggleRule(rule.id)}
className={`
flex-shrink-0 w-3 h-3 rounded-sm border transition-colors
${rule.enabled
? 'bg-primary border-primary'
: 'bg-transparent border-muted-foreground/50'
}
`}
title={rule.enabled ? t('common.enabled') : t('common.disabled')}
/>
<div className="flex-1 min-w-0">
<div
className="text-xs font-medium truncate"
style={{ color: rule.enabled ? rule.color : 'inherit' }}
>
{rule.label}
</div>
<div className="text-[10px] text-muted-foreground font-mono truncate">
{rule.patterns.join(', ')}
</div>
</div>
<label className="relative flex-shrink-0">
<input
type="color"
value={rule.color}
onChange={(e) => handleColorChange(rule.id, e.target.value)}
className="sr-only"
aria-label={`${t('terminal.toolbar.hostHighlight.changeColor')} ${rule.label}`}
/>
<span
className="block w-6 h-4 rounded cursor-pointer border border-border/50 hover:border-border"
style={{ backgroundColor: rule.color }}
/>
</label>
<Button
variant="ghost"
size="icon"
className="h-5 w-5 opacity-0 group-hover:opacity-100 transition-opacity text-destructive hover:text-destructive hover:bg-destructive/10"
onClick={() => handleDeleteRule(rule.id)}
>
<Trash2 size={10} />
</Button>
</div>
))
)}
</div>
</ScrollArea>
{/* Add new rule form */}
<div className="p-2 border-t bg-muted/20 space-y-2">
<div className="text-xs font-medium text-muted-foreground mb-1">
{t('terminal.toolbar.hostHighlight.addRule')}
</div>
<div className="flex gap-1.5">
<Input
placeholder={t('terminal.toolbar.hostHighlight.labelPlaceholder')}
value={newRuleLabel}
onChange={(e) => setNewRuleLabel(e.target.value)}
className="h-7 text-xs flex-1"
/>
<label className="relative flex-shrink-0">
<input
type="color"
value={newRuleColor}
onChange={(e) => setNewRuleColor(e.target.value)}
className="sr-only"
aria-label={t('terminal.toolbar.hostHighlight.selectColor')}
/>
<span
className="block w-7 h-7 rounded cursor-pointer border border-border/50 hover:border-border"
style={{ backgroundColor: newRuleColor }}
/>
</label>
</div>
<div className="flex gap-1.5">
<Input
placeholder={t('terminal.toolbar.hostHighlight.patternPlaceholder')}
value={newRulePattern}
onChange={(e) => handlePatternChange(e.target.value)}
className={`h-7 text-xs font-mono flex-1 ${patternError ? 'border-destructive' : ''}`}
/>
<Button
variant="secondary"
size="icon"
className="h-7 w-7 flex-shrink-0"
onClick={handleAddRule}
disabled={!newRuleLabel.trim() || !newRulePattern.trim()}
>
<Plus size={12} />
</Button>
</div>
{patternError && (
<div className="text-[10px] text-destructive">{patternError}</div>
)}
</div>
{/* Footer actions */}
{rules.length > 0 && (
<div className="p-2 border-t flex justify-end">
<Button
variant="ghost"
size="sm"
className="h-6 text-xs text-muted-foreground hover:text-destructive"
onClick={handleClearAll}
>
<RotateCcw size={10} className="mr-1" />
{t('terminal.toolbar.hostHighlight.clearAll')}
</Button>
</div>
)}
</PopoverContent>
</Popover>
);
};
export default HostKeywordHighlightPopover;

View File

@@ -1,6 +1,6 @@
/**
* Terminal Toolbar
* Displays SFTP, Scripts, Theme, Search buttons and close button in terminal status bar
* Displays SFTP, Scripts, Theme, Highlight, Search buttons and close button in terminal status bar
*/
import { FolderInput, X, Zap, Palette, Search } from 'lucide-react';
import React, { useState } from 'react';
@@ -10,6 +10,7 @@ import { Button } from '../ui/button';
import { Popover, PopoverContent, PopoverTrigger } from '../ui/popover';
import { ScrollArea } from '../ui/scroll-area';
import ThemeCustomizeModal from './ThemeCustomizeModal';
import HostKeywordHighlightPopover from './HostKeywordHighlightPopover';
export interface TerminalToolbarProps {
status: 'connecting' | 'connected' | 'disconnected';
@@ -55,6 +56,7 @@ export const TerminalToolbar: React.FC<TerminalToolbarProps> = ({
}) => {
const { t } = useI18n();
const [themeModalOpen, setThemeModalOpen] = useState(false);
const [highlightPopoverOpen, setHighlightPopoverOpen] = useState(false);
const buttonBase = "h-6 w-6 p-0 shadow-none border-none text-[color:var(--terminal-toolbar-fg)] bg-transparent hover:bg-transparent";
const isLocalTerminal = host?.protocol === 'local' || host?.id?.startsWith('local-');
@@ -163,6 +165,14 @@ export const TerminalToolbar: React.FC<TerminalToolbarProps> = ({
<Palette size={12} />
</Button>
<HostKeywordHighlightPopover
host={host}
onUpdateHost={onUpdateHost}
isOpen={highlightPopoverOpen}
setIsOpen={setHighlightPopoverOpen}
buttonClassName={buttonBase}
/>
<Button
variant="secondary"
size="icon"

View File

@@ -31,6 +31,8 @@ export interface ServerStats {
memFree: number | null; // Free memory in MB
memBuffers: number | null; // Buffers in MB
memCached: number | null; // Cached in MB
swapTotal: number | null; // Total swap in MB
swapUsed: number | null; // Used swap in MB
topProcesses: ProcessInfo[]; // Top 10 processes by memory
diskPercent: number | null; // Disk usage percentage for root partition
diskUsed: number | null; // Disk used in GB
@@ -66,6 +68,8 @@ export function useServerStats({
memFree: null,
memBuffers: null,
memCached: null,
swapTotal: null,
swapUsed: null,
topProcesses: [],
diskPercent: null,
diskUsed: null,
@@ -109,6 +113,8 @@ export function useServerStats({
memFree: result.stats.memFree,
memBuffers: result.stats.memBuffers,
memCached: result.stats.memCached,
swapTotal: result.stats.swapTotal ?? null,
swapUsed: result.stats.swapUsed ?? null,
topProcesses: result.stats.topProcesses || [],
diskPercent: result.stats.diskPercent,
diskUsed: result.stats.diskUsed,
@@ -155,6 +161,8 @@ export function useServerStats({
memFree: null,
memBuffers: null,
memCached: null,
swapTotal: null,
swapUsed: null,
topProcesses: [],
diskPercent: null,
diskUsed: null,

View File

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

View File

@@ -12,6 +12,9 @@ export type { TerminalConnectionProgressProps } from './TerminalConnectionProgre
export { TerminalToolbar } from './TerminalToolbar';
export type { TerminalToolbarProps } from './TerminalToolbar';
export { HostKeywordHighlightPopover } from './HostKeywordHighlightPopover';
export type { HostKeywordHighlightPopoverProps } from './HostKeywordHighlightPopover';
export { TerminalConnectionDialog } from './TerminalConnectionDialog';
export type { ChainProgress,TerminalConnectionDialogProps } from './TerminalConnectionDialog';

View File

@@ -4,6 +4,10 @@ import type { Terminal as XTerm } from "@xterm/xterm";
import type { Dispatch, RefObject, SetStateAction } from "react";
import { logger } from "../../../lib/logger";
import type { Host, Identity, SerialConfig, SSHKey, TerminalSession, TerminalSettings } from "../../../types";
import {
isEncryptedCredentialPlaceholder,
sanitizeCredentialValue,
} from "../../../domain/credentials";
import { resolveHostAuth } from "../../../domain/sshAuth";
type TerminalBackendApi = {
@@ -85,6 +89,7 @@ export type TerminalSessionStartersContext = {
setProgressLogs: Dispatch<SetStateAction<string[]>>;
setProgressValue: Dispatch<SetStateAction<number>>;
setChainProgress: Dispatch<SetStateAction<ChainProgressState>>;
t?: (key: string) => string;
onSessionExit?: (sessionId: string) => void;
onTerminalDataCapture?: (sessionId: string, data: string) => void;
@@ -194,6 +199,12 @@ const runDistroDetection = async (
};
export const createTerminalSessionStarters = (ctx: TerminalSessionStartersContext) => {
const tr = (key: string, fallback: string): string => {
const translated = ctx.t?.(key);
if (!translated || translated === key) return fallback;
return translated;
};
const startSSH = async (term: XTerm) => {
try {
term.clear?.();
@@ -227,9 +238,11 @@ export const createTerminalSessionStarters = (ctx: TerminalSessionStartersContex
});
const effectiveUsername = resolvedAuth.username || "root";
const effectivePassword = resolvedAuth.password;
const key = resolvedAuth.key;
const effectivePassphrase = resolvedAuth.passphrase;
const effectivePassword = sanitizeCredentialValue(resolvedAuth.password);
const effectivePassphrase = sanitizeCredentialValue(resolvedAuth.passphrase);
const hasEncryptedPrimaryPassword = isEncryptedCredentialPlaceholder(resolvedAuth.password);
const hasEncryptedPrimaryKey = isEncryptedCredentialPlaceholder(key?.privateKey);
let usedKey: SSHKey | undefined;
let usedPassword: string | undefined;
@@ -244,16 +257,19 @@ export const createTerminalSessionStarters = (ctx: TerminalSessionStartersContex
);
};
const rawProxyPassword = ctx.host.proxyConfig?.password;
const hasEncryptedProxyPassword = isEncryptedCredentialPlaceholder(rawProxyPassword);
const proxyConfig = ctx.host.proxyConfig
? {
type: ctx.host.proxyConfig.type,
host: ctx.host.proxyConfig.host,
port: ctx.host.proxyConfig.port,
username: ctx.host.proxyConfig.username,
password: ctx.host.proxyConfig.password,
password: sanitizeCredentialValue(rawProxyPassword),
}
: undefined;
const jumpHostsWithUnavailableCredentials: string[] = [];
const jumpHosts = ctx.resolvedChainHosts.map<NetcattyJumpHost>((jumpHost) => {
const jumpAuth = resolveHostAuth({
host: jumpHost,
@@ -261,14 +277,30 @@ export const createTerminalSessionStarters = (ctx: TerminalSessionStartersContex
identities: ctx.identities,
});
const jumpKey = jumpAuth.key;
const rawJumpPassword = jumpAuth.password;
const rawJumpPrivateKey = jumpKey?.privateKey;
const rawJumpPassphrase = jumpAuth.passphrase || jumpKey?.passphrase;
const jumpPassword = sanitizeCredentialValue(rawJumpPassword);
const jumpPrivateKey = sanitizeCredentialValue(rawJumpPrivateKey);
const jumpPassphrase = sanitizeCredentialValue(rawJumpPassphrase);
const hasEncryptedJumpCredential =
isEncryptedCredentialPlaceholder(rawJumpPassword) ||
isEncryptedCredentialPlaceholder(rawJumpPrivateKey) ||
isEncryptedCredentialPlaceholder(rawJumpPassphrase);
if (hasEncryptedJumpCredential && !jumpPassword && !jumpPrivateKey) {
jumpHostsWithUnavailableCredentials.push(jumpHost.label || jumpHost.hostname);
}
return {
hostname: jumpHost.hostname,
port: jumpHost.port || 22,
username: jumpAuth.username || "root",
password: jumpAuth.password,
privateKey: jumpKey?.privateKey,
password: jumpPassword,
privateKey: jumpPrivateKey,
certificate: jumpKey?.certificate,
passphrase: jumpAuth.passphrase || jumpKey?.passphrase,
passphrase: jumpPassphrase,
publicKey: jumpKey?.publicKey,
keyId: jumpAuth.keyId,
keySource: jumpKey?.source,
@@ -276,6 +308,38 @@ export const createTerminalSessionStarters = (ctx: TerminalSessionStartersContex
};
});
if (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.",
);
ctx.setNeedsAuth(false);
ctx.setAuthRetryMessage(null);
ctx.setError(message);
term.writeln(`\r\n[${message}]`);
ctx.updateStatus("disconnected");
return;
}
if (jumpHostsWithUnavailableCredentials.length > 0) {
const jumpList = jumpHostsWithUnavailableCredentials.slice(0, 2).join(", ");
const suffix =
jumpHostsWithUnavailableCredentials.length > 2
? ` +${jumpHostsWithUnavailableCredentials.length - 2}`
: "";
const base = tr(
"terminal.auth.jumpCredentialsUnavailable",
"A jump host has saved credentials that cannot be decrypted on this device. Open host settings and re-enter them.",
);
const message = `${base} (${jumpList}${suffix})`;
ctx.setNeedsAuth(false);
ctx.setAuthRetryMessage(null);
ctx.setError(message);
term.writeln(`\r\n[${message}]`);
ctx.updateStatus("disconnected");
return;
}
const totalHops = jumpHosts.length + 1;
let unsubscribeChainProgress: (() => void) | undefined;
@@ -334,8 +398,11 @@ export const createTerminalSessionStarters = (ctx: TerminalSessionStartersContex
publicKey: attempt.key?.publicKey,
keyId: attempt.key?.id,
keySource: attempt.key?.source,
passphrase: attempt.key ? (effectivePassphrase || attempt.key.passphrase) : undefined,
passphrase: attempt.key
? (effectivePassphrase || sanitizeCredentialValue(attempt.key.passphrase))
: undefined,
agentForwarding: ctx.host.agentForwarding,
legacyAlgorithms: ctx.host.legacyAlgorithms,
cols: term.cols,
rows: term.rows,
charset: ctx.host.charset,
@@ -349,9 +416,46 @@ export const createTerminalSessionStarters = (ctx: TerminalSessionStartersContex
let id: string;
// Respect explicit auth method selection - don't use key if password auth was explicitly selected
const authMethod = resolvedAuth.authMethod;
const hasKeyMaterial = !!key?.privateKey && authMethod !== 'password';
const hasKeyMaterial = !!sanitizeCredentialValue(key?.privateKey) && authMethod !== 'password';
const hasPassword = !!effectivePassword;
const needsCredentialReentry =
(authMethod === "password" && hasEncryptedPrimaryPassword && !hasPassword) ||
(authMethod !== "password" && hasEncryptedPrimaryKey && !hasKeyMaterial && !hasPassword);
if (needsCredentialReentry) {
if (unsubscribeChainProgress) unsubscribeChainProgress();
ctx.setError(null);
ctx.setNeedsAuth(true);
ctx.setAuthRetryMessage(
tr(
"terminal.auth.credentialsUnavailable",
"Saved credentials cannot be decrypted on this device. Please re-enter and save them again.",
),
);
ctx.setAuthPassword("");
ctx.setProgressLogs((prev) => [
...prev,
tr(
"terminal.auth.credentialsUnavailable",
"Saved credentials cannot be decrypted on this device. Please re-enter and save them again.",
),
]);
ctx.setStatus("connecting");
ctx.setChainProgress(null);
return;
}
if (!hasKeyMaterial && authMethod !== "password" && hasEncryptedPrimaryKey && hasPassword) {
ctx.setProgressLogs((prev) => [
...prev,
tr(
"terminal.auth.keyUnavailableFallbackPassword",
"Saved SSH key is unavailable on this device. Falling back to password authentication.",
),
]);
}
if (hasKeyMaterial) {
try {

View File

@@ -18,7 +18,7 @@ import {
resolveXTermPerformanceConfig,
} from "../../../infrastructure/config/xtermPerformance";
import { logger } from "../../../lib/logger";
import { isMacPlatform, normalizeLineEndings } from "../../../lib/utils";
import { isMacPlatform, normalizeLineEndings, wrapBracketedPaste } from "../../../lib/utils";
import type {
Host,
KeyBinding,
@@ -316,7 +316,12 @@ export const createXTermRuntime = (ctx: CreateXTermRuntimeContext): XTermRuntime
if (ctx.terminalBackend.openExternalAvailable()) {
void ctx.terminalBackend.openExternal(uri);
} else {
window.open(uri, "_blank");
const safeUri = String(uri || "");
if (/^https?:\/\//i.test(safeUri)) {
window.open(safeUri, "_blank", "noopener,noreferrer");
} else {
logger.warn("[XTerm] Refusing to open non-http(s) link:", safeUri);
}
}
});
term.loadAddon(webLinksAddon);
@@ -407,7 +412,11 @@ export const createXTermRuntime = (ctx: CreateXTermRuntimeContext): XTermRuntime
case "paste": {
navigator.clipboard.readText().then((text) => {
const id = ctx.sessionRef.current;
if (id) ctx.terminalBackend.writeToSession(id, normalizeLineEndings(text));
if (id) {
let data = normalizeLineEndings(text);
if (term.modes.bracketedPasteMode) data = wrapBracketedPaste(data);
ctx.terminalBackend.writeToSession(id, data);
}
});
break;
}
@@ -439,7 +448,9 @@ export const createXTermRuntime = (ctx: CreateXTermRuntimeContext): XTermRuntime
try {
const text = await navigator.clipboard.readText();
if (text && ctx.sessionRef.current) {
ctx.terminalBackend.writeToSession(ctx.sessionRef.current, normalizeLineEndings(text));
let data = normalizeLineEndings(text);
if (term.modes.bracketedPasteMode) data = wrapBracketedPaste(data);
ctx.terminalBackend.writeToSession(ctx.sessionRef.current, data);
}
} catch (err) {
logger.warn("[Terminal] Failed to paste from clipboard:", err);

92
domain/credentials.ts Normal file
View File

@@ -0,0 +1,92 @@
import type { SyncPayload } from "./sync";
export const CREDENTIAL_ENCRYPTION_PREFIX = "enc:v1:";
/**
* Base64 pattern: only allows A-Z, a-z, 0-9, +, / and trailing = padding.
*/
const BASE64_RE = /^[A-Za-z0-9+/]+=*$/;
/**
* Chromium/Electron safeStorage ciphertext carries known platform headers:
* - macOS/Linux: plaintext bytes start with "v10" or "v11"
* - Windows (legacy DPAPI blob): leading bytes are 0x01 0x00 0x00 0x00
*
* We validate the base64 payload starts with one of these header signatures
* instead of relying only on prefix+length heuristics. This greatly reduces
* false positives for plaintext credentials that happen to start with "enc:v1:".
*
* References:
* - components/os_crypt/sync/os_crypt_mac.mm (kObfuscationPrefixV10 = "v10")
* - components/os_crypt/sync/os_crypt_linux.cc (kObfuscationPrefixV10/V11)
* - components/os_crypt/sync/os_crypt_win.cc (DPAPI legacy path)
*/
const SAFE_STORAGE_BASE64_HEADER_PREFIXES = [
"djEw", // "v10"
"djEx", // "v11"
"AQAAAA", // 0x01 0x00 0x00 0x00 (DPAPI blob header)
] as const;
export const isEncryptedCredentialPlaceholder = (
value: string | undefined | null,
): value is string => {
if (typeof value !== "string" || !value.startsWith(CREDENTIAL_ENCRYPTION_PREFIX)) {
return false;
}
const payload = value.slice(CREDENTIAL_ENCRYPTION_PREFIX.length);
if (!payload || !BASE64_RE.test(payload)) return false;
return SAFE_STORAGE_BASE64_HEADER_PREFIXES.some((prefix) => payload.startsWith(prefix));
};
/**
* Strip enc:v1: placeholders from a single credential value.
* Used at the terminal connection boundary to avoid sending encrypted
* placeholders as actual passwords to SSH/Telnet servers.
*/
export const sanitizeCredentialValue = (
value: string | undefined,
): string | undefined => {
if (isEncryptedCredentialPlaceholder(value)) return undefined;
return value;
};
/**
* Scan a sync payload for any fields that still carry device-bound
* enc:v1: ciphertext. Returns the dotted paths of offending fields.
* Used as a pre-upload guard to prevent pushing un-decryptable data.
*/
export const findSyncPayloadEncryptedCredentialPaths = (
payload: SyncPayload,
): string[] => {
const issues: string[] = [];
payload.hosts.forEach((host, index) => {
if (isEncryptedCredentialPlaceholder(host.password)) {
issues.push(`hosts[${index}].password`);
}
if (isEncryptedCredentialPlaceholder(host.telnetPassword)) {
issues.push(`hosts[${index}].telnetPassword`);
}
if (isEncryptedCredentialPlaceholder(host.proxyConfig?.password)) {
issues.push(`hosts[${index}].proxyConfig.password`);
}
});
payload.keys.forEach((key, index) => {
if (isEncryptedCredentialPlaceholder(key.privateKey)) {
issues.push(`keys[${index}].privateKey`);
}
if (isEncryptedCredentialPlaceholder(key.passphrase)) {
issues.push(`keys[${index}].passphrase`);
}
});
payload.identities?.forEach((identity, index) => {
if (isEncryptedCredentialPlaceholder(identity.password)) {
issues.push(`identities[${index}].password`);
}
});
return issues;
};

View File

@@ -96,6 +96,11 @@ export interface Host {
sftpEncoding?: SftpFilenameEncoding; // Filename encoding for SFTP operations
// Managed source: if this host is managed by an external file (e.g., ~/.ssh/config)
managedSourceId?: string; // Reference to ManagedSource.id
// Host-level keyword highlighting (overrides/extends global settings)
keywordHighlightRules?: KeywordHighlightRule[];
keywordHighlightEnabled?: boolean;
// Legacy SSH algorithm support for older network equipment (switches, routers)
legacyAlgorithms?: boolean;
}
export type KeyType = 'RSA' | 'ECDSA' | 'ED25519';
@@ -191,7 +196,7 @@ export const parseKeyCombo = (keyStr: string): { modifiers: string[]; key: strin
// Convert keyboard event to a key string
export const keyEventToString = (e: KeyboardEvent, isMac: boolean): string => {
const parts: string[] = [];
if (isMac) {
if (e.metaKey) parts.push('⌘');
if (e.ctrlKey) parts.push('⌃');
@@ -203,7 +208,7 @@ export const keyEventToString = (e: KeyboardEvent, isMac: boolean): string => {
if (e.shiftKey) parts.push('Shift');
if (e.metaKey) parts.push('Win');
}
// Get the key name
let keyName = e.key;
// Normalize special keys
@@ -218,12 +223,12 @@ export const keyEventToString = (e: KeyboardEvent, isMac: boolean): string => {
else if (keyName === 'Enter') keyName = '↵';
else if (keyName === 'Tab') keyName = '⇥';
else if (keyName.length === 1) keyName = keyName.toUpperCase();
// Don't include modifier keys themselves
if (['Meta', 'Control', 'Alt', 'Shift'].includes(e.key)) {
return parts.join(' + ');
}
parts.push(keyName);
return parts.join(' + ');
};
@@ -231,7 +236,7 @@ export const keyEventToString = (e: KeyboardEvent, isMac: boolean): string => {
// Check if a keyboard event matches a key binding string
export const matchesKeyBinding = (e: KeyboardEvent, keyStr: string, isMac: boolean): boolean => {
if (!keyStr || keyStr === 'Disabled') return false;
// Handle range patterns like "[1...9]"
if (keyStr.includes('[1...9]')) {
const basePattern = keyStr.replace('[1...9]', '');
@@ -241,7 +246,7 @@ export const matchesKeyBinding = (e: KeyboardEvent, keyStr: string, isMac: boole
const testStr = basePattern + key;
return matchesKeyBinding(e, testStr.trim(), isMac);
}
// Handle arrow key patterns like "arrows"
if (keyStr.includes('arrows')) {
const basePattern = keyStr.replace('arrows', '');
@@ -249,18 +254,18 @@ export const matchesKeyBinding = (e: KeyboardEvent, keyStr: string, isMac: boole
// Check if it's an arrow key
if (!['ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight'].includes(key)) return false;
// Map arrow key to symbol for matching
const arrowSymbol = key === 'ArrowUp' ? '↑'
const arrowSymbol = key === 'ArrowUp' ? '↑'
: key === 'ArrowDown' ? '↓'
: key === 'ArrowLeft' ? '←'
: '→';
: key === 'ArrowLeft' ? '←'
: '→';
// Check modifiers match the base pattern
const testStr = basePattern + arrowSymbol;
return matchesKeyBinding(e, testStr.trim(), isMac);
}
const parsed = parseKeyCombo(keyStr);
if (!parsed) return false;
const { modifiers, key } = parsed;
const hasMacModifiers = modifiers.some((modifier) => ['⌘', '⌃', '⌥'].includes(modifier));
@@ -268,14 +273,14 @@ export const matchesKeyBinding = (e: KeyboardEvent, keyStr: string, isMac: boole
if ((!isMac && hasMacModifiers) || (isMac && hasPcModifiers)) {
return false;
}
// Check modifiers
if (isMac) {
const needMeta = modifiers.includes('⌘');
const needCtrl = modifiers.includes('⌃');
const needAlt = modifiers.includes('⌥');
const needShift = modifiers.includes('Shift');
if (e.metaKey !== needMeta) return false;
if (e.ctrlKey !== needCtrl) return false;
if (e.altKey !== needAlt) return false;
@@ -285,13 +290,13 @@ export const matchesKeyBinding = (e: KeyboardEvent, keyStr: string, isMac: boole
const needAlt = modifiers.includes('Alt');
const needShift = modifiers.includes('Shift');
const needMeta = modifiers.includes('Win');
if (e.ctrlKey !== needCtrl) return false;
if (e.altKey !== needAlt) return false;
if (e.shiftKey !== needShift) return false;
if (e.metaKey !== needMeta) return false;
}
const normalizeKey = (rawKey: string): string => {
let normalizedKey = rawKey;
if (normalizedKey === ' ') normalizedKey = 'Space';
@@ -521,17 +526,17 @@ export interface RemoteFile {
export type WorkspaceNode =
| {
id: string;
type: 'pane';
sessionId: string;
}
id: string;
type: 'pane';
sessionId: string;
}
| {
id: string;
type: 'split';
direction: 'horizontal' | 'vertical';
children: WorkspaceNode[];
sizes?: number[]; // relative sizes for children
};
id: string;
type: 'split';
direction: 'horizontal' | 'vertical';
children: WorkspaceNode[];
sizes?: number[]; // relative sizes for children
};
export type WorkspaceViewMode = 'split' | 'focus';

View File

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

View File

@@ -0,0 +1,739 @@
/**
* Global Shortcut Bridge - Handles global keyboard shortcuts and system tray
* Implements the "Quake mode" / drop-down terminal feature
*/
const path = require("node:path");
const fs = require("node:fs");
let electronModule = null;
let tray = null;
let closeToTray = false;
let currentHotkey = null;
let hotkeyEnabled = false;
const STATUS_TEXT = {
session: {
connected: "Connected",
connecting: "Connecting",
disconnected: "Disconnected",
},
portForward: {
active: "Active",
connecting: "Connecting",
inactive: "Inactive",
error: "Error",
},
};
// Dynamic tray menu data (synced from renderer)
let trayMenuData = {
sessions: [], // { id, label, hostLabel, status }
portForwardRules: [], // { id, label, type, localPort, remoteHost, remotePort, status, hostId }
};
let trayPanelWindow = null;
let trayPanelRefreshTimer = null;
function openMainWindow() {
const { app } = electronModule;
const win = getMainWindow();
if (!win) return;
if (win.isMinimized()) win.restore();
win.show();
win.focus();
try {
app.focus({ steal: true });
} catch {
// ignore
}
}
function getTrayPanelUrl() {
const devServerUrl = process.env.VITE_DEV_SERVER_URL;
if (devServerUrl) {
return `${devServerUrl.replace(/\/$/, "")}/#/tray`;
}
return "app://netcatty/index.html#/tray";
}
function ensureTrayPanelWindow() {
const { BrowserWindow } = electronModule;
if (trayPanelWindow && !trayPanelWindow.isDestroyed()) return trayPanelWindow;
trayPanelWindow = new BrowserWindow({
width: 360,
height: 520,
show: false,
frame: false,
resizable: false,
movable: false,
fullscreenable: false,
minimizable: false,
maximizable: false,
skipTaskbar: true,
alwaysOnTop: true,
transparent: true,
hasShadow: true,
webPreferences: {
preload: path.join(__dirname, "../preload.cjs"),
contextIsolation: true,
nodeIntegration: false,
sandbox: false,
},
});
trayPanelWindow.webContents.on("console-message", (_event, level, message) => {
// Forward renderer logs to main process output for easy debugging.
console.log(`[TrayPanel:renderer:${level}] ${message}`);
});
trayPanelWindow.on("blur", () => {
try {
trayPanelWindow?.hide();
} catch {
// ignore
}
});
const url = getTrayPanelUrl();
console.log("[TrayPanel] loadURL", url);
void trayPanelWindow.loadURL(url);
trayPanelWindow.webContents.on("did-finish-load", () => {
try {
trayPanelWindow?.webContents?.send("netcatty:trayPanel:setMenuData", trayMenuData);
} catch {
// ignore
}
});
return trayPanelWindow;
}
function showTrayPanel() {
if (!tray) return;
const { screen } = electronModule;
const win = ensureTrayPanelWindow();
const trayBounds = tray.getBounds();
const display = screen.getDisplayNearestPoint({ x: trayBounds.x, y: trayBounds.y });
const workArea = display.workArea;
const panelBounds = win.getBounds();
const x = Math.min(
Math.max(trayBounds.x + Math.round(trayBounds.width / 2) - Math.round(panelBounds.width / 2), workArea.x),
workArea.x + workArea.width - panelBounds.width,
);
const y = Math.min(trayBounds.y + trayBounds.height + 6, workArea.y + workArea.height - panelBounds.height);
win.setBounds({ x, y, width: panelBounds.width, height: panelBounds.height }, false);
win.show();
win.focus();
try {
win.webContents?.send("netcatty:trayPanel:setMenuData", trayMenuData);
} catch {
// ignore
}
if (trayPanelRefreshTimer) clearInterval(trayPanelRefreshTimer);
trayPanelRefreshTimer = setInterval(() => {
try {
if (!trayPanelWindow || trayPanelWindow.isDestroyed() || !trayPanelWindow.isVisible()) return;
trayPanelWindow.webContents?.send("netcatty:trayPanel:refresh");
} catch {
// ignore
}
}, 1000);
}
function hideTrayPanel() {
if (trayPanelWindow && !trayPanelWindow.isDestroyed()) {
trayPanelWindow.hide();
}
if (trayPanelRefreshTimer) {
clearInterval(trayPanelRefreshTimer);
trayPanelRefreshTimer = null;
}
}
function toggleTrayPanel() {
if (trayPanelWindow && !trayPanelWindow.isDestroyed() && trayPanelWindow.isVisible()) {
hideTrayPanel();
} else {
showTrayPanel();
}
}
function resolveTrayIconPath() {
const { app } = electronModule;
// Use different icons for different platforms
// macOS: template image (black + transparent, system handles color)
// Windows/Linux: colored icon
const isMac = process.platform === "darwin";
const iconName = isMac ? "tray-iconTemplate.png" : "tray-icon.png";
// Security: Only use known packaged icon locations, ignore renderer-provided paths
const candidates = [
path.join(app.getAppPath(), "dist", iconName),
path.join(app.getAppPath(), "public", iconName),
path.join(__dirname, "../../public", iconName),
path.join(__dirname, "../../dist", iconName),
];
for (const candidate of candidates) {
if (fs.existsSync(candidate)) {
return candidate;
}
}
return null;
}
/**
* Initialize the bridge with dependencies
*/
function init(deps) {
electronModule = deps.electronModule;
}
/**
* Get the main window reference
* Uses windowManager's tracked mainWindow for reliability
*/
function getMainWindow() {
// Prefer the explicitly tracked main window from windowManager
const windowManager = require("./windowManager.cjs");
const tracked = windowManager.getMainWindow?.();
if (tracked && !tracked.isDestroyed?.()) {
return tracked;
}
// Fallback: filter out tray panel window from all windows
const { BrowserWindow } = electronModule;
const wins = BrowserWindow.getAllWindows();
const mainWins = wins.filter((w) => w !== trayPanelWindow && !w.isDestroyed?.());
return mainWins && mainWins.length ? mainWins[0] : null;
}
/**
* Convert a hotkey string from frontend format to Electron accelerator format
* e.g., "⌘ + Space" -> "CommandOrControl+Space"
* "Ctrl + `" -> "CommandOrControl+`"
* "Alt + Space" -> "Alt+Space"
*/
function toElectronAccelerator(hotkeyStr) {
if (!hotkeyStr || hotkeyStr === "Disabled" || hotkeyStr === "") {
return null;
}
// Parse the hotkey string
const parts = hotkeyStr.split("+").map((p) => p.trim());
// Convert each part to Electron accelerator format
const acceleratorParts = parts.map((part) => {
// Mac symbols to Electron format
if (part === "⌘" || part === "Cmd" || part === "Command") {
return "CommandOrControl";
}
if (part === "⌃" || part === "Ctrl" || part === "Control") {
return "Control";
}
if (part === "⌥" || part === "Alt" || part === "Option") {
return "Alt";
}
if (part === "Shift") {
return "Shift";
}
if (part === "Win" || part === "Super" || part === "Meta") {
return "Super";
}
// Arrow symbols
if (part === "↑") return "Up";
if (part === "↓") return "Down";
if (part === "←") return "Left";
if (part === "→") return "Right";
// Special keys
if (part === "↵" || part === "Enter" || part === "Return") return "Return";
if (part === "⇥" || part === "Tab") return "Tab";
if (part === "⌫" || part === "Backspace") return "Backspace";
if (part === "Del" || part === "Delete") return "Delete";
if (part === "Esc" || part === "Escape") return "Escape";
if (part === "Space") return "Space";
// Backtick/grave accent
if (part === "`" || part === "~") return "`";
// Function keys
if (/^F\d+$/i.test(part)) return part.toUpperCase();
// Single character - keep as-is
return part;
});
return acceleratorParts.join("+");
}
/**
* Toggle the main window visibility
*/
function toggleWindowVisibility() {
const win = getMainWindow();
if (!win) return;
try {
// Check if window is minimized first - minimized windows may still report isVisible() = true
if (win.isMinimized()) {
win.restore();
win.show();
win.focus();
const { app } = electronModule;
try {
app.focus({ steal: true });
} catch {
// ignore
}
} else if (win.isVisible()) {
if (win.isFocused()) {
// Window is visible and focused - hide it
win.hide();
} else {
// Window is visible but not focused - focus it
win.focus();
const { app } = electronModule;
try {
app.focus({ steal: true });
} catch {
// ignore
}
}
} else {
// Window is hidden - show and focus it
win.show();
win.focus();
const { app } = electronModule;
try {
app.focus({ steal: true });
} catch {
// ignore
}
}
} catch (err) {
console.warn("[GlobalShortcut] Error toggling window visibility:", err);
}
}
/**
* Register the global toggle hotkey
*/
function registerGlobalHotkey(hotkeyStr) {
const { globalShortcut } = electronModule;
// Unregister existing hotkey first
unregisterGlobalHotkey();
if (!hotkeyStr || hotkeyStr === "Disabled" || hotkeyStr === "") {
hotkeyEnabled = false;
currentHotkey = null;
return { success: true, enabled: false };
}
const accelerator = toElectronAccelerator(hotkeyStr);
if (!accelerator) {
hotkeyEnabled = false;
currentHotkey = null;
return { success: false, error: "Invalid hotkey format" };
}
try {
const registered = globalShortcut.register(accelerator, toggleWindowVisibility);
if (registered) {
hotkeyEnabled = true;
currentHotkey = hotkeyStr;
console.log(`[GlobalShortcut] Registered hotkey: ${accelerator}`);
return { success: true, enabled: true, accelerator };
} else {
console.warn(`[GlobalShortcut] Failed to register hotkey: ${accelerator}`);
return { success: false, error: "Hotkey may be in use by another application" };
}
} catch (err) {
console.error("[GlobalShortcut] Error registering hotkey:", err);
return { success: false, error: err.message };
}
}
/**
* Unregister the global toggle hotkey
*/
function unregisterGlobalHotkey() {
if (!hotkeyEnabled || !currentHotkey) return;
const { globalShortcut } = electronModule;
const accelerator = toElectronAccelerator(currentHotkey);
if (accelerator) {
try {
globalShortcut.unregister(accelerator);
console.log(`[GlobalShortcut] Unregistered hotkey: ${accelerator}`);
} catch (err) {
console.warn("[GlobalShortcut] Error unregistering hotkey:", err);
}
}
hotkeyEnabled = false;
currentHotkey = null;
}
/**
* Create the system tray icon
*/
function createTray() {
const { Tray, Menu, app, nativeImage } = electronModule;
if (tray) {
// Tray already exists
return;
}
try {
// Load the tray icon
let trayIcon;
const resolvedIconPath = resolveTrayIconPath();
if (resolvedIconPath) {
trayIcon = nativeImage.createFromPath(resolvedIconPath);
// Resize for tray (16x16 on most platforms, 22x22 on some Linux)
if (process.platform === "darwin") {
trayIcon = trayIcon.resize({ width: 16, height: 16 });
trayIcon.setTemplateImage(true);
} else {
trayIcon = trayIcon.resize({ width: 16, height: 16 });
}
}
tray = new Tray(trayIcon || nativeImage.createEmpty());
tray.setToolTip("Netcatty");
// Build and set initial context menu
updateTrayMenu();
// Click on tray icon toggles tray panel
tray.on("click", () => {
toggleTrayPanel();
});
console.log("[GlobalShortcut] System tray created");
} catch (err) {
console.error("[GlobalShortcut] Error creating tray:", err);
}
}
/**
* Build the tray context menu with dynamic content
*/
function buildTrayMenuTemplate() {
const { app } = electronModule;
const menuTemplate = [];
// Open Main Window
menuTemplate.push({
label: "Open Main Window",
click: () => {
const win = getMainWindow();
if (win) {
if (win.isMinimized()) win.restore();
win.show();
win.focus();
try {
app.focus({ steal: true });
} catch {
// ignore
}
}
},
});
menuTemplate.push({ type: "separator" });
// Active Sessions
if (trayMenuData.sessions && trayMenuData.sessions.length > 0) {
menuTemplate.push({
label: "Sessions",
enabled: false,
});
for (const session of trayMenuData.sessions) {
const statusText =
session.status === "connected"
? STATUS_TEXT.session.connected
: session.status === "connecting"
? STATUS_TEXT.session.connecting
: STATUS_TEXT.session.disconnected;
menuTemplate.push({
label: ` ${session.hostLabel || session.label} (${statusText})`,
click: () => {
// Focus window and switch to this session
const win = getMainWindow();
if (win) {
if (win.isMinimized()) win.restore();
win.show();
win.focus();
// Notify renderer to focus this session
win.webContents?.send("netcatty:tray:focusSession", session.id);
}
},
});
}
menuTemplate.push({ type: "separator" });
}
// Port Forwarding Rules
if (trayMenuData.portForwardRules && trayMenuData.portForwardRules.length > 0) {
menuTemplate.push({
label: "Port Forwarding",
enabled: false,
});
for (const rule of trayMenuData.portForwardRules) {
const isActive = rule.status === "active";
const isConnecting = rule.status === "connecting";
const statusText =
rule.status === "active"
? STATUS_TEXT.portForward.active
: rule.status === "connecting"
? STATUS_TEXT.portForward.connecting
: rule.status === "error"
? STATUS_TEXT.portForward.error
: STATUS_TEXT.portForward.inactive;
const typeLabel = rule.type === "local" ? "L" : rule.type === "remote" ? "R" : "D";
const portInfo = rule.type === "dynamic"
? `${rule.localPort}`
: `${rule.localPort}${rule.remoteHost}:${rule.remotePort}`;
menuTemplate.push({
label: ` [${typeLabel}] ${rule.label || portInfo} (${statusText})`,
enabled: !isConnecting,
click: () => {
const win = getMainWindow();
if (win) {
win.webContents?.send("netcatty:tray:togglePortForward", rule.id, !isActive);
}
},
});
}
menuTemplate.push({ type: "separator" });
}
// Quit
menuTemplate.push({
label: "Quit",
click: () => {
closeToTray = false;
app.quit();
},
});
return menuTemplate;
}
/**
* Update the tray context menu
*/
function updateTrayMenu() {
if (!tray) return;
// Avoid showing a context menu on left-click; we toggle our custom panel instead.
// On macOS, right-click may still show a menu if one is set, so we don't set any.
try {
tray.setContextMenu(null);
} catch {
// ignore
}
}
/**
* Update tray menu data from renderer
*/
function setTrayMenuData(data) {
if (data.sessions !== undefined) {
trayMenuData.sessions = data.sessions;
}
if (data.portForwardRules !== undefined) {
trayMenuData.portForwardRules = data.portForwardRules;
}
// Rebuild menu with new data
updateTrayMenu();
}
/**
* Destroy the system tray icon
*/
function destroyTray() {
if (tray) {
try {
tray.destroy();
tray = null;
console.log("[GlobalShortcut] System tray destroyed");
} catch (err) {
console.warn("[GlobalShortcut] Error destroying tray:", err);
}
}
}
/**
* Set close-to-tray behavior
*/
function setCloseToTray(enabled) {
closeToTray = !!enabled;
if (closeToTray) {
// Create tray if it doesn't exist
if (!tray) {
createTray();
}
} else {
// Destroy tray if it exists
destroyTray();
}
return { success: true, enabled: closeToTray };
}
/**
* Check if close-to-tray is enabled
*/
function isCloseToTrayEnabled() {
return closeToTray;
}
/**
* Get current hotkey status
*/
function getHotkeyStatus() {
return {
enabled: hotkeyEnabled,
hotkey: currentHotkey,
};
}
/**
* Handle window close event - hide to tray instead of closing
*/
function handleWindowClose(event, win) {
if (closeToTray && tray) {
event.preventDefault();
win.hide();
return true; // Prevented close
}
return false; // Allow close
}
/**
* Register IPC handlers
*/
function registerHandlers(ipcMain) {
// Register global toggle hotkey
ipcMain.handle("netcatty:globalHotkey:register", async (_event, { hotkey }) => {
return registerGlobalHotkey(hotkey);
});
// Unregister global toggle hotkey
ipcMain.handle("netcatty:globalHotkey:unregister", async () => {
unregisterGlobalHotkey();
return { success: true };
});
// Get current hotkey status
ipcMain.handle("netcatty:globalHotkey:status", async () => {
return getHotkeyStatus();
});
// Set close-to-tray behavior
ipcMain.handle("netcatty:tray:setCloseToTray", async (_event, { enabled }) => {
return setCloseToTray(enabled);
});
// Get close-to-tray status
ipcMain.handle("netcatty:tray:isCloseToTray", async () => {
return { enabled: closeToTray };
});
// Update tray menu data
ipcMain.handle("netcatty:tray:updateMenuData", async (_event, data) => {
setTrayMenuData(data);
return { success: true };
});
ipcMain.handle("netcatty:trayPanel:hide", async () => {
hideTrayPanel();
return { success: true };
});
ipcMain.handle("netcatty:trayPanel:openMainWindow", async () => {
openMainWindow();
return { success: true };
});
ipcMain.handle("netcatty:trayPanel:jumpToSession", async (_event, sessionId) => {
openMainWindow();
try {
const win = getMainWindow();
win?.webContents?.send("netcatty:trayPanel:jumpToSession", sessionId);
} catch {
// ignore
}
return { success: true };
});
ipcMain.handle("netcatty:trayPanel:connectToHost", async (_event, hostId) => {
openMainWindow();
try {
const win = getMainWindow();
win?.webContents?.send("netcatty:trayPanel:connectToHost", hostId);
} catch {
// ignore
}
return { success: true };
});
ipcMain.handle("netcatty:trayPanel:quitApp", async () => {
const { app } = electronModule;
closeToTray = false;
app.quit();
return { success: true };
});
console.log("[GlobalShortcut] IPC handlers registered");
}
/**
* Cleanup on app quit
*/
function cleanup() {
unregisterGlobalHotkey();
destroyTray();
if (trayPanelRefreshTimer) {
clearInterval(trayPanelRefreshTimer);
trayPanelRefreshTimer = null;
}
if (trayPanelWindow && !trayPanelWindow.isDestroyed()) {
try {
trayPanelWindow.destroy();
} catch {
// ignore
}
trayPanelWindow = null;
}
}
module.exports = {
init,
registerHandlers,
registerGlobalHotkey,
unregisterGlobalHotkey,
setCloseToTray,
isCloseToTrayEnabled,
handleWindowClose,
toggleWindowVisibility,
getHotkeyStatus,
setTrayMenuData,
updateTrayMenu,
cleanup,
};

View File

@@ -23,9 +23,9 @@ const { NetcattyAgent } = require("./netcattyAgent.cjs");
const fileWatcherBridge = require("./fileWatcherBridge.cjs");
const keyboardInteractiveHandler = require("./keyboardInteractiveHandler.cjs");
const { createProxySocket } = require("./proxyUtils.cjs");
const {
buildAuthHandler,
createKeyboardInteractiveHandler,
const {
buildAuthHandler,
createKeyboardInteractiveHandler,
applyAuthToConnOpts,
safeSend: authSafeSend,
findAllDefaultPrivateKeys: findAllDefaultPrivateKeysFromHelper,
@@ -254,6 +254,44 @@ const ensureRemoteDirForSession = async (sftpId, dirPath, requestedEncoding) =>
return true;
};
/**
* Build SSH algorithm configuration for SFTP connections.
* When legacyEnabled is true, legacy algorithms are appended for older device compatibility.
*/
function buildSftpAlgorithms(legacyEnabled) {
const algorithms = {
cipher: [
'aes128-gcm@openssh.com', 'aes256-gcm@openssh.com',
'aes128-ctr', 'aes192-ctr', 'aes256-ctr',
],
kex: [
'curve25519-sha256', 'curve25519-sha256@libssh.org',
'ecdh-sha2-nistp256', 'ecdh-sha2-nistp384', 'ecdh-sha2-nistp521',
'diffie-hellman-group14-sha256',
'diffie-hellman-group16-sha512', 'diffie-hellman-group18-sha512',
'diffie-hellman-group-exchange-sha256',
],
compress: ['none'],
};
if (legacyEnabled) {
algorithms.kex.push(
'diffie-hellman-group14-sha1',
'diffie-hellman-group1-sha1',
);
algorithms.cipher.push(
'aes128-cbc', 'aes256-cbc', '3des-cbc',
);
algorithms.serverHostKey = [
'ssh-ed25519', 'ecdsa-sha2-nistp256', 'ecdsa-sha2-nistp384', 'ecdsa-sha2-nistp521',
'rsa-sha2-512', 'rsa-sha2-256',
'ssh-rsa', 'ssh-dss',
];
}
return algorithms;
}
/**
* Send message to renderer safely
*/
@@ -307,23 +345,7 @@ async function connectThroughChainForSftp(event, options, jumpHosts, targetHost,
keepaliveCountMax: 3,
// Enable keyboard-interactive authentication (required for 2FA/MFA)
tryKeyboard: true,
algorithms: {
// Prioritize fastest ciphers (GCM modes are hardware-accelerated)
cipher: [
'aes128-gcm@openssh.com', 'aes256-gcm@openssh.com',
'chacha20-poly1305@openssh.com',
'aes128-ctr', 'aes192-ctr', 'aes256-ctr',
],
// Prioritize modern key exchange algorithms for broad compatibility
kex: [
'curve25519-sha256', 'curve25519-sha256@libssh.org',
'ecdh-sha2-nistp256', 'ecdh-sha2-nistp384', 'ecdh-sha2-nistp521',
'diffie-hellman-group14-sha256',
'diffie-hellman-group16-sha512', 'diffie-hellman-group18-sha512',
'diffie-hellman-group-exchange-sha256',
],
compress: ['none'],
},
algorithms: buildSftpAlgorithms(options.legacyAlgorithms),
};
// Auth - support agent (certificate), key, and password fallback
@@ -727,6 +749,7 @@ async function openSftp(event, options) {
// Enable keyboard-interactive authentication (required for 2FA/MFA)
tryKeyboard: true,
readyTimeout: 120000, // 2 minutes for 2FA input
algorithms: buildSftpAlgorithms(options.legacyAlgorithms),
};
// Use the tunneled socket if we have one

View File

@@ -173,6 +173,45 @@ const log = (msg, data) => {
console.log("[SSH]", msg, data ? JSON.stringify(data, null, 2) : "");
};
/**
* Build SSH algorithm configuration.
* When legacyEnabled is true, legacy algorithms are appended to each list
* (lower priority than modern ones) for compatibility with older network equipment.
*/
function buildAlgorithms(legacyEnabled) {
const algorithms = {
cipher: [
'aes128-gcm@openssh.com', 'aes256-gcm@openssh.com',
'aes128-ctr', 'aes192-ctr', 'aes256-ctr',
],
kex: [
'curve25519-sha256', 'curve25519-sha256@libssh.org',
'ecdh-sha2-nistp256', 'ecdh-sha2-nistp384', 'ecdh-sha2-nistp521',
'diffie-hellman-group14-sha256',
'diffie-hellman-group16-sha512', 'diffie-hellman-group18-sha512',
'diffie-hellman-group-exchange-sha256',
],
compress: ['none'],
};
if (legacyEnabled) {
algorithms.kex.push(
'diffie-hellman-group14-sha1',
'diffie-hellman-group1-sha1',
);
algorithms.cipher.push(
'aes128-cbc', 'aes256-cbc', '3des-cbc',
);
algorithms.serverHostKey = [
'ssh-ed25519', 'ecdsa-sha2-nistp256', 'ecdsa-sha2-nistp384', 'ecdsa-sha2-nistp521',
'rsa-sha2-512', 'rsa-sha2-256',
'ssh-rsa', 'ssh-dss',
];
}
return algorithms;
}
// Session storage - shared reference passed from main
let sessions = null;
let electronModule = null;
@@ -277,22 +316,7 @@ async function connectThroughChain(event, options, jumpHosts, targetHost, target
keepaliveCountMax: 3,
// Enable keyboard-interactive authentication (required for 2FA/MFA)
tryKeyboard: true,
algorithms: {
// Prioritize fastest ciphers (GCM modes are hardware-accelerated)
cipher: [
'aes128-gcm@openssh.com', 'aes256-gcm@openssh.com',
'aes128-ctr', 'aes192-ctr', 'aes256-ctr',
],
// Prioritize modern key exchange algorithms for broad compatibility
kex: [
'curve25519-sha256', 'curve25519-sha256@libssh.org',
'ecdh-sha2-nistp256', 'ecdh-sha2-nistp384', 'ecdh-sha2-nistp521',
'diffie-hellman-group14-sha256',
'diffie-hellman-group16-sha512', 'diffie-hellman-group18-sha512',
'diffie-hellman-group-exchange-sha256',
],
compress: ['none'],
},
algorithms: buildAlgorithms(options.legacyAlgorithms),
};
// Auth - support agent (certificate), key, password, and default key fallback
@@ -465,22 +489,7 @@ async function startSSHSession(event, options) {
keepaliveCountMax: 3,
// Enable keyboard-interactive authentication (required for 2FA/MFA)
tryKeyboard: true,
algorithms: {
// Prioritize fastest ciphers (GCM modes are hardware-accelerated)
cipher: [
'aes128-gcm@openssh.com', 'aes256-gcm@openssh.com',
'aes128-ctr', 'aes192-ctr', 'aes256-ctr',
],
// Prioritize modern key exchange algorithms for broad compatibility
kex: [
'curve25519-sha256', 'curve25519-sha256@libssh.org',
'ecdh-sha2-nistp256', 'ecdh-sha2-nistp384', 'ecdh-sha2-nistp521',
'diffie-hellman-group14-sha256',
'diffie-hellman-group16-sha512', 'diffie-hellman-group18-sha512',
'diffie-hellman-group-exchange-sha256',
],
compress: ['none'],
},
algorithms: buildAlgorithms(options.legacyAlgorithms),
};
// Authentication for final target
@@ -1470,8 +1479,8 @@ async function getServerStats(event, payload) {
`cpuraw=$(awk '/^cpu / {total=0; for(i=2;i<=NF;i++) total+=$i; printf "%d %d", total, $5}' /proc/stat 2>/dev/null || echo "")`,
// Get raw per-core CPU values from /proc/stat: "total:idle,total:idle,..."
`percoreraw=$(awk '/^cpu[0-9]/ {total=0; for(i=2;i<=NF;i++) total+=$i; printf "%d:%d,", total, $5}' /proc/stat 2>/dev/null | sed 's/,$//' || echo "")`,
// Get memory details from /proc/meminfo (total, free, buffers, cached in KB)
`meminfo=$(awk '/^MemTotal:/{t=$2} /^MemFree:/{f=$2} /^Buffers:/{b=$2} /^Cached:/{c=$2} /^SReclaimable:/{s=$2} END{printf "%d %d %d %d", t/1024, f/1024, b/1024, (c+s)/1024}' /proc/meminfo 2>/dev/null || echo "")`,
// Get memory details from /proc/meminfo (total, free, buffers, cached, swapTotal, swapFree in KB)
`meminfo=$(awk '/^MemTotal:/{t=$2} /^MemFree:/{f=$2} /^Buffers:/{b=$2} /^Cached:/{c=$2} /^SReclaimable:/{s=$2} /^SwapTotal:/{st=$2} /^SwapFree:/{sf=$2} END{printf "%d %d %d %d %d %d", t/1024, f/1024, b/1024, (c+s)/1024, st/1024, sf/1024}' /proc/meminfo 2>/dev/null || echo "")`,
// Get top 10 processes by memory - with BusyBox fallback
// GNU ps: ps -eo pid,%mem,comm --sort=-%mem
// BusyBox fallback: ps -o pid,vsz,comm and sort manually (BusyBox ps doesn't have %mem, use vsz instead)
@@ -1525,6 +1534,8 @@ async function getServerStats(event, payload) {
let memBuffers = null;
let memCached = null;
let memUsed = null;
let swapTotal = null;
let swapUsed = null;
let topProcesses = []; // Array of { pid, memPercent, command }
let disks = []; // Array of { mountPoint, used, total, percent }
let networkInterfaces = []; // Array of { name, rxBytes, txBytes }
@@ -1571,6 +1582,16 @@ async function getServerStats(event, payload) {
memUsed = memTotal - memFree - memBuffers - memCached;
if (memUsed < 0) memUsed = 0;
}
// Parse swap info (fields 5 and 6)
if (memParts.length >= 6) {
const st = parseInt(memParts[4], 10);
const sf = parseInt(memParts[5], 10);
if (!isNaN(st)) swapTotal = st;
if (!isNaN(sf)) {
swapUsed = (swapTotal !== null) ? swapTotal - sf : null;
if (swapUsed !== null && swapUsed < 0) swapUsed = 0;
}
}
}
} else if (part.startsWith('PROCS:')) {
const procsStr = part.substring(6).trim();
@@ -1743,6 +1764,8 @@ async function getServerStats(event, payload) {
memFree, // Free memory in MB
memBuffers, // Buffers in MB
memCached, // Cached in MB
swapTotal, // Swap total in MB
swapUsed, // Swap used in MB
topProcesses, // Top 10 processes by memory
diskPercent, // Disk usage percentage for root partition (backward compat)
diskUsed, // Disk used in GB for root partition (backward compat)

View File

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

View File

@@ -5,6 +5,7 @@
const path = require("node:path");
const fs = require("node:fs");
const globalShortcutBridge = require("./globalShortcutBridge.cjs");
// Theme colors configuration
const THEME_COLORS = {
@@ -28,11 +29,13 @@ let currentLanguage = "en";
let handlersRegistered = false; // Prevent duplicate IPC handler registration
let menuDeps = null;
let electronApp = null; // Reference to Electron app for userData path
let isQuitting = false;
const rendererReadyCallbacksByWebContentsId = new Map();
const DEBUG_WINDOWS = process.env.NETCATTY_DEBUG_WINDOWS === "1";
const OAUTH_DEFAULT_WIDTH = 600;
const OAUTH_DEFAULT_HEIGHT = 700;
const OAUTH_OVERLAY_ID = "__netcatty_oauth_loading__";
const OAUTH_LOOPBACK_PORT = 45678; // must match electron/bridges/oauthBridge.cjs
const WINDOW_STATE_FILE = "window-state.json";
const DEFAULT_WINDOW_WIDTH = 1400;
const DEFAULT_WINDOW_HEIGHT = 900;
@@ -47,6 +50,10 @@ function debugLog(...args) {
}
}
function setIsQuitting(nextValue) {
isQuitting = Boolean(nextValue);
}
/**
* Get the path to the window state file
*/
@@ -161,8 +168,8 @@ function getWindowBoundsState(win, overrideBounds) {
}
const MENU_LABELS = {
en: { edit: "Edit", view: "View", window: "Window" },
"zh-CN": { edit: "编辑", view: "视图", window: "窗口" },
en: { edit: "Edit", view: "View", window: "Window", reload: "Reload" },
"zh-CN": { edit: "编辑", view: "视图", window: "窗口", reload: "重新加载" },
};
function tMenu(language, key) {
@@ -362,6 +369,86 @@ function parseWindowOpenFeatures(features) {
};
}
function createExternalOnlyWindowOpenHandler(shell) {
return (details) => {
const targetUrl = details?.url;
if (targetUrl && typeof targetUrl === "string" && /^https?:/i.test(targetUrl)) {
try {
void shell?.openExternal?.(targetUrl);
} catch {
// ignore
}
}
return { action: "deny" };
};
}
function createAppWindowOpenHandler(shell, { backgroundColor, appIcon }) {
const allowedPopupHosts = new Set([
// OAuth (PKCE loopback)
"accounts.google.com",
"login.microsoftonline.com",
"login.live.com",
]);
const isAllowedInAppPopupUrl = (rawUrl) => {
try {
const u = new URL(String(rawUrl));
if (u.protocol === "https:") {
return allowedPopupHosts.has(u.hostname);
}
if (u.protocol === "http:") {
// Allow ONLY the loopback OAuth callback page.
const isLoopback =
u.hostname === "127.0.0.1" || u.hostname === "localhost";
return isLoopback && u.port === String(OAUTH_LOOPBACK_PORT) && u.pathname === "/oauth/callback";
}
return false;
} catch {
return false;
}
};
return (details) => {
const targetUrl = details?.url;
if (!targetUrl || typeof targetUrl !== "string" || !/^https?:/i.test(targetUrl)) {
return { action: "deny" };
}
// Default: open in system browser to reduce remote-content attack surface.
if (!isAllowedInAppPopupUrl(targetUrl)) {
try {
void shell?.openExternal?.(targetUrl);
} catch {
// ignore
}
return { action: "deny" };
}
const size = parseWindowOpenFeatures(details?.features);
return {
action: "allow",
overrideBrowserWindowOptions: {
width: size.width || OAUTH_DEFAULT_WIDTH,
height: size.height || OAUTH_DEFAULT_HEIGHT,
minWidth: 420,
minHeight: 560,
backgroundColor,
icon: appIcon,
autoHideMenuBar: true,
menuBarVisible: false,
title: "Netcatty Authorization",
webPreferences: {
contextIsolation: true,
nodeIntegration: false,
// Sandboxed because this window renders remote content and does not need a preload bridge.
sandbox: true,
},
},
};
};
}
function attachOAuthLoadingOverlay(win) {
if (!win || win.isDestroyed?.()) return;
@@ -537,7 +624,7 @@ function setupDeferredShow(win, { timeoutMs = 3000, waitForRendererReady = true
* Create the main application window
*/
async function createWindow(electronModule, options) {
const { BrowserWindow, nativeTheme, app, screen } = electronModule;
const { BrowserWindow, nativeTheme, app, screen, shell } = electronModule;
const { preload, devServerUrl, isDev, appIcon, isMac, onRegisterBridge, electronDir } = options;
// Store app reference for window state persistence
@@ -605,6 +692,35 @@ async function createWindow(electronModule, options) {
mainWindow = win;
// 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"]);
if (isDev && devServerUrl) {
try {
allowedOrigins.add(new URL(getDevRendererBaseUrl(devServerUrl)).origin);
} catch {
// ignore invalid dev server URL
}
}
const isAllowedTopLevelUrl = (targetUrl) => {
try {
return allowedOrigins.has(new URL(String(targetUrl)).origin);
} catch {
return false;
}
};
const blockUntrustedNavigation = (event, targetUrl) => {
if (isAllowedTopLevelUrl(targetUrl)) return;
try {
event.preventDefault();
} catch {
// ignore
}
debugLog("Blocked navigation to untrusted origin", { targetUrl });
};
win.webContents.on("will-navigate", blockUntrustedNavigation);
win.webContents.on("will-redirect", blockUntrustedNavigation);
// Restore maximized state if it was saved
if (savedState?.isMaximized && !savedState?.isFullScreen) {
win.once("ready-to-show", () => {
@@ -653,6 +769,15 @@ 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)) {
// Window was hidden to tray - save state before returning
if (saveStateTimer) clearTimeout(saveStateTimer);
const state = getWindowBoundsState(win, lastNormalBounds);
if (state) saveWindowStateSync(state);
return;
}
if (windowStateCloseRequested) {
return;
}
@@ -717,36 +842,18 @@ async function createWindow(electronModule, options) {
} catch {
// ignore
}
// Never allow chained popups from remote content windows.
try {
childWindow.webContents?.setWindowOpenHandler?.(createExternalOnlyWindowOpenHandler(shell));
} catch {
// ignore
}
attachOAuthLoadingOverlay(childWindow);
});
win.webContents.setWindowOpenHandler((details) => {
const url = details?.url;
if (!url || !/^https?:/i.test(url)) {
return { action: "deny" };
}
const size = parseWindowOpenFeatures(details?.features);
return {
action: "allow",
overrideBrowserWindowOptions: {
width: size.width || OAUTH_DEFAULT_WIDTH,
height: size.height || OAUTH_DEFAULT_HEIGHT,
minWidth: 420,
minHeight: 560,
backgroundColor,
icon: appIcon,
autoHideMenuBar: true,
menuBarVisible: false,
title: "Netcatty Authorization",
webPreferences: {
contextIsolation: true,
nodeIntegration: false,
sandbox: false,
},
},
};
});
win.webContents.setWindowOpenHandler(
createAppWindowOpenHandler(shell, { backgroundColor, appIcon })
);
// Register window control handlers
registerWindowHandlers(electronModule.ipcMain, nativeTheme);
@@ -773,7 +880,7 @@ async function createWindow(electronModule, options) {
* Create or focus the settings window
*/
async function openSettingsWindow(electronModule, options) {
const { BrowserWindow } = electronModule;
const { BrowserWindow, shell } = electronModule;
const { preload, devServerUrl, isDev, appIcon, isMac, electronDir } = options;
// If settings window already exists, just focus it
@@ -815,6 +922,52 @@ async function openSettingsWindow(electronModule, options) {
settingsWindow = win;
// Open external links in system browser by default, and allow only known OAuth hosts in-app.
try {
win.webContents?.setWindowOpenHandler?.(
createAppWindowOpenHandler(shell, { backgroundColor, appIcon })
);
} catch {
// ignore
}
// Never allow chained popups from remote content windows spawned from settings.
win.webContents?.on?.("did-create-window", (childWindow) => {
try {
childWindow.webContents?.setWindowOpenHandler?.(createExternalOnlyWindowOpenHandler(shell));
} catch {
// ignore
}
});
// Same navigation hardening as the main window (settings has preload access too).
const allowedOrigins = new Set(["app://netcatty"]);
if (isDev && devServerUrl) {
try {
allowedOrigins.add(new URL(getDevRendererBaseUrl(devServerUrl)).origin);
} catch {
// ignore invalid dev server URL
}
}
const isAllowedTopLevelUrl = (targetUrl) => {
try {
return allowedOrigins.has(new URL(String(targetUrl)).origin);
} catch {
return false;
}
};
const blockUntrustedNavigation = (event, targetUrl) => {
if (isAllowedTopLevelUrl(targetUrl)) return;
try {
event.preventDefault();
} catch {
// ignore
}
debugLog("Blocked navigation to untrusted origin (settings)", { targetUrl });
};
win.webContents.on("will-navigate", blockUntrustedNavigation);
win.webContents.on("will-redirect", blockUntrustedNavigation);
if (isMac) {
try {
win.setWindowButtonVisibility(true);
@@ -1064,7 +1217,7 @@ function buildAppMenu(Menu, app, isMac, language = currentLanguage) {
{
label: tMenu(language, "view"),
submenu: [
{ role: "reload" },
{ label: tMenu(language, "reload"), click: (_, win) => { if (win) win.reload(); } },
{ role: "forceReload" },
{ role: "toggleDevTools" },
{ type: "separator" },
@@ -1111,5 +1264,6 @@ module.exports = {
buildAppMenu,
getMainWindow,
getSettingsWindow,
setIsQuitting,
THEME_COLORS,
};

View File

@@ -36,7 +36,7 @@ try {
electronModule = require("electron");
}
const { app, BrowserWindow, Menu, protocol, shell } = electronModule || {};
const { app, BrowserWindow, Menu, protocol, shell, clipboard } = electronModule || {};
if (!app || !BrowserWindow) {
throw new Error("Failed to load Electron runtime. Ensure the app is launched with the Electron binary.");
}
@@ -80,6 +80,8 @@ const fileWatcherBridge = require("./bridges/fileWatcherBridge.cjs");
const tempDirBridge = require("./bridges/tempDirBridge.cjs");
const sessionLogsBridge = require("./bridges/sessionLogsBridge.cjs");
const compressUploadBridge = require("./bridges/compressUploadBridge.cjs");
const globalShortcutBridge = require("./bridges/globalShortcutBridge.cjs");
const credentialBridge = require("./bridges/credentialBridge.cjs");
const windowManager = require("./bridges/windowManager.cjs");
// GPU settings
@@ -240,6 +242,15 @@ function focusMainWindow() {
const win = wins && wins.length ? wins[0] : null;
if (!win) return false;
// Check if the webContents has crashed or been destroyed
try {
if (win.webContents?.isCrashed?.()) {
console.warn('[Main] Main window webContents has crashed, destroying window');
win.destroy();
return false;
}
} catch {}
try {
if (win.isMinimized && win.isMinimized()) win.restore();
} catch {}
@@ -278,7 +289,8 @@ const ensureKeyDir = async () => {
const writeKeyToDisk = async (keyId, privateKey) => {
if (!privateKey) return null;
await ensureKeyDir();
const filename = `${keyId || "temp"}.pem`;
const safeId = String(keyId || "temp").replace(/[^a-zA-Z0-9_-]/g, "_").slice(0, 120);
const filename = `${safeId}.pem`;
const target = path.join(keyRoot, filename);
const normalized = privateKey.endsWith("\n") ? privateKey : `${privateKey}\n`;
try {
@@ -364,6 +376,7 @@ const registerBridges = (win) => {
transferBridge.init(deps);
terminalBridge.init(deps);
fileWatcherBridge.init(deps);
globalShortcutBridge.init(deps);
// Initialize compress upload bridge with transferBridge dependency
compressUploadBridge.init({
@@ -390,6 +403,8 @@ const registerBridges = (win) => {
tempDirBridge.registerHandlers(ipcMain, shell);
sessionLogsBridge.registerHandlers(ipcMain);
compressUploadBridge.registerHandlers(ipcMain);
globalShortcutBridge.registerHandlers(ipcMain);
credentialBridge.registerHandlers(ipcMain, electronModule);
// Settings window handler
ipcMain.handle("netcatty:settings:open", async () => {
@@ -450,6 +465,15 @@ const registerBridges = (win) => {
};
});
// Clipboard helpers for renderer fallback paths (e.g. Monaco paste in Electron)
ipcMain.handle("netcatty:clipboard:readText", async () => {
try {
return clipboard?.readText?.() || "";
} catch {
return "";
}
});
// Select an application from system file picker
ipcMain.handle("netcatty:selectApplication", async () => {
const { dialog } = electronModule;
@@ -621,9 +645,9 @@ const registerBridges = (win) => {
ipcMain.handle("netcatty:deleteTempFile", async (_event, { filePath }) => {
try {
// Only allow deleting files in Netcatty temp directory for security
const netcattyTempDir = tempDirBridge.getTempDir();
const resolvedPath = path.resolve(filePath);
if (!resolvedPath.startsWith(netcattyTempDir)) {
const netcattyTempDir = path.resolve(tempDirBridge.getTempDir());
const resolvedPath = path.resolve(String(filePath || ""));
if (!isPathInside(netcattyTempDir, resolvedPath)) {
console.warn(`[Main] Refused to delete file outside Netcatty temp dir: ${filePath}`);
return { success: false };
}
@@ -673,91 +697,130 @@ function showStartupError(err) {
}
}
// Application lifecycle
app.whenReady().then(() => {
registerAppProtocol();
// Set dock icon on macOS
if (isMac && appIcon && app.dock?.setIcon) {
try {
app.dock.setIcon(appIcon);
} catch (err) {
console.warn("Failed to set dock icon", err);
}
}
// Build and set application menu
const menu = windowManager.buildAppMenu(Menu, app, isMac);
Menu.setApplicationMenu(menu);
app.on("browser-window-created", (_event, win) => {
try {
const mainWin = windowManager.getMainWindow();
const settingsWin = windowManager.getSettingsWindow();
const isPrimary = win === mainWin || win === settingsWin;
if (!isPrimary) {
win.setMenuBarVisibility(false);
win.autoHideMenuBar = true;
win.setMenu(null);
if (appIcon && win.setIcon) win.setIcon(appIcon);
}
} catch {
// ignore
}
});
// Create the main window
void createWindow().catch((err) => {
console.error("[Main] Failed to create main window:", err);
showStartupError(err);
try {
app.quit();
} catch {}
});
// Re-create window on macOS dock click
app.on("activate", () => {
if (BrowserWindow.getAllWindows().length === 0) {
// Ensure single-instance behavior — must run before app.whenReady() so
// the second instance never attempts to register the app:// protocol or
// create a BrowserWindow (which would fail with ERR_FAILED).
const gotLock = app.requestSingleInstanceLock();
if (!gotLock) {
app.quit();
} else {
app.on("second-instance", () => {
if (!focusMainWindow()) {
// Window is missing or crashed — try to recreate it
void createWindow().catch((err) => {
console.error("[Main] Failed to create window on activate:", err);
console.error("[Main] Failed to recreate window on second-instance:", err);
showStartupError(err);
});
}
});
});
// Ensure single-instance behavior focuses existing window
try {
const gotLock = app.requestSingleInstanceLock();
if (!gotLock) {
app.quit();
} else {
app.on("second-instance", () => {
focusMainWindow();
// Application lifecycle
app.whenReady().then(() => {
registerAppProtocol();
// Set dock icon on macOS
if (isMac && appIcon && app.dock?.setIcon) {
try {
app.dock.setIcon(appIcon);
} catch (err) {
console.warn("Failed to set dock icon", err);
}
}
// Build and set application menu
const menu = windowManager.buildAppMenu(Menu, app, isMac);
Menu.setApplicationMenu(menu);
app.on("browser-window-created", (_event, win) => {
try {
const mainWin = windowManager.getMainWindow();
const settingsWin = windowManager.getSettingsWindow();
const isPrimary = win === mainWin || win === settingsWin;
if (!isPrimary) {
win.setMenuBarVisibility(false);
win.autoHideMenuBar = true;
win.setMenu(null);
if (appIcon && win.setIcon) win.setIcon(appIcon);
}
} catch {
// ignore
}
});
}
} catch {}
// Cleanup on all windows closed
app.on("window-all-closed", () => {
if (process.platform !== "darwin") {
// Create the main window
void createWindow().catch((err) => {
console.error("[Main] Failed to create main window:", err);
showStartupError(err);
try {
app.quit();
} catch {}
});
// Re-create or focus window on macOS dock click
app.on("activate", () => {
// 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?.();
if (mainWin && !mainWin.isDestroyed?.()) {
if (mainWin.isMinimized?.()) mainWin.restore();
mainWin.show?.();
mainWin.focus?.();
try {
app.focus({ steal: true });
} catch {}
return;
}
} catch {}
if (focusMainWindow()) return;
if (BrowserWindow.getAllWindows().length === 0) {
void createWindow().catch((err) => {
console.error("[Main] Failed to create window on activate:", err);
showStartupError(err);
});
}
});
});
// Cleanup on all windows closed
app.on("window-all-closed", () => {
if (process.platform !== "darwin") {
app.quit();
}
});
app.on("before-quit", () => {
windowManager.setIsQuitting(true);
});
// Cleanup all PTY sessions and port forwarding tunnels before quitting
app.on("will-quit", () => {
try {
terminalBridge.cleanupAllSessions();
} catch (err) {
console.warn("Error during terminal cleanup:", err);
}
try {
portForwardingBridge.stopAllPortForwards();
} catch (err) {
console.warn("Error during port forwarding cleanup:", err);
}
try {
globalShortcutBridge.cleanup();
} catch (err) {
console.warn("Error during global shortcut cleanup:", err);
}
});
}
// Graceful shutdown on SIGTERM/SIGINT to prevent zombie processes
for (const sig of ['SIGTERM', 'SIGINT']) {
process.on(sig, () => {
console.log(`[Main] Received ${sig}, quitting…`);
app.quit();
}
});
// Cleanup all PTY sessions and port forwarding tunnels before quitting
app.on("will-quit", () => {
try {
terminalBridge.cleanupAllSessions();
} catch (err) {
console.warn("Error during terminal cleanup:", err);
}
try {
portForwardingBridge.stopAllPortForwards();
} catch (err) {
console.warn("Error during port forwarding cleanup:", err);
}
});
});
}
// Export for testing
module.exports = {

View File

@@ -312,6 +312,13 @@ ipcRenderer.on("netcatty:filewatch:error", (_event, payload) => {
});
});
// Buffer the latest tray menu data so it can be replayed when the React
// component subscribes after lazy-mount (avoiding the first-open race).
let _lastTrayMenuData = null;
ipcRenderer.on("netcatty:trayPanel:setMenuData", (_event, data) => {
_lastTrayMenuData = data;
});
const api = {
startSSHSession: async (options) => {
const result = await ipcRenderer.invoke("netcatty:start", options);
@@ -752,6 +759,79 @@ const api = {
openSessionLogsDir: (directory) =>
ipcRenderer.invoke("netcatty:sessionLogs:openDir", { directory }),
// Global Toggle Hotkey (Quake Mode)
registerGlobalHotkey: (hotkey) =>
ipcRenderer.invoke("netcatty:globalHotkey:register", { hotkey }),
unregisterGlobalHotkey: () =>
ipcRenderer.invoke("netcatty:globalHotkey:unregister"),
getGlobalHotkeyStatus: () =>
ipcRenderer.invoke("netcatty:globalHotkey:status"),
// System Tray / Close to Tray
setCloseToTray: (enabled) =>
ipcRenderer.invoke("netcatty:tray:setCloseToTray", { enabled }),
isCloseToTray: () =>
ipcRenderer.invoke("netcatty:tray:isCloseToTray"),
updateTrayMenuData: (data) =>
ipcRenderer.invoke("netcatty:tray:updateMenuData", data),
// Listen for tray menu actions
onTrayFocusSession: (callback) => {
const handler = (_event, sessionId) => callback(sessionId);
ipcRenderer.on("netcatty:tray:focusSession", handler);
return () => ipcRenderer.removeListener("netcatty:tray:focusSession", handler);
},
onTrayTogglePortForward: (callback) => {
const handler = (_event, ruleId, start) => callback(ruleId, start);
ipcRenderer.on("netcatty:tray:togglePortForward", handler);
return () => ipcRenderer.removeListener("netcatty:tray:togglePortForward", handler);
},
// Tray panel actions forwarded to main window
onTrayPanelJumpToSession: (callback) => {
const handler = (_event, sessionId) => callback(sessionId);
ipcRenderer.on("netcatty:trayPanel:jumpToSession", handler);
return () => ipcRenderer.removeListener("netcatty:trayPanel:jumpToSession", handler);
},
onTrayPanelConnectToHost: (callback) => {
const handler = (_event, hostId) => callback(hostId);
ipcRenderer.on("netcatty:trayPanel:connectToHost", handler);
return () => ipcRenderer.removeListener("netcatty:trayPanel:connectToHost", handler);
},
// Tray panel window
hideTrayPanel: () => ipcRenderer.invoke("netcatty:trayPanel:hide"),
openMainWindow: () => ipcRenderer.invoke("netcatty:trayPanel:openMainWindow"),
quitApp: () => ipcRenderer.invoke("netcatty:trayPanel:quitApp"),
jumpToSessionFromTrayPanel: (sessionId) =>
ipcRenderer.invoke("netcatty:trayPanel:jumpToSession", sessionId),
connectToHostFromTrayPanel: (hostId) =>
ipcRenderer.invoke("netcatty:trayPanel:connectToHost", hostId),
onTrayPanelCloseRequest: (callback) => {
const handler = () => callback();
ipcRenderer.on("netcatty:trayPanel:closeRequest", handler);
return () => ipcRenderer.removeListener("netcatty:trayPanel:closeRequest", handler);
},
onTrayPanelRefresh: (callback) => {
const handler = () => callback();
ipcRenderer.on("netcatty:trayPanel:refresh", handler);
return () => ipcRenderer.removeListener("netcatty:trayPanel:refresh", handler);
},
onTrayPanelMenuData: (callback) => {
// Replay buffered data so late subscribers (e.g. after React lazy-mount) don't miss
// the initial payload that was sent before the useEffect listener was registered.
if (_lastTrayMenuData) {
queueMicrotask(() => callback(_lastTrayMenuData));
}
const handler = (_event, data) => {
_lastTrayMenuData = data;
callback(data);
};
ipcRenderer.on("netcatty:trayPanel:setMenuData", handler);
return () => ipcRenderer.removeListener("netcatty:trayPanel:setMenuData", handler);
},
// Get file path from File object (for drag-and-drop)
getPathForFile: (file) => {
try {
@@ -760,8 +840,64 @@ const api = {
return undefined;
}
},
// Clipboard fallback helpers
readClipboardText: async () => {
return ipcRenderer.invoke("netcatty:clipboard:readText");
},
// Credential encryption (field-level safeStorage)
credentialsAvailable: () => ipcRenderer.invoke("netcatty:credentials:available"),
credentialsEncrypt: (plaintext) => ipcRenderer.invoke("netcatty:credentials:encrypt", plaintext),
credentialsDecrypt: (value) => ipcRenderer.invoke("netcatty:credentials:decrypt", value),
};
// Merge with existing netcatty (if any) to avoid stale objects on hot reload
const existing = (typeof window !== "undefined" && window.netcatty) ? window.netcatty : {};
contextBridge.exposeInMainWorld("netcatty", { ...existing, ...api });
function getAllowedRendererOrigins() {
const origins = new Set(["app://netcatty"]);
const devServerUrl = process.env.VITE_DEV_SERVER_URL;
if (typeof devServerUrl === "string" && devServerUrl.length > 0) {
try {
const u = new URL(devServerUrl);
origins.add(u.origin);
// Vite often binds to 0.0.0.0, but Chromium navigates via localhost.
if (
u.hostname === "0.0.0.0" ||
u.hostname === "127.0.0.1" ||
u.hostname === "::1" ||
u.hostname === "[::1]" ||
u.hostname === "::" ||
u.hostname === "[::]"
) {
u.hostname = "localhost";
origins.add(u.origin);
}
} catch {
// ignore invalid dev URL
}
}
return origins;
}
function isTrustedRendererLocation(allowedOrigins) {
try {
const origin = window?.location?.origin;
return typeof origin === "string" && allowedOrigins.has(origin);
} catch {
return false;
}
}
const allowedOrigins = getAllowedRendererOrigins();
if (isTrustedRendererLocation(allowedOrigins)) {
contextBridge.exposeInMainWorld("netcatty", { ...existing, ...api });
} else {
// If a window navigates to an untrusted origin, do NOT expose the bridge.
try {
console.warn("[Preload] Refusing to expose netcatty bridge to untrusted origin:", window?.location?.origin);
} catch {
// ignore
}
}

67
global.d.ts vendored
View File

@@ -72,6 +72,8 @@ declare global {
jumpHosts?: NetcattyJumpHost[];
// SSH-level keepalive interval in seconds (0 = disabled)
keepaliveInterval?: number;
// Enable legacy SSH algorithms for older network equipment
legacyAlgorithms?: boolean;
// Use sudo for SFTP server
sudo?: boolean;
}
@@ -200,6 +202,8 @@ declare global {
memFree: number | null; // Free memory in MB
memBuffers: number | null; // Buffers in MB
memCached: number | null; // Cached in MB
swapTotal: number | null; // Total swap in MB
swapUsed: number | null; // Used swap in MB
topProcesses: Array<{ // Top 10 processes by memory
pid: string;
memPercent: number;
@@ -293,9 +297,9 @@ declare global {
// Write binary with real-time progress callback
writeSftpBinaryWithProgress?(
sftpId: string,
path: string,
content: ArrayBuffer,
sftpId: string,
path: string,
content: ArrayBuffer,
transferId: string,
encoding?: SftpFilenameEncoding,
onProgress?: (transferred: number, total: number, speed: number) => void,
@@ -310,7 +314,7 @@ declare global {
uploadFile?(sftpId: string, localPath: string, remotePath: string, transferId: string): Promise<void>;
downloadFile?(sftpId: string, remotePath: string, localPath: string, transferId: string): Promise<void>;
cancelTransfer?(transferId: string): Promise<void>;
// Compressed folder upload
startCompressedUpload?(
options: {
@@ -331,7 +335,7 @@ declare global {
remoteTar: boolean;
error?: string;
}>;
onTransferProgress?(transferId: string, cb: (progress: SftpTransferProgress) => void): () => void;
// Streaming transfer with real progress and cancellation
@@ -580,6 +584,59 @@ declare global {
// Get file path from File object (for drag-and-drop, uses Electron's webUtils)
getPathForFile?(file: File): string | undefined;
readClipboardText?(): Promise<string>;
// Credential encryption (field-level safeStorage for sensitive data at rest)
credentialsAvailable?(): Promise<boolean>;
credentialsEncrypt?(plaintext: string): Promise<string>;
credentialsDecrypt?(value: string): Promise<string>;
// Global Toggle Hotkey (Quake Mode)
registerGlobalHotkey?(hotkey: string): Promise<{ success: boolean; enabled?: boolean; error?: string; accelerator?: string }>;
unregisterGlobalHotkey?(): Promise<{ success: boolean }>;
getGlobalHotkeyStatus?(): Promise<{ enabled: boolean; hotkey: string | null }>;
// System Tray / Close to Tray
setCloseToTray?(enabled: boolean): Promise<{ success: boolean; enabled: boolean }>;
isCloseToTray?(): Promise<{ enabled: boolean }>;
updateTrayMenuData?(data: {
sessions?: Array<{ id: string; label: string; hostLabel: string; status: "connecting" | "connected" | "disconnected"; workspaceId?: string; workspaceTitle?: string }>;
portForwardRules?: Array<{
id: string;
label: string;
type: "local" | "remote" | "dynamic";
localPort: number;
remoteHost?: string;
remotePort?: number;
status: "inactive" | "connecting" | "active" | "error";
}>;
}): Promise<{ success: boolean }>;
onTrayFocusSession?(callback: (sessionId: string) => void): () => void;
onTrayTogglePortForward?(callback: (ruleId: string, start: boolean) => void): () => void;
onTrayPanelJumpToSession?(callback: (sessionId: string) => void): () => void;
onTrayPanelConnectToHost?(callback: (hostId: string) => void): () => void;
hideTrayPanel?(): Promise<{ success: boolean }>;
openMainWindow?(): Promise<{ success: boolean }>;
quitApp?(): Promise<{ success: boolean }>;
jumpToSessionFromTrayPanel?(sessionId: string): Promise<{ success: boolean }>;
connectToHostFromTrayPanel?(hostId: string): Promise<{ success: boolean }>;
onTrayPanelCloseRequest?(callback: () => void): () => void;
onTrayPanelRefresh?(callback: () => void): () => void;
onTrayPanelMenuData?(callback: (data: {
sessions?: Array<{ id: string; label: string; hostLabel: string; status: "connecting" | "connected" | "disconnected"; workspaceId?: string; workspaceTitle?: string }>;
portForwardRules?: Array<{
id: string;
label: string;
type: "local" | "remote" | "dynamic";
localPort: number;
remoteHost?: string;
remotePort?: number;
status: "inactive" | "connecting" | "active" | "error";
hostId?: string;
}>;
}) => void): () => void;
}
interface Window {

View File

@@ -11,6 +11,7 @@ import App from './App';
import { ToastProvider } from './components/ui/toast';
const LazySettingsPage = lazy(() => import('./components/SettingsPage'));
const LazyTrayPanel = lazy(() => import('./components/TrayPanel'));
const rootElement = document.getElementById('root');
if (!rootElement) {
@@ -23,6 +24,9 @@ const getRoute = () => {
if (hash === '#/settings' || hash.startsWith('#/settings')) {
return 'settings';
}
if (hash === '#/tray' || hash.startsWith('#/tray')) {
return 'tray';
}
return 'main';
};
@@ -38,6 +42,14 @@ const renderApp = () => {
</Suspense>
</ToastProvider>
);
} else if (route === 'tray') {
root.render(
<ToastProvider>
<Suspense fallback={<div style={{ padding: 12, color: '#fff' }}>Loading tray panel</div>}>
<LazyTrayPanel />
</Suspense>
</ToastProvider>
);
} else {
root.render(<App />);
}

View File

@@ -29,6 +29,7 @@ export const STORAGE_KEY_CONNECTION_LOGS = 'netcatty_connection_logs_v1';
export const STORAGE_KEY_IDENTITIES = 'netcatty_identities_v1';
export const STORAGE_KEY_VAULT_HOSTS_VIEW_MODE = 'netcatty_vault_hosts_view_mode_v1';
export const STORAGE_KEY_VAULT_HOSTS_TREE_EXPANDED = 'netcatty_vault_hosts_tree_expanded_v1';
export const STORAGE_KEY_VAULT_SIDEBAR_COLLAPSED = 'netcatty_vault_sidebar_collapsed_v1';
export const STORAGE_KEY_VAULT_KEYS_VIEW_MODE = 'netcatty_vault_keys_view_mode_v1';
export const STORAGE_KEY_VAULT_SNIPPETS_VIEW_MODE = 'netcatty_vault_snippets_view_mode_v1';
export const STORAGE_KEY_VAULT_KNOWN_HOSTS_VIEW_MODE = 'netcatty_vault_known_hosts_view_mode_v1';
@@ -46,6 +47,9 @@ export const STORAGE_KEY_SFTP_AUTO_SYNC = 'netcatty_sftp_auto_sync_v1';
export const STORAGE_KEY_SFTP_SHOW_HIDDEN_FILES = 'netcatty_sftp_show_hidden_files_v1';
export const STORAGE_KEY_SFTP_USE_COMPRESSED_UPLOAD = 'netcatty_sftp_use_compressed_upload_v1';
// Editor Settings
export const STORAGE_KEY_EDITOR_WORD_WRAP = 'netcatty_editor_word_wrap_v1';
// Session Logs Settings
export const STORAGE_KEY_SESSION_LOGS_ENABLED = 'netcatty_session_logs_enabled_v1';
export const STORAGE_KEY_SESSION_LOGS_DIR = 'netcatty_session_logs_dir_v1';
@@ -56,3 +60,7 @@ export const STORAGE_KEY_LEGACY_KEYS = 'netcatty_legacy_keys_v1';
// Managed Sources - external files that manage groups of hosts (e.g., ~/.ssh/config)
export const STORAGE_KEY_MANAGED_SOURCES = 'netcatty_managed_sources_v1';
// Global Toggle Window Settings (Quake Mode)
export const STORAGE_KEY_TOGGLE_WINDOW_HOTKEY = 'netcatty_toggle_window_hotkey_v1';
export const STORAGE_KEY_CLOSE_TO_TRAY = 'netcatty_close_to_tray_v1';

View File

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

View File

@@ -38,6 +38,10 @@ import { createAdapter, type CloudAdapter } from './adapters';
import type { GitHubAdapter } from './adapters/GitHubAdapter';
import type { GoogleDriveAdapter } from './adapters/GoogleDriveAdapter';
import type { OneDriveAdapter } from './adapters/OneDriveAdapter';
import {
decryptProviderSecrets,
encryptProviderSecrets,
} from '../persistence/secureFieldAdapter';
const SYNC_HISTORY_STORAGE_KEY = 'netcatty_sync_history_v1';
@@ -79,11 +83,25 @@ export class CloudSyncManager {
private autoSyncTimer: ReturnType<typeof setInterval> | null = null;
private masterPassword: string | null = null; // In memory only!
private hasStorageListener = false;
// Per-provider sequence counters for async decrypt callbacks (startup,
// cross-window storage events). Bumped by any state mutation so stale
// decrypt results are discarded.
private providerDecryptSeq: Record<CloudProvider, number> = {
github: 0, google: 0, onedrive: 0, webdav: 0, s3: 0,
};
// Per-provider write sequence counters for saveProviderConnection.
// Only bumped when a new save is initiated, so status-only updates
// (which don't persist) cannot discard an in-flight encrypted write.
private providerWriteSeq: Record<CloudProvider, number> = {
github: 0, google: 0, onedrive: 0, webdav: 0, s3: 0,
};
constructor() {
this.state = this.loadInitialState();
this.stateSnapshot = { ...this.state };
this.setupCrossWindowSync();
// Decrypt provider secrets asynchronously after initial load
this.initProviderDecryption();
}
// ==========================================================================
@@ -95,10 +113,10 @@ export class CloudSyncManager {
const masterKeyConfig = this.loadFromStorage<MasterKeyConfig>(
SYNC_STORAGE_KEYS.MASTER_KEY_CONFIG
);
const deviceId = this.loadFromStorage<string>(SYNC_STORAGE_KEYS.DEVICE_ID)
const deviceId = this.loadFromStorage<string>(SYNC_STORAGE_KEYS.DEVICE_ID)
|| generateDeviceId();
const deviceName = this.loadFromStorage<string>(SYNC_STORAGE_KEYS.DEVICE_NAME)
|| getDefaultDeviceName();
@@ -153,13 +171,13 @@ export class CloudSyncManager {
private loadProviderConnection(provider: CloudProvider): ProviderConnection {
const key = SYNC_STORAGE_KEYS[`PROVIDER_${provider.toUpperCase()}` as keyof typeof SYNC_STORAGE_KEYS];
const stored = this.loadFromStorage<Partial<ProviderConnection>>(key);
// Determine the correct status: if tokens or config exist, should be 'connected'
// Never restore 'syncing' or 'error' status - those are transient
const status: ProviderConnection['status'] = (stored?.tokens || stored?.config)
? 'connected'
: 'disconnected';
return {
provider,
...stored,
@@ -167,11 +185,41 @@ export class CloudSyncManager {
} as ProviderConnection;
}
private saveProviderConnection(provider: CloudProvider, connection: ProviderConnection): void {
/**
* Asynchronously decrypt provider connection secrets after initial load.
* Runs once at construction; decrypted tokens replace the encrypted ones
* in-memory so adapters can use them.
*/
private async initProviderDecryption(): Promise<void> {
const providers: CloudProvider[] = ['github', 'google', 'onedrive', 'webdav', 's3'];
for (const p of providers) {
try {
const conn = this.state.providers[p];
if (conn.tokens || conn.config) {
const seq = ++this.providerDecryptSeq[p];
const decrypted = await decryptProviderSecrets(conn);
// Only apply if no newer update has occurred during the async gap
if (seq === this.providerDecryptSeq[p]) {
this.state.providers[p] = decrypted;
}
}
} catch {
// Decryption failure is non-fatal; the adapter will fail on use
}
}
this.notifyStateChange();
}
private async saveProviderConnection(provider: CloudProvider, connection: ProviderConnection): Promise<void> {
const key = SYNC_STORAGE_KEYS[`PROVIDER_${provider.toUpperCase()}` as keyof typeof SYNC_STORAGE_KEYS];
// Don't persist sensitive tokens directly - use safeStorage in production
const { tokens, ...safeData } = connection;
this.saveToStorage(key, { ...safeData, tokens }); // In production, encrypt tokens/config
// Use write-specific counter so status-only updates cannot discard
// an in-flight encrypted write that must be persisted.
const seq = ++this.providerWriteSeq[provider];
const encrypted = await encryptProviderSecrets(connection);
// Only persist if no newer save has started during the async gap
if (seq === this.providerWriteSeq[provider]) {
this.saveToStorage(key, encrypted);
}
}
private loadFromStorage<T>(key: string): T | null {
@@ -222,7 +270,7 @@ export class CloudSyncManager {
// Handle master key config changes (e.g., when set up in settings window)
if (key === SYNC_STORAGE_KEYS.MASTER_KEY_CONFIG) {
const nextConfig = this.safeJsonParse<MasterKeyConfig>(event.newValue);
if (nextConfig && !this.state.masterKeyConfig) {
// Master key was set up in another window - update our state
this.state.masterKeyConfig = nextConfig;
@@ -292,48 +340,61 @@ export class CloudSyncManager {
};
const provider = providerByKey[key];
if (provider) {
const prev = this.state.providers[provider];
const next = this.loadProviderConnection(provider);
const rawNext = this.loadProviderConnection(provider);
const seq = ++this.providerDecryptSeq[provider];
// Also bump write seq so any in-flight save from this window for the
// same provider is discarded — the cross-window data is newer.
++this.providerWriteSeq[provider];
const preserveTransientStatus =
prev.status === 'connecting' || prev.status === 'syncing';
// Decrypt secrets asynchronously, then update state.
// Use sequence counter to discard stale results when multiple events
// for the same provider arrive in quick succession.
decryptProviderSecrets(rawNext).then((next) => {
if (seq !== this.providerDecryptSeq[provider]) return; // stale — discard
this.state.providers[provider] = {
...next,
status: preserveTransientStatus ? prev.status : next.status,
error: preserveTransientStatus ? prev.error : next.error,
};
const prev = this.state.providers[provider];
const preserveTransientStatus =
prev.status === 'connecting' || prev.status === 'syncing';
const nextTokens = next.tokens;
const nextConfig = next.config;
const adapter = this.adapters.get(provider);
if (!nextTokens && !nextConfig) {
if (adapter) {
this.state.providers[provider] = {
...next,
status: preserveTransientStatus ? prev.status : next.status,
error: preserveTransientStatus ? prev.error : next.error,
};
const nextTokens = next.tokens;
const nextConfig = next.config;
const adapter = this.adapters.get(provider);
if (!nextTokens && !nextConfig) {
if (adapter) {
adapter.signOut();
this.adapters.delete(provider);
}
this.notifyStateChange();
return;
}
const tokenChanged =
(prev.tokens?.accessToken || null) !== (nextTokens?.accessToken || null) ||
(prev.tokens?.refreshToken || null) !== (nextTokens?.refreshToken || null) ||
(prev.tokens?.expiresAt || null) !== (nextTokens?.expiresAt || null) ||
(prev.tokens?.tokenType || null) !== (nextTokens?.tokenType || null) ||
(prev.tokens?.scope || null) !== (nextTokens?.scope || null);
const configChanged =
JSON.stringify(prev.config || null) !== JSON.stringify(nextConfig || null);
const resourceChanged = (adapter?.resourceId || null) !== (next.resourceId || null);
if (adapter && (tokenChanged || configChanged || resourceChanged)) {
adapter.signOut();
this.adapters.delete(provider);
}
this.notifyStateChange();
return;
}
const tokenChanged =
(prev.tokens?.accessToken || null) !== (nextTokens?.accessToken || null) ||
(prev.tokens?.refreshToken || null) !== (nextTokens?.refreshToken || null) ||
(prev.tokens?.expiresAt || null) !== (nextTokens?.expiresAt || null) ||
(prev.tokens?.tokenType || null) !== (nextTokens?.tokenType || null) ||
(prev.tokens?.scope || null) !== (nextTokens?.scope || null);
const configChanged =
JSON.stringify(prev.config || null) !== JSON.stringify(nextConfig || null);
const resourceChanged = (adapter?.resourceId || null) !== (next.resourceId || null);
if (adapter && (tokenChanged || configChanged || resourceChanged)) {
adapter.signOut();
this.adapters.delete(provider);
}
this.notifyStateChange();
}).catch(() => {
// Decryption failure in cross-window handler is non-fatal
});
}
};
@@ -451,10 +512,10 @@ export class CloudSyncManager {
}
const config = await EncryptionService.createMasterKeyConfig(password);
this.state.masterKeyConfig = config;
this.state.securityState = 'LOCKED';
this.saveToStorage(SYNC_STORAGE_KEYS.MASTER_KEY_CONFIG, config);
this.emit({ type: 'SECURITY_STATE_CHANGED', state: 'LOCKED' });
@@ -537,7 +598,7 @@ export class CloudSyncManager {
this.state.masterKeyConfig = newConfig;
this.state.securityState = 'UNLOCKED';
this.masterPassword = newPassword;
// Re-derive key with new password
this.state.unlockedKey = await EncryptionService.unlockMasterKey(
newPassword,
@@ -589,7 +650,7 @@ export class CloudSyncManager {
// GitHub uses Device Flow
const ghAdapter = adapter as GitHubAdapter;
const deviceFlow = await ghAdapter.startAuth();
return {
type: 'device_code',
data: deviceFlow,
@@ -597,7 +658,7 @@ export class CloudSyncManager {
} else {
// Google and OneDrive use PKCE with redirect
const redirectUri = 'http://127.0.0.1:45678/oauth/callback';
if (provider === 'google') {
const gdAdapter = adapter as GoogleDriveAdapter;
const url = await gdAdapter.startAuth(redirectUri);
@@ -636,7 +697,8 @@ export class CloudSyncManager {
try {
const tokens = await ghAdapter.completeAuth(deviceCode, interval, expiresAt, onPending);
++this.providerDecryptSeq.github;
this.state.providers.github = {
...this.state.providers.github,
status: 'connected',
@@ -650,7 +712,7 @@ export class CloudSyncManager {
this.state.providers.github.resourceId = resourceId;
}
this.saveProviderConnection('github', this.state.providers.github);
await this.saveProviderConnection('github', this.state.providers.github);
this.emit({
type: 'AUTH_COMPLETED',
provider: 'github',
@@ -689,6 +751,7 @@ export class CloudSyncManager {
account = odAdapter.accountInfo;
}
++this.providerDecryptSeq[provider];
this.state.providers[provider] = {
...this.state.providers[provider],
status: 'connected',
@@ -702,7 +765,7 @@ export class CloudSyncManager {
this.state.providers[provider].resourceId = resourceId;
}
this.saveProviderConnection(provider, this.state.providers[provider]);
await this.saveProviderConnection(provider, this.state.providers[provider]);
this.emit({
type: 'AUTH_COMPLETED',
provider,
@@ -729,6 +792,7 @@ export class CloudSyncManager {
const resourceId = await adapter.initializeSync();
const account = adapter.accountInfo || this.buildAccountFromConfig(provider, config);
++this.providerDecryptSeq[provider];
this.state.providers[provider] = {
provider,
status: 'connected',
@@ -737,7 +801,7 @@ export class CloudSyncManager {
resourceId: resourceId || undefined,
};
this.saveProviderConnection(provider, this.state.providers[provider]);
await this.saveProviderConnection(provider, this.state.providers[provider]);
this.emit({
type: 'AUTH_COMPLETED',
provider,
@@ -759,12 +823,13 @@ export class CloudSyncManager {
this.adapters.delete(provider);
}
++this.providerDecryptSeq[provider];
this.state.providers[provider] = {
provider,
status: 'disconnected',
};
this.saveProviderConnection(provider, this.state.providers[provider]);
await this.saveProviderConnection(provider, this.state.providers[provider]);
this.notifyStateChange(); // Ensure UI updates immediately after disconnect
}
@@ -773,6 +838,8 @@ export class CloudSyncManager {
status: ProviderConnection['status'],
error?: string
): void {
// Bump sequence to invalidate any in-flight async decrypt for this provider
++this.providerDecryptSeq[provider];
this.state.providers[provider] = {
...this.state.providers[provider],
status,
@@ -842,11 +909,14 @@ export class CloudSyncManager {
this.state.localUpdatedAt = syncedFile.meta.updatedAt;
this.state.remoteVersion = syncedFile.meta.version;
this.state.remoteUpdatedAt = syncedFile.meta.updatedAt;
// Invalidate any pending provider decrypt so it cannot overwrite
// the lastSync/lastSyncVersion we are about to set.
++this.providerDecryptSeq[provider];
this.state.providers[provider].lastSync = Date.now();
this.state.providers[provider].lastSyncVersion = syncedFile.meta.version;
this.saveSyncConfig();
this.saveProviderConnection(provider, this.state.providers[provider]);
await this.saveProviderConnection(provider, this.state.providers[provider]);
this.notifyStateChange();
// Add to sync history
@@ -1028,9 +1098,9 @@ export class CloudSyncManager {
deviceName: this.state.deviceName,
error: String(error),
});
this.emit({ type: 'SYNC_ERROR', provider, error: String(error) });
return {
success: false,
provider,
@@ -1288,6 +1358,7 @@ export class CloudSyncManager {
this.state.syncState = 'ERROR';
// lastError is set by uploadToProvider
}
this.notifyStateChange(); // Notify UI that sync is complete
// Process errors from initial checks (if any)
checkResults.forEach((r) => {
@@ -1371,7 +1442,7 @@ export class CloudSyncManager {
...entry,
id: crypto.randomUUID(),
};
// Keep only the last 50 entries
this.state.syncHistory = [newEntry, ...this.state.syncHistory].slice(0, 50);
this.saveToStorage(SYNC_HISTORY_STORAGE_KEY, this.state.syncHistory);

View File

@@ -0,0 +1,12 @@
import { netcattyBridge } from "./netcattyBridge";
export const getCredentialProtectionAvailability = async (): Promise<boolean | null> => {
const bridge = netcattyBridge.get();
if (!bridge?.credentialsAvailable) return null;
try {
return await bridge.credentialsAvailable();
} catch {
return null;
}
};

View File

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

71
package-lock.json generated
View File

@@ -955,13 +955,13 @@
}
},
"node_modules/@aws-sdk/xml-builder": {
"version": "3.972.2",
"resolved": "https://registry.npmjs.org/@aws-sdk/xml-builder/-/xml-builder-3.972.2.tgz",
"integrity": "sha512-jGOOV/bV1DhkkUhHiZ3/1GZ67cZyOXaDb7d1rYD6ZiXf5V9tBNOcgqXwRRPvrCbYaFRa1pPMFb3ZjqjWpR3YfA==",
"version": "3.972.4",
"resolved": "https://registry.npmjs.org/@aws-sdk/xml-builder/-/xml-builder-3.972.4.tgz",
"integrity": "sha512-0zJ05ANfYqI6+rGqj8samZBFod0dPPousBjLEqg8WdxSgbMAkRgLyn81lP215Do0rFJ/17LIXwr7q0yK24mP6Q==",
"license": "Apache-2.0",
"dependencies": {
"@smithy/types": "^4.12.0",
"fast-xml-parser": "5.2.5",
"fast-xml-parser": "5.3.4",
"tslib": "^2.6.2"
},
"engines": {
@@ -2541,9 +2541,9 @@
}
},
"node_modules/@isaacs/brace-expansion": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/@isaacs/brace-expansion/-/brace-expansion-5.0.0.tgz",
"integrity": "sha512-ZT55BDLV0yv0RBm2czMiZ+SqCGO7AvmOM3G/w2xhVPH+te0aKgFjmBvGlL1dH+ql2tgGO3MVrbb3jCKyvpgnxA==",
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/@isaacs/brace-expansion/-/brace-expansion-5.0.1.tgz",
"integrity": "sha512-WMz71T1JS624nWj2n2fnYAuPovhv7EUhk69R6i9dsVyzxt5eM3bjwvgk9L+APE1TRscGysAVMANkB0jh0LQZrQ==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -6378,14 +6378,14 @@
}
},
"node_modules/axios": {
"version": "1.13.4",
"resolved": "https://registry.npmjs.org/axios/-/axios-1.13.4.tgz",
"integrity": "sha512-1wVkUaAO6WyaYtCkcYCOx12ZgpGf9Zif+qXa4n+oYzK558YryKqiL6UWwd5DqiH3VRW0GYhTZQ/vlgJrCoNQlg==",
"version": "1.13.5",
"resolved": "https://registry.npmjs.org/axios/-/axios-1.13.5.tgz",
"integrity": "sha512-cz4ur7Vb0xS4/KUN0tPWe44eqxrIu31me+fbang3ijiNscE129POzipJJA6zniq2C/Z6sJCjMimjS8Lc/GAs8Q==",
"dev": true,
"license": "MIT",
"dependencies": {
"follow-redirects": "^1.15.6",
"form-data": "^4.0.4",
"follow-redirects": "^1.15.11",
"form-data": "^4.0.5",
"proxy-from-env": "^1.1.0"
}
},
@@ -7366,6 +7366,7 @@
"integrity": "sha512-uOOBA3f+kW3o4KpSoMQ6SNpdXU7WtxlJRb9vCZgOvqhTz4b3GjcoWKstdisizNZLsylhTMv8TLHFPFW0Uxsj/g==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"app-builder-lib": "26.7.0",
"builder-util": "26.4.1",
@@ -8240,9 +8241,9 @@
"license": "MIT"
},
"node_modules/fast-xml-parser": {
"version": "5.2.5",
"resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-5.2.5.tgz",
"integrity": "sha512-pfX9uG9Ki0yekDHx2SiuRIyFdyAr1kMIMitPvb0YBo8SUfKvia7w7FIyd/l6av85pFYRhZscS75MwMnbvY+hcQ==",
"version": "5.3.4",
"resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-5.3.4.tgz",
"integrity": "sha512-EFd6afGmXlCx8H8WTZHhAoDaWaGyuIBoZJ2mknrNxug+aZKjkp0a0dlars9Izl+jF+7Gu1/5f/2h68cQpe0IiA==",
"funding": [
{
"type": "github",
@@ -12436,16 +12437,16 @@
}
},
"node_modules/webdav": {
"version": "5.8.0",
"resolved": "https://registry.npmjs.org/webdav/-/webdav-5.8.0.tgz",
"integrity": "sha512-iuFG7NamJ41Oshg4930iQgfIpRrUiatPWIekeznYgEf2EOraTRcDPTjy7gIOMtkdpKTaqPk1E68NO5PAGtJahA==",
"version": "5.9.0",
"resolved": "https://registry.npmjs.org/webdav/-/webdav-5.9.0.tgz",
"integrity": "sha512-OMJ6wtK1WvCO++aOLoQgE96S8KT4e5aaClWHmHXfFU369r4eyELN569B7EqT4OOUb99mmO58GkyuiCv/Ag6J0Q==",
"license": "MIT",
"dependencies": {
"@buttercup/fetch": "^0.2.1",
"base-64": "^1.0.0",
"byte-length": "^1.0.2",
"entities": "^6.0.0",
"fast-xml-parser": "^4.5.1",
"entities": "^6.0.1",
"fast-xml-parser": "^5.3.4",
"hot-patcher": "^2.0.1",
"layerr": "^3.0.0",
"md5": "^2.3.0",
@@ -12460,36 +12461,6 @@
"node": ">=14"
}
},
"node_modules/webdav/node_modules/fast-xml-parser": {
"version": "4.5.3",
"resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-4.5.3.tgz",
"integrity": "sha512-RKihhV+SHsIUGXObeVy9AXiBbFwkVk7Syp8XgwN5U3JV416+Gwp/GO9i0JYKmikykgz/UHRrrV4ROuZEo/T0ig==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/NaturalIntelligence"
}
],
"license": "MIT",
"dependencies": {
"strnum": "^1.1.1"
},
"bin": {
"fxparser": "src/cli/cli.js"
}
},
"node_modules/webdav/node_modules/strnum": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/strnum/-/strnum-1.1.2.tgz",
"integrity": "sha512-vrN+B7DBIoTTZjnPNewwhx6cBA/H+IS7rfW68n7XxC1y7uoiGQBxaKzqucGUgavX15dJgiGztLJ8vxuEzwqBdA==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/NaturalIntelligence"
}
],
"license": "MIT"
},
"node_modules/which": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",

View File

@@ -82,6 +82,7 @@
"wait-on": "^9.0.3"
},
"overrides": {
"cpu-features": "npm:empty-npm-package@1.0.0"
"cpu-features": "npm:empty-npm-package@1.0.0",
"axios": "1.13.5"
}
}
}

View File

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

BIN
public/tray-icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 615 B

BIN
public/tray-icon@2x.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 512 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 942 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 825 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 995 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 888 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.8 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.1 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 849 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 849 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 871 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 764 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 657 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 671 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 814 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 833 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 875 KiB

After

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 832 KiB

After

Width:  |  Height:  |  Size: 1.4 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 832 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 897 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 759 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 977 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1002 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 977 KiB

After

Width:  |  Height:  |  Size: 1.7 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 906 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 868 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 777 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 817 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 494 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

Some files were not shown because too many files have changed in this diff Show More